@team-agent/installer 0.5.42 → 0.5.44

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 (156) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +129 -54
  4. package/crates/team-agent/src/cli/diagnose.rs +1 -2
  5. package/crates/team-agent/src/cli/emit.rs +1 -7
  6. package/crates/team-agent/src/cli/helpers.rs +3 -1
  7. package/crates/team-agent/src/cli/leader.rs +2 -1
  8. package/crates/team-agent/src/cli/mod.rs +126 -16
  9. package/crates/team-agent/src/cli/named_address.rs +14 -5
  10. package/crates/team-agent/src/cli/profile.rs +19 -7
  11. package/crates/team-agent/src/cli/send.rs +9 -3
  12. package/crates/team-agent/src/cli/status.rs +8 -30
  13. package/crates/team-agent/src/cli/status_port.rs +1341 -1272
  14. package/crates/team-agent/src/cli/tests/base.rs +738 -660
  15. package/crates/team-agent/src/cli/tests/compile.rs +45 -18
  16. package/crates/team-agent/src/cli/tests/divergence.rs +462 -444
  17. package/crates/team-agent/src/cli/tests/lane_c.rs +365 -282
  18. package/crates/team-agent/src/cli/tests/leader_watch.rs +356 -329
  19. package/crates/team-agent/src/cli/tests/main_preserved.rs +672 -564
  20. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +284 -224
  21. package/crates/team-agent/src/cli/tests/mod.rs +17 -7
  22. package/crates/team-agent/src/cli/tests/named_address.rs +8 -2
  23. package/crates/team-agent/src/cli/tests/peer_allow.rs +10 -2
  24. package/crates/team-agent/src/cli/tests/run_delegation.rs +314 -273
  25. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +26 -15
  26. package/crates/team-agent/src/cli/tests/status_send.rs +707 -629
  27. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +20 -4
  28. package/crates/team-agent/src/cli/tests/verb_profile.rs +46 -17
  29. package/crates/team-agent/src/cli/tests/verb_validate.rs +15 -3
  30. package/crates/team-agent/src/codex_app_server.rs +2 -5
  31. package/crates/team-agent/src/compiler/tests.rs +139 -33
  32. package/crates/team-agent/src/compiler.rs +55 -22
  33. package/crates/team-agent/src/conpty/backend.rs +23 -33
  34. package/crates/team-agent/src/coordinator/backoff.rs +2 -7
  35. package/crates/team-agent/src/coordinator/conpty_shim.rs +55 -67
  36. package/crates/team-agent/src/coordinator/health.rs +46 -31
  37. package/crates/team-agent/src/coordinator/mod.rs +3 -3
  38. package/crates/team-agent/src/coordinator/orphan.rs +22 -10
  39. package/crates/team-agent/src/coordinator/steps/abnormal.rs +51 -56
  40. package/crates/team-agent/src/coordinator/tests/abnormal.rs +55 -19
  41. package/crates/team-agent/src/coordinator/tests/basics.rs +179 -41
  42. package/crates/team-agent/src/coordinator/tests/daemon.rs +53 -13
  43. package/crates/team-agent/src/coordinator/tests/health_sync.rs +78 -19
  44. package/crates/team-agent/src/coordinator/tests/main_preserved.rs +61 -11
  45. package/crates/team-agent/src/coordinator/tests/mod.rs +33 -39
  46. package/crates/team-agent/src/coordinator/tests/spine.rs +52 -12
  47. package/crates/team-agent/src/coordinator/tests/takeover.rs +73 -15
  48. package/crates/team-agent/src/coordinator/tests/tick_core.rs +50 -15
  49. package/crates/team-agent/src/coordinator/tests/watch.rs +74 -20
  50. package/crates/team-agent/src/db/message_store.rs +138 -30
  51. package/crates/team-agent/src/db/migration.rs +249 -61
  52. package/crates/team-agent/src/db/schema.rs +303 -82
  53. package/crates/team-agent/src/diagnose/comms.rs +9 -2
  54. package/crates/team-agent/src/diagnose/mod.rs +1 -3
  55. package/crates/team-agent/src/diagnose/orphans.rs +79 -61
  56. package/crates/team-agent/src/event_log.rs +70 -16
  57. package/crates/team-agent/src/layout/manager.rs +15 -4
  58. package/crates/team-agent/src/layout/mod.rs +4 -4
  59. package/crates/team-agent/src/layout/overlay.rs +10 -3
  60. package/crates/team-agent/src/layout/placement.rs +5 -1
  61. package/crates/team-agent/src/layout/recovery.rs +4 -2
  62. package/crates/team-agent/src/layout/runtime_sessions.rs +7 -7
  63. package/crates/team-agent/src/layout/sessions.rs +17 -9
  64. package/crates/team-agent/src/layout/tmux_endpoint.rs +1 -1
  65. package/crates/team-agent/src/layout/worker_env.rs +87 -19
  66. package/crates/team-agent/src/leader/helpers.rs +7 -1
  67. package/crates/team-agent/src/leader/lease.rs +199 -89
  68. package/crates/team-agent/src/leader/owner_bind.rs +55 -22
  69. package/crates/team-agent/src/leader/provider_attribution.rs +25 -6
  70. package/crates/team-agent/src/leader/rediscover/tests.rs +88 -24
  71. package/crates/team-agent/src/leader/rediscover.rs +74 -25
  72. package/crates/team-agent/src/leader/registry.rs +1 -1
  73. package/crates/team-agent/src/leader/start.rs +75 -54
  74. package/crates/team-agent/src/leader/takeover.rs +46 -11
  75. package/crates/team-agent/src/leader/tests/basics.rs +320 -167
  76. package/crates/team-agent/src/leader/tests/byte_findings.rs +361 -219
  77. package/crates/team-agent/src/leader/tests/identity.rs +428 -356
  78. package/crates/team-agent/src/leader/tests/idle.rs +285 -254
  79. package/crates/team-agent/src/leader/tests/lease_api.rs +338 -274
  80. package/crates/team-agent/src/leader/tests/lease_claim.rs +643 -593
  81. package/crates/team-agent/src/leader/tests/mod.rs +115 -99
  82. package/crates/team-agent/src/leader/tests/rediscover.rs +74 -22
  83. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +237 -211
  84. package/crates/team-agent/src/lib.rs +4 -4
  85. package/crates/team-agent/src/lifecycle/display.rs +7 -3
  86. package/crates/team-agent/src/lifecycle/launch.rs +55 -15
  87. package/crates/team-agent/src/lifecycle/mod.rs +9 -1
  88. package/crates/team-agent/src/lifecycle/profile_launch.rs +77 -34
  89. package/crates/team-agent/src/lifecycle/profile_smoke.rs +3 -1
  90. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -6
  91. package/crates/team-agent/src/lifecycle/restart/common.rs +6 -2
  92. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +1 -4
  93. package/crates/team-agent/src/lifecycle/restart/preflight.rs +6 -5
  94. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -22
  95. package/crates/team-agent/src/lifecycle/restart/remove.rs +45 -35
  96. package/crates/team-agent/src/lifecycle/restart/team_state.rs +63 -17
  97. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +251 -84
  98. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +198 -48
  99. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +2 -1
  100. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +152 -32
  101. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -5
  102. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
  103. package/crates/team-agent/src/lifecycle/tests.rs +2 -2
  104. package/crates/team-agent/src/main.rs +4 -4
  105. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +47 -20
  106. package/crates/team-agent/src/mcp_server/mod.rs +11 -2
  107. package/crates/team-agent/src/mcp_server/types.rs +14 -3
  108. package/crates/team-agent/src/mcp_server/wire.rs +230 -68
  109. package/crates/team-agent/src/messaging/delivery.rs +20 -18
  110. package/crates/team-agent/src/messaging/helpers.rs +25 -4
  111. package/crates/team-agent/src/messaging/leader_receiver.rs +4 -5
  112. package/crates/team-agent/src/messaging/mod.rs +2 -3
  113. package/crates/team-agent/src/messaging/selftest.rs +46 -26
  114. package/crates/team-agent/src/messaging/tests/main_preserved.rs +47 -12
  115. package/crates/team-agent/src/messaging/tests/runtime.rs +57 -22
  116. package/crates/team-agent/src/messaging/tests/spine.rs +154 -40
  117. package/crates/team-agent/src/messaging/tests/wave2.rs +31 -32
  118. package/crates/team-agent/src/messaging/trust.rs +25 -2
  119. package/crates/team-agent/src/messaging/watchers.rs +37 -9
  120. package/crates/team-agent/src/model/enums.rs +65 -16
  121. package/crates/team-agent/src/model/ids.rs +12 -3
  122. package/crates/team-agent/src/model/paths.rs +28 -7
  123. package/crates/team-agent/src/model/permissions.rs +176 -33
  124. package/crates/team-agent/src/model/routing.rs +66 -20
  125. package/crates/team-agent/src/model/spec.rs +365 -69
  126. package/crates/team-agent/src/model/task_graph.rs +36 -9
  127. package/crates/team-agent/src/model/yaml/tests.rs +24 -6
  128. package/crates/team-agent/src/model/yaml.rs +7 -6
  129. package/crates/team-agent/src/packaging/install.rs +23 -9
  130. package/crates/team-agent/src/packaging/migrate.rs +5 -7
  131. package/crates/team-agent/src/packaging/mod.rs +9 -1
  132. package/crates/team-agent/src/packaging/repair.rs +13 -6
  133. package/crates/team-agent/src/packaging/tests.rs +63 -16
  134. package/crates/team-agent/src/packaging/types.rs +22 -7
  135. package/crates/team-agent/src/platform/argv.rs +4 -1
  136. package/crates/team-agent/src/platform/file_lock.rs +22 -8
  137. package/crates/team-agent/src/platform/process.rs +54 -24
  138. package/crates/team-agent/src/provider/adapters/claude.rs +1 -3
  139. package/crates/team-agent/src/provider/approvals/parsing.rs +134 -26
  140. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +13 -3
  141. package/crates/team-agent/src/provider/classify.rs +127 -42
  142. package/crates/team-agent/src/provider/faults.rs +17 -5
  143. package/crates/team-agent/src/provider/helpers.rs +6 -5
  144. package/crates/team-agent/src/provider/startup_prompt.rs +75 -27
  145. package/crates/team-agent/src/state/persist.rs +28 -0
  146. package/crates/team-agent/src/state/repository.rs +8 -3
  147. package/crates/team-agent/src/tmux_backend/tests.rs +1637 -1398
  148. package/crates/team-agent/src/tmux_backend.rs +80 -44
  149. package/crates/team-agent/src/topology.rs +40 -20
  150. package/crates/team-agent/src/transport/test_support.rs +20 -23
  151. package/crates/team-agent/src/transport/tests/behavior.rs +292 -293
  152. package/crates/team-agent/src/transport/tests/mod.rs +178 -187
  153. package/crates/team-agent/src/transport/tests/wire.rs +561 -525
  154. package/crates/team-agent/src/transport.rs +12 -17
  155. package/crates/team-agent/src/transport_factory.rs +29 -14
  156. package/package.json +4 -4
@@ -1,634 +1,684 @@
1
1
  use super::*;
2
2
 
3
- // = RED. The porter must reproduce the EXACT LeaseResult shape per outcome.
4
- // We seed state.json directly and inject a PaneLivenessProbe so no real tmux
5
- // is needed for the no-pane / live-owner / dead-owner gate decisions.
3
+ // = RED. The porter must reproduce the EXACT LeaseResult shape per outcome.
4
+ // We seed state.json directly and inject a PaneLivenessProbe so no real tmux
5
+ // is needed for the no-pane / live-owner / dead-owner gate decisions.
6
6
 
7
- /// Injectable liveness probe (`state::owner_gate::PaneLivenessProbe`): a pane
8
- /// in the seeded set is `Live`, otherwise `Dead`. The porter's real probe
9
- /// (step 9 tmux) replaces it; here it drives the no-incident lease gate
10
- /// (bound-pane liveness + caller eligibility) deterministically with no tmux.
11
- #[test]
12
- #[serial_test::serial(env)]
13
- // Foundation-0 F0-2 conversion: `claim_lease_no_incident` used to
14
- // dual-write the legacy per-session snapshot; that is retired
15
- // (`.team/artifacts/foundation-0-slice-design.md` §§4-5). Canonical
16
- // teams.<team_key> now carries the sole authoritative owner tuple.
17
- fn claim_lease_no_incident_vacant_acquire_advances_epoch_and_writes_canonical_only() {
18
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
19
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
20
- let ws = p2_temp_ws("claim_vacant");
21
- let team_id = TeamKey::new("current");
22
- let caller = PaneId::new("%5");
23
- let mut state = serde_json::json!({"session_name": "team-agent-x"});
24
- let event_log = crate::event_log::EventLog::new(&ws);
25
- let live = seeded_liveness(&["%5"]);
26
- let r = claim_lease_no_incident(
27
- &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
28
- )
29
- .unwrap();
30
- assert!(r.ok);
31
- assert_eq!(r.status, LeaseStatus::Claimed);
32
- assert_eq!(r.reason, Some(LeaseReason::VacantAcquired));
33
- assert_eq!(r.owner_epoch, Some(OwnerEpoch(1)), "vacant acquire 0→1");
34
- let owner = r.owner.as_ref().expect("claimed → owner");
35
- assert_eq!(owner.pane_id, caller);
36
- assert_eq!(owner.owner_epoch, OwnerEpoch(1));
37
- assert_eq!(owner.claimed_via, ClaimedVia::ClaimLeader);
38
- let receiver = r.receiver.as_ref().expect("claimed → receiver");
39
- assert_eq!(receiver.pane_id, caller);
40
- assert_eq!(receiver.discovery, Some(Discovery::ClaimLeader));
41
- let ws_path = crate::state::persist::runtime_state_path(&ws);
42
- let ws_state: serde_json::Value =
43
- serde_json::from_str(&std::fs::read_to_string(&ws_path).unwrap()).unwrap();
44
- let canonical_key = crate::state::projection::team_state_key(&ws_state);
45
- let ws_owner =
46
- crate::state::ownership::read_owner_value(&ws_state, &canonical_key)
47
- .expect("Stage 3d: vacant acquire writes canonical owner");
48
- assert_eq!(ws_owner["pane_id"], serde_json::json!("%5"));
49
- assert_eq!(ws_owner["owner_epoch"], serde_json::json!(1));
50
- let _ = &team_id;
51
- // F0-2: canonical claim path must NOT side-effect the legacy
52
- // per-session snapshot any more.
53
- let snap_path = crate::model::paths::runtime_dir(&ws)
54
- .join("teams").join("team-agent-x").join("state.json");
55
- assert!(
56
- !snap_path.exists(),
57
- "F0-2: vacant claim must not write a legacy per-session snapshot; found {}",
58
- snap_path.display(),
59
- );
60
- }
7
+ /// Injectable liveness probe (`state::owner_gate::PaneLivenessProbe`): a pane
8
+ /// in the seeded set is `Live`, otherwise `Dead`. The porter's real probe
9
+ /// (step 9 tmux) replaces it; here it drives the no-incident lease gate
10
+ /// (bound-pane liveness + caller eligibility) deterministically with no tmux.
11
+ #[test]
12
+ #[serial_test::serial(env)]
13
+ // Foundation-0 F0-2 conversion: `claim_lease_no_incident` used to
14
+ // dual-write the legacy per-session snapshot; that is retired
15
+ // (`.team/artifacts/foundation-0-slice-design.md` §§4-5). Canonical
16
+ // teams.<team_key> now carries the sole authoritative owner tuple.
17
+ fn claim_lease_no_incident_vacant_acquire_advances_epoch_and_writes_canonical_only() {
18
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
19
+ let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
20
+ let ws = p2_temp_ws("claim_vacant");
21
+ let team_id = TeamKey::new("current");
22
+ let caller = PaneId::new("%5");
23
+ let mut state = serde_json::json!({"session_name": "team-agent-x"});
24
+ let event_log = crate::event_log::EventLog::new(&ws);
25
+ let live = seeded_liveness(&["%5"]);
26
+ let r = claim_lease_no_incident(
27
+ &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
28
+ )
29
+ .unwrap();
30
+ assert!(r.ok);
31
+ assert_eq!(r.status, LeaseStatus::Claimed);
32
+ assert_eq!(r.reason, Some(LeaseReason::VacantAcquired));
33
+ assert_eq!(r.owner_epoch, Some(OwnerEpoch(1)), "vacant acquire 0→1");
34
+ let owner = r.owner.as_ref().expect("claimed → owner");
35
+ assert_eq!(owner.pane_id, caller);
36
+ assert_eq!(owner.owner_epoch, OwnerEpoch(1));
37
+ assert_eq!(owner.claimed_via, ClaimedVia::ClaimLeader);
38
+ let receiver = r.receiver.as_ref().expect("claimed → receiver");
39
+ assert_eq!(receiver.pane_id, caller);
40
+ assert_eq!(receiver.discovery, Some(Discovery::ClaimLeader));
41
+ let ws_path = crate::state::persist::runtime_state_path(&ws);
42
+ let ws_state: serde_json::Value =
43
+ serde_json::from_str(&std::fs::read_to_string(&ws_path).unwrap()).unwrap();
44
+ let canonical_key = crate::state::projection::team_state_key(&ws_state);
45
+ let ws_owner = crate::state::ownership::read_owner_value(&ws_state, &canonical_key)
46
+ .expect("Stage 3d: vacant acquire writes canonical owner");
47
+ assert_eq!(ws_owner["pane_id"], serde_json::json!("%5"));
48
+ assert_eq!(ws_owner["owner_epoch"], serde_json::json!(1));
49
+ let _ = &team_id;
50
+ // F0-2: canonical claim path must NOT side-effect the legacy
51
+ // per-session snapshot any more.
52
+ let snap_path = crate::model::paths::runtime_dir(&ws)
53
+ .join("teams")
54
+ .join("team-agent-x")
55
+ .join("state.json");
56
+ assert!(
57
+ !snap_path.exists(),
58
+ "F0-2: vacant claim must not write a legacy per-session snapshot; found {}",
59
+ snap_path.display(),
60
+ );
61
+ }
61
62
 
62
- // RED — DEAD-OWNER RECOVER: bound pane %1 absent from live set, caller %5
63
- // eligible, confirm=false → still acquires (dead owner never blocks),
64
- // reason=previous_owner_pane_dead, epoch 3→4. golden /tmp/probe_confirm.py.
65
- #[test]
66
- #[serial_test::serial(env)]
67
- fn claim_lease_no_incident_dead_owner_recovers_without_confirm() {
68
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
69
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
70
- let ws = p2_temp_ws("claim_dead");
71
- let team_id = TeamKey::new("current");
72
- let caller = PaneId::new("%5");
73
- let mut state = serde_json::json!({
74
- "session_name": "team-agent-x",
75
- "team_owner": {"pane_id":"%1","provider":"codex","machine_fingerprint":"fp","leader_session_uuid":"OLDUUID","owner_epoch":3,"claimed_at":"t","claimed_via":"claim-leader"},
76
- "leader_receiver": {"pane_id":"%1","owner_epoch":3,"leader_session_uuid":"OLDUUID"},
77
- });
78
- let event_log = crate::event_log::EventLog::new(&ws);
79
- // %1 (the recorded owner) is NOT live; only the caller %5 is live.
80
- let live = seeded_liveness(&["%5"]);
81
- let r = claim_lease_no_incident(
82
- &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
83
- )
84
- .unwrap();
85
- assert!(r.ok);
86
- assert_eq!(r.status, LeaseStatus::Claimed);
87
- assert_eq!(r.reason, Some(LeaseReason::PreviousOwnerPaneDead));
88
- assert_eq!(r.owner_epoch, Some(OwnerEpoch(4)), "dead-owner recover 3→4");
89
- assert_eq!(r.owner.as_ref().unwrap().pane_id, caller);
90
- }
63
+ // RED — DEAD-OWNER RECOVER: bound pane %1 absent from live set, caller %5
64
+ // eligible, confirm=false → still acquires (dead owner never blocks),
65
+ // reason=previous_owner_pane_dead, epoch 3→4. golden /tmp/probe_confirm.py.
66
+ #[test]
67
+ #[serial_test::serial(env)]
68
+ fn claim_lease_no_incident_dead_owner_recovers_without_confirm() {
69
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
70
+ let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
71
+ let ws = p2_temp_ws("claim_dead");
72
+ let team_id = TeamKey::new("current");
73
+ let caller = PaneId::new("%5");
74
+ let mut state = serde_json::json!({
75
+ "session_name": "team-agent-x",
76
+ "team_owner": {"pane_id":"%1","provider":"codex","machine_fingerprint":"fp","leader_session_uuid":"OLDUUID","owner_epoch":3,"claimed_at":"t","claimed_via":"claim-leader"},
77
+ "leader_receiver": {"pane_id":"%1","owner_epoch":3,"leader_session_uuid":"OLDUUID"},
78
+ });
79
+ let event_log = crate::event_log::EventLog::new(&ws);
80
+ // %1 (the recorded owner) is NOT live; only the caller %5 is live.
81
+ let live = seeded_liveness(&["%5"]);
82
+ let r = claim_lease_no_incident(
83
+ &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
84
+ )
85
+ .unwrap();
86
+ assert!(r.ok);
87
+ assert_eq!(r.status, LeaseStatus::Claimed);
88
+ assert_eq!(r.reason, Some(LeaseReason::PreviousOwnerPaneDead));
89
+ assert_eq!(r.owner_epoch, Some(OwnerEpoch(4)), "dead-owner recover 3→4");
90
+ assert_eq!(r.owner.as_ref().unwrap().pane_id, caller);
91
+ }
91
92
 
92
- // RED — LIVE-OWNER REFUSAL (no --confirm): bound pane %1 live, caller %5,
93
- // confirm=false → refused force_confirm_required + action + bound_pane_id=%1
94
- // + owner_epoch=2 (precheck epoch, NOT advanced). golden /tmp/probe_confirm.py.
95
- #[test]
96
- #[serial_test::serial(env)]
97
- fn claim_lease_no_incident_live_owner_refuses_without_confirm() {
98
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
99
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
100
- let ws = p2_temp_ws("claim_live");
101
- let team_id = TeamKey::new("current");
102
- let caller = PaneId::new("%5");
103
- let mut state = serde_json::json!({
104
- "session_name": "team-agent-x",
105
- "team_owner": {"pane_id":"%1","provider":"codex","machine_fingerprint":"fp","leader_session_uuid":"OWNERUUID","owner_epoch":2,"claimed_at":"t","claimed_via":"claim-leader"},
106
- "leader_receiver": {"pane_id":"%1","owner_epoch":2,"leader_session_uuid":"OWNERUUID"},
107
- });
108
- let event_log = crate::event_log::EventLog::new(&ws);
109
- // both %1 (live owner, matching uuid) and %5 (eligible caller) live.
110
- let live = seeded_liveness(&["%1", "%5"]);
111
- let r = claim_lease_no_incident(
112
- &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
113
- )
114
- .unwrap();
115
- assert!(!r.ok);
116
- assert_eq!(r.status, LeaseStatus::Refused);
117
- assert_eq!(r.reason, Some(LeaseReason::ForceConfirmRequired));
118
- assert_eq!(
119
- r.action.as_deref(),
120
- Some("rerun with --confirm to take over the live leader pane")
121
- );
122
- assert_eq!(r.bound_pane_id, Some(PaneId::new("%1")), "refusal carries bound pane");
123
- assert_eq!(r.owner_epoch, Some(OwnerEpoch(2)), "refused → precheck epoch, NOT advanced");
124
- }
93
+ // RED — LIVE-OWNER REFUSAL (no --confirm): bound pane %1 live, caller %5,
94
+ // confirm=false → refused force_confirm_required + action + bound_pane_id=%1
95
+ // + owner_epoch=2 (precheck epoch, NOT advanced). golden /tmp/probe_confirm.py.
96
+ #[test]
97
+ #[serial_test::serial(env)]
98
+ fn claim_lease_no_incident_live_owner_refuses_without_confirm() {
99
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
100
+ let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
101
+ let ws = p2_temp_ws("claim_live");
102
+ let team_id = TeamKey::new("current");
103
+ let caller = PaneId::new("%5");
104
+ let mut state = serde_json::json!({
105
+ "session_name": "team-agent-x",
106
+ "team_owner": {"pane_id":"%1","provider":"codex","machine_fingerprint":"fp","leader_session_uuid":"OWNERUUID","owner_epoch":2,"claimed_at":"t","claimed_via":"claim-leader"},
107
+ "leader_receiver": {"pane_id":"%1","owner_epoch":2,"leader_session_uuid":"OWNERUUID"},
108
+ });
109
+ let event_log = crate::event_log::EventLog::new(&ws);
110
+ // both %1 (live owner, matching uuid) and %5 (eligible caller) live.
111
+ let live = seeded_liveness(&["%1", "%5"]);
112
+ let r = claim_lease_no_incident(
113
+ &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
114
+ )
115
+ .unwrap();
116
+ assert!(!r.ok);
117
+ assert_eq!(r.status, LeaseStatus::Refused);
118
+ assert_eq!(r.reason, Some(LeaseReason::ForceConfirmRequired));
119
+ assert_eq!(
120
+ r.action.as_deref(),
121
+ Some("rerun with --confirm to take over the live leader pane")
122
+ );
123
+ assert_eq!(
124
+ r.bound_pane_id,
125
+ Some(PaneId::new("%1")),
126
+ "refusal carries bound pane"
127
+ );
128
+ assert_eq!(
129
+ r.owner_epoch,
130
+ Some(OwnerEpoch(2)),
131
+ "refused → precheck epoch, NOT advanced"
132
+ );
133
+ }
125
134
 
126
- // RED — ALREADY-BOUND: caller pane == bound pane → status=AlreadyBound,
127
- // ok=true, owner_epoch unchanged (no advance, no rewrite). golden probe_claimed.py.
128
- #[test]
129
- #[serial_test::serial(env)]
130
- fn claim_lease_no_incident_already_bound_when_caller_is_bound_pane() {
131
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
132
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
133
- let ws = p2_temp_ws("claim_bound");
134
- let team_id = TeamKey::new("current");
135
- let caller = PaneId::new("%5");
136
- let mut state = serde_json::json!({
137
- "session_name": "team-agent-x",
138
- "team_owner": {"pane_id":"%5","provider":"codex","machine_fingerprint":"fp","leader_session_uuid":"U","owner_epoch":1,"claimed_at":"t","claimed_via":"claim-leader"},
139
- "leader_receiver": {"pane_id":"%5","owner_epoch":1,"leader_session_uuid":"U"},
140
- });
141
- let event_log = crate::event_log::EventLog::new(&ws);
142
- let live = seeded_liveness(&["%5"]);
143
- let r = claim_lease_no_incident(
144
- &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
145
- )
146
- .unwrap();
147
- assert!(r.ok);
148
- assert_eq!(r.status, LeaseStatus::AlreadyBound);
149
- assert_eq!(r.owner_epoch, Some(OwnerEpoch(1)), "already-bound → epoch unchanged");
150
- assert!(r.reason.is_none(), "already-bound path carries no acquire reason");
151
- }
135
+ // RED — ALREADY-BOUND: caller pane == bound pane → status=AlreadyBound,
136
+ // ok=true, owner_epoch unchanged (no advance, no rewrite). golden probe_claimed.py.
137
+ #[test]
138
+ #[serial_test::serial(env)]
139
+ fn claim_lease_no_incident_already_bound_when_caller_is_bound_pane() {
140
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
141
+ let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
142
+ let ws = p2_temp_ws("claim_bound");
143
+ let team_id = TeamKey::new("current");
144
+ let caller = PaneId::new("%5");
145
+ let mut state = serde_json::json!({
146
+ "session_name": "team-agent-x",
147
+ "team_owner": {"pane_id":"%5","provider":"codex","machine_fingerprint":"fp","leader_session_uuid":"U","owner_epoch":1,"claimed_at":"t","claimed_via":"claim-leader"},
148
+ "leader_receiver": {"pane_id":"%5","owner_epoch":1,"leader_session_uuid":"U"},
149
+ });
150
+ let event_log = crate::event_log::EventLog::new(&ws);
151
+ let live = seeded_liveness(&["%5"]);
152
+ let r = claim_lease_no_incident(
153
+ &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
154
+ )
155
+ .unwrap();
156
+ assert!(r.ok);
157
+ assert_eq!(r.status, LeaseStatus::AlreadyBound);
158
+ assert_eq!(
159
+ r.owner_epoch,
160
+ Some(OwnerEpoch(1)),
161
+ "already-bound → epoch unchanged"
162
+ );
163
+ assert!(
164
+ r.reason.is_none(),
165
+ "already-bound path carries no acquire reason"
166
+ );
167
+ }
152
168
 
153
- #[test]
154
- #[serial_test::serial(env)]
155
- fn claim_leader_persists_full_tmux_endpoint_when_tmux_tmpdir_differs_from_coordinator() {
156
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
157
- let leader_socket = "/tmp/ta-leader-root/tmux-501/dl2f";
158
- let _e = EnvGuard::apply(&[
159
- ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
160
- ("TMUX", Some("/tmp/ta-leader-root/tmux-501/dl2f,12345,0")),
161
- ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
162
- ]);
163
- let ws = p2_temp_ws("claim_full_endpoint");
164
- let team_id = TeamKey::new("current");
165
- let caller = PaneId::new("%5");
166
- let mut state = serde_json::json!({"session_name": "team-agent-x"});
167
- let event_log = crate::event_log::EventLog::new(&ws);
168
- let live = seeded_liveness(&["%5"]);
169
+ #[test]
170
+ #[serial_test::serial(env)]
171
+ fn claim_leader_persists_full_tmux_endpoint_when_tmux_tmpdir_differs_from_coordinator() {
172
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
173
+ let leader_socket = "/tmp/ta-leader-root/tmux-501/dl2f";
174
+ let _e = EnvGuard::apply(&[
175
+ ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
176
+ ("TMUX", Some("/tmp/ta-leader-root/tmux-501/dl2f,12345,0")),
177
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
178
+ ]);
179
+ let ws = p2_temp_ws("claim_full_endpoint");
180
+ let team_id = TeamKey::new("current");
181
+ let caller = PaneId::new("%5");
182
+ let mut state = serde_json::json!({"session_name": "team-agent-x"});
183
+ let event_log = crate::event_log::EventLog::new(&ws);
184
+ let live = seeded_liveness(&["%5"]);
169
185
 
170
- let r = claim_lease_no_incident(
171
- &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
172
- )
173
- .unwrap();
186
+ let r = claim_lease_no_incident(
187
+ &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
188
+ )
189
+ .unwrap();
174
190
 
175
- assert!(r.ok);
176
- assert_eq!(r.status, LeaseStatus::Claimed);
177
- assert_eq!(
178
- r.receiver.as_ref().and_then(|receiver| receiver.tmux_socket.as_deref()),
179
- Some(leader_socket),
180
- "claim-leader must persist the full $TMUX socket path, not only the -L short name"
181
- );
182
- let persisted: serde_json::Value = serde_json::from_str(
183
- &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
184
- )
185
- .unwrap();
186
- // Stage 3d: canonical receiver at teams.<team_key>.leader_receiver.
187
- let team_key = crate::state::projection::team_state_key(&persisted);
188
- let receiver = &persisted["teams"][&team_key]["leader_receiver"];
189
- assert_eq!(
190
- receiver["tmux_socket"],
191
- serde_json::json!(leader_socket),
192
- "state.json must carry enough endpoint information for later delivery to use tmux -S"
193
- );
194
- }
191
+ assert!(r.ok);
192
+ assert_eq!(r.status, LeaseStatus::Claimed);
193
+ assert_eq!(
194
+ r.receiver
195
+ .as_ref()
196
+ .and_then(|receiver| receiver.tmux_socket.as_deref()),
197
+ Some(leader_socket),
198
+ "claim-leader must persist the full $TMUX socket path, not only the -L short name"
199
+ );
200
+ let persisted: serde_json::Value = serde_json::from_str(
201
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
202
+ )
203
+ .unwrap();
204
+ // Stage 3d: canonical receiver at teams.<team_key>.leader_receiver.
205
+ let team_key = crate::state::projection::team_state_key(&persisted);
206
+ let receiver = &persisted["teams"][&team_key]["leader_receiver"];
207
+ assert_eq!(
208
+ receiver["tmux_socket"],
209
+ serde_json::json!(leader_socket),
210
+ "state.json must carry enough endpoint information for later delivery to use tmux -S"
211
+ );
212
+ }
195
213
 
196
- #[test]
197
- #[serial_test::serial(env)]
198
- fn claim_leader_already_bound_requires_same_delivery_endpoint_not_only_same_pane_id() {
199
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
200
- let current_socket = "/tmp/ta-current-leader-root/tmux-501/dl2f";
201
- let stale_socket = "/tmp/ta-stale-leader-root/tmux-501/dl2f";
202
- let _e = EnvGuard::apply(&[
203
- ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
204
- ("TMUX", Some("/tmp/ta-current-leader-root/tmux-501/dl2f,12345,0")),
205
- ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
206
- ]);
207
- let ws = p2_temp_ws("claim_bound_endpoint");
208
- let team_id = TeamKey::new("current");
209
- let caller = PaneId::new("%5");
210
- let mut state = serde_json::json!({
211
- "session_name": "team-agent-x",
212
- "team_owner": {
213
- "pane_id":"%5",
214
- "provider":"codex",
215
- "machine_fingerprint":"fp",
216
- "leader_session_uuid":"U",
217
- "owner_epoch":1,
218
- "claimed_at":"t",
219
- "claimed_via":"claim-leader",
220
- "tmux_socket": stale_socket
221
- },
222
- "leader_receiver": {
223
- "pane_id":"%5",
224
- "owner_epoch":1,
225
- "leader_session_uuid":"U",
226
- "tmux_socket": stale_socket
227
- },
228
- });
229
- let event_log = crate::event_log::EventLog::new(&ws);
230
- let live = seeded_liveness(&["%5"]);
214
+ #[test]
215
+ #[serial_test::serial(env)]
216
+ fn claim_leader_already_bound_requires_same_delivery_endpoint_not_only_same_pane_id() {
217
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
218
+ let current_socket = "/tmp/ta-current-leader-root/tmux-501/dl2f";
219
+ let stale_socket = "/tmp/ta-stale-leader-root/tmux-501/dl2f";
220
+ let _e = EnvGuard::apply(&[
221
+ ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
222
+ (
223
+ "TMUX",
224
+ Some("/tmp/ta-current-leader-root/tmux-501/dl2f,12345,0"),
225
+ ),
226
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
227
+ ]);
228
+ let ws = p2_temp_ws("claim_bound_endpoint");
229
+ let team_id = TeamKey::new("current");
230
+ let caller = PaneId::new("%5");
231
+ let mut state = serde_json::json!({
232
+ "session_name": "team-agent-x",
233
+ "team_owner": {
234
+ "pane_id":"%5",
235
+ "provider":"codex",
236
+ "machine_fingerprint":"fp",
237
+ "leader_session_uuid":"U",
238
+ "owner_epoch":1,
239
+ "claimed_at":"t",
240
+ "claimed_via":"claim-leader",
241
+ "tmux_socket": stale_socket
242
+ },
243
+ "leader_receiver": {
244
+ "pane_id":"%5",
245
+ "owner_epoch":1,
246
+ "leader_session_uuid":"U",
247
+ "tmux_socket": stale_socket
248
+ },
249
+ });
250
+ let event_log = crate::event_log::EventLog::new(&ws);
251
+ let live = seeded_liveness(&["%5"]);
231
252
 
232
- let r = claim_lease_no_incident(
233
- &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
234
- )
235
- .unwrap();
253
+ let r = claim_lease_no_incident(
254
+ &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
255
+ )
256
+ .unwrap();
236
257
 
237
- assert_ne!(
238
- r.status,
239
- LeaseStatus::AlreadyBound,
240
- "already_bound must verify the same delivery endpoint is reachable; matching bare pane \
258
+ assert_ne!(
259
+ r.status,
260
+ LeaseStatus::AlreadyBound,
261
+ "already_bound must verify the same delivery endpoint is reachable; matching bare pane \
241
262
  ids are not sufficient because different tmux socket roots can both have %5"
242
- );
243
- assert_eq!(
244
- r.receiver.as_ref().and_then(|receiver| receiver.tmux_socket.as_deref()),
245
- Some(current_socket),
246
- "claim should refresh the receiver to the caller's current full tmux endpoint"
247
- );
248
- }
263
+ );
264
+ assert_eq!(
265
+ r.receiver
266
+ .as_ref()
267
+ .and_then(|receiver| receiver.tmux_socket.as_deref()),
268
+ Some(current_socket),
269
+ "claim should refresh the receiver to the caller's current full tmux endpoint"
270
+ );
271
+ }
249
272
 
250
- #[test]
251
- #[serial_test::serial(env)]
252
- fn claim_leader_already_bound_normalizes_short_tmux_endpoint_to_full_path() {
253
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
254
- let current_socket = "/tmp/ta-current-leader-root/tmux-501/dl9aa40c88";
255
- let short_socket = "dl9aa40c88";
256
- let _e = EnvGuard::apply(&[
257
- ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
258
- ("TMUX", Some("/tmp/ta-current-leader-root/tmux-501/dl9aa40c88,12345,0")),
259
- ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
260
- ]);
261
- let ws = p2_temp_ws("claim_bound_short_endpoint");
262
- let team_id = TeamKey::new("current");
263
- let caller = PaneId::new("%5");
264
- let mut state = serde_json::json!({
265
- "session_name": "team-agent-x",
266
- "team_owner": {
267
- "pane_id":"%5",
268
- "provider":"codex",
269
- "machine_fingerprint":"fp",
270
- "leader_session_uuid":"U",
271
- "owner_epoch":1,
272
- "claimed_at":"t",
273
- "claimed_via":"claim-leader",
274
- "tmux_socket": short_socket
275
- },
276
- "leader_receiver": {
277
- "pane_id":"%5",
278
- "owner_epoch":1,
279
- "leader_session_uuid":"U",
280
- "tmux_socket": short_socket
281
- },
282
- });
283
- let event_log = crate::event_log::EventLog::new(&ws);
284
- let live = seeded_liveness(&["%5"]);
273
+ #[test]
274
+ #[serial_test::serial(env)]
275
+ fn claim_leader_already_bound_normalizes_short_tmux_endpoint_to_full_path() {
276
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
277
+ let current_socket = "/tmp/ta-current-leader-root/tmux-501/dl9aa40c88";
278
+ let short_socket = "dl9aa40c88";
279
+ let _e = EnvGuard::apply(&[
280
+ ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
281
+ (
282
+ "TMUX",
283
+ Some("/tmp/ta-current-leader-root/tmux-501/dl9aa40c88,12345,0"),
284
+ ),
285
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
286
+ ]);
287
+ let ws = p2_temp_ws("claim_bound_short_endpoint");
288
+ let team_id = TeamKey::new("current");
289
+ let caller = PaneId::new("%5");
290
+ let mut state = serde_json::json!({
291
+ "session_name": "team-agent-x",
292
+ "team_owner": {
293
+ "pane_id":"%5",
294
+ "provider":"codex",
295
+ "machine_fingerprint":"fp",
296
+ "leader_session_uuid":"U",
297
+ "owner_epoch":1,
298
+ "claimed_at":"t",
299
+ "claimed_via":"claim-leader",
300
+ "tmux_socket": short_socket
301
+ },
302
+ "leader_receiver": {
303
+ "pane_id":"%5",
304
+ "owner_epoch":1,
305
+ "leader_session_uuid":"U",
306
+ "tmux_socket": short_socket
307
+ },
308
+ });
309
+ let event_log = crate::event_log::EventLog::new(&ws);
310
+ let live = seeded_liveness(&["%5"]);
285
311
 
286
- let r = claim_lease_no_incident(
287
- &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
288
- )
289
- .unwrap();
312
+ let r = claim_lease_no_incident(
313
+ &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
314
+ )
315
+ .unwrap();
290
316
 
291
- assert_ne!(
292
- r.status,
293
- LeaseStatus::AlreadyBound,
294
- "claim already_bound must not treat a stored short socket name as equivalent to the \
317
+ assert_ne!(
318
+ r.status,
319
+ LeaseStatus::AlreadyBound,
320
+ "claim already_bound must not treat a stored short socket name as equivalent to the \
295
321
  caller's full $TMUX endpoint by basename; it must refresh/normalize the receiver"
296
- );
297
- assert_eq!(
322
+ );
323
+ assert_eq!(
298
324
  r.receiver.as_ref().and_then(|receiver| receiver.tmux_socket.as_deref()),
299
325
  Some(current_socket),
300
326
  "claim should rewrite any legacy short leader_receiver.tmux_socket to the full physical endpoint"
301
327
  );
302
- let persisted: serde_json::Value = serde_json::from_str(
303
- &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
304
- )
305
- .unwrap();
306
- // Stage 3d: canonical receiver at teams.<team_key>.leader_receiver.
307
- let team_key = crate::state::projection::team_state_key(&persisted);
308
- let receiver = &persisted["teams"][&team_key]["leader_receiver"];
309
- assert_eq!(
310
- receiver["tmux_socket"],
311
- serde_json::json!(current_socket),
312
- "state must not preserve a short endpoint after explicit claim; state={persisted}"
313
- );
314
- }
328
+ let persisted: serde_json::Value = serde_json::from_str(
329
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
330
+ )
331
+ .unwrap();
332
+ // Stage 3d: canonical receiver at teams.<team_key>.leader_receiver.
333
+ let team_key = crate::state::projection::team_state_key(&persisted);
334
+ let receiver = &persisted["teams"][&team_key]["leader_receiver"];
335
+ assert_eq!(
336
+ receiver["tmux_socket"],
337
+ serde_json::json!(current_socket),
338
+ "state must not preserve a short endpoint after explicit claim; state={persisted}"
339
+ );
340
+ }
315
341
 
316
- // RED — NOT-IN-TMUX-PANE: empty caller pane → refused not_in_tmux_pane with
317
- // the EXACT golden action string (differs from the current claim_leader stub
318
- // string). golden /tmp/probe_claim.py _lease_refused("not_in_tmux_pane",...).
319
- #[test]
320
- #[serial_test::serial(env)]
321
- fn claim_lease_no_incident_empty_caller_refuses_not_in_tmux_pane() {
322
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
323
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
324
- let ws = p2_temp_ws("claim_nopane");
325
- let team_id = TeamKey::new("current");
326
- let empty = PaneId::new("");
327
- let mut state = serde_json::json!({});
328
- let event_log = crate::event_log::EventLog::new(&ws);
329
- let live = seeded_liveness(&[]);
330
- let r = claim_lease_no_incident(
331
- &ws, &mut state, None, &team_id, &empty, false, &event_log, &live,
332
- )
333
- .unwrap();
334
- assert!(!r.ok);
335
- assert_eq!(r.status, LeaseStatus::Refused);
336
- assert_eq!(r.reason, Some(LeaseReason::NotInTmuxPane));
337
- assert_eq!(
338
- r.action.as_deref(),
339
- Some("run team-agent claim-leader from the leader's tmux pane"),
340
- "byte-parity: not_in_tmux_pane action string from _lease_refused (probe_claim.py)"
341
- );
342
- }
342
+ // RED — NOT-IN-TMUX-PANE: empty caller pane → refused not_in_tmux_pane with
343
+ // the EXACT golden action string (differs from the current claim_leader stub
344
+ // string). golden /tmp/probe_claim.py _lease_refused("not_in_tmux_pane",...).
345
+ #[test]
346
+ #[serial_test::serial(env)]
347
+ fn claim_lease_no_incident_empty_caller_refuses_not_in_tmux_pane() {
348
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
349
+ let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
350
+ let ws = p2_temp_ws("claim_nopane");
351
+ let team_id = TeamKey::new("current");
352
+ let empty = PaneId::new("");
353
+ let mut state = serde_json::json!({});
354
+ let event_log = crate::event_log::EventLog::new(&ws);
355
+ let live = seeded_liveness(&[]);
356
+ let r = claim_lease_no_incident(
357
+ &ws, &mut state, None, &team_id, &empty, false, &event_log, &live,
358
+ )
359
+ .unwrap();
360
+ assert!(!r.ok);
361
+ assert_eq!(r.status, LeaseStatus::Refused);
362
+ assert_eq!(r.reason, Some(LeaseReason::NotInTmuxPane));
363
+ assert_eq!(
364
+ r.action.as_deref(),
365
+ Some("run team-agent claim-leader from the leader's tmux pane"),
366
+ "byte-parity: not_in_tmux_pane action string from _lease_refused (probe_claim.py)"
367
+ );
368
+ }
343
369
 
344
- // OLD (Python parity, pre-Bug-3): caller pane present but NOT in the live
345
- // leader-shape set → refused with CallerNotLeaderShaped + eligibility action
346
- // ("from a leader (claude/codex) tmux pane").
347
- // NEW (Bug 3 / I-RN-3 / tests/explicit_claim_takeover_any_live_pane_red):
348
- // the hard leader-shape gate is removed by design. claim_lease_no_incident
349
- // now accepts any LIVE caller pane (Codex / Claude / any worker shape). The
350
- // only remaining caller refusal on this no-incident path is "caller pane is
351
- // not live at all"; with seeded_liveness(&[]) the caller %7 is therefore
352
- // refused as CallerPaneNotLive (CallerNotLeaderShaped is no longer produced
353
- // on this code path).
354
- #[test]
355
- #[serial_test::serial(env)]
356
- fn claim_lease_no_incident_caller_not_live_refused_under_n_rn3_any_live_pane() {
357
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
358
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
359
- let ws = p2_temp_ws("claim_notleader");
360
- let team_id = TeamKey::new("current");
361
- let caller = PaneId::new("%7");
362
- let mut state = serde_json::json!({});
363
- let event_log = crate::event_log::EventLog::new(&ws);
364
- // Empty live-set → caller %7 is not a live pane at all.
365
- let live = seeded_liveness(&[]);
366
- let r = claim_lease_no_incident(
367
- &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
368
- )
369
- .unwrap();
370
- assert!(!r.ok);
371
- assert_eq!(r.status, LeaseStatus::Refused);
372
- assert_eq!(r.reason, Some(LeaseReason::CallerPaneNotLive));
373
- }
370
+ // OLD (Python parity, pre-Bug-3): caller pane present but NOT in the live
371
+ // leader-shape set → refused with CallerNotLeaderShaped + eligibility action
372
+ // ("from a leader (claude/codex) tmux pane").
373
+ // NEW (Bug 3 / I-RN-3 / tests/explicit_claim_takeover_any_live_pane_red):
374
+ // the hard leader-shape gate is removed by design. claim_lease_no_incident
375
+ // now accepts any LIVE caller pane (Codex / Claude / any worker shape). The
376
+ // only remaining caller refusal on this no-incident path is "caller pane is
377
+ // not live at all"; with seeded_liveness(&[]) the caller %7 is therefore
378
+ // refused as CallerPaneNotLive (CallerNotLeaderShaped is no longer produced
379
+ // on this code path).
380
+ #[test]
381
+ #[serial_test::serial(env)]
382
+ fn claim_lease_no_incident_caller_not_live_refused_under_n_rn3_any_live_pane() {
383
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
384
+ let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
385
+ let ws = p2_temp_ws("claim_notleader");
386
+ let team_id = TeamKey::new("current");
387
+ let caller = PaneId::new("%7");
388
+ let mut state = serde_json::json!({});
389
+ let event_log = crate::event_log::EventLog::new(&ws);
390
+ // Empty live-set → caller %7 is not a live pane at all.
391
+ let live = seeded_liveness(&[]);
392
+ let r = claim_lease_no_incident(
393
+ &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
394
+ )
395
+ .unwrap();
396
+ assert!(!r.ok);
397
+ assert_eq!(r.status, LeaseStatus::Refused);
398
+ assert_eq!(r.reason, Some(LeaseReason::CallerPaneNotLive));
399
+ }
400
+
401
+ // ── 14b. leader_identity dict — workspace_abspath key (golden __init__.py:363) ──
402
+ //
403
+ // LOCK→RED: leader_identity IS implemented (owner_bind.rs:22) but its dict
404
+ // OMITS the golden `workspace_abspath` key (probe_lid.py shows it sits between
405
+ // machine_fingerprint and os_user). This pins the missing key so the porter
406
+ // must add it. Fails today (key absent → null) = RED against the omission.
407
+ #[test]
408
+ #[serial_test::serial(env)]
409
+ fn leader_identity_dict_includes_workspace_abspath_key() {
410
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
411
+ let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
412
+ let ws = p2_temp_ws("lid_wsabs");
413
+ let v = leader_identity(&ws, None).unwrap();
414
+ let ctx = leader_identity_context(&ws, None, Some(&serde_json::json!({}))).unwrap();
415
+ // golden key present + equals the resolved workspace abspath from context.
416
+ assert!(
417
+ v.get("workspace_abspath").is_some(),
418
+ "golden leader_identity dict carries 'workspace_abspath' (probe_lid.py)"
419
+ );
420
+ assert_eq!(
421
+ v["workspace_abspath"].as_str().unwrap(),
422
+ ctx.workspace_abspath.to_string_lossy(),
423
+ "workspace_abspath must equal the context's resolved abspath"
424
+ );
425
+ }
374
426
 
375
- // ── 14b. leader_identity dict workspace_abspath key (golden __init__.py:363) ──
376
- //
377
- // LOCK→RED: leader_identity IS implemented (owner_bind.rs:22) but its dict
378
- // OMITS the golden `workspace_abspath` key (probe_lid.py shows it sits between
379
- // machine_fingerprint and os_user). This pins the missing key so the porter
380
- // must add it. Fails today (key absent → null) = RED against the omission.
381
- #[test]
382
- #[serial_test::serial(env)]
383
- fn leader_identity_dict_includes_workspace_abspath_key() {
384
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
385
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
386
- let ws = p2_temp_ws("lid_wsabs");
387
- let v = leader_identity(&ws, None).unwrap();
388
- let ctx = leader_identity_context(&ws, None, Some(&serde_json::json!({}))).unwrap();
389
- // golden key present + equals the resolved workspace abspath from context.
427
+ // LOCK leader_identity key ORDER & full key set (golden probe_lid.py):
428
+ // [ok, uuid_prefix, machine_fingerprint, workspace_abspath, os_user, team_id,
429
+ // current_pane_id, last_seen_at, source]. Pins the 9-key surface CLI emits.
430
+ #[test]
431
+ #[serial_test::serial(env)]
432
+ fn leader_identity_dict_has_exact_nine_golden_keys() {
433
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
434
+ let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
435
+ let ws = p2_temp_ws("lid_keys");
436
+ let v = leader_identity(&ws, None).unwrap();
437
+ let obj = v.as_object().expect("leader_identity → JSON object");
438
+ for key in [
439
+ "ok",
440
+ "uuid_prefix",
441
+ "machine_fingerprint",
442
+ "workspace_abspath",
443
+ "os_user",
444
+ "team_id",
445
+ "current_pane_id",
446
+ "last_seen_at",
447
+ "source",
448
+ ] {
390
449
  assert!(
391
- v.get("workspace_abspath").is_some(),
392
- "golden leader_identity dict carries 'workspace_abspath' (probe_lid.py)"
450
+ obj.contains_key(key),
451
+ "golden leader_identity dict must carry '{key}'"
393
452
  );
394
- assert_eq!(
395
- v["workspace_abspath"].as_str().unwrap(),
396
- ctx.workspace_abspath.to_string_lossy(),
397
- "workspace_abspath must equal the context's resolved abspath"
398
- );
399
- }
400
-
401
- // LOCK — leader_identity key ORDER & full key set (golden probe_lid.py):
402
- // [ok, uuid_prefix, machine_fingerprint, workspace_abspath, os_user, team_id,
403
- // current_pane_id, last_seen_at, source]. Pins the 9-key surface CLI emits.
404
- #[test]
405
- #[serial_test::serial(env)]
406
- fn leader_identity_dict_has_exact_nine_golden_keys() {
407
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
408
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
409
- let ws = p2_temp_ws("lid_keys");
410
- let v = leader_identity(&ws, None).unwrap();
411
- let obj = v.as_object().expect("leader_identity → JSON object");
412
- for key in [
413
- "ok", "uuid_prefix", "machine_fingerprint", "workspace_abspath",
414
- "os_user", "team_id", "current_pane_id", "last_seen_at", "source",
415
- ] {
416
- assert!(obj.contains_key(key), "golden leader_identity dict must carry '{key}'");
417
- }
418
453
  }
454
+ }
419
455
 
420
- // ═════════════════════════════════════════════════════════════════════════
421
- // WAVE-2 Lane B — leader-lease DIVERGENCE round (adversarial review @ 2cd71ce).
422
- // The lease gate is green but NOT byte-parity. Golden: leader/__init__.py
423
- // (_claim_lease_no_incident:598, _receiver_from_claim_target:861, new_owner:686,
424
- // _lease_epoch:400, _emit_lease_refusal:469, audit :712-729). These REDs lock the EXACT
425
- // golden shape per divergence; driven via claim_lease_no_incident(+seeded_liveness), OS-safe.
426
- // ═════════════════════════════════════════════════════════════════════════
456
+ // ═════════════════════════════════════════════════════════════════════════
457
+ // WAVE-2 Lane B — leader-lease DIVERGENCE round (adversarial review @ 2cd71ce).
458
+ // The lease gate is green but NOT byte-parity. Golden: leader/__init__.py
459
+ // (_claim_lease_no_incident:598, _receiver_from_claim_target:861, new_owner:686,
460
+ // _lease_epoch:400, _emit_lease_refusal:469, audit :712-729). These REDs lock the EXACT
461
+ // golden shape per divergence; driven via claim_lease_no_incident(+seeded_liveness), OS-safe.
462
+ // ═════════════════════════════════════════════════════════════════════════
427
463
 
428
- /// **S3-OWNER-001 regression** (architect Stage 3 owner persist fix,
429
- /// .team/artifacts/stage3-owner-persist-fix.md). Pre-fix on commit
430
- /// `5d980c7`:
431
- /// 1. `claim-leader --team alpha` returns `ok=true/status=claimed`.
432
- /// 2. In-memory state has `state.teams.alpha.team_owner` set by
433
- /// `write_binding_to_state` → `state::ownership::write_owner`.
434
- /// 3. `save_claim_team_scoped_state` calls `compact_team_state(state)` to
435
- /// build the per-team snapshot. `compact_team_state` strips the
436
- /// `teams` key entirely — the just-written nested ownership record
437
- /// is DROPPED on the way to disk.
438
- /// 4. After Stage 3d removed the top-level dual-write, no copy survives.
439
- /// 5. Reloading the workspace state.json shows `persisted_owner_locations=[]`.
440
- ///
441
- /// Fix: `save_claim_team_scoped_state` now uses
442
- /// `compact_team_state_preserving_ownership(state, target_key)` which
443
- /// re-injects `team_owner / leader_receiver / owner_epoch` from
444
- /// `state.teams[target_key]` into the compacted entry. Top-level fields
445
- /// remain absent (Stage 3d canonical shape preserved).
446
- #[test]
447
- #[serial_test::serial(env)]
448
- fn claim_team_scoped_persists_canonical_owner_under_teams_target_key() {
449
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
450
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
451
- let ws = p2_temp_ws("claim_team_persist");
452
- let target = "alpha";
453
- let team_id = TeamKey::new(target);
454
- let caller = PaneId::new("%5");
455
- // Seed runtime state with a teams entry but no owner fields anywhere
456
- // (the S3-OWNER-001 starting condition). team_dir is set so
457
- // team_state_key resolves to the target team_key, matching the
458
- // claim path's projected-state shape.
459
- let mut state = serde_json::json!({
460
- "session_name": "team-agent-x",
461
- "team_dir": format!("/tmp/{}", target),
462
- "teams": {target: {}},
463
- });
464
- let event_log = crate::event_log::EventLog::new(&ws);
465
- let live = seeded_liveness(&["%5"]);
466
- let r = claim_lease_no_incident(
467
- &ws, &mut state, Some(target), &team_id, &caller, false, &event_log, &live,
468
- )
469
- .unwrap();
470
- assert!(r.ok, "S3-OWNER-001: claim must succeed; got {r:?}");
471
- assert_eq!(r.status, LeaseStatus::Claimed);
464
+ /// **S3-OWNER-001 regression** (architect Stage 3 owner persist fix,
465
+ /// .team/artifacts/stage3-owner-persist-fix.md). Pre-fix on commit
466
+ /// `5d980c7`:
467
+ /// 1. `claim-leader --team alpha` returns `ok=true/status=claimed`.
468
+ /// 2. In-memory state has `state.teams.alpha.team_owner` set by
469
+ /// `write_binding_to_state` → `state::ownership::write_owner`.
470
+ /// 3. `save_claim_team_scoped_state` calls `compact_team_state(state)` to
471
+ /// build the per-team snapshot. `compact_team_state` strips the
472
+ /// `teams` key entirely — the just-written nested ownership record
473
+ /// is DROPPED on the way to disk.
474
+ /// 4. After Stage 3d removed the top-level dual-write, no copy survives.
475
+ /// 5. Reloading the workspace state.json shows `persisted_owner_locations=[]`.
476
+ ///
477
+ /// Fix: `save_claim_team_scoped_state` now uses
478
+ /// `compact_team_state_preserving_ownership(state, target_key)` which
479
+ /// re-injects `team_owner / leader_receiver / owner_epoch` from
480
+ /// `state.teams[target_key]` into the compacted entry. Top-level fields
481
+ /// remain absent (Stage 3d canonical shape preserved).
482
+ #[test]
483
+ #[serial_test::serial(env)]
484
+ fn claim_team_scoped_persists_canonical_owner_under_teams_target_key() {
485
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
486
+ let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
487
+ let ws = p2_temp_ws("claim_team_persist");
488
+ let target = "alpha";
489
+ let team_id = TeamKey::new(target);
490
+ let caller = PaneId::new("%5");
491
+ // Seed runtime state with a teams entry but no owner fields anywhere
492
+ // (the S3-OWNER-001 starting condition). team_dir is set so
493
+ // team_state_key resolves to the target team_key, matching the
494
+ // claim path's projected-state shape.
495
+ let mut state = serde_json::json!({
496
+ "session_name": "team-agent-x",
497
+ "team_dir": format!("/tmp/{}", target),
498
+ "teams": {target: {}},
499
+ });
500
+ let event_log = crate::event_log::EventLog::new(&ws);
501
+ let live = seeded_liveness(&["%5"]);
502
+ let r = claim_lease_no_incident(
503
+ &ws,
504
+ &mut state,
505
+ Some(target),
506
+ &team_id,
507
+ &caller,
508
+ false,
509
+ &event_log,
510
+ &live,
511
+ )
512
+ .unwrap();
513
+ assert!(r.ok, "S3-OWNER-001: claim must succeed; got {r:?}");
514
+ assert_eq!(r.status, LeaseStatus::Claimed);
472
515
 
473
- // Reload state from disk — this is where the pre-fix loss surfaced.
474
- let persisted_path = crate::state::persist::runtime_state_path(&ws);
475
- let persisted: serde_json::Value = serde_json::from_str(
476
- &std::fs::read_to_string(&persisted_path).unwrap(),
477
- )
478
- .unwrap();
516
+ // Reload state from disk — this is where the pre-fix loss surfaced.
517
+ let persisted_path = crate::state::persist::runtime_state_path(&ws);
518
+ let persisted: serde_json::Value =
519
+ serde_json::from_str(&std::fs::read_to_string(&persisted_path).unwrap()).unwrap();
479
520
 
480
- // Canonical: teams.<target_key>.{team_owner, leader_receiver, owner_epoch}.
481
- let entry = &persisted["teams"][target];
482
- assert_eq!(
483
- entry["team_owner"]["pane_id"],
484
- serde_json::json!("%5"),
485
- "S3-OWNER-001 fix: teams.{target}.team_owner.pane_id persisted; \
521
+ // Canonical: teams.<target_key>.{team_owner, leader_receiver, owner_epoch}.
522
+ let entry = &persisted["teams"][target];
523
+ assert_eq!(
524
+ entry["team_owner"]["pane_id"],
525
+ serde_json::json!("%5"),
526
+ "S3-OWNER-001 fix: teams.{target}.team_owner.pane_id persisted; \
486
527
  persisted={persisted}"
487
- );
488
- assert_eq!(
489
- entry["leader_receiver"]["pane_id"],
490
- serde_json::json!("%5"),
491
- "S3-OWNER-001 fix: teams.{target}.leader_receiver.pane_id persisted; \
528
+ );
529
+ assert_eq!(
530
+ entry["leader_receiver"]["pane_id"],
531
+ serde_json::json!("%5"),
532
+ "S3-OWNER-001 fix: teams.{target}.leader_receiver.pane_id persisted; \
492
533
  persisted={persisted}"
493
- );
494
- assert_eq!(
495
- entry["owner_epoch"], serde_json::json!(1),
496
- "S3-OWNER-001 fix: teams.{target}.owner_epoch=1 persisted; \
534
+ );
535
+ assert_eq!(
536
+ entry["owner_epoch"],
537
+ serde_json::json!(1),
538
+ "S3-OWNER-001 fix: teams.{target}.owner_epoch=1 persisted; \
497
539
  persisted={persisted}"
498
- );
540
+ );
499
541
 
500
- // Stage 3d invariant: top-level owner fields stay ABSENT (no
501
- // recurrence of the legacy dual-write).
502
- assert!(
503
- persisted.get("team_owner").is_none(),
504
- "Stage 3d: top-level team_owner must remain absent; persisted={persisted}"
505
- );
506
- assert!(
507
- persisted.get("leader_receiver").is_none(),
508
- "Stage 3d: top-level leader_receiver must remain absent; persisted={persisted}"
509
- );
510
- assert!(
511
- persisted.get("owner_epoch").is_none(),
512
- "Stage 3d: top-level owner_epoch must remain absent; persisted={persisted}"
513
- );
514
- }
542
+ // Stage 3d invariant: top-level owner fields stay ABSENT (no
543
+ // recurrence of the legacy dual-write).
544
+ assert!(
545
+ persisted.get("team_owner").is_none(),
546
+ "Stage 3d: top-level team_owner must remain absent; persisted={persisted}"
547
+ );
548
+ assert!(
549
+ persisted.get("leader_receiver").is_none(),
550
+ "Stage 3d: top-level leader_receiver must remain absent; persisted={persisted}"
551
+ );
552
+ assert!(
553
+ persisted.get("owner_epoch").is_none(),
554
+ "Stage 3d: top-level owner_epoch must remain absent; persisted={persisted}"
555
+ );
556
+ }
515
557
 
516
- /// **S3-OWNER-002 regression** (architect Stage 3 top-level cleanup,
517
- /// .team/artifacts/stage3-toplevel-cleanup-fix.md). Even with the
518
- /// nested-ownership-preserving compact (aae818f), a SECOND claim on a
519
- /// state whose root still carries legacy top-level `team_owner` /
520
- /// `leader_receiver` / `owner_epoch` would leave the stale root copy
521
- /// on disk while the canonical `teams.<key>` advanced. Evidence:
522
- /// `state-after-claim.json` showed `team_owner=%2/epoch=1` at root and
523
- /// `teams.<key>.team_owner=%3/epoch=2` — exact dual-source recurrence.
524
- ///
525
- /// Fix: `save_claim_team_scoped_state` strips legacy root ownership
526
- /// fields before save via `state::ownership::strip_top_level_ownership`,
527
- /// and the legacy "copy top-level owner from incoming state into
528
- /// merged" loop is removed. After every successful named-team owner
529
- /// mutation, persisted root is canonical-only.
530
- #[test]
531
- #[serial_test::serial(env)]
532
- fn second_claim_strips_legacy_top_level_ownership_from_persisted_root() {
533
- let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
534
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
535
- let ws = p2_temp_ws("claim_team_second_strip");
536
- let target = "alpha";
537
- let team_id = TeamKey::new(target);
538
- let caller = PaneId::new("%3");
539
- // Seed a state whose root carries stale legacy top-level owner
540
- // (%2/epoch=1) AND a teams.<target_key> entry that already has
541
- // canonical ownership at the previous epoch. This is the
542
- // post-S3-OWNER-001-fix shape on disk after one prior claim.
543
- let mut state = serde_json::json!({
544
- "session_name": "team-agent-x",
545
- "team_dir": format!("/tmp/{}", target),
546
- "active_team_key": target,
547
- "team_owner": {
548
- "pane_id": "%2",
549
- "provider": "codex",
550
- "machine_fingerprint": "fp",
551
- "leader_session_uuid": "OLDUUID",
552
- "owner_epoch": 1,
553
- "claimed_at": "t",
554
- "claimed_via": "claim-leader",
555
- },
556
- "leader_receiver": {
557
- "pane_id": "%2",
558
- "owner_epoch": 1,
559
- "leader_session_uuid": "OLDUUID",
560
- },
558
+ /// **S3-OWNER-002 regression** (architect Stage 3 top-level cleanup,
559
+ /// .team/artifacts/stage3-toplevel-cleanup-fix.md). Even with the
560
+ /// nested-ownership-preserving compact (aae818f), a SECOND claim on a
561
+ /// state whose root still carries legacy top-level `team_owner` /
562
+ /// `leader_receiver` / `owner_epoch` would leave the stale root copy
563
+ /// on disk while the canonical `teams.<key>` advanced. Evidence:
564
+ /// `state-after-claim.json` showed `team_owner=%2/epoch=1` at root and
565
+ /// `teams.<key>.team_owner=%3/epoch=2` — exact dual-source recurrence.
566
+ ///
567
+ /// Fix: `save_claim_team_scoped_state` strips legacy root ownership
568
+ /// fields before save via `state::ownership::strip_top_level_ownership`,
569
+ /// and the legacy "copy top-level owner from incoming state into
570
+ /// merged" loop is removed. After every successful named-team owner
571
+ /// mutation, persisted root is canonical-only.
572
+ #[test]
573
+ #[serial_test::serial(env)]
574
+ fn second_claim_strips_legacy_top_level_ownership_from_persisted_root() {
575
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
576
+ let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
577
+ let ws = p2_temp_ws("claim_team_second_strip");
578
+ let target = "alpha";
579
+ let team_id = TeamKey::new(target);
580
+ let caller = PaneId::new("%3");
581
+ // Seed a state whose root carries stale legacy top-level owner
582
+ // (%2/epoch=1) AND a teams.<target_key> entry that already has
583
+ // canonical ownership at the previous epoch. This is the
584
+ // post-S3-OWNER-001-fix shape on disk after one prior claim.
585
+ let mut state = serde_json::json!({
586
+ "session_name": "team-agent-x",
587
+ "team_dir": format!("/tmp/{}", target),
588
+ "active_team_key": target,
589
+ "team_owner": {
590
+ "pane_id": "%2",
591
+ "provider": "codex",
592
+ "machine_fingerprint": "fp",
593
+ "leader_session_uuid": "OLDUUID",
594
+ "owner_epoch": 1,
595
+ "claimed_at": "t",
596
+ "claimed_via": "claim-leader",
597
+ },
598
+ "leader_receiver": {
599
+ "pane_id": "%2",
561
600
  "owner_epoch": 1,
562
- "teams": {
563
- target: {
564
- "team_owner": {
565
- "pane_id": "%2",
566
- "provider": "codex",
567
- "machine_fingerprint": "fp",
568
- "leader_session_uuid": "OLDUUID",
569
- "owner_epoch": 1,
570
- "claimed_at": "t",
571
- "claimed_via": "claim-leader",
572
- },
573
- "leader_receiver": {
574
- "pane_id": "%2",
575
- "owner_epoch": 1,
576
- "leader_session_uuid": "OLDUUID",
577
- },
601
+ "leader_session_uuid": "OLDUUID",
602
+ },
603
+ "owner_epoch": 1,
604
+ "teams": {
605
+ target: {
606
+ "team_owner": {
607
+ "pane_id": "%2",
608
+ "provider": "codex",
609
+ "machine_fingerprint": "fp",
610
+ "leader_session_uuid": "OLDUUID",
578
611
  "owner_epoch": 1,
579
- }
580
- },
581
- });
582
- // Persist the seeded shape so `save_claim_team_scoped_state` loads
583
- // it back as `existing` on save.
584
- crate::state::persist::save_runtime_state(&ws, &state).unwrap();
585
- let event_log = crate::event_log::EventLog::new(&ws);
586
- // Both panes live so the dead-owner reclaim branch doesn't apply;
587
- // explicit takeover via confirm=true forces the claim past the
588
- // live-owner gate.
589
- let live = seeded_liveness(&["%2", "%3"]);
590
- let r = claim_lease_no_incident(
591
- &ws, &mut state, Some(target), &team_id, &caller, true, &event_log, &live,
592
- )
593
- .unwrap();
594
- assert!(r.ok, "S3-OWNER-002: explicit takeover must succeed; got {r:?}");
595
- assert_eq!(r.status, LeaseStatus::Claimed);
612
+ "claimed_at": "t",
613
+ "claimed_via": "claim-leader",
614
+ },
615
+ "leader_receiver": {
616
+ "pane_id": "%2",
617
+ "owner_epoch": 1,
618
+ "leader_session_uuid": "OLDUUID",
619
+ },
620
+ "owner_epoch": 1,
621
+ }
622
+ },
623
+ });
624
+ // Persist the seeded shape so `save_claim_team_scoped_state` loads
625
+ // it back as `existing` on save.
626
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
627
+ let event_log = crate::event_log::EventLog::new(&ws);
628
+ // Both panes live so the dead-owner reclaim branch doesn't apply;
629
+ // explicit takeover via confirm=true forces the claim past the
630
+ // live-owner gate.
631
+ let live = seeded_liveness(&["%2", "%3"]);
632
+ let r = claim_lease_no_incident(
633
+ &ws,
634
+ &mut state,
635
+ Some(target),
636
+ &team_id,
637
+ &caller,
638
+ true,
639
+ &event_log,
640
+ &live,
641
+ )
642
+ .unwrap();
643
+ assert!(
644
+ r.ok,
645
+ "S3-OWNER-002: explicit takeover must succeed; got {r:?}"
646
+ );
647
+ assert_eq!(r.status, LeaseStatus::Claimed);
596
648
 
597
- let persisted_path = crate::state::persist::runtime_state_path(&ws);
598
- let persisted: serde_json::Value = serde_json::from_str(
599
- &std::fs::read_to_string(&persisted_path).unwrap(),
600
- )
601
- .unwrap();
649
+ let persisted_path = crate::state::persist::runtime_state_path(&ws);
650
+ let persisted: serde_json::Value =
651
+ serde_json::from_str(&std::fs::read_to_string(&persisted_path).unwrap()).unwrap();
602
652
 
603
- // Canonical advanced.
604
- let entry = &persisted["teams"][target];
605
- assert_eq!(
606
- entry["team_owner"]["pane_id"],
607
- serde_json::json!("%3"),
608
- "S3-OWNER-002 fix: canonical owner advanced to new pane; persisted={persisted}"
609
- );
610
- assert_eq!(
611
- entry["owner_epoch"],
612
- serde_json::json!(2),
613
- "S3-OWNER-002 fix: canonical owner_epoch advanced to 2; persisted={persisted}"
614
- );
615
- assert_eq!(
616
- entry["leader_receiver"]["pane_id"],
617
- serde_json::json!("%3"),
618
- "S3-OWNER-002 fix: canonical receiver advanced; persisted={persisted}"
619
- );
653
+ // Canonical advanced.
654
+ let entry = &persisted["teams"][target];
655
+ assert_eq!(
656
+ entry["team_owner"]["pane_id"],
657
+ serde_json::json!("%3"),
658
+ "S3-OWNER-002 fix: canonical owner advanced to new pane; persisted={persisted}"
659
+ );
660
+ assert_eq!(
661
+ entry["owner_epoch"],
662
+ serde_json::json!(2),
663
+ "S3-OWNER-002 fix: canonical owner_epoch advanced to 2; persisted={persisted}"
664
+ );
665
+ assert_eq!(
666
+ entry["leader_receiver"]["pane_id"],
667
+ serde_json::json!("%3"),
668
+ "S3-OWNER-002 fix: canonical receiver advanced; persisted={persisted}"
669
+ );
620
670
 
621
- // Top-level stale legacy owner stripped.
622
- assert!(
623
- persisted.get("team_owner").is_none(),
624
- "S3-OWNER-002 fix: stale top-level team_owner must be stripped; persisted={persisted}"
625
- );
626
- assert!(
627
- persisted.get("leader_receiver").is_none(),
628
- "S3-OWNER-002 fix: stale top-level leader_receiver must be stripped; persisted={persisted}"
629
- );
630
- assert!(
631
- persisted.get("owner_epoch").is_none(),
632
- "S3-OWNER-002 fix: stale top-level owner_epoch must be stripped; persisted={persisted}"
633
- );
634
- }
671
+ // Top-level stale legacy owner stripped.
672
+ assert!(
673
+ persisted.get("team_owner").is_none(),
674
+ "S3-OWNER-002 fix: stale top-level team_owner must be stripped; persisted={persisted}"
675
+ );
676
+ assert!(
677
+ persisted.get("leader_receiver").is_none(),
678
+ "S3-OWNER-002 fix: stale top-level leader_receiver must be stripped; persisted={persisted}"
679
+ );
680
+ assert!(
681
+ persisted.get("owner_epoch").is_none(),
682
+ "S3-OWNER-002 fix: stale top-level owner_epoch must be stripped; persisted={persisted}"
683
+ );
684
+ }