@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
@@ -8,6 +8,60 @@ const DELEG_ROLE_ALPHA_COMPAT: &str = "---\nname: alpha\nrole: Alpha Worker\npro
8
8
  type LaneKills = std::sync::Arc<std::sync::Mutex<Vec<String>>>;
9
9
  pub(super) type LaneSpawns = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<String>)>>>;
10
10
 
11
+ fn emit_codex_fork_backing(argv: &[String], cwd: &std::path::Path) {
12
+ if !argv.windows(2).any(|pair| pair == ["codex", "fork"]) {
13
+ return;
14
+ }
15
+ static SESSION_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
16
+ let sequence = SESSION_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
17
+ let session_id = format!("lane-fork-{}-{sequence}", std::process::id());
18
+ let root = std::env::var_os("HOME")
19
+ .map(std::path::PathBuf::from)
20
+ .unwrap_or_else(|| std::path::PathBuf::from("."))
21
+ .join(".codex/sessions/lane-fixture");
22
+ std::fs::create_dir_all(&root).unwrap();
23
+ std::fs::write(
24
+ root.join(format!("rollout-{session_id}.jsonl")),
25
+ format!(
26
+ "{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}},\"created_at\":\"{}\"}}\n",
27
+ cwd.to_string_lossy(),
28
+ chrono::Utc::now().to_rfc3339()
29
+ ),
30
+ )
31
+ .unwrap();
32
+ }
33
+
34
+ pub(super) struct LaneHomeGuard {
35
+ previous: Option<std::ffi::OsString>,
36
+ root: PathBuf,
37
+ }
38
+
39
+ impl LaneHomeGuard {
40
+ pub(super) fn enter(tag: &str) -> Self {
41
+ static HOME_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
42
+ let root = std::env::temp_dir().join(format!(
43
+ "ta-lane-home-{tag}-{}-{}",
44
+ std::process::id(),
45
+ HOME_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
46
+ ));
47
+ let _ = std::fs::remove_dir_all(&root);
48
+ std::fs::create_dir_all(&root).unwrap();
49
+ let previous = std::env::var_os("HOME");
50
+ std::env::set_var("HOME", &root);
51
+ Self { previous, root }
52
+ }
53
+ }
54
+
55
+ impl Drop for LaneHomeGuard {
56
+ fn drop(&mut self) {
57
+ match &self.previous {
58
+ Some(value) => std::env::set_var("HOME", value),
59
+ None => std::env::remove_var("HOME"),
60
+ }
61
+ let _ = std::fs::remove_dir_all(&self.root);
62
+ }
63
+ }
64
+
11
65
  /// Recording transport for Lane-A v2: `list_windows`/`list_targets` answer from a configurable window
12
66
  /// set (golden's `_tmux_window_exists` primitive = `tmux list-windows`); `kill_window` + spawn_first/into
13
67
  /// are RECORDED. Every other method returns a benign Ok (never panics) so stop/reset/remove/fork run
@@ -18,6 +72,7 @@ pub(super) struct LaneTransport {
18
72
  foreign: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
19
73
  killed: LaneKills,
20
74
  spawns: LaneSpawns,
75
+ spawn_observed_at: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
21
76
  }
22
77
  impl LaneTransport {
23
78
  pub(super) fn new(session: &str, windows: &[&str]) -> Self {
@@ -29,6 +84,7 @@ impl LaneTransport {
29
84
  foreign: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
30
85
  killed: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
31
86
  spawns: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
87
+ spawn_observed_at: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
32
88
  }
33
89
  }
34
90
  pub(super) fn killed(&self) -> Vec<String> {
@@ -37,6 +93,9 @@ impl LaneTransport {
37
93
  pub(super) fn spawns(&self) -> Vec<(String, Vec<String>)> {
38
94
  self.spawns.lock().unwrap().clone()
39
95
  }
96
+ fn spawn_observed_at(&self) -> Vec<String> {
97
+ self.spawn_observed_at.lock().unwrap().clone()
98
+ }
40
99
  fn add_foreign(&self, session: &str, window: &str) {
41
100
  self.foreign
42
101
  .lock()
@@ -53,9 +112,14 @@ impl crate::transport::Transport for LaneTransport {
53
112
  session: &crate::transport::SessionName,
54
113
  window: &crate::transport::WindowName,
55
114
  argv: &[String],
56
- _c: &std::path::Path,
115
+ c: &std::path::Path,
57
116
  _e: &std::collections::BTreeMap<String, String>,
58
117
  ) -> Result<crate::transport::SpawnResult, crate::transport::TransportError> {
118
+ self.spawn_observed_at
119
+ .lock()
120
+ .unwrap()
121
+ .push(chrono::Utc::now().to_rfc3339());
122
+ emit_codex_fork_backing(argv, c);
59
123
  self.spawns
60
124
  .lock()
61
125
  .unwrap()
@@ -84,9 +148,14 @@ impl crate::transport::Transport for LaneTransport {
84
148
  session: &crate::transport::SessionName,
85
149
  window: &crate::transport::WindowName,
86
150
  argv: &[String],
87
- _c: &std::path::Path,
151
+ c: &std::path::Path,
88
152
  _e: &std::collections::BTreeMap<String, String>,
89
153
  ) -> Result<crate::transport::SpawnResult, crate::transport::TransportError> {
154
+ self.spawn_observed_at
155
+ .lock()
156
+ .unwrap()
157
+ .push(chrono::Utc::now().to_rfc3339());
158
+ emit_codex_fork_backing(argv, c);
90
159
  self.spawns
91
160
  .lock()
92
161
  .unwrap()
@@ -147,7 +216,7 @@ impl crate::transport::Transport for LaneTransport {
147
216
  &self,
148
217
  _p: &crate::transport::PaneId,
149
218
  ) -> Result<crate::model::enums::PaneLiveness, crate::transport::TransportError> {
150
- Ok(crate::model::enums::PaneLiveness::Unknown)
219
+ Ok(crate::model::enums::PaneLiveness::Live)
151
220
  }
152
221
  fn list_targets(
153
222
  &self,
@@ -434,6 +503,319 @@ fn force_remove_reconciles_all_seat_quadrants_without_cross_team_same_name_kill(
434
503
  }
435
504
  }
436
505
 
506
+ #[test]
507
+ fn remove_persists_retired_tombstone_and_explicit_readd_clears_it() {
508
+ let ws = lanea_ws_agents(json!({
509
+ "alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
510
+ "bravo": { "status": "running", "provider": "codex", "window": "bravo" }
511
+ }));
512
+ let mut seeded = crate::state::persist::load_runtime_state(&ws).unwrap();
513
+ seeded["team_key"] = json!("laneateam");
514
+ seeded["active_team_key"] = json!("laneateam");
515
+ let mut nested = crate::state::projection::compact_team_state(&seeded);
516
+ nested["status"] = json!("alive");
517
+ seeded["teams"] = json!({
518
+ "laneateam": nested,
519
+ "sibling": {
520
+ "team_key": "sibling",
521
+ "status": "shutdown",
522
+ "agents": {},
523
+ "agent_lifecycle": {
524
+ "alpha": {
525
+ "state": "retired",
526
+ "changed_at": "2026-07-20T00:00:00Z",
527
+ "reason": "sibling retirement"
528
+ }
529
+ }
530
+ }
531
+ });
532
+ crate::state::persist::save_runtime_state(&ws, &seeded).unwrap();
533
+ let stale_tick = crate::state::projection::select_runtime_state(&ws, Some("laneateam"))
534
+ .expect("capture the coordinator projection from before remove");
535
+ let tx = LaneTransport::new("team-laneateam", &[]);
536
+ let role = ws.join("agents").join("alpha.md");
537
+
538
+ remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &tx).unwrap();
539
+ let removed: serde_json::Value = serde_json::from_str(
540
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
541
+ )
542
+ .unwrap();
543
+ assert_eq!(
544
+ removed["teams"]["laneateam"]["agent_lifecycle"]["alpha"]["state"], "retired",
545
+ "the first successful remove must directly persist the canonical tombstone"
546
+ );
547
+ assert!(
548
+ removed["teams"]["laneateam"]["agents"]
549
+ .get("alpha")
550
+ .is_none(),
551
+ "the first successful remove must directly delete the canonical agent row"
552
+ );
553
+ crate::state::repository::StateRepository::new(&ws)
554
+ .save(
555
+ crate::state::repository::StateWriteIntent::CoordinatorTick {
556
+ team_key: "laneateam",
557
+ },
558
+ &stale_tick,
559
+ )
560
+ .unwrap();
561
+ let after_stale_tick: serde_json::Value = serde_json::from_str(
562
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
563
+ )
564
+ .unwrap();
565
+ assert_eq!(
566
+ after_stale_tick["teams"]["laneateam"]["agent_lifecycle"]["alpha"]["state"], "retired",
567
+ "a stale coordinator save must not erase the first remove's tombstone"
568
+ );
569
+ assert!(
570
+ after_stale_tick["teams"]["laneateam"]["agents"]
571
+ .get("alpha")
572
+ .is_none(),
573
+ "a stale coordinator save must not resurrect the first removed agent"
574
+ );
575
+
576
+ crate::lifecycle::add_agent_with_transport(&ws, &aid("alpha"), &role, false, None, &tx)
577
+ .unwrap();
578
+ let readded = crate::state::persist::load_runtime_state(&ws).unwrap();
579
+ assert!(
580
+ readded["teams"]["laneateam"]["agent_lifecycle"]
581
+ .get("alpha")
582
+ .is_none(),
583
+ "explicit re-add must consume the retired tombstone"
584
+ );
585
+ assert!(readded["teams"]["laneateam"]["agents"]
586
+ .get("alpha")
587
+ .is_some());
588
+ assert_eq!(
589
+ readded["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"], "retired",
590
+ "selected-team re-add must not clear a sibling team's same-id retirement"
591
+ );
592
+ }
593
+
594
+ /// Cross-product RED (verifier, slice2b authority leak): the FAILURE dimension
595
+ /// (a stale writer whose incoming projection predates a sibling retirement) x the
596
+ /// BOUNDARY dimension (a sibling team carrying the SAME agent id). The tracked
597
+ /// `remove_persists_..._readd_clears_it` sibling assertion covers only the
598
+ /// non-stale corner (sibling tombstone already current in the add's input); it
599
+ /// does NOT exercise an incoming sibling projection captured BEFORE the sibling
600
+ /// retirement. topology-authority is keyed by bare `agent_id` with no team
601
+ /// dimension (repository.rs:277-283 drops team_key; persist.rs iterates every
602
+ /// sibling team with the same global id set), so an explicit AddAgent(alpha) in
603
+ /// team A exempts alpha in team B too and a stale incoming resurrects B's alpha /
604
+ /// erases B's retirement tombstone. Red at da11f98c: teamB.alpha lifecycle Null.
605
+ #[test]
606
+ fn add_agent_authority_must_not_cross_team_erase_sibling_same_id_retirement() {
607
+ // Disk latest: team A alive with alpha; team B alpha ABSENT + retired.
608
+ let ws = lanea_ws_agents(json!({
609
+ "alpha": { "status": "running", "provider": "codex", "window": "alpha" },
610
+ "bravo": { "status": "running", "provider": "codex", "window": "bravo" }
611
+ }));
612
+ let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
613
+ latest["team_key"] = json!("laneateam");
614
+ latest["active_team_key"] = json!("laneateam");
615
+ let mut nested_a = crate::state::projection::compact_team_state(&latest);
616
+ nested_a["status"] = json!("alive");
617
+ latest["teams"] = json!({
618
+ "laneateam": nested_a,
619
+ "sibling": {
620
+ "team_key": "sibling",
621
+ "status": "alive",
622
+ "agents": {},
623
+ "agent_lifecycle": {
624
+ "alpha": {
625
+ "state": "retired",
626
+ "changed_at": "2026-07-21T00:00:00Z",
627
+ "reason": "sibling retirement (newer than the stale incoming)"
628
+ }
629
+ }
630
+ }
631
+ });
632
+ crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
633
+
634
+ // Incoming STALE root: captured BEFORE team B retired alpha, so its sibling
635
+ // entry still carries alpha ACTIVE with no tombstone. A concurrent explicit
636
+ // AddAgent(alpha) in team A carries this whole stale multi-team projection.
637
+ let mut incoming_stale = latest.clone();
638
+ incoming_stale["teams"]["sibling"] = json!({
639
+ "team_key": "sibling",
640
+ "status": "alive",
641
+ "agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
642
+ "agent_lifecycle": {}
643
+ });
644
+
645
+ crate::state::repository::StateRepository::new(&ws)
646
+ .save(
647
+ crate::state::repository::StateWriteIntent::AddAgent {
648
+ team_key: "laneateam",
649
+ agent_id: "alpha",
650
+ },
651
+ &incoming_stale,
652
+ )
653
+ .unwrap();
654
+
655
+ let after: serde_json::Value = serde_json::from_str(
656
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
657
+ )
658
+ .unwrap();
659
+ // The AddAgent authority is scoped to team A's alpha ONLY; team B's newer
660
+ // same-id retirement must survive (the stale incoming must not resurrect it).
661
+ assert_eq!(
662
+ after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
663
+ json!("retired"),
664
+ "selected-team AddAgent authority must not cross team boundary by bare agent id: \
665
+ team B's newer same-id retirement was erased by team A's stale re-add"
666
+ );
667
+ assert!(
668
+ after["teams"]["sibling"]["agents"].get("alpha").is_none(),
669
+ "a stale incoming must not resurrect the sibling team's retired same-id agent"
670
+ );
671
+ }
672
+
673
+ /// Identity-source boundary (verifier, slice2b authority fix new adjudication
674
+ /// face, same family as slice1 empty-current): the legacy top-level scope anchor
675
+ /// now prefers `active_team_key`. Verify it does not over-exempt / under-protect
676
+ /// when `active_team_key` is a NON-EMPTY but TERMINAL / mismatched value (points
677
+ /// at a team not present in `teams`). The AddAgent authority is (laneateam,
678
+ /// alpha); with a ghost active_team_key, the top-level filter must resolve NO
679
+ /// exemption for the ghost, so a real retirement anywhere is still protected —
680
+ /// the anchor must not silently widen authority to a bare id under a dangling
681
+ /// active_team_key (that would be the empty-current forgery shape at slice1).
682
+ #[test]
683
+ fn terminal_active_team_key_does_not_widen_topology_authority_to_bare_id() {
684
+ // Disk latest: laneateam alive with alpha; sibling alpha ABSENT + retired.
685
+ let ws = lanea_ws_agents(json!({
686
+ "alpha": { "status": "running", "provider": "codex", "window": "alpha" },
687
+ "bravo": { "status": "running", "provider": "codex", "window": "bravo" }
688
+ }));
689
+ let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
690
+ latest["team_key"] = json!("laneateam");
691
+ latest["active_team_key"] = json!("laneateam");
692
+ let mut nested_a = crate::state::projection::compact_team_state(&latest);
693
+ nested_a["status"] = json!("alive");
694
+ latest["teams"] = json!({
695
+ "laneateam": nested_a,
696
+ "sibling": {
697
+ "team_key": "sibling",
698
+ "status": "alive",
699
+ "agents": {},
700
+ "agent_lifecycle": {
701
+ "alpha": { "state": "retired", "changed_at": "2026-07-21T00:00:00Z", "reason": "sibling" }
702
+ }
703
+ }
704
+ });
705
+ crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
706
+
707
+ // Incoming STALE root whose active_team_key points at a GHOST team not in
708
+ // `teams`, and whose sibling entry is the pre-retire (alpha active) shape.
709
+ let mut incoming_stale = latest.clone();
710
+ incoming_stale["active_team_key"] = json!("ghostteam-does-not-exist");
711
+ incoming_stale["teams"]["sibling"] = json!({
712
+ "team_key": "sibling",
713
+ "status": "alive",
714
+ "agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
715
+ "agent_lifecycle": {}
716
+ });
717
+
718
+ crate::state::repository::StateRepository::new(&ws)
719
+ .save(
720
+ crate::state::repository::StateWriteIntent::AddAgent {
721
+ team_key: "laneateam",
722
+ agent_id: "alpha",
723
+ },
724
+ &incoming_stale,
725
+ )
726
+ .unwrap();
727
+
728
+ let after: serde_json::Value = serde_json::from_str(
729
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
730
+ )
731
+ .unwrap();
732
+ // A dangling active_team_key must not widen the (laneateam, alpha) authority
733
+ // into a bare `alpha` exemption that reaches the sibling: sibling stays
734
+ // retired, not resurrected.
735
+ assert_eq!(
736
+ after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
737
+ json!("retired"),
738
+ "a terminal/ghost active_team_key must not widen topology authority to a bare id \
739
+ and erase the sibling's same-id retirement"
740
+ );
741
+ assert!(
742
+ after["teams"]["sibling"]["agents"].get("alpha").is_none(),
743
+ "a terminal active_team_key must not let a stale incoming resurrect the sibling agent"
744
+ );
745
+ }
746
+
747
+ /// Cross-product cell (verifier, slice2b current-alias fix): the `current`
748
+ /// physical alias dimension x the sibling same-id retirement dimension. The
749
+ /// reset fix routes `team == "current"` authority through `top_level_team`
750
+ /// (active_team_key). This pins that the alias branch does NOT leak that
751
+ /// active-team authority into a REAL sibling that merely shares the agent id:
752
+ /// a `current` alias entry (= the active team's physical projection) plus a
753
+ /// distinct sibling team both carrying `alpha`, sibling retired, stale incoming
754
+ /// resurrecting sibling alpha. The current-alias authority must clear only the
755
+ /// active team's own alpha, never the sibling's newer retirement.
756
+ #[test]
757
+ fn current_alias_authority_does_not_leak_into_real_sibling_same_id_retirement() {
758
+ let ws = lanea_ws_agents(json!({
759
+ "alpha": { "status": "running", "provider": "codex", "window": "alpha" },
760
+ "bravo": { "status": "running", "provider": "codex", "window": "bravo" }
761
+ }));
762
+ let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
763
+ latest["team_key"] = json!("laneateam");
764
+ latest["active_team_key"] = json!("laneateam");
765
+ let mut nested_a = crate::state::projection::compact_team_state(&latest);
766
+ nested_a["status"] = json!("alive");
767
+ // `current` is the physical alias of the active team (laneateam); a REAL
768
+ // sibling team also carries alpha, retired and newer than the stale incoming.
769
+ latest["teams"] = json!({
770
+ "laneateam": nested_a.clone(),
771
+ "current": nested_a,
772
+ "sibling": {
773
+ "team_key": "sibling",
774
+ "status": "alive",
775
+ "agents": {},
776
+ "agent_lifecycle": {
777
+ "alpha": { "state": "retired", "changed_at": "2026-07-21T00:00:00Z", "reason": "sibling" }
778
+ }
779
+ }
780
+ });
781
+ crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
782
+
783
+ let mut incoming_stale = latest.clone();
784
+ incoming_stale["teams"]["sibling"] = json!({
785
+ "team_key": "sibling",
786
+ "status": "alive",
787
+ "agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
788
+ "agent_lifecycle": {}
789
+ });
790
+
791
+ crate::state::repository::StateRepository::new(&ws)
792
+ .save(
793
+ crate::state::repository::StateWriteIntent::AddAgent {
794
+ team_key: "laneateam",
795
+ agent_id: "alpha",
796
+ },
797
+ &incoming_stale,
798
+ )
799
+ .unwrap();
800
+
801
+ let after: serde_json::Value = serde_json::from_str(
802
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
803
+ )
804
+ .unwrap();
805
+ // The current-alias authority is the ACTIVE team's; it must not reach the
806
+ // real sibling that only shares the id.
807
+ assert_eq!(
808
+ after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
809
+ json!("retired"),
810
+ "current-alias authority must not leak into a real sibling's same-id retirement"
811
+ );
812
+ assert!(
813
+ after["teams"]["sibling"]["agents"].get("alpha").is_none(),
814
+ "a stale incoming must not resurrect the real sibling's retired same-id agent \
815
+ via the current-alias authority path"
816
+ );
817
+ }
818
+
437
819
  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
820
 
439
821
  struct HermeticTestEnv {
@@ -558,6 +940,10 @@ fn force_recreate_is_one_lock_transaction_for_all_seat_quadrants_and_rolls_back_
558
940
  state["agents"].get("charlie").is_some(),
559
941
  "{quadrant}: state restored"
560
942
  );
943
+ assert!(
944
+ state["agent_lifecycle"].get("charlie").is_none(),
945
+ "{quadrant}: successful force-recreate consumes its interim tombstone"
946
+ );
561
947
  let spec_text = std::fs::read_to_string(selected_spec_path(&ws)).unwrap();
562
948
  assert_eq!(
563
949
  spec_text.matches("id: \"charlie\"").count(),
@@ -604,6 +990,10 @@ fn force_recreate_is_one_lock_transaction_for_all_seat_quadrants_and_rolls_back_
604
990
  state["agents"].get("charlie").is_some(),
605
991
  "rollback restores state"
606
992
  );
993
+ assert!(
994
+ state["agent_lifecycle"].get("charlie").is_none(),
995
+ "preflight failure must not create a retirement tombstone"
996
+ );
607
997
  assert!(
608
998
  std::fs::read_to_string(selected_spec_path(&ws))
609
999
  .unwrap()
@@ -702,6 +1092,11 @@ fn force_recreate_post_consumption_failure_restores_before_tuple_and_health() {
702
1092
  sibling_health,
703
1093
  "rollback never changes the sibling team's same-agent health row"
704
1094
  );
1095
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
1096
+ assert!(
1097
+ state["agent_lifecycle"].get("charlie").is_none(),
1098
+ "post-consumption rollback restores the before-state without a new tombstone"
1099
+ );
705
1100
  }
706
1101
 
707
1102
  #[test]
@@ -1140,6 +1535,11 @@ fn lanea_remove_rollback_restarts_force_stopped_worker() {
1140
1535
  RemoveRollback::restore. spawns={:?}",
1141
1536
  tx.spawns()
1142
1537
  );
1538
+ let restored = crate::state::persist::load_runtime_state(&ws).unwrap();
1539
+ assert!(
1540
+ restored["agent_lifecycle"].get("alpha").is_none(),
1541
+ "failed remove must roll back the transaction-created tombstone"
1542
+ );
1143
1543
  }
1144
1544
 
1145
1545
  // ── REMOVE #11 (remove-dynamic-role-path-and-required-8, warn) [RED] — missing REQUIRED role file raises
@@ -1263,14 +1663,11 @@ fn lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm() {
1263
1663
  );
1264
1664
  }
1265
1665
 
1266
- // ── FORK (fork-report-session-id-is-pane-id) [RED] report session_id is the captured id / None, not pane
1267
- // Golden operations.py:399,408 returns state['agents'][as_agent_id].get('session_id') — the captured
1268
- // provider session id (or None if capture missed, raise_on_missed=False). Rust sets
1269
- // session_id: Some(SessionId::new(spawn.pane_id)) (launch.rs:502) — the tmux pane id ('%newfork'),
1270
- // a different value kind. RED: the report session_id must NOT be the pane id (None, since the Rust fork
1271
- // path performs no session capture).
1666
+ // Successful fork reports the verified provider session id, never the pane id.
1272
1667
  #[test]
1668
+ #[serial_test::serial(env)]
1273
1669
  fn lanea_fork_report_session_id_is_not_pane_id() {
1670
+ let _home = LaneHomeGuard::enter("fork-report");
1274
1671
  let ws = fork_ws(DELEG_ROLE_ALPHA); // codex+subscription -> native fork supported -> full success path
1275
1672
  let tx = LaneTransport::new("team-laneateam", &[]);
1276
1673
  let report =
@@ -1282,9 +1679,41 @@ fn lanea_fork_report_session_id_is_not_pane_id() {
1282
1679
  "golden operations.py:399,408: report.session_id is the captured provider session id / None, NEVER the \
1283
1680
  tmux pane id; Rust returns Some(pane_id='%newfork')"
1284
1681
  );
1285
- assert_eq!(
1286
- report.session_id, None,
1287
- "the Rust fork path captures no session -> report.session_id must be None (golden capture-missed), not the pane id"
1682
+ assert!(
1683
+ report.session_id.is_some(),
1684
+ "a successful fork must carry the verified NEW provider session id"
1685
+ );
1686
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
1687
+ let row = &state["agents"]["newfork"];
1688
+ assert!(row["spawned_at"]
1689
+ .as_str()
1690
+ .is_some_and(|value| chrono::DateTime::parse_from_rfc3339(value).is_ok()));
1691
+ assert_eq!(row["spawn_epoch"], serde_json::json!(1));
1692
+ }
1693
+
1694
+ #[test]
1695
+ fn fresh_start_persists_boundary_from_before_transport_spawn() {
1696
+ let ws = lanea_one_agent_ws("stopped");
1697
+ let tx = LaneTransport::new("team-laneateam", &[]);
1698
+ crate::lifecycle::start_agent_with_transport(&ws, &aid("alpha"), false, false, true, None, &tx)
1699
+ .expect("fresh start");
1700
+
1701
+ let transport_observed_at = tx
1702
+ .spawn_observed_at()
1703
+ .into_iter()
1704
+ .next()
1705
+ .expect("transport spawn observation");
1706
+ let transport_observed_at =
1707
+ chrono::DateTime::parse_from_rfc3339(&transport_observed_at).unwrap();
1708
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
1709
+ let spawned_at = state["agents"]["alpha"]["spawned_at"]
1710
+ .as_str()
1711
+ .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
1712
+ .expect("persisted spawn boundary");
1713
+ assert!(
1714
+ spawned_at <= transport_observed_at,
1715
+ "the persisted capture boundary must precede the transport spawn; \
1716
+ spawned_at={spawned_at}, transport_observed_at={transport_observed_at}"
1288
1717
  );
1289
1718
  }
1290
1719
 
@@ -71,10 +71,11 @@ fn codex_identity_rollout(
71
71
  std::fs::write(
72
72
  &path,
73
73
  format!(
74
- "{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}}}}\n\
74
+ "{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\",\"created_at\":\"{}\"}}}}}}\n\
75
75
  {{\"type\":\"turn_context\",\"payload\":{{}}}}\n\
76
76
  {{\"type\":\"response_item\",\"payload\":{{\"content\":[{{\"type\":\"input_text\",\"text\":\"You are Team Agent worker `{embedded_agent_id}` with role `fixture`.\"}}]}}}}\n",
77
- workspace.to_string_lossy()
77
+ workspace.to_string_lossy(),
78
+ chrono::Utc::now().to_rfc3339()
78
79
  ),
79
80
  )
80
81
  .unwrap();
@@ -705,6 +706,7 @@ fn spine_add_agent_rejects_duplicate_agent_id() {
705
706
  #[test]
706
707
  fn e5_add_agent_does_not_copy_role_into_platform_dir_and_injects_into_spec() {
707
708
  let team = quick_start_team_dir(QS_VALID_ROLE);
709
+ seed_canonical_team_identity(&team, "teamdir");
708
710
  // External role file OUTSIDE team_dir/agents, with recognizable body content.
709
711
  let role = team.parent().unwrap().join("w2-external-role.md");
710
712
  let role_body = "---\nname: w2\nrole: Second Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nUNIQUE-E5-BUG1-MARKER body for w2.\n";
@@ -1154,6 +1156,12 @@ pub(super) fn seed_healthy_coordinator(workspace: &std::path::Path) {
1154
1156
  .unwrap();
1155
1157
  }
1156
1158
 
1159
+ fn seed_canonical_team_identity(workspace: &std::path::Path, team_key: &str) {
1160
+ let mut state = crate::state::persist::load_runtime_state(workspace).unwrap();
1161
+ state["active_team_key"] = json!(team_key);
1162
+ crate::state::persist::save_runtime_state(workspace, &state).unwrap();
1163
+ }
1164
+
1157
1165
  // RED — quick_start_with_transport must drive the REAL spawn path: launch.dry_run==false, started
1158
1166
  // non-empty (one per compiled agent), and the transport records >=1 spawn carrying that agent's
1159
1167
  // provider build_command. Today the dry_run=true bug -> launch.started empty + ZERO spawns recorded ->
@@ -2263,6 +2271,7 @@ fn add_agent_with_transport_spawns_new_worker_not_stub() {
2263
2271
  let role_file = team.join("worker2-role.md"); // OUTSIDE agents/ -> not a duplicate of an existing agent
2264
2272
  std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
2265
2273
  seed_healthy_coordinator(&team);
2274
+ seed_canonical_team_identity(&team, "addteam");
2266
2275
  let transport = OfflineTransport::new();
2267
2276
 
2268
2277
  let _result = add_agent_with_transport(
@@ -2473,6 +2482,10 @@ fn restart_allow_fresh_copilot_keeps_session_id_null_until_capture_confirms() {
2473
2482
  .windows(2)
2474
2483
  .find_map(|pair| (pair[0] == "--session-id").then(|| pair[1].clone()))
2475
2484
  .expect("copilot fresh spawn argv must include --session-id");
2485
+ let instructions =
2486
+ std::fs::read_to_string(ws.join(".team/runtime/copilot-instructions/alpha/AGENTS.md"))
2487
+ .expect("restart/add/clone spawn must materialize Copilot instructions");
2488
+ assert!(instructions.contains("Alpha Worker"));
2476
2489
  let state = crate::state::persist::load_runtime_state(&ws).expect("load state");
2477
2490
  let agent = state.pointer("/agents/alpha").expect("alpha state");
2478
2491
  // 0.4.6 contract: session_id stays null until scanner confirms sqlite.
@@ -3076,6 +3089,7 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
3076
3089
  "captured_at",
3077
3090
  "captured_via",
3078
3091
  "attribution_confidence",
3092
+ "capture_state",
3079
3093
  "spawn_cwd",
3080
3094
  "spawned_at",
3081
3095
  "pane_id",
@@ -3331,6 +3345,7 @@ fn add_agent_reachability_gate_returns_error_when_spawn_pane_not_addressable() {
3331
3345
  let role_file = team.join("worker2-role.md");
3332
3346
  std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
3333
3347
  seed_healthy_coordinator(&team);
3348
+ seed_canonical_team_identity(&team, "reachgate");
3334
3349
 
3335
3350
  // Models "spawn to wrong socket" / "spawn succeeded but pane orphan": the
3336
3351
  // OfflineTransport records the spawn but reports the new pane is NOT
@@ -3390,6 +3405,7 @@ fn add_agent_reachability_gate_passes_when_spawn_pane_addressable() {
3390
3405
  let role_file = team.join("worker2-role.md");
3391
3406
  std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
3392
3407
  seed_healthy_coordinator(&team);
3408
+ seed_canonical_team_identity(&team, "gatepass");
3393
3409
 
3394
3410
  // Default OfflineTransport: spawned_panes_addressable=true, liveness=Unknown
3395
3411
  // (treated as not-Dead by the gate's fallthrough).
@@ -4149,6 +4165,7 @@ fn e42_re_add_and_remove_agree_on_existence_after_add() {
4149
4165
  let role_file = team.join("newagent.md");
4150
4166
  std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
4151
4167
  seed_healthy_coordinator(&team);
4168
+ seed_canonical_team_identity(&team, "e42sym");
4152
4169
  let transport = OfflineTransport::new();
4153
4170
  // (1) first add
4154
4171
  let r1 = crate::lifecycle::add_agent_with_transport(
@@ -4205,6 +4222,7 @@ fn e42_add_writes_to_canonical_runtime_spec_and_state_consistently() {
4205
4222
  let role_file = team.join("newagent.md");
4206
4223
  std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
4207
4224
  seed_healthy_coordinator(&team);
4225
+ seed_canonical_team_identity(&team, "e42proj");
4208
4226
  let transport = OfflineTransport::new();
4209
4227
  let _ = crate::lifecycle::add_agent_with_transport(
4210
4228
  &team,
@@ -318,6 +318,7 @@ fn reset_agent_discard_session_rebuilds_window_via_start_respawn() {
318
318
  }
319
319
 
320
320
  #[test]
321
+ #[serial_test::serial(env)]
321
322
  fn reset_agent_discard_session_syncs_projection_epoch_inputs_for_restart_agent_command() {
322
323
  let ws = restart_ws_two_resumable_workers();
323
324
  let mut state = crate::state::persist::load_runtime_state(&ws).expect("load state");
@@ -1,5 +1,5 @@
1
1
  use super::agent_ops::lanea_team_ws;
2
- use super::lane_ops::{fork_ws, LaneTransport};
2
+ use super::lane_ops::{fork_ws, LaneHomeGuard, LaneTransport};
3
3
  use super::launch_spawn::{
4
4
  quick_start_team_dir, seed_healthy_coordinator, DELEG_ROLE_ALPHA, QS_VALID_ROLE,
5
5
  };
@@ -129,7 +129,9 @@ fn concurrent_reset_discard_session_serializes() {
129
129
  }
130
130
 
131
131
  #[test]
132
+ #[serial_test::serial(env)]
132
133
  fn add_fork_remove_share_lifecycle_lock_behavior() {
134
+ let _home = LaneHomeGuard::enter("phase-b-fork");
133
135
  let (add_team, add_workspace, add_role, add_transport) = add_fixture();
134
136
  let add_agent = aid("w2");
135
137
  let add_state_before =