feat(agents): session-boundary classification for sub-agents (#405)
\`agent_list\` previously surfaced every persisted sub-agent the manager had on disk — including agents from prior sessions still hanging around in \`subagents.v1.json\`. In long-lived sessions this piled up and the model had to reason past 13 listed agents when only four were live. Now: each \`SubAgentManager\` assigns a fresh \`session_boot_id\` at construction. Every agent it spawns is stamped with that id, persisted alongside the existing fields, and reloaded as-is by future managers. At list time the manager classifies any agent whose stamp doesn't match the current id (or that loaded with no stamp at all from pre-#405 records) as \`from_prior_session\`. \`agent_list\` defaults to the current-session view: prior-session agents are dropped from the listing **unless** they're still \`Running\` (which can happen after a process restart — the manager flagged them as \`Interrupted\` on load). Pass \`include_archived=true\` to surface every record, with the \`from_prior_session\` flag on each result so the model can tell live vs archived apart at a glance. ### What's wired - \`SubAgentManager::current_session_boot_id\` — UUID-derived, generated in \`new\`. - \`SubAgent::session_boot_id\` and \`PersistedSubAgent::session_boot_id\` — the latter \`#[serde(default)]\` for backward compat (pre-#405 records load with empty string and classify as prior-session). - \`SubAgentResult::from_prior_session\` — \`#[serde(default, skip_serializing_if = "is_false")]\` so today's clients reading archived snapshots see the field, while default-false snapshots serialize without an extra noisy key. - \`SubAgentManager::list_filtered(include_archived)\` — the new user-facing API. \`SubAgent::snapshot()\` still defaults the flag to \`false\`; \`snapshot_for_listing\` (manager-only) fills it in. - \`AgentListTool\` accepts \`include_archived: bool\` (default false) and routes through the filter. The model-facing description explains the behaviour. ### Tests - \`session_boot_ids_are_unique_per_manager\` — each manager mints its own id. - \`list_filtered_drops_prior_session_terminals_by_default\` — the three-agent matrix (current running / prior completed / prior running) collapses to the right two with the right flags. - \`list_filtered_with_include_archived_returns_everything\` — archived view returns all records with correct flags. - \`agents_with_empty_boot_id_classify_as_prior_session\` — pre-#405 records load and behave as expected. - \`persist_round_trip_preserves_session_boot_id\` — write with one manager, reload with a fresh manager, confirm the agent flips to prior-session in the new manager's view. ### Verification cargo fmt --all -- --check ✓ cargo clippy --workspace --all-targets --all-features --locked -- -D warnings ✓ cargo test --workspace --all-features --locked ✓ 1847 + supporting Closes #405 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -318,6 +318,16 @@ pub struct SubAgentResult {
|
||||
pub result: Option<String>,
|
||||
pub steps_taken: u32,
|
||||
pub duration_ms: u64,
|
||||
/// `true` when this agent was loaded from a prior-session persisted
|
||||
/// state file rather than spawned in the current session (#405).
|
||||
/// Lets `agent_list` filter out historical noise by default while
|
||||
/// keeping the records reachable via `include_archived=true`.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub from_prior_session: bool,
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -405,6 +415,14 @@ struct PersistedSubAgent {
|
||||
duration_ms: u64,
|
||||
allowed_tools: Vec<String>,
|
||||
updated_at_ms: u64,
|
||||
/// Stable id of the manager / process boot that spawned this agent
|
||||
/// (#405). Lets a fresh manager filter out agents that were
|
||||
/// persisted by a prior session. Optional with `#[serde(default)]`
|
||||
/// for backward compatibility — older records lack the field and
|
||||
/// load with an empty string, which the manager treats as
|
||||
/// "from_prior_session" because it can't match any current id.
|
||||
#[serde(default)]
|
||||
session_boot_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -587,12 +605,17 @@ pub struct SubAgent {
|
||||
/// `None` = full registry inheritance (v0.6.6 default).
|
||||
/// `Some(list)` = explicit narrow allowlist (Custom agents, legacy).
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// Stable id of the manager that spawned this agent (#405). Compared
|
||||
/// against the manager's `current_session_boot_id` to classify the
|
||||
/// agent as in-session vs prior-session at list time.
|
||||
pub session_boot_id: String,
|
||||
input_tx: Option<mpsc::UnboundedSender<SubAgentInput>>,
|
||||
task_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl SubAgent {
|
||||
/// Create a new sub-agent.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
agent_type: SubAgentType,
|
||||
prompt: String,
|
||||
@@ -601,6 +624,7 @@ impl SubAgent {
|
||||
nickname: Option<String>,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
input_tx: mpsc::UnboundedSender<SubAgentInput>,
|
||||
session_boot_id: String,
|
||||
) -> Self {
|
||||
let id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
@@ -616,6 +640,7 @@ impl SubAgent {
|
||||
steps_taken: 0,
|
||||
started_at: Instant::now(),
|
||||
allowed_tools,
|
||||
session_boot_id,
|
||||
input_tx: Some(input_tx),
|
||||
task_handle: None,
|
||||
}
|
||||
@@ -634,6 +659,11 @@ impl SubAgent {
|
||||
result: self.result.clone(),
|
||||
steps_taken: self.steps_taken,
|
||||
duration_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
// Snapshots from the agent itself don't know the manager's
|
||||
// current boot id, so default to false. The manager fills
|
||||
// this in when it produces a snapshot via its own
|
||||
// `snapshot_for_listing` helper (#405).
|
||||
from_prior_session: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -646,6 +676,13 @@ pub struct SubAgentManager {
|
||||
state_path: Option<PathBuf>,
|
||||
max_steps: u32,
|
||||
max_agents: usize,
|
||||
/// Stable id assigned at manager construction (#405). Stamped on
|
||||
/// every agent the manager spawns; agents loaded from the
|
||||
/// persisted state file carry whatever id the prior session
|
||||
/// stamped (or empty for pre-#405 records). The manager classifies
|
||||
/// agents whose `session_boot_id` doesn't match this value as
|
||||
/// "from prior session" so `agent_list` can hide them by default.
|
||||
current_session_boot_id: String,
|
||||
}
|
||||
|
||||
impl SubAgentManager {
|
||||
@@ -658,9 +695,27 @@ impl SubAgentManager {
|
||||
state_path: None,
|
||||
max_steps: DEFAULT_MAX_STEPS,
|
||||
max_agents,
|
||||
// Fresh boot id per manager. Used by #405 to classify
|
||||
// re-loaded persisted agents as "prior session".
|
||||
current_session_boot_id: format!("boot_{}", &Uuid::new_v4().to_string()[..12]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the boot id this manager stamps on agents it spawns.
|
||||
/// Exposed for tests; internal callers use the field directly.
|
||||
#[cfg(test)]
|
||||
pub fn session_boot_id(&self) -> &str {
|
||||
&self.current_session_boot_id
|
||||
}
|
||||
|
||||
/// Classify an agent by its `session_boot_id`: `true` when the
|
||||
/// agent was either (a) loaded from disk with no id, or (b) carries
|
||||
/// a different id than the manager's current boot. Filters
|
||||
/// `agent_list` output by default (#405).
|
||||
fn is_from_prior_session(&self, agent: &SubAgent) -> bool {
|
||||
agent.session_boot_id.is_empty() || agent.session_boot_id != self.current_session_boot_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn with_state_path(mut self, path: PathBuf) -> Self {
|
||||
self.state_path = Some(path);
|
||||
@@ -690,6 +745,7 @@ impl SubAgentManager {
|
||||
// Reload converts empty vec back to None (full inheritance).
|
||||
allowed_tools: agent.allowed_tools.clone().unwrap_or_default(),
|
||||
updated_at_ms: now_ms,
|
||||
session_boot_id: agent.session_boot_id.clone(),
|
||||
});
|
||||
}
|
||||
agents.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
@@ -755,6 +811,10 @@ impl SubAgentManager {
|
||||
steps_taken: persisted.steps_taken,
|
||||
started_at,
|
||||
allowed_tools,
|
||||
// Empty string when loading pre-#405 records; the
|
||||
// manager treats that the same as a non-matching id —
|
||||
// i.e. agent classified as prior-session.
|
||||
session_boot_id: persisted.session_boot_id,
|
||||
input_tx: None,
|
||||
task_handle: None,
|
||||
};
|
||||
@@ -863,6 +923,7 @@ impl SubAgentManager {
|
||||
nickname,
|
||||
tools.clone(),
|
||||
input_tx,
|
||||
self.current_session_boot_id.clone(),
|
||||
);
|
||||
let agent_id = agent.id.clone();
|
||||
let started_at = agent.started_at;
|
||||
@@ -1147,8 +1208,50 @@ impl SubAgentManager {
|
||||
|
||||
/// List all agents and their status.
|
||||
#[must_use]
|
||||
/// Snapshot a single agent and tag it with the manager's
|
||||
/// classification. The bare `SubAgent::snapshot` defaults
|
||||
/// `from_prior_session` to `false`; only the manager knows the
|
||||
/// matching boot id, so listing goes through here.
|
||||
fn snapshot_for_listing(&self, agent: &SubAgent) -> SubAgentResult {
|
||||
let mut snap = agent.snapshot();
|
||||
snap.from_prior_session = self.is_from_prior_session(agent);
|
||||
snap
|
||||
}
|
||||
|
||||
/// List all agents currently held by the manager, regardless of
|
||||
/// session origin. Use [`Self::list_filtered`] in user-facing tool
|
||||
/// paths so prior-session agents stay hidden by default (#405).
|
||||
pub fn list(&self) -> Vec<SubAgentResult> {
|
||||
self.agents.values().map(SubAgent::snapshot).collect()
|
||||
self.agents
|
||||
.values()
|
||||
.map(|agent| self.snapshot_for_listing(agent))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// List agents respecting the session-boundary filter (#405).
|
||||
///
|
||||
/// `include_archived = false` (the default for `agent_list`) drops
|
||||
/// any prior-session agent that is no longer running. Prior-session
|
||||
/// agents that are still `Running` (e.g. interrupted by a process
|
||||
/// restart) stay visible — they may matter for ongoing recovery.
|
||||
///
|
||||
/// `include_archived = true` returns everything, with the
|
||||
/// `from_prior_session` flag on each `SubAgentResult` so the model
|
||||
/// can tell active and archived apart at a glance.
|
||||
pub fn list_filtered(&self, include_archived: bool) -> Vec<SubAgentResult> {
|
||||
self.agents
|
||||
.values()
|
||||
.filter(|agent| {
|
||||
if include_archived {
|
||||
return true;
|
||||
}
|
||||
if agent.status == SubAgentStatus::Running {
|
||||
return true;
|
||||
}
|
||||
!self.is_from_prior_session(agent)
|
||||
})
|
||||
.map(|agent| self.snapshot_for_listing(agent))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Clean up completed agents older than the given duration.
|
||||
@@ -1767,14 +1870,22 @@ impl ToolSpec for AgentListTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"List all active and recently completed sub-agents with their status, type, assignment, \
|
||||
steps taken, and duration."
|
||||
"List sub-agents from the current session with their status, type, assignment, steps, \
|
||||
and duration. Pass `include_archived=true` to also see agents that were spawned in a \
|
||||
prior session (e.g. before the TUI restarted) and persisted on disk; those carry \
|
||||
`from_prior_session: true` in the result. Default is the current-session view because \
|
||||
prior-session agents almost never matter for the live turn."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"include_archived": {
|
||||
"type": "boolean",
|
||||
"description": "When true, include agents from prior sessions in the listing. Default false."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1782,14 +1893,14 @@ impl ToolSpec for AgentListTool {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_input: Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let include_archived = input
|
||||
.get("include_archived")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let mut manager = self.manager.write().await;
|
||||
manager.cleanup(COMPLETED_AGENT_RETENTION);
|
||||
let results = manager.list();
|
||||
let results = manager.list_filtered(include_archived);
|
||||
ToolResult::json(&results).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -2431,6 +2542,7 @@ async fn run_subagent(
|
||||
result: None,
|
||||
steps_taken: steps,
|
||||
duration_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
from_prior_session: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2504,6 +2616,7 @@ async fn run_subagent(
|
||||
steps_taken: steps,
|
||||
duration_ms: u64::try_from(started_at.elapsed().as_millis())
|
||||
.unwrap_or(u64::MAX),
|
||||
from_prior_session: false,
|
||||
});
|
||||
}
|
||||
api = tokio::time::timeout(STEP_API_TIMEOUT, runtime.client.create_message(request)) => {
|
||||
@@ -2637,6 +2750,7 @@ async fn run_subagent(
|
||||
result: final_result,
|
||||
steps_taken: steps,
|
||||
duration_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
from_prior_session: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ fn make_snapshot(status: SubAgentStatus) -> SubAgentResult {
|
||||
result: None,
|
||||
steps_taken: 0,
|
||||
duration_ms: 0,
|
||||
from_prior_session: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +311,7 @@ async fn test_wait_for_result_reports_timeout_when_still_running() {
|
||||
Some("Blue".to_string()),
|
||||
Some(vec!["read_file".to_string()]),
|
||||
input_tx,
|
||||
"boot_test".to_string(),
|
||||
);
|
||||
let agent_id = agent.id.clone();
|
||||
{
|
||||
@@ -336,6 +338,7 @@ async fn test_running_count_counts_only_agents_with_live_task_handles() {
|
||||
Some("Blue".to_string()),
|
||||
Some(vec!["read_file".to_string()]),
|
||||
input_tx,
|
||||
"boot_test".to_string(),
|
||||
);
|
||||
agent.status = SubAgentStatus::Running;
|
||||
let handle = tokio::spawn(async {
|
||||
@@ -366,6 +369,7 @@ fn test_running_count_ignores_running_status_without_task_handle() {
|
||||
Some("Blue".to_string()),
|
||||
Some(vec!["read_file".to_string()]),
|
||||
input_tx,
|
||||
"boot_test".to_string(),
|
||||
);
|
||||
agent.status = SubAgentStatus::Running;
|
||||
manager.agents.insert(agent.id.clone(), agent);
|
||||
@@ -385,6 +389,7 @@ async fn test_running_count_ignores_finished_task_handles() {
|
||||
Some("Blue".to_string()),
|
||||
Some(vec!["read_file".to_string()]),
|
||||
input_tx,
|
||||
"boot_test".to_string(),
|
||||
);
|
||||
agent.status = SubAgentStatus::Running;
|
||||
let handle = tokio::spawn(async {});
|
||||
@@ -412,6 +417,7 @@ fn test_assign_updates_running_agent_and_sends_message() {
|
||||
Some("Blue".to_string()),
|
||||
Some(vec!["read_file".to_string()]),
|
||||
input_tx,
|
||||
"boot_test".to_string(),
|
||||
);
|
||||
let agent_id = agent.id.clone();
|
||||
manager.agents.insert(agent_id.clone(), agent);
|
||||
@@ -448,6 +454,7 @@ fn test_assign_rejects_message_for_non_running_agent() {
|
||||
Some("Blue".to_string()),
|
||||
Some(vec!["read_file".to_string()]),
|
||||
input_tx,
|
||||
"boot_test".to_string(),
|
||||
);
|
||||
agent.status = SubAgentStatus::Completed;
|
||||
let agent_id = agent.id.clone();
|
||||
@@ -471,6 +478,7 @@ fn test_assign_updates_non_running_metadata_without_message() {
|
||||
Some("Blue".to_string()),
|
||||
Some(vec!["read_file".to_string()]),
|
||||
input_tx,
|
||||
"boot_test".to_string(),
|
||||
);
|
||||
agent.status = SubAgentStatus::Completed;
|
||||
let agent_id = agent.id.clone();
|
||||
@@ -505,6 +513,7 @@ fn test_persist_and_reload_marks_running_agent_as_interrupted() {
|
||||
Some("Blue".to_string()),
|
||||
Some(vec!["read_file".to_string()]),
|
||||
input_tx,
|
||||
"boot_test".to_string(),
|
||||
);
|
||||
let running_id = running.id.clone();
|
||||
manager.agents.insert(running_id.clone(), running);
|
||||
@@ -1000,3 +1009,184 @@ fn stub_client() -> DeepSeekClient {
|
||||
};
|
||||
DeepSeekClient::new(&config).expect("stub client should construct")
|
||||
}
|
||||
|
||||
// ---- #405 session-boundary classification ----
|
||||
//
|
||||
// Each manager assigns a fresh session_boot_id; agents stamp the id at
|
||||
// spawn time. After persist + reload by a *new* manager, those agents
|
||||
// carry the prior boot id and are classified as `from_prior_session`.
|
||||
// `agent_list` defaults to current-session only; `include_archived=true`
|
||||
// surfaces the prior-session records with the flag set.
|
||||
|
||||
fn insert_prior_session_agent(
|
||||
manager: &mut SubAgentManager,
|
||||
id: &str,
|
||||
status: SubAgentStatus,
|
||||
boot_id: &str,
|
||||
) {
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
SubAgentType::General,
|
||||
"old prompt".to_string(),
|
||||
make_assignment(),
|
||||
"deepseek-v4-flash".to_string(),
|
||||
None,
|
||||
None,
|
||||
input_tx,
|
||||
boot_id.to_string(),
|
||||
);
|
||||
agent.status = status;
|
||||
agent.id = id.to_string();
|
||||
manager.agents.insert(id.to_string(), agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_boot_ids_are_unique_per_manager() {
|
||||
let a = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let b = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
assert_ne!(a.session_boot_id(), b.session_boot_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_filtered_drops_prior_session_terminals_by_default() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 5);
|
||||
let current_boot = manager.session_boot_id().to_string();
|
||||
insert_prior_session_agent(
|
||||
&mut manager,
|
||||
"current_running",
|
||||
SubAgentStatus::Running,
|
||||
¤t_boot,
|
||||
);
|
||||
insert_prior_session_agent(
|
||||
&mut manager,
|
||||
"prior_completed",
|
||||
SubAgentStatus::Completed,
|
||||
"boot_old_session",
|
||||
);
|
||||
insert_prior_session_agent(
|
||||
&mut manager,
|
||||
"prior_running",
|
||||
SubAgentStatus::Running,
|
||||
"boot_old_session",
|
||||
);
|
||||
|
||||
let listed = manager.list_filtered(false);
|
||||
let ids: Vec<&str> = listed.iter().map(|s| s.agent_id.as_str()).collect();
|
||||
assert!(ids.contains(&"current_running"), "{ids:?}");
|
||||
assert!(
|
||||
ids.contains(&"prior_running"),
|
||||
"still-running prior-session agents stay visible: {ids:?}"
|
||||
);
|
||||
assert!(
|
||||
!ids.contains(&"prior_completed"),
|
||||
"completed prior-session agents are hidden by default: {ids:?}"
|
||||
);
|
||||
|
||||
let prior = listed
|
||||
.iter()
|
||||
.find(|s| s.agent_id == "prior_running")
|
||||
.unwrap();
|
||||
assert!(prior.from_prior_session);
|
||||
let current = listed
|
||||
.iter()
|
||||
.find(|s| s.agent_id == "current_running")
|
||||
.unwrap();
|
||||
assert!(!current.from_prior_session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_filtered_with_include_archived_returns_everything() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 5);
|
||||
let current_boot = manager.session_boot_id().to_string();
|
||||
insert_prior_session_agent(
|
||||
&mut manager,
|
||||
"current_done",
|
||||
SubAgentStatus::Completed,
|
||||
¤t_boot,
|
||||
);
|
||||
insert_prior_session_agent(
|
||||
&mut manager,
|
||||
"prior_done",
|
||||
SubAgentStatus::Completed,
|
||||
"boot_old",
|
||||
);
|
||||
insert_prior_session_agent(
|
||||
&mut manager,
|
||||
"prior_failed",
|
||||
SubAgentStatus::Failed("boom".to_string()),
|
||||
"boot_old",
|
||||
);
|
||||
|
||||
let listed = manager.list_filtered(true);
|
||||
assert_eq!(listed.len(), 3, "{listed:?}");
|
||||
let prior = listed.iter().find(|s| s.agent_id == "prior_done").unwrap();
|
||||
assert!(prior.from_prior_session);
|
||||
let current = listed
|
||||
.iter()
|
||||
.find(|s| s.agent_id == "current_done")
|
||||
.unwrap();
|
||||
assert!(!current.from_prior_session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_with_empty_boot_id_classify_as_prior_session() {
|
||||
// Records persisted before #405 land with an empty `session_boot_id`
|
||||
// due to `#[serde(default)]`. The manager treats those the same as
|
||||
// a non-matching id — i.e. prior session.
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 5);
|
||||
insert_prior_session_agent(&mut manager, "legacy", SubAgentStatus::Completed, "");
|
||||
|
||||
let listed_default = manager.list_filtered(false);
|
||||
assert!(
|
||||
listed_default.iter().all(|s| s.agent_id != "legacy"),
|
||||
"legacy completed agents are hidden by default"
|
||||
);
|
||||
|
||||
let listed_archived = manager.list_filtered(true);
|
||||
let legacy = listed_archived
|
||||
.iter()
|
||||
.find(|s| s.agent_id == "legacy")
|
||||
.unwrap();
|
||||
assert!(legacy.from_prior_session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_round_trip_preserves_session_boot_id() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let state_path = dir.path().join(SUBAGENT_STATE_FILE);
|
||||
|
||||
let original_boot;
|
||||
{
|
||||
let mut writer =
|
||||
SubAgentManager::new(dir.path().to_path_buf(), 2).with_state_path(state_path.clone());
|
||||
original_boot = writer.session_boot_id().to_string();
|
||||
insert_prior_session_agent(
|
||||
&mut writer,
|
||||
"agent_persist",
|
||||
SubAgentStatus::Completed,
|
||||
&original_boot,
|
||||
);
|
||||
writer
|
||||
.persist_state()
|
||||
.expect("persist round-trip should write");
|
||||
}
|
||||
|
||||
// A fresh manager comes up with a *different* boot id and reloads
|
||||
// the persisted state; the agent should now be classified prior.
|
||||
let mut reader =
|
||||
SubAgentManager::new(dir.path().to_path_buf(), 2).with_state_path(state_path.clone());
|
||||
reader.load_state().expect("reload should succeed");
|
||||
assert_ne!(reader.session_boot_id(), original_boot);
|
||||
|
||||
let listed_default = reader.list_filtered(false);
|
||||
assert!(
|
||||
!listed_default.iter().any(|s| s.agent_id == "agent_persist"),
|
||||
"completed prior-session agent hidden after reload: {listed_default:?}"
|
||||
);
|
||||
let listed_all = reader.list_filtered(true);
|
||||
let snap = listed_all
|
||||
.iter()
|
||||
.find(|s| s.agent_id == "agent_persist")
|
||||
.unwrap();
|
||||
assert!(snap.from_prior_session);
|
||||
}
|
||||
|
||||
@@ -777,6 +777,7 @@ fn make_subagent(
|
||||
result: None,
|
||||
steps_taken: 0,
|
||||
duration_ms: 0,
|
||||
from_prior_session: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user