@team-agent/installer 0.5.51 → 0.5.52

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 (27) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/named_address.rs +35 -67
  4. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  5. package/crates/team-agent/src/leader/lease.rs +202 -16
  6. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +7 -1
  7. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
  8. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
  9. package/crates/team-agent/src/lifecycle/restart/agent.rs +19 -3
  10. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +100 -1
  11. package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
  12. package/crates/team-agent/src/lifecycle/restart.rs +25 -1
  13. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +331 -0
  14. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +12 -0
  15. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  16. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  17. package/crates/team-agent/src/messaging/delivery.rs +126 -147
  18. package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
  19. package/crates/team-agent/src/messaging/mod.rs +5 -0
  20. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  21. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  22. package/crates/team-agent/src/state/persist.rs +282 -20
  23. package/crates/team-agent/src/state/projection.rs +245 -30
  24. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  25. package/crates/team-agent/src/state/repository.rs +36 -12
  26. package/crates/team-agent/src/state/selector.rs +9 -18
  27. package/package.json +4 -4
@@ -3119,10 +3119,16 @@ fn restart_candidate_from_state(
3119
3119
  .filter(|s| !s.is_empty())
3120
3120
  .map(SessionName::new)
3121
3121
  .unwrap_or_else(|| SessionName::new(team_name));
3122
+ let retired = retired_agent_ids(state);
3122
3123
  let mut agents = state
3123
3124
  .get("agents")
3124
3125
  .and_then(serde_json::Value::as_object)
3125
- .map(|map| map.keys().map(AgentId::new).collect::<Vec<_>>())
3126
+ .map(|map| {
3127
+ map.keys()
3128
+ .filter(|agent_id| !retired.contains(agent_id.as_str()))
3129
+ .map(AgentId::new)
3130
+ .collect::<Vec<_>>()
3131
+ })
3126
3132
  .unwrap_or_default();
3127
3133
  agents.sort_by(|a, b| a.as_str().cmp(b.as_str()));
3128
3134
  RestartCandidate {
@@ -3135,6 +3141,19 @@ fn restart_candidate_from_state(
3135
3141
  }
3136
3142
  }
3137
3143
 
3144
+ fn retired_agent_ids(state: &serde_json::Value) -> std::collections::BTreeSet<String> {
3145
+ state
3146
+ .get("agent_lifecycle")
3147
+ .and_then(serde_json::Value::as_object)
3148
+ .into_iter()
3149
+ .flat_map(|lifecycle| lifecycle.iter())
3150
+ .filter(|(_, entry)| {
3151
+ entry.get("state").and_then(serde_json::Value::as_str) == Some("retired")
3152
+ })
3153
+ .map(|(agent_id, _)| agent_id.clone())
3154
+ .collect()
3155
+ }
3156
+
3138
3157
  /// E5 task#3 / RC-A6a:每次 restart 都以**角色定义**(team_dir 的 TEAM.md+agents/*.md)
3139
3158
  /// compile_team 重建 runtime spec(覆盖),保留运行期 override(session_name 必须延续,
3140
3159
  /// 否则 tmux session 对不上)。写到 .team/runtime/<team_key>/team.spec.yaml。
@@ -3204,6 +3223,7 @@ fn rebuild_runtime_spec_from_roles(
3204
3223
  // 静态 team_dir/agents/*.md + state 记录的 dynamic role source。缺文件 fail-closed
3205
3224
  // 三行式,不静默 prune 已 live 的 helper(persist SaveConflict 保护继续生效)。
3206
3225
  merge_state_dynamic_role_files(&mut spec, run_workspace, &team_dir, team_key, state)?;
3226
+ filter_retired_agents_from_spec(&mut spec, &retired_agent_ids(state));
3207
3227
  // 写 runtime spec(覆盖,原子 tmp+rename;Bug2)。
3208
3228
  let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
3209
3229
  crate::lifecycle::launch::write_spec_atomic(&spec_path, &spec)?;
@@ -3216,6 +3236,15 @@ fn rebuild_runtime_spec_from_roles(
3216
3236
  Ok(spec)
3217
3237
  }
3218
3238
 
3239
+ fn filter_retired_agents_from_spec(
3240
+ spec: &mut YamlValue,
3241
+ retired: &std::collections::BTreeSet<String>,
3242
+ ) {
3243
+ for agent_id in retired {
3244
+ *spec = super::remove::spec_without_agent(spec, &AgentId::new(agent_id));
3245
+ }
3246
+ }
3247
+
3219
3248
  /// 0.5.30 (`add-agent-restart-saveconflict-locate.md` §4/§11): 把 add-agent
3220
3249
  /// 写入 `state.agents.<id>.dynamic_role_file` 的动态 role 文档合并回 restart
3221
3250
  /// 重建 spec。规则:
@@ -3418,6 +3447,76 @@ mod tests {
3418
3447
  TransportError,
3419
3448
  };
3420
3449
 
3450
+ #[test]
3451
+ fn retired_filter_clears_every_spec_reference_via_remove_primitive() {
3452
+ let mut spec = crate::model::yaml::loads(
3453
+ r#"
3454
+ name: retirement-filter
3455
+ agents:
3456
+ - id: gone
3457
+ - id: keep
3458
+ runtime:
3459
+ startup_order:
3460
+ - gone
3461
+ - keep
3462
+ routing:
3463
+ default_assignee: gone
3464
+ rules:
3465
+ - match: gone-task
3466
+ assign_to: gone
3467
+ - match: keep-task
3468
+ assign_to: keep
3469
+ tasks:
3470
+ - title: gone-task
3471
+ assignee: gone
3472
+ - title: keep-task
3473
+ assignee: keep
3474
+ "#,
3475
+ )
3476
+ .expect("fixture spec");
3477
+ let retired = std::collections::BTreeSet::from(["gone".to_string()]);
3478
+
3479
+ filter_retired_agents_from_spec(&mut spec, &retired);
3480
+
3481
+ let agents = spec
3482
+ .get("agents")
3483
+ .and_then(YamlValue::as_list)
3484
+ .expect("agents");
3485
+ assert!(agents.iter().all(|agent| {
3486
+ agent.get("id").and_then(YamlValue::as_str) != Some("gone")
3487
+ }));
3488
+ let startup_order = spec
3489
+ .get("runtime")
3490
+ .and_then(|runtime| runtime.get("startup_order"))
3491
+ .and_then(YamlValue::as_list)
3492
+ .expect("startup_order");
3493
+ assert!(startup_order
3494
+ .iter()
3495
+ .all(|agent_id| agent_id.as_str() != Some("gone")));
3496
+ let routing = spec.get("routing").expect("routing");
3497
+ assert_ne!(
3498
+ routing
3499
+ .get("default_assignee")
3500
+ .and_then(YamlValue::as_str),
3501
+ Some("gone"),
3502
+ "spec_default_assignee must not return a retired agent"
3503
+ );
3504
+ let rules = routing
3505
+ .get("rules")
3506
+ .and_then(YamlValue::as_list)
3507
+ .expect("routing rules");
3508
+ assert!(rules.iter().all(|rule| {
3509
+ rule.get("assign_to").and_then(YamlValue::as_str) != Some("gone")
3510
+ }));
3511
+ let tasks = spec
3512
+ .get("tasks")
3513
+ .and_then(YamlValue::as_list)
3514
+ .expect("tasks");
3515
+ assert!(tasks.iter().all(|task| {
3516
+ task.get("assignee").and_then(YamlValue::as_str) != Some("gone")
3517
+ }));
3518
+ }
3519
+
3421
3520
  #[test]
3422
3521
  fn resume_decision_event_uses_central_scrub_and_preserves_required_failure() {
3423
3522
  let marker = "synthetic-resume-decision-marker";
@@ -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
 
@@ -434,6 +434,319 @@ fn force_remove_reconciles_all_seat_quadrants_without_cross_team_same_name_kill(
434
434
  }
435
435
  }
436
436
 
437
+ #[test]
438
+ fn remove_persists_retired_tombstone_and_explicit_readd_clears_it() {
439
+ let ws = lanea_ws_agents(json!({
440
+ "alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
441
+ "bravo": { "status": "running", "provider": "codex", "window": "bravo" }
442
+ }));
443
+ let mut seeded = crate::state::persist::load_runtime_state(&ws).unwrap();
444
+ seeded["team_key"] = json!("laneateam");
445
+ seeded["active_team_key"] = json!("laneateam");
446
+ let mut nested = crate::state::projection::compact_team_state(&seeded);
447
+ nested["status"] = json!("alive");
448
+ seeded["teams"] = json!({
449
+ "laneateam": nested,
450
+ "sibling": {
451
+ "team_key": "sibling",
452
+ "status": "shutdown",
453
+ "agents": {},
454
+ "agent_lifecycle": {
455
+ "alpha": {
456
+ "state": "retired",
457
+ "changed_at": "2026-07-20T00:00:00Z",
458
+ "reason": "sibling retirement"
459
+ }
460
+ }
461
+ }
462
+ });
463
+ crate::state::persist::save_runtime_state(&ws, &seeded).unwrap();
464
+ let stale_tick = crate::state::projection::select_runtime_state(&ws, Some("laneateam"))
465
+ .expect("capture the coordinator projection from before remove");
466
+ let tx = LaneTransport::new("team-laneateam", &[]);
467
+ let role = ws.join("agents").join("alpha.md");
468
+
469
+ remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &tx).unwrap();
470
+ let removed: serde_json::Value = serde_json::from_str(
471
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
472
+ )
473
+ .unwrap();
474
+ assert_eq!(
475
+ removed["teams"]["laneateam"]["agent_lifecycle"]["alpha"]["state"], "retired",
476
+ "the first successful remove must directly persist the canonical tombstone"
477
+ );
478
+ assert!(
479
+ removed["teams"]["laneateam"]["agents"]
480
+ .get("alpha")
481
+ .is_none(),
482
+ "the first successful remove must directly delete the canonical agent row"
483
+ );
484
+ crate::state::repository::StateRepository::new(&ws)
485
+ .save(
486
+ crate::state::repository::StateWriteIntent::CoordinatorTick {
487
+ team_key: "laneateam",
488
+ },
489
+ &stale_tick,
490
+ )
491
+ .unwrap();
492
+ let after_stale_tick: serde_json::Value = serde_json::from_str(
493
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
494
+ )
495
+ .unwrap();
496
+ assert_eq!(
497
+ after_stale_tick["teams"]["laneateam"]["agent_lifecycle"]["alpha"]["state"], "retired",
498
+ "a stale coordinator save must not erase the first remove's tombstone"
499
+ );
500
+ assert!(
501
+ after_stale_tick["teams"]["laneateam"]["agents"]
502
+ .get("alpha")
503
+ .is_none(),
504
+ "a stale coordinator save must not resurrect the first removed agent"
505
+ );
506
+
507
+ crate::lifecycle::add_agent_with_transport(&ws, &aid("alpha"), &role, false, None, &tx)
508
+ .unwrap();
509
+ let readded = crate::state::persist::load_runtime_state(&ws).unwrap();
510
+ assert!(
511
+ readded["teams"]["laneateam"]["agent_lifecycle"]
512
+ .get("alpha")
513
+ .is_none(),
514
+ "explicit re-add must consume the retired tombstone"
515
+ );
516
+ assert!(readded["teams"]["laneateam"]["agents"]
517
+ .get("alpha")
518
+ .is_some());
519
+ assert_eq!(
520
+ readded["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"], "retired",
521
+ "selected-team re-add must not clear a sibling team's same-id retirement"
522
+ );
523
+ }
524
+
525
+ /// Cross-product RED (verifier, slice2b authority leak): the FAILURE dimension
526
+ /// (a stale writer whose incoming projection predates a sibling retirement) x the
527
+ /// BOUNDARY dimension (a sibling team carrying the SAME agent id). The tracked
528
+ /// `remove_persists_..._readd_clears_it` sibling assertion covers only the
529
+ /// non-stale corner (sibling tombstone already current in the add's input); it
530
+ /// does NOT exercise an incoming sibling projection captured BEFORE the sibling
531
+ /// retirement. topology-authority is keyed by bare `agent_id` with no team
532
+ /// dimension (repository.rs:277-283 drops team_key; persist.rs iterates every
533
+ /// sibling team with the same global id set), so an explicit AddAgent(alpha) in
534
+ /// team A exempts alpha in team B too and a stale incoming resurrects B's alpha /
535
+ /// erases B's retirement tombstone. Red at da11f98c: teamB.alpha lifecycle Null.
536
+ #[test]
537
+ fn add_agent_authority_must_not_cross_team_erase_sibling_same_id_retirement() {
538
+ // Disk latest: team A alive with alpha; team B alpha ABSENT + retired.
539
+ let ws = lanea_ws_agents(json!({
540
+ "alpha": { "status": "running", "provider": "codex", "window": "alpha" },
541
+ "bravo": { "status": "running", "provider": "codex", "window": "bravo" }
542
+ }));
543
+ let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
544
+ latest["team_key"] = json!("laneateam");
545
+ latest["active_team_key"] = json!("laneateam");
546
+ let mut nested_a = crate::state::projection::compact_team_state(&latest);
547
+ nested_a["status"] = json!("alive");
548
+ latest["teams"] = json!({
549
+ "laneateam": nested_a,
550
+ "sibling": {
551
+ "team_key": "sibling",
552
+ "status": "alive",
553
+ "agents": {},
554
+ "agent_lifecycle": {
555
+ "alpha": {
556
+ "state": "retired",
557
+ "changed_at": "2026-07-21T00:00:00Z",
558
+ "reason": "sibling retirement (newer than the stale incoming)"
559
+ }
560
+ }
561
+ }
562
+ });
563
+ crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
564
+
565
+ // Incoming STALE root: captured BEFORE team B retired alpha, so its sibling
566
+ // entry still carries alpha ACTIVE with no tombstone. A concurrent explicit
567
+ // AddAgent(alpha) in team A carries this whole stale multi-team projection.
568
+ let mut incoming_stale = latest.clone();
569
+ incoming_stale["teams"]["sibling"] = json!({
570
+ "team_key": "sibling",
571
+ "status": "alive",
572
+ "agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
573
+ "agent_lifecycle": {}
574
+ });
575
+
576
+ crate::state::repository::StateRepository::new(&ws)
577
+ .save(
578
+ crate::state::repository::StateWriteIntent::AddAgent {
579
+ team_key: "laneateam",
580
+ agent_id: "alpha",
581
+ },
582
+ &incoming_stale,
583
+ )
584
+ .unwrap();
585
+
586
+ let after: serde_json::Value = serde_json::from_str(
587
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
588
+ )
589
+ .unwrap();
590
+ // The AddAgent authority is scoped to team A's alpha ONLY; team B's newer
591
+ // same-id retirement must survive (the stale incoming must not resurrect it).
592
+ assert_eq!(
593
+ after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
594
+ json!("retired"),
595
+ "selected-team AddAgent authority must not cross team boundary by bare agent id: \
596
+ team B's newer same-id retirement was erased by team A's stale re-add"
597
+ );
598
+ assert!(
599
+ after["teams"]["sibling"]["agents"].get("alpha").is_none(),
600
+ "a stale incoming must not resurrect the sibling team's retired same-id agent"
601
+ );
602
+ }
603
+
604
+ /// Identity-source boundary (verifier, slice2b authority fix new adjudication
605
+ /// face, same family as slice1 empty-current): the legacy top-level scope anchor
606
+ /// now prefers `active_team_key`. Verify it does not over-exempt / under-protect
607
+ /// when `active_team_key` is a NON-EMPTY but TERMINAL / mismatched value (points
608
+ /// at a team not present in `teams`). The AddAgent authority is (laneateam,
609
+ /// alpha); with a ghost active_team_key, the top-level filter must resolve NO
610
+ /// exemption for the ghost, so a real retirement anywhere is still protected —
611
+ /// the anchor must not silently widen authority to a bare id under a dangling
612
+ /// active_team_key (that would be the empty-current forgery shape at slice1).
613
+ #[test]
614
+ fn terminal_active_team_key_does_not_widen_topology_authority_to_bare_id() {
615
+ // Disk latest: laneateam alive with alpha; sibling alpha ABSENT + retired.
616
+ let ws = lanea_ws_agents(json!({
617
+ "alpha": { "status": "running", "provider": "codex", "window": "alpha" },
618
+ "bravo": { "status": "running", "provider": "codex", "window": "bravo" }
619
+ }));
620
+ let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
621
+ latest["team_key"] = json!("laneateam");
622
+ latest["active_team_key"] = json!("laneateam");
623
+ let mut nested_a = crate::state::projection::compact_team_state(&latest);
624
+ nested_a["status"] = json!("alive");
625
+ latest["teams"] = json!({
626
+ "laneateam": nested_a,
627
+ "sibling": {
628
+ "team_key": "sibling",
629
+ "status": "alive",
630
+ "agents": {},
631
+ "agent_lifecycle": {
632
+ "alpha": { "state": "retired", "changed_at": "2026-07-21T00:00:00Z", "reason": "sibling" }
633
+ }
634
+ }
635
+ });
636
+ crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
637
+
638
+ // Incoming STALE root whose active_team_key points at a GHOST team not in
639
+ // `teams`, and whose sibling entry is the pre-retire (alpha active) shape.
640
+ let mut incoming_stale = latest.clone();
641
+ incoming_stale["active_team_key"] = json!("ghostteam-does-not-exist");
642
+ incoming_stale["teams"]["sibling"] = json!({
643
+ "team_key": "sibling",
644
+ "status": "alive",
645
+ "agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
646
+ "agent_lifecycle": {}
647
+ });
648
+
649
+ crate::state::repository::StateRepository::new(&ws)
650
+ .save(
651
+ crate::state::repository::StateWriteIntent::AddAgent {
652
+ team_key: "laneateam",
653
+ agent_id: "alpha",
654
+ },
655
+ &incoming_stale,
656
+ )
657
+ .unwrap();
658
+
659
+ let after: serde_json::Value = serde_json::from_str(
660
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
661
+ )
662
+ .unwrap();
663
+ // A dangling active_team_key must not widen the (laneateam, alpha) authority
664
+ // into a bare `alpha` exemption that reaches the sibling: sibling stays
665
+ // retired, not resurrected.
666
+ assert_eq!(
667
+ after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
668
+ json!("retired"),
669
+ "a terminal/ghost active_team_key must not widen topology authority to a bare id \
670
+ and erase the sibling's same-id retirement"
671
+ );
672
+ assert!(
673
+ after["teams"]["sibling"]["agents"].get("alpha").is_none(),
674
+ "a terminal active_team_key must not let a stale incoming resurrect the sibling agent"
675
+ );
676
+ }
677
+
678
+ /// Cross-product cell (verifier, slice2b current-alias fix): the `current`
679
+ /// physical alias dimension x the sibling same-id retirement dimension. The
680
+ /// reset fix routes `team == "current"` authority through `top_level_team`
681
+ /// (active_team_key). This pins that the alias branch does NOT leak that
682
+ /// active-team authority into a REAL sibling that merely shares the agent id:
683
+ /// a `current` alias entry (= the active team's physical projection) plus a
684
+ /// distinct sibling team both carrying `alpha`, sibling retired, stale incoming
685
+ /// resurrecting sibling alpha. The current-alias authority must clear only the
686
+ /// active team's own alpha, never the sibling's newer retirement.
687
+ #[test]
688
+ fn current_alias_authority_does_not_leak_into_real_sibling_same_id_retirement() {
689
+ let ws = lanea_ws_agents(json!({
690
+ "alpha": { "status": "running", "provider": "codex", "window": "alpha" },
691
+ "bravo": { "status": "running", "provider": "codex", "window": "bravo" }
692
+ }));
693
+ let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
694
+ latest["team_key"] = json!("laneateam");
695
+ latest["active_team_key"] = json!("laneateam");
696
+ let mut nested_a = crate::state::projection::compact_team_state(&latest);
697
+ nested_a["status"] = json!("alive");
698
+ // `current` is the physical alias of the active team (laneateam); a REAL
699
+ // sibling team also carries alpha, retired and newer than the stale incoming.
700
+ latest["teams"] = json!({
701
+ "laneateam": nested_a.clone(),
702
+ "current": nested_a,
703
+ "sibling": {
704
+ "team_key": "sibling",
705
+ "status": "alive",
706
+ "agents": {},
707
+ "agent_lifecycle": {
708
+ "alpha": { "state": "retired", "changed_at": "2026-07-21T00:00:00Z", "reason": "sibling" }
709
+ }
710
+ }
711
+ });
712
+ crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
713
+
714
+ let mut incoming_stale = latest.clone();
715
+ incoming_stale["teams"]["sibling"] = json!({
716
+ "team_key": "sibling",
717
+ "status": "alive",
718
+ "agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
719
+ "agent_lifecycle": {}
720
+ });
721
+
722
+ crate::state::repository::StateRepository::new(&ws)
723
+ .save(
724
+ crate::state::repository::StateWriteIntent::AddAgent {
725
+ team_key: "laneateam",
726
+ agent_id: "alpha",
727
+ },
728
+ &incoming_stale,
729
+ )
730
+ .unwrap();
731
+
732
+ let after: serde_json::Value = serde_json::from_str(
733
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
734
+ )
735
+ .unwrap();
736
+ // The current-alias authority is the ACTIVE team's; it must not reach the
737
+ // real sibling that only shares the id.
738
+ assert_eq!(
739
+ after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
740
+ json!("retired"),
741
+ "current-alias authority must not leak into a real sibling's same-id retirement"
742
+ );
743
+ assert!(
744
+ after["teams"]["sibling"]["agents"].get("alpha").is_none(),
745
+ "a stale incoming must not resurrect the real sibling's retired same-id agent \
746
+ via the current-alias authority path"
747
+ );
748
+ }
749
+
437
750
  const CHARLIE_ROLE: &str = "---\nname: charlie\nrole: Charlie Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nCharlie.\n";
438
751
 
439
752
  struct HermeticTestEnv {
@@ -558,6 +871,10 @@ fn force_recreate_is_one_lock_transaction_for_all_seat_quadrants_and_rolls_back_
558
871
  state["agents"].get("charlie").is_some(),
559
872
  "{quadrant}: state restored"
560
873
  );
874
+ assert!(
875
+ state["agent_lifecycle"].get("charlie").is_none(),
876
+ "{quadrant}: successful force-recreate consumes its interim tombstone"
877
+ );
561
878
  let spec_text = std::fs::read_to_string(selected_spec_path(&ws)).unwrap();
562
879
  assert_eq!(
563
880
  spec_text.matches("id: \"charlie\"").count(),
@@ -604,6 +921,10 @@ fn force_recreate_is_one_lock_transaction_for_all_seat_quadrants_and_rolls_back_
604
921
  state["agents"].get("charlie").is_some(),
605
922
  "rollback restores state"
606
923
  );
924
+ assert!(
925
+ state["agent_lifecycle"].get("charlie").is_none(),
926
+ "preflight failure must not create a retirement tombstone"
927
+ );
607
928
  assert!(
608
929
  std::fs::read_to_string(selected_spec_path(&ws))
609
930
  .unwrap()
@@ -702,6 +1023,11 @@ fn force_recreate_post_consumption_failure_restores_before_tuple_and_health() {
702
1023
  sibling_health,
703
1024
  "rollback never changes the sibling team's same-agent health row"
704
1025
  );
1026
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
1027
+ assert!(
1028
+ state["agent_lifecycle"].get("charlie").is_none(),
1029
+ "post-consumption rollback restores the before-state without a new tombstone"
1030
+ );
705
1031
  }
706
1032
 
707
1033
  #[test]
@@ -1140,6 +1466,11 @@ fn lanea_remove_rollback_restarts_force_stopped_worker() {
1140
1466
  RemoveRollback::restore. spawns={:?}",
1141
1467
  tx.spawns()
1142
1468
  );
1469
+ let restored = crate::state::persist::load_runtime_state(&ws).unwrap();
1470
+ assert!(
1471
+ restored["agent_lifecycle"].get("alpha").is_none(),
1472
+ "failed remove must roll back the transaction-created tombstone"
1473
+ );
1143
1474
  }
1144
1475
 
1145
1476
  // ── REMOVE #11 (remove-dynamic-role-path-and-required-8, warn) [RED] — missing REQUIRED role file raises