@team-agent/installer 0.5.51 → 0.5.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +18 -2
  4. package/crates/team-agent/src/cli/emit.rs +17 -0
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/named_address.rs +35 -67
  7. package/crates/team-agent/src/cli/spec.rs +3 -0
  8. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +11 -0
  10. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  11. package/crates/team-agent/src/kill_audit.rs +67 -0
  12. package/crates/team-agent/src/leader/lease.rs +526 -73
  13. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  14. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  15. package/crates/team-agent/src/leader/start.rs +144 -23
  16. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  17. package/crates/team-agent/src/leader/types.rs +6 -0
  18. package/crates/team-agent/src/lib.rs +1 -0
  19. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  20. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +12 -1
  21. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  24. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  25. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  26. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  27. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  29. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
  30. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  32. package/crates/team-agent/src/lifecycle/restart/agent.rs +20 -4
  33. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  34. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +113 -4
  35. package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
  36. package/crates/team-agent/src/lifecycle/restart.rs +25 -1
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +441 -12
  38. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -2
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  41. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  42. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  43. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  44. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  45. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  46. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  47. package/crates/team-agent/src/mcp_server/tools.rs +18 -4
  48. package/crates/team-agent/src/mcp_server/types.rs +3 -0
  49. package/crates/team-agent/src/mcp_server/wire.rs +30 -7
  50. package/crates/team-agent/src/messaging/delivery.rs +126 -147
  51. package/crates/team-agent/src/messaging/leader_channel.rs +198 -0
  52. package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
  53. package/crates/team-agent/src/messaging/mod.rs +5 -0
  54. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  55. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  56. package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
  57. package/crates/team-agent/src/provider/adapter.rs +54 -13
  58. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  59. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  60. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  61. package/crates/team-agent/src/provider/session/capture.rs +321 -52
  62. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  63. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  64. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  65. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  66. package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
  67. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  68. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  69. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  70. package/crates/team-agent/src/provider/tests.rs +1 -0
  71. package/crates/team-agent/src/state/persist.rs +282 -20
  72. package/crates/team-agent/src/state/projection.rs +245 -30
  73. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  74. package/crates/team-agent/src/state/repository.rs +36 -12
  75. package/crates/team-agent/src/state/selector.rs +9 -18
  76. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  77. package/crates/team-agent/src/tmux_backend.rs +29 -2
  78. package/package.json +4 -4
  79. package/skills/team-agent/SKILL.md +8 -3
@@ -18,13 +18,15 @@ pub fn start_agent(
18
18
  team: Option<&str>,
19
19
  ) -> Result<StartAgentOutcome, LifecycleError> {
20
20
  let paths = lifecycle_paths(workspace, team)?;
21
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
22
+ let team = canonical_team.as_deref();
21
23
  let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
22
24
  workspace: &paths.run_workspace,
23
25
  operation: "start-agent",
24
26
  team,
25
27
  agent_id: Some(agent_id),
26
28
  })?;
27
- let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
29
+ let transport = paths.tmux_backend()?;
28
30
  start_agent_at_paths(
29
31
  &paths.run_workspace,
30
32
  &paths.spec_workspace,
@@ -54,9 +56,12 @@ pub fn start_agent_with_transport(
54
56
  Err(_) if team.is_none() => LifecyclePaths {
55
57
  run_workspace: workspace.to_path_buf(),
56
58
  spec_workspace: workspace.to_path_buf(),
59
+ selected: None,
57
60
  },
58
61
  Err(error) => return Err(error),
59
62
  };
63
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
64
+ let team = canonical_team.as_deref();
60
65
  let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
61
66
  workspace: &paths.run_workspace,
62
67
  operation: "start-agent",
@@ -778,13 +783,15 @@ pub fn stop_agent(
778
783
  team: Option<&str>,
779
784
  ) -> Result<StopAgentReport, LifecycleError> {
780
785
  let paths = lifecycle_paths(workspace, team)?;
786
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
787
+ let team = canonical_team.as_deref();
781
788
  let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
782
789
  workspace: &paths.run_workspace,
783
790
  operation: "stop-agent",
784
791
  team,
785
792
  agent_id: Some(agent_id),
786
793
  })?;
787
- let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
794
+ let transport = paths.tmux_backend()?;
788
795
  stop_agent_at_paths(
789
796
  &paths.run_workspace,
790
797
  &paths.spec_workspace,
@@ -801,6 +808,8 @@ pub fn stop_agent_with_transport(
801
808
  transport: &dyn crate::transport::Transport,
802
809
  ) -> Result<StopAgentReport, LifecycleError> {
803
810
  let paths = lifecycle_paths(workspace, team)?;
811
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
812
+ let team = canonical_team.as_deref();
804
813
  let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
805
814
  workspace: &paths.run_workspace,
806
815
  operation: "stop-agent",
@@ -840,9 +849,12 @@ pub(crate) fn start_agent_at_paths_for_recovery(
840
849
  Err(_) if team.is_none() => LifecyclePaths {
841
850
  run_workspace: workspace.to_path_buf(),
842
851
  spec_workspace: workspace.to_path_buf(),
852
+ selected: None,
843
853
  },
844
854
  Err(error) => return Err(RecoveryError::Lifecycle(error.to_string())),
845
855
  };
856
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
857
+ let team = canonical_team.as_deref();
846
858
  // Best-effort stop: if the live pane still exists we tear it down so the
847
859
  // subsequent start creates a fresh provider process instead of a Noop.
848
860
  // Stop errors are absorbed into the start attempt result — the important
@@ -1137,7 +1149,7 @@ fn mark_agent_started(
1137
1149
  }
1138
1150
  agent.insert(
1139
1151
  "spawned_at".to_string(),
1140
- serde_json::json!(chrono::Utc::now().to_rfc3339()),
1152
+ serde_json::json!(spawn.spawned_at.as_str()),
1141
1153
  );
1142
1154
  agent.insert(
1143
1155
  "spawn_cwd".to_string(),
@@ -1217,13 +1229,15 @@ pub fn reset_agent(
1217
1229
  });
1218
1230
  }
1219
1231
  let paths = lifecycle_paths(workspace, team)?;
1232
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
1233
+ let team = canonical_team.as_deref();
1220
1234
  let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
1221
1235
  workspace: &paths.run_workspace,
1222
1236
  operation: "reset-agent",
1223
1237
  team,
1224
1238
  agent_id: Some(agent_id),
1225
1239
  })?;
1226
- let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
1240
+ let transport = paths.tmux_backend()?;
1227
1241
  reset_agent_at_paths(
1228
1242
  &paths.run_workspace,
1229
1243
  &paths.spec_workspace,
@@ -1249,6 +1263,8 @@ pub fn reset_agent_with_transport(
1249
1263
  });
1250
1264
  }
1251
1265
  let paths = lifecycle_paths(workspace, team)?;
1266
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
1267
+ let team = canonical_team.as_deref();
1252
1268
  let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
1253
1269
  workspace: &paths.run_workspace,
1254
1270
  operation: "reset-agent",
@@ -2,6 +2,7 @@ use super::*;
2
2
 
3
3
  pub(super) struct SpawnedAgentWindow {
4
4
  pub spawn: crate::transport::SpawnResult,
5
+ pub spawned_at: String,
5
6
  pub plan: crate::provider::CommandPlan,
6
7
  pub profile_launch: crate::provider::ProviderProfileLaunch,
7
8
  pub layout_placement: Option<crate::lifecycle::launch::LayoutPlacement>,
@@ -327,6 +328,14 @@ pub(super) fn spawn_agent_window(
327
328
  );
328
329
  crate::lifecycle::launch::apply_profile_launch_env(&mut env, &profile_launch);
329
330
  crate::lifecycle::launch::apply_mcp_auto_approval_env(&mut env, safety);
331
+ if provider == crate::provider::Provider::Copilot {
332
+ crate::lifecycle::launch::apply_copilot_instructions_overlay(
333
+ workspace,
334
+ agent_id.as_str(),
335
+ &system_prompt,
336
+ &mut env,
337
+ )?;
338
+ }
330
339
  // 0.3.28 Step 3: per Python parity, worker spawn cwd is ALWAYS `workspace`.
331
340
  // The persisted-state `agent.spawn_cwd` override is ignored (it was a
332
341
  // Rust-only extension that drifted to `.team/runtime/<team_key>/` after
@@ -359,6 +368,7 @@ pub(super) fn spawn_agent_window(
359
368
  // failure can now be diagnosed from events.jsonl — the recorded
360
369
  // expected_session_id == state._pending_session_id (after
361
370
  // mark_agent_started persists the same plan tuple).
371
+ let spawned_at = crate::lifecycle::launch::spawn_timestamp();
362
372
  {
363
373
  let session_id_in_argv = plan
364
374
  .argv
@@ -385,6 +395,7 @@ pub(super) fn spawn_agent_window(
385
395
  "env_unset": env_unset,
386
396
  "tmux_start_mode": tmux_start_mode_pre_spawn,
387
397
  "spawn_epoch": spawn_epoch,
398
+ "spawned_at": spawned_at.as_str(),
388
399
  "source": "restart",
389
400
  "tmux_endpoint": tmux_endpoint,
390
401
  "tmux_endpoint_source": tmux_endpoint_source.unwrap_or("transport"),
@@ -521,6 +532,7 @@ pub(super) fn spawn_agent_window(
521
532
  }
522
533
  Ok(SpawnedAgentWindow {
523
534
  spawn,
535
+ spawned_at,
524
536
  plan,
525
537
  profile_launch,
526
538
  layout_placement: layout_placement.cloned(),
@@ -449,6 +449,15 @@ fn restart_with_selected_team_and_transport(
449
449
  moved leader to dedicated session)",
450
450
  session_name.as_str()
451
451
  );
452
+ let targets = vec![session_name.as_str().to_string()];
453
+ crate::kill_audit::pre_kill_audit(
454
+ &crate::event_log::EventLog::new(&selected.run_workspace),
455
+ transport,
456
+ "restart.stale_worker_session",
457
+ crate::kill_audit::KILL_SESSION,
458
+ &targets,
459
+ )
460
+ .map_err(|error| LifecycleError::Transport(format!("pre-kill audit failed: {error}")))?;
452
461
  transport
453
462
  .kill_session(&session_name)
454
463
  .map_err(|e| LifecycleError::Transport(e.to_string()))?;
@@ -1984,7 +1993,7 @@ fn mark_agent_respawned(
1984
1993
  }
1985
1994
  agent.insert(
1986
1995
  "spawned_at".to_string(),
1987
- serde_json::json!(chrono::Utc::now().to_rfc3339()),
1996
+ serde_json::json!(spawn.spawned_at.as_str()),
1988
1997
  );
1989
1998
  agent.insert(
1990
1999
  "spawn_cwd".to_string(),
@@ -2462,6 +2471,7 @@ impl Clone for ParallelSpawnResult {
2462
2471
  Self {
2463
2472
  spawn: SpawnedAgentWindow {
2464
2473
  spawn: self.spawn.spawn.clone(),
2474
+ spawned_at: self.spawn.spawned_at.clone(),
2465
2475
  plan: self.spawn.plan.clone(),
2466
2476
  profile_launch: self.spawn.profile_launch.clone(),
2467
2477
  layout_placement: self.spawn.layout_placement.clone(),
@@ -3119,10 +3129,16 @@ fn restart_candidate_from_state(
3119
3129
  .filter(|s| !s.is_empty())
3120
3130
  .map(SessionName::new)
3121
3131
  .unwrap_or_else(|| SessionName::new(team_name));
3132
+ let retired = retired_agent_ids(state);
3122
3133
  let mut agents = state
3123
3134
  .get("agents")
3124
3135
  .and_then(serde_json::Value::as_object)
3125
- .map(|map| map.keys().map(AgentId::new).collect::<Vec<_>>())
3136
+ .map(|map| {
3137
+ map.keys()
3138
+ .filter(|agent_id| !retired.contains(agent_id.as_str()))
3139
+ .map(AgentId::new)
3140
+ .collect::<Vec<_>>()
3141
+ })
3126
3142
  .unwrap_or_default();
3127
3143
  agents.sort_by(|a, b| a.as_str().cmp(b.as_str()));
3128
3144
  RestartCandidate {
@@ -3135,6 +3151,19 @@ fn restart_candidate_from_state(
3135
3151
  }
3136
3152
  }
3137
3153
 
3154
+ fn retired_agent_ids(state: &serde_json::Value) -> std::collections::BTreeSet<String> {
3155
+ state
3156
+ .get("agent_lifecycle")
3157
+ .and_then(serde_json::Value::as_object)
3158
+ .into_iter()
3159
+ .flat_map(|lifecycle| lifecycle.iter())
3160
+ .filter(|(_, entry)| {
3161
+ entry.get("state").and_then(serde_json::Value::as_str) == Some("retired")
3162
+ })
3163
+ .map(|(agent_id, _)| agent_id.clone())
3164
+ .collect()
3165
+ }
3166
+
3138
3167
  /// E5 task#3 / RC-A6a:每次 restart 都以**角色定义**(team_dir 的 TEAM.md+agents/*.md)
3139
3168
  /// compile_team 重建 runtime spec(覆盖),保留运行期 override(session_name 必须延续,
3140
3169
  /// 否则 tmux session 对不上)。写到 .team/runtime/<team_key>/team.spec.yaml。
@@ -3204,6 +3233,7 @@ fn rebuild_runtime_spec_from_roles(
3204
3233
  // 静态 team_dir/agents/*.md + state 记录的 dynamic role source。缺文件 fail-closed
3205
3234
  // 三行式,不静默 prune 已 live 的 helper(persist SaveConflict 保护继续生效)。
3206
3235
  merge_state_dynamic_role_files(&mut spec, run_workspace, &team_dir, team_key, state)?;
3236
+ filter_retired_agents_from_spec(&mut spec, &retired_agent_ids(state));
3207
3237
  // 写 runtime spec(覆盖,原子 tmp+rename;Bug2)。
3208
3238
  let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
3209
3239
  crate::lifecycle::launch::write_spec_atomic(&spec_path, &spec)?;
@@ -3216,6 +3246,15 @@ fn rebuild_runtime_spec_from_roles(
3216
3246
  Ok(spec)
3217
3247
  }
3218
3248
 
3249
+ fn filter_retired_agents_from_spec(
3250
+ spec: &mut YamlValue,
3251
+ retired: &std::collections::BTreeSet<String>,
3252
+ ) {
3253
+ for agent_id in retired {
3254
+ *spec = super::remove::spec_without_agent(spec, &AgentId::new(agent_id));
3255
+ }
3256
+ }
3257
+
3219
3258
  /// 0.5.30 (`add-agent-restart-saveconflict-locate.md` §4/§11): 把 add-agent
3220
3259
  /// 写入 `state.agents.<id>.dynamic_role_file` 的动态 role 文档合并回 restart
3221
3260
  /// 重建 spec。规则:
@@ -3418,6 +3457,76 @@ mod tests {
3418
3457
  TransportError,
3419
3458
  };
3420
3459
 
3460
+ #[test]
3461
+ fn retired_filter_clears_every_spec_reference_via_remove_primitive() {
3462
+ let mut spec = crate::model::yaml::loads(
3463
+ r#"
3464
+ name: retirement-filter
3465
+ agents:
3466
+ - id: gone
3467
+ - id: keep
3468
+ runtime:
3469
+ startup_order:
3470
+ - gone
3471
+ - keep
3472
+ routing:
3473
+ default_assignee: gone
3474
+ rules:
3475
+ - match: gone-task
3476
+ assign_to: gone
3477
+ - match: keep-task
3478
+ assign_to: keep
3479
+ tasks:
3480
+ - title: gone-task
3481
+ assignee: gone
3482
+ - title: keep-task
3483
+ assignee: keep
3484
+ "#,
3485
+ )
3486
+ .expect("fixture spec");
3487
+ let retired = std::collections::BTreeSet::from(["gone".to_string()]);
3488
+
3489
+ filter_retired_agents_from_spec(&mut spec, &retired);
3490
+
3491
+ let agents = spec
3492
+ .get("agents")
3493
+ .and_then(YamlValue::as_list)
3494
+ .expect("agents");
3495
+ assert!(agents.iter().all(|agent| {
3496
+ agent.get("id").and_then(YamlValue::as_str) != Some("gone")
3497
+ }));
3498
+ let startup_order = spec
3499
+ .get("runtime")
3500
+ .and_then(|runtime| runtime.get("startup_order"))
3501
+ .and_then(YamlValue::as_list)
3502
+ .expect("startup_order");
3503
+ assert!(startup_order
3504
+ .iter()
3505
+ .all(|agent_id| agent_id.as_str() != Some("gone")));
3506
+ let routing = spec.get("routing").expect("routing");
3507
+ assert_ne!(
3508
+ routing
3509
+ .get("default_assignee")
3510
+ .and_then(YamlValue::as_str),
3511
+ Some("gone"),
3512
+ "spec_default_assignee must not return a retired agent"
3513
+ );
3514
+ let rules = routing
3515
+ .get("rules")
3516
+ .and_then(YamlValue::as_list)
3517
+ .expect("routing rules");
3518
+ assert!(rules.iter().all(|rule| {
3519
+ rule.get("assign_to").and_then(YamlValue::as_str) != Some("gone")
3520
+ }));
3521
+ let tasks = spec
3522
+ .get("tasks")
3523
+ .and_then(YamlValue::as_list)
3524
+ .expect("tasks");
3525
+ assert!(tasks.iter().all(|task| {
3526
+ task.get("assignee").and_then(YamlValue::as_str) != Some("gone")
3527
+ }));
3528
+ }
3529
+
3421
3530
  #[test]
3422
3531
  fn resume_decision_event_uses_central_scrub_and_preserves_required_failure() {
3423
3532
  let marker = "synthetic-resume-decision-marker";
@@ -3590,6 +3699,7 @@ mod tests {
3590
3699
  }
3591
3700
  }
3592
3701
  });
3702
+ let before = chrono::Utc::now();
3593
3703
  let spawn = SpawnedAgentWindow {
3594
3704
  spawn: SpawnResult {
3595
3705
  pane_id: PaneId::new("%new"),
@@ -3597,14 +3707,13 @@ mod tests {
3597
3707
  window: WindowName::new("w1"),
3598
3708
  child_pid: Some(2020),
3599
3709
  },
3710
+ spawned_at: chrono::Utc::now().to_rfc3339(),
3600
3711
  plan: crate::provider::CommandPlan::argv_only(vec!["codex".to_string()]),
3601
3712
  profile_launch: crate::provider::ProviderProfileLaunch::default(),
3602
3713
  layout_placement: None,
3603
3714
  spawn_cwd: std::path::PathBuf::from("/tmp/team-epoch"),
3604
3715
  owner_team_id: None,
3605
3716
  };
3606
- let before = chrono::Utc::now();
3607
-
3608
3717
  mark_agent_respawned(
3609
3718
  &mut state,
3610
3719
  &AgentId::new("w1"),
@@ -15,13 +15,15 @@ pub fn remove_agent(
15
15
  team: Option<&str>,
16
16
  ) -> Result<RemoveAgentOutcome, LifecycleError> {
17
17
  let paths = lifecycle_paths(workspace, team)?;
18
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
19
+ let team = canonical_team.as_deref();
18
20
  let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
19
21
  workspace: &paths.run_workspace,
20
22
  operation: "remove-agent",
21
23
  team,
22
24
  agent_id: Some(agent_id),
23
25
  })?;
24
- let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
26
+ let transport = paths.tmux_backend()?;
25
27
  remove_agent_at_paths(
26
28
  &paths.run_workspace,
27
29
  &paths.spec_workspace,
@@ -42,6 +44,8 @@ pub fn remove_agent_with_transport(
42
44
  transport: &dyn crate::transport::Transport,
43
45
  ) -> Result<RemoveAgentOutcome, LifecycleError> {
44
46
  let paths = lifecycle_paths(workspace, team)?;
47
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
48
+ let team = canonical_team.as_deref();
45
49
  let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
46
50
  workspace: &paths.run_workspace,
47
51
  operation: "remove-agent",
@@ -68,6 +72,8 @@ pub(crate) fn remove_agent_with_transport_locked(
68
72
  transport: &dyn crate::transport::Transport,
69
73
  ) -> Result<RemoveAgentOutcome, LifecycleError> {
70
74
  let paths = lifecycle_paths(workspace, team)?;
75
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
76
+ let team = canonical_team.as_deref();
71
77
  remove_agent_at_paths(
72
78
  &paths.run_workspace,
73
79
  &paths.spec_workspace,
@@ -94,6 +100,8 @@ impl ForceRecreateSnapshot {
94
100
  transport: &dyn crate::transport::Transport,
95
101
  ) -> Result<Self, LifecycleError> {
96
102
  let paths = lifecycle_paths(workspace, team)?;
103
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
104
+ let team = canonical_team.as_deref();
97
105
  let seat = resolve_seat(
98
106
  &paths.run_workspace,
99
107
  &paths.spec_workspace,
@@ -215,7 +223,9 @@ pub fn remove_agent_flag_requirements(
215
223
  team: Option<&str>,
216
224
  ) -> Result<RemoveAgentFlagRequirements, LifecycleError> {
217
225
  let paths = lifecycle_paths(workspace, team)?;
218
- let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
226
+ let canonical_team = paths.canonical_team(team).map(str::to_string);
227
+ let team = canonical_team.as_deref();
228
+ let transport = paths.tmux_backend()?;
219
229
  Ok(remove_agent_preflight(
220
230
  &paths.run_workspace,
221
231
  &paths.spec_workspace,
@@ -564,14 +574,16 @@ fn remove_agent_inner(
564
574
  // (team projection) — NOT a raw save, so other teams in a multi-team workspace are preserved.
565
575
  let mut removed_state = working_state;
566
576
  remove_agent_from_state(&mut removed_state, agent_id)?;
567
- crate::state::repository::StateRepository::new(paths.run_workspace).save(
568
- crate::state::repository::StateWriteIntent::RemoveAgent {
569
- team_key,
570
- agent_id: agent_id.as_str(),
571
- },
572
- &removed_state,
573
- )
574
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
577
+ mark_agent_retired_in_state(&mut removed_state, agent_id)?;
578
+ crate::state::repository::StateRepository::new(paths.run_workspace)
579
+ .save(
580
+ crate::state::repository::StateWriteIntent::RemoveAgent {
581
+ team_key,
582
+ agent_id: agent_id.as_str(),
583
+ },
584
+ &removed_state,
585
+ )
586
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
575
587
  cleared_locations.push(serde_json::json!("state.json:agents"));
576
588
  write_remove_step_event(
577
589
  paths.run_workspace,
@@ -783,6 +795,57 @@ fn remove_agent_from_state(
783
795
  }
784
796
  }
785
797
 
798
+ pub(crate) fn mark_agent_retired_in_state(
799
+ state: &mut serde_json::Value,
800
+ agent_id: &AgentId,
801
+ ) -> Result<(), LifecycleError> {
802
+ let root = state.as_object_mut().ok_or_else(|| {
803
+ LifecycleError::StatePersist("runtime state root is not an object".to_string())
804
+ })?;
805
+ let lifecycle = root
806
+ .entry("agent_lifecycle".to_string())
807
+ .or_insert_with(|| serde_json::json!({}));
808
+ let lifecycle = lifecycle.as_object_mut().ok_or_else(|| {
809
+ LifecycleError::StatePersist("runtime state agent_lifecycle is not an object".to_string())
810
+ })?;
811
+ let entry = lifecycle
812
+ .entry(agent_id.as_str().to_string())
813
+ .or_insert_with(|| serde_json::json!({}));
814
+ if entry.get("state").and_then(serde_json::Value::as_str) == Some("retired") {
815
+ return Ok(());
816
+ }
817
+ let entry = entry.as_object_mut().ok_or_else(|| {
818
+ LifecycleError::StatePersist(format!(
819
+ "runtime state agent_lifecycle.{} is not an object",
820
+ agent_id.as_str()
821
+ ))
822
+ })?;
823
+ entry.insert("state".to_string(), serde_json::json!("retired"));
824
+ entry.insert(
825
+ "changed_at".to_string(),
826
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
827
+ );
828
+ entry.insert("reason".to_string(), serde_json::json!("remove-agent"));
829
+ Ok(())
830
+ }
831
+
832
+ pub(crate) fn clear_agent_retirement_in_state(state: &mut serde_json::Value, agent_id: &AgentId) {
833
+ let Some(lifecycle) = state
834
+ .get_mut("agent_lifecycle")
835
+ .and_then(serde_json::Value::as_object_mut)
836
+ else {
837
+ return;
838
+ };
839
+ let is_retired = lifecycle
840
+ .get(agent_id.as_str())
841
+ .and_then(|entry| entry.get("state"))
842
+ .and_then(serde_json::Value::as_str)
843
+ == Some("retired");
844
+ if is_retired {
845
+ lifecycle.remove(agent_id.as_str());
846
+ }
847
+ }
848
+
786
849
  /// Build the persisted spec after removing one worker. Besides deleting the worker and startup entry,
787
850
  /// prune routing references that would otherwise point at the removed worker.
788
851
  pub(crate) fn spec_without_agent(spec: &YamlValue, agent_id: &AgentId) -> YamlValue {
@@ -16,6 +16,29 @@ use super::*;
16
16
  struct LifecyclePaths {
17
17
  run_workspace: std::path::PathBuf,
18
18
  spec_workspace: std::path::PathBuf,
19
+ selected: Option<crate::state::selector::SelectedTeam>,
20
+ }
21
+
22
+ impl LifecyclePaths {
23
+ fn canonical_team<'a>(&'a self, requested: Option<&'a str>) -> Option<&'a str> {
24
+ self.selected
25
+ .as_ref()
26
+ .map(|selected| selected.team_key.as_str())
27
+ .or(requested)
28
+ }
29
+
30
+ fn tmux_backend(&self) -> Result<crate::tmux_backend::TmuxBackend, LifecycleError> {
31
+ match self.selected.as_ref() {
32
+ Some(selected) => common::lifecycle_worker_tmux_backend_selection_for_state(
33
+ &selected.run_workspace,
34
+ &selected.state,
35
+ )
36
+ .map(|selection| selection.backend),
37
+ None => Ok(crate::tmux_backend::TmuxBackend::for_workspace(
38
+ &self.run_workspace,
39
+ )),
40
+ }
41
+ }
19
42
  }
20
43
 
21
44
  struct LifecyclePathRefs<'a> {
@@ -115,8 +138,9 @@ fn lifecycle_paths(workspace: &Path, team: Option<&str>) -> Result<LifecyclePath
115
138
  LifecycleError::TeamSelect("active team spec workspace not found".to_string())
116
139
  })?;
117
140
  Ok(LifecyclePaths {
118
- run_workspace: selected.run_workspace,
141
+ run_workspace: selected.run_workspace.clone(),
119
142
  spec_workspace,
143
+ selected: Some(selected),
120
144
  })
121
145
  }
122
146