@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,400 +1,427 @@
1
1
  use super::*;
2
2
 
3
- // =========================================================================
4
- // (DELEGATION sweep, rt-host-a loop #2) — cmd_send persistence + shutdown teardown.
5
- // =========================================================================
3
+ // =========================================================================
4
+ // (DELEGATION sweep, rt-host-a loop #2) — cmd_send persistence + shutdown teardown.
5
+ // =========================================================================
6
6
 
7
- // 1 [P0, CONFIRMED BUG] — cmd_send (cli/send.rs) returns delivery_json synthetic (true,"delivered")
8
- // and NEVER calls messaging::send_message, so NO `messages` row is persisted. RED: after cmd_send to
9
- // a worker on a seeded ws (real MessageStore), assert a messages row was PERSISTED (query by
10
- // recipient+content) — proving the real messaging::send_message -> MessageStore.create_message path.
11
- // DB-persist is observable without a transport. (messaging::send_message is ALSO a stub today, so the
12
- // porter wires BOTH cmd_send->send_message AND send_message->persist.)
13
- //
14
- // OLD seed: flat `{"agents": {"w1": ...}}` worked because send_message read agents
15
- // directly off the raw runtime state.
16
- // NEW seed (Bug 1/2 — team-in-team state scope, see tests/team_in_team_state_scope_red.rs):
17
- // cmd_send → resolve_active_team yields a team_key, send_message projects the
18
- // raw state through `project_top_level_view(team_key)` which reads agents off
19
- // `teams[team_key].agents`. The seed therefore lives under `teams.current.agents`
20
- // with `active_team_key=current`; the delegation + persistence behavior under test
21
- // is unchanged — only the shape of "an in-team recipient" is now nested.
22
- #[test]
23
- fn cli_send_persists_real_message_row() {
24
- let ws = deleg_uniq_dir("send");
25
- let _ = crate::message_store::MessageStore::open(&ws).unwrap(); // real store at the workspace
26
- // w1 must be a known team agent — golden send.py refuses non-team targets
27
- // (target_not_in_team); an in-team recipient is the one that persists. Bug 1/2
28
- // scopes agents under teams[<key>].agents (NEW shape).
29
- crate::state::persist::save_runtime_state(
30
- &ws,
31
- &serde_json::json!({
32
- "active_team_key": "current",
33
- "teams": {"current": {"agents": {"w1": {"provider": "codex"}}}}
34
- }),
35
- )
36
- .unwrap();
37
- let args = SendArgs {
38
- target: Some("w1".to_string()),
39
- message: vec!["hello-real-delegation".to_string()],
40
- targets: None,
41
- workspace: ws.clone(),
42
- team: None,
43
- task: None,
44
- sender: "leader".to_string(),
45
- no_ack: false,
46
- no_wait: true,
47
- watch_result: false,
48
- timeout: 0.0,
49
- confirm_human: false,
50
- json: true,
51
- message_id: None,
52
- pane: None,
53
- to_name: None,
7
+ // 1 [P0, CONFIRMED BUG] — cmd_send (cli/send.rs) returns delivery_json synthetic (true,"delivered")
8
+ // and NEVER calls messaging::send_message, so NO `messages` row is persisted. RED: after cmd_send to
9
+ // a worker on a seeded ws (real MessageStore), assert a messages row was PERSISTED (query by
10
+ // recipient+content) — proving the real messaging::send_message -> MessageStore.create_message path.
11
+ // DB-persist is observable without a transport. (messaging::send_message is ALSO a stub today, so the
12
+ // porter wires BOTH cmd_send->send_message AND send_message->persist.)
13
+ //
14
+ // OLD seed: flat `{"agents": {"w1": ...}}` worked because send_message read agents
15
+ // directly off the raw runtime state.
16
+ // NEW seed (Bug 1/2 — team-in-team state scope, see tests/team_in_team_state_scope_red.rs):
17
+ // cmd_send → resolve_active_team yields a team_key, send_message projects the
18
+ // raw state through `project_top_level_view(team_key)` which reads agents off
19
+ // `teams[team_key].agents`. The seed therefore lives under `teams.current.agents`
20
+ // with `active_team_key=current`; the delegation + persistence behavior under test
21
+ // is unchanged — only the shape of "an in-team recipient" is now nested.
22
+ #[test]
23
+ fn cli_send_persists_real_message_row() {
24
+ let ws = deleg_uniq_dir("send");
25
+ let _ = crate::message_store::MessageStore::open(&ws).unwrap(); // real store at the workspace
26
+ // w1 must be a known team agent — golden send.py refuses non-team targets
27
+ // (target_not_in_team); an in-team recipient is the one that persists. Bug 1/2
28
+ // scopes agents under teams[<key>].agents (NEW shape).
29
+ crate::state::persist::save_runtime_state(
30
+ &ws,
31
+ &serde_json::json!({
32
+ "active_team_key": "current",
33
+ "teams": {"current": {"agents": {"w1": {"provider": "codex"}}}}
34
+ }),
35
+ )
36
+ .unwrap();
37
+ let args = SendArgs {
38
+ target: Some("w1".to_string()),
39
+ message: vec!["hello-real-delegation".to_string()],
40
+ targets: None,
41
+ workspace: ws.clone(),
42
+ team: None,
43
+ task: None,
44
+ sender: "leader".to_string(),
45
+ no_ack: false,
46
+ no_wait: true,
47
+ watch_result: false,
48
+ timeout: 0.0,
49
+ confirm_human: false,
50
+ json: true,
51
+ message_id: None,
52
+ pane: None,
53
+ to_name: None,
54
54
  to_leader: None,
55
- };
56
- let _ = cmd_send(&args);
55
+ };
56
+ let _ = cmd_send(&args);
57
57
 
58
- let store = crate::message_store::MessageStore::open(&ws).unwrap();
59
- let conn = crate::db::schema::open_db(store.db_path()).unwrap();
60
- let count: i64 = conn
58
+ let store = crate::message_store::MessageStore::open(&ws).unwrap();
59
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
60
+ let count: i64 = conn
61
61
  .query_row(
62
62
  "select count(*) from messages where recipient = 'w1' and content = 'hello-real-delegation'",
63
63
  [],
64
64
  |r| r.get(0),
65
65
  )
66
66
  .unwrap();
67
- assert!(
67
+ assert!(
68
68
  count >= 1,
69
69
  "cmd_send must delegate to messaging::send_message and PERSIST a `messages` row (recipient=w1); \
70
70
  the synthetic delivery_json writes NO DB row -> count={count}"
71
71
  );
72
- }
72
+ }
73
73
 
74
- // 5 [P1, CONFIRMED PARTIAL] — shutdown stops the coordinator but NEVER kills the team tmux session
75
- // (-> orphan worker panes). #[ignore] real-machine: the wired shutdown kills the real tmux session.
76
- // SEAM NEEDED (note to porter): add shutdown_with_transport(workspace, keep_logs, team, &dyn Transport)
77
- // (mirror restart_with_transport) so this can assert IN-PROCESS that transport.kill_session(team
78
- // session) was called via a RecordingTransport — the clean "team session killed / workers reaped"
79
- // observable. Until that seam lands, this asserts the real teardown surfaces (session-kill / stop).
80
- #[test]
81
- #[ignore = "real-machine: shutdown kills the team tmux session. PORTER SEAM: add \
74
+ // 5 [P1, CONFIRMED PARTIAL] — shutdown stops the coordinator but NEVER kills the team tmux session
75
+ // (-> orphan worker panes). #[ignore] real-machine: the wired shutdown kills the real tmux session.
76
+ // SEAM NEEDED (note to porter): add shutdown_with_transport(workspace, keep_logs, team, &dyn Transport)
77
+ // (mirror restart_with_transport) so this can assert IN-PROCESS that transport.kill_session(team
78
+ // session) was called via a RecordingTransport — the clean "team session killed / workers reaped"
79
+ // observable. Until that seam lands, this asserts the real teardown surfaces (session-kill / stop).
80
+ #[test]
81
+ #[ignore = "real-machine: shutdown kills the team tmux session. PORTER SEAM: add \
82
82
  shutdown_with_transport(workspace, keep_logs, team, &dyn Transport) so kill_session is \
83
83
  assertable in-process via a RecordingTransport (workers reaped, no orphan panes)."]
84
- fn cli_shutdown_kills_team_session_real_teardown() {
85
- let ws = seed_status_workspace(); // state.json with a running agent + session_name
86
- let args = ShutdownArgs { workspace: ws, team: None, keep_logs: true, json: true };
87
- let text = format!("{:?}", cmd_shutdown(&args)).to_lowercase();
88
- assert!(
84
+ fn cli_shutdown_kills_team_session_real_teardown() {
85
+ let ws = seed_status_workspace(); // state.json with a running agent + session_name
86
+ let args = ShutdownArgs {
87
+ workspace: ws,
88
+ team: None,
89
+ keep_logs: true,
90
+ json: true,
91
+ };
92
+ let text = format!("{:?}", cmd_shutdown(&args)).to_lowercase();
93
+ assert!(
89
94
  text.contains("session") || text.contains("killed") || text.contains("kill_session"),
90
95
  "shutdown must KILL the team tmux session + reap workers (real teardown); the rt-host-a partial \
91
96
  bug stops the coordinator but leaves the session + workers running (orphans); got {text}"
92
97
  );
93
- }
98
+ }
94
99
 
95
- // =========================================================================
96
- // WAVE-2 Lane B — CLI leader handler delegation byte-parity (leader_port::*).
97
- // The three CLI verbs are thin pass-throughs (cli/commands.py:152-161):
98
- // cmd_takeover -> runtime.takeover(ws, team, confirm)
99
- // cmd_claim_leader -> runtime.claim_leader(ws, team, confirm) (Family A)
100
- // cmd_identity -> runtime.leader_identity(ws, team)
101
- // leader_port::{takeover,claim_leader,leader_identity} are STUBS returning
102
- // the WRONG shape today -> these LOCK the golden dict so the porter wires
103
- // them into leader::* / runtime.* and matches byte-for-byte.
104
- // Golden re-probed @ team-agent-public (probe_claim.py / probe_rtclaim.py /
105
- // probe_lid.py). Label: RED = stub returns wrong shape today.
106
- // =========================================================================
100
+ // =========================================================================
101
+ // WAVE-2 Lane B — CLI leader handler delegation byte-parity (leader_port::*).
102
+ // The three CLI verbs are thin pass-throughs (cli/commands.py:152-161):
103
+ // cmd_takeover -> runtime.takeover(ws, team, confirm)
104
+ // cmd_claim_leader -> runtime.claim_leader(ws, team, confirm) (Family A)
105
+ // cmd_identity -> runtime.leader_identity(ws, team)
106
+ // leader_port::{takeover,claim_leader,leader_identity} are STUBS returning
107
+ // the WRONG shape today -> these LOCK the golden dict so the porter wires
108
+ // them into leader::* / runtime.* and matches byte-for-byte.
109
+ // Golden re-probed @ team-agent-public (probe_claim.py / probe_rtclaim.py /
110
+ // probe_lid.py). Label: RED = stub returns wrong shape today.
111
+ // =========================================================================
107
112
 
108
- fn leader_port_ws(tag: &str) -> std::path::PathBuf {
109
- let dir = std::env::temp_dir().join(format!(
110
- "ta-cli-leaderport-{}-{}",
111
- tag,
112
- std::process::id()
113
- ));
114
- std::fs::create_dir_all(&dir).unwrap();
115
- dir
116
- }
113
+ fn leader_port_ws(tag: &str) -> std::path::PathBuf {
114
+ let dir =
115
+ std::env::temp_dir().join(format!("ta-cli-leaderport-{}-{}", tag, std::process::id()));
116
+ std::fs::create_dir_all(&dir).unwrap();
117
+ dir
118
+ }
117
119
 
118
- // #235 / I-RN-3 — explicit takeover is unconditional once the caller has a live pane:
119
- // it is not a permission gate and must replace a live owner, advancing owner_epoch.
120
- // `--confirm` may remain a UX affordance, but it is not the authority check.
121
- #[test]
122
- fn leader_port_takeover_refuses_without_confirm_byte_parity_obsolete_now_unconditional() {
123
- let cli = std::fs::read_to_string(
124
- std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/cli/mod.rs"),
125
- )
126
- .unwrap();
127
- assert_eq!(
120
+ // #235 / I-RN-3 — explicit takeover is unconditional once the caller has a live pane:
121
+ // it is not a permission gate and must replace a live owner, advancing owner_epoch.
122
+ // `--confirm` may remain a UX affordance, but it is not the authority check.
123
+ #[test]
124
+ fn leader_port_takeover_refuses_without_confirm_byte_parity_obsolete_now_unconditional() {
125
+ let cli = std::fs::read_to_string(
126
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/cli/mod.rs"),
127
+ )
128
+ .unwrap();
129
+ assert_eq!(
128
130
  cli.matches("claim_leader(workspace, team, true)").count(),
129
131
  1,
130
132
  "takeover must call the lease path as an explicit takeover, not pass the CLI confirm flag as a permission gate"
131
133
  );
132
134
 
133
- let ws = leader_port_ws("tk_unconditional_live");
134
- let team_id = crate::model::ids::TeamKey::new("current");
135
- let caller = crate::transport::PaneId::new("%5");
136
- let mut state = json!({
137
- "session_name": "team-agent-x",
138
- "team_owner": {
139
- "pane_id": "%1",
140
- "provider": "codex",
141
- "machine_fingerprint": "fp",
142
- "leader_session_uuid": "OWNERUUID",
143
- "owner_epoch": 2,
144
- "claimed_at": "t",
145
- "claimed_via": "claim-leader"
146
- },
147
- "leader_receiver": {
148
- "pane_id": "%1",
149
- "provider": "codex",
150
- "owner_epoch": 2,
151
- "leader_session_uuid": "OWNERUUID"
152
- }
153
- });
154
- let event_log = crate::event_log::EventLog::new(&ws);
155
- let live = LeaderPortSeededLiveness::new(&["%1", "%5"]);
156
- let r = crate::leader::claim_lease_no_incident(
157
- &ws,
158
- &mut state,
159
- None,
160
- &team_id,
161
- &caller,
162
- true,
163
- &event_log,
164
- &live,
165
- )
166
- .unwrap();
135
+ let ws = leader_port_ws("tk_unconditional_live");
136
+ let team_id = crate::model::ids::TeamKey::new("current");
137
+ let caller = crate::transport::PaneId::new("%5");
138
+ let mut state = json!({
139
+ "session_name": "team-agent-x",
140
+ "team_owner": {
141
+ "pane_id": "%1",
142
+ "provider": "codex",
143
+ "machine_fingerprint": "fp",
144
+ "leader_session_uuid": "OWNERUUID",
145
+ "owner_epoch": 2,
146
+ "claimed_at": "t",
147
+ "claimed_via": "claim-leader"
148
+ },
149
+ "leader_receiver": {
150
+ "pane_id": "%1",
151
+ "provider": "codex",
152
+ "owner_epoch": 2,
153
+ "leader_session_uuid": "OWNERUUID"
154
+ }
155
+ });
156
+ let event_log = crate::event_log::EventLog::new(&ws);
157
+ let live = LeaderPortSeededLiveness::new(&["%1", "%5"]);
158
+ let r = crate::leader::claim_lease_no_incident(
159
+ &ws, &mut state, None, &team_id, &caller, true, &event_log, &live,
160
+ )
161
+ .unwrap();
167
162
 
168
- assert!(r.ok, "explicit takeover must succeed even when old owner pane is live: {r:?}");
169
- assert_eq!(r.status, crate::leader::LeaseStatus::Claimed);
170
- assert_eq!(r.owner_epoch, Some(crate::model::ids::OwnerEpoch(3)));
171
- assert_eq!(r.bound_pane_id, Some(caller.clone()));
172
- // Stage 3d: canonical owner/receiver at teams.<team_key>.
173
- let team_key = crate::state::projection::team_state_key(&state);
174
- assert_eq!(state["teams"][&team_key]["leader_receiver"]["pane_id"], json!("%5"));
175
- assert_eq!(state["teams"][&team_key]["team_owner"]["pane_id"], json!("%5"));
176
- assert_eq!(state["teams"][&team_key]["team_owner"]["owner_epoch"], json!(3));
177
- }
163
+ assert!(
164
+ r.ok,
165
+ "explicit takeover must succeed even when old owner pane is live: {r:?}"
166
+ );
167
+ assert_eq!(r.status, crate::leader::LeaseStatus::Claimed);
168
+ assert_eq!(r.owner_epoch, Some(crate::model::ids::OwnerEpoch(3)));
169
+ assert_eq!(r.bound_pane_id, Some(caller.clone()));
170
+ // Stage 3d: canonical owner/receiver at teams.<team_key>.
171
+ let team_key = crate::state::projection::team_state_key(&state);
172
+ assert_eq!(
173
+ state["teams"][&team_key]["leader_receiver"]["pane_id"],
174
+ json!("%5")
175
+ );
176
+ assert_eq!(
177
+ state["teams"][&team_key]["team_owner"]["pane_id"],
178
+ json!("%5")
179
+ );
180
+ assert_eq!(
181
+ state["teams"][&team_key]["team_owner"]["owner_epoch"],
182
+ json!(3)
183
+ );
184
+ }
178
185
 
179
- struct LeaderPortSeededLiveness {
180
- live_panes: std::collections::BTreeSet<String>,
181
- }
186
+ struct LeaderPortSeededLiveness {
187
+ live_panes: std::collections::BTreeSet<String>,
188
+ }
182
189
 
183
- impl LeaderPortSeededLiveness {
184
- fn new(panes: &[&str]) -> Self {
185
- Self {
186
- live_panes: panes.iter().map(|pane| (*pane).to_string()).collect(),
187
- }
190
+ impl LeaderPortSeededLiveness {
191
+ fn new(panes: &[&str]) -> Self {
192
+ Self {
193
+ live_panes: panes.iter().map(|pane| (*pane).to_string()).collect(),
188
194
  }
189
195
  }
196
+ }
190
197
 
191
- impl crate::state::owner_gate::PaneLivenessProbe for LeaderPortSeededLiveness {
192
- fn liveness(&self, pane_id: &str) -> crate::model::enums::PaneLiveness {
193
- if self.live_panes.contains(pane_id) {
194
- crate::model::enums::PaneLiveness::Live
195
- } else {
196
- crate::model::enums::PaneLiveness::Dead
197
- }
198
+ impl crate::state::owner_gate::PaneLivenessProbe for LeaderPortSeededLiveness {
199
+ fn liveness(&self, pane_id: &str) -> crate::model::enums::PaneLiveness {
200
+ if self.live_panes.contains(pane_id) {
201
+ crate::model::enums::PaneLiveness::Live
202
+ } else {
203
+ crate::model::enums::PaneLiveness::Dead
198
204
  }
199
205
  }
206
+ }
200
207
 
201
- // RED — takeover(confirm=true) with no $TMUX_PANE: the Family A positive-source
202
- // bind gate fires -> refused caller_pane_missing with the bind diagnostic dict.
203
- // golden probe_claim.py takeover(confirm=True): {ok:false, status:"refused",
204
- // reason:"caller_pane_missing", caller_pane_id:"", caller_current_command:"",
205
- // hint:"run team-agent from inside your leader pane (the tmux pane you want to
206
- // own this team)."}. Current stub returns {ok:true,...} (wrong) -> RED.
207
- #[test]
208
- #[ignore = "RED needs $TMUX_PANE ABSENT (Family A bind gate); run `--ignored` in a non-tmux shell. \
208
+ // RED — takeover(confirm=true) with no $TMUX_PANE: the Family A positive-source
209
+ // bind gate fires -> refused caller_pane_missing with the bind diagnostic dict.
210
+ // golden probe_claim.py takeover(confirm=True): {ok:false, status:"refused",
211
+ // reason:"caller_pane_missing", caller_pane_id:"", caller_current_command:"",
212
+ // hint:"run team-agent from inside your leader pane (the tmux pane you want to
213
+ // own this team)."}. Current stub returns {ok:true,...} (wrong) -> RED.
214
+ #[test]
215
+ #[ignore = "RED needs $TMUX_PANE ABSENT (Family A bind gate); run `--ignored` in a non-tmux shell. \
209
216
  Inside tmux the live-pane resolver would engage. Seam: porter wires leader_port::takeover \
210
217
  -> runtime.takeover whose Family A bind refuses caller_pane_missing when $TMUX_PANE missing."]
211
- fn leader_port_takeover_confirm_without_pane_refuses_caller_pane_missing() {
212
- if std::env::var_os("TMUX_PANE").is_some() {
213
- return; // inside tmux the bind gate would pass; this case verifies the missing arm.
214
- }
215
- let ws = leader_port_ws("tk_confirm_nopane");
216
- // seed a resolvable team so the gate reaches the bind step (not team_target_unresolved).
217
- let st = json!({"session_name": "team-agent-x", "teams": {"team-agent-x": {}}});
218
- let path = crate::state::persist::runtime_state_path(&ws);
219
- std::fs::create_dir_all(path.parent().unwrap()).unwrap();
220
- std::fs::write(&path, serde_json::to_string(&st).unwrap()).unwrap();
221
- let v = super::leader_port::takeover(&ws, Some("team-agent-x"), true).unwrap();
222
- assert_eq!(v["ok"], json!(false));
223
- assert_eq!(v["status"], json!("refused"));
224
- assert_eq!(v["reason"], json!("caller_pane_missing"));
225
- assert_eq!(v["caller_pane_id"], json!(""));
226
- assert_eq!(v["caller_current_command"], json!(""));
227
- assert_eq!(
218
+ fn leader_port_takeover_confirm_without_pane_refuses_caller_pane_missing() {
219
+ if std::env::var_os("TMUX_PANE").is_some() {
220
+ return; // inside tmux the bind gate would pass; this case verifies the missing arm.
221
+ }
222
+ let ws = leader_port_ws("tk_confirm_nopane");
223
+ // seed a resolvable team so the gate reaches the bind step (not team_target_unresolved).
224
+ let st = json!({"session_name": "team-agent-x", "teams": {"team-agent-x": {}}});
225
+ let path = crate::state::persist::runtime_state_path(&ws);
226
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
227
+ std::fs::write(&path, serde_json::to_string(&st).unwrap()).unwrap();
228
+ let v = super::leader_port::takeover(&ws, Some("team-agent-x"), true).unwrap();
229
+ assert_eq!(v["ok"], json!(false));
230
+ assert_eq!(v["status"], json!("refused"));
231
+ assert_eq!(v["reason"], json!("caller_pane_missing"));
232
+ assert_eq!(v["caller_pane_id"], json!(""));
233
+ assert_eq!(v["caller_current_command"], json!(""));
234
+ assert_eq!(
228
235
  v["hint"],
229
236
  json!("run team-agent from inside your leader pane (the tmux pane you want to own this team).")
230
237
  );
231
- }
238
+ }
232
239
 
233
- // RED — claim_leader(confirm=false) with no $TMUX_PANE: runtime.claim_leader is
234
- // ALSO Family A (runtime.py:791) -> the bind gate fires FIRST, so the no-pane
235
- // refusal is caller_pane_missing (NOT the leader-lane "not_in_tmux_pane").
236
- // golden probe_rtclaim.py. This pins the runtime-vs-leader distinction:
237
- // the CLI projection must reflect runtime.claim_leader's Family A bind gate.
238
- // Current stub returns {ok:true, inbox_hint:...} (wrong) -> RED.
239
- #[test]
240
- #[ignore = "RED needs $TMUX_PANE ABSENT (Family A bind gate fires first); run `--ignored` in a \
240
+ // RED — claim_leader(confirm=false) with no $TMUX_PANE: runtime.claim_leader is
241
+ // ALSO Family A (runtime.py:791) -> the bind gate fires FIRST, so the no-pane
242
+ // refusal is caller_pane_missing (NOT the leader-lane "not_in_tmux_pane").
243
+ // golden probe_rtclaim.py. This pins the runtime-vs-leader distinction:
244
+ // the CLI projection must reflect runtime.claim_leader's Family A bind gate.
245
+ // Current stub returns {ok:true, inbox_hint:...} (wrong) -> RED.
246
+ #[test]
247
+ #[ignore = "RED needs $TMUX_PANE ABSENT (Family A bind gate fires first); run `--ignored` in a \
241
248
  non-tmux shell. Inside tmux the resolver would engage. Seam: porter wires \
242
249
  leader_port::claim_leader -> runtime.claim_leader (Family A) whose bind refuses \
243
250
  caller_pane_missing (NOT leader-lane not_in_tmux_pane) when $TMUX_PANE missing."]
244
- fn leader_port_claim_leader_no_pane_refuses_caller_pane_missing_family_a() {
245
- if std::env::var_os("TMUX_PANE").is_some() {
246
- return;
247
- }
248
- let ws = leader_port_ws("claim_nopane");
249
- let v = super::leader_port::claim_leader(&ws, None, false).unwrap();
250
- assert_eq!(v["ok"], json!(false));
251
- assert_eq!(v["status"], json!("refused"));
252
- assert_eq!(
253
- v["reason"],
254
- json!("caller_pane_missing"),
255
- "runtime.claim_leader (Family A) bind gate -> caller_pane_missing, NOT not_in_tmux_pane"
256
- );
257
- assert_eq!(v["caller_pane_id"], json!(""));
258
- assert_eq!(
251
+ fn leader_port_claim_leader_no_pane_refuses_caller_pane_missing_family_a() {
252
+ if std::env::var_os("TMUX_PANE").is_some() {
253
+ return;
254
+ }
255
+ let ws = leader_port_ws("claim_nopane");
256
+ let v = super::leader_port::claim_leader(&ws, None, false).unwrap();
257
+ assert_eq!(v["ok"], json!(false));
258
+ assert_eq!(v["status"], json!("refused"));
259
+ assert_eq!(
260
+ v["reason"],
261
+ json!("caller_pane_missing"),
262
+ "runtime.claim_leader (Family A) bind gate -> caller_pane_missing, NOT not_in_tmux_pane"
263
+ );
264
+ assert_eq!(v["caller_pane_id"], json!(""));
265
+ assert_eq!(
259
266
  v["hint"],
260
267
  json!("run team-agent from inside your leader pane (the tmux pane you want to own this team).")
261
268
  );
262
- }
269
+ }
263
270
 
264
- // RED — leader_identity(): CLI directly emits leader.leader_identity's 9-key
265
- // dict (runtime.leader_identity is imported from leader). golden probe_lid.py
266
- // keys: ok, uuid_prefix, machine_fingerprint, workspace_abspath, os_user,
267
- // team_id, current_pane_id, last_seen_at, source. Current stub returns
268
- // {ok:true, team:...} (wrong shape) -> RED.
269
- #[test]
270
- fn leader_port_leader_identity_emits_nine_key_dict() {
271
- let ws = leader_port_ws("identity");
272
- std::fs::create_dir_all(crate::model::paths::runtime_dir(&ws)).unwrap();
273
- let v = super::leader_port::leader_identity(&ws, None).unwrap();
274
- assert_eq!(v["ok"], json!(true));
275
- let obj = v.as_object().expect("identity → JSON object");
276
- for key in [
277
- "ok", "uuid_prefix", "machine_fingerprint", "workspace_abspath",
278
- "os_user", "team_id", "current_pane_id", "last_seen_at", "source",
279
- ] {
280
- assert!(obj.contains_key(key), "golden identity dict must carry '{key}', got {obj:?}");
281
- }
282
- // no override/state uuid → source is the leader-plan "derived" string.
283
- assert_eq!(v["source"], json!("derived"));
284
- // uuid_prefix is exactly 12 hex chars (derive[:12]).
285
- let prefix = v["uuid_prefix"].as_str().expect("uuid_prefix str");
286
- assert_eq!(prefix.len(), 12, "uuid_prefix == derived[:12]");
287
- assert!(prefix.chars().all(|c| c.is_ascii_hexdigit()));
288
- // no team registered + no TMUX_PANE/receiver → these are JSON null.
289
- if std::env::var_os("TMUX_PANE").is_none() {
290
- assert_eq!(v["current_pane_id"], serde_json::Value::Null);
291
- }
292
- assert_eq!(v["last_seen_at"], serde_json::Value::Null);
271
+ // RED — leader_identity(): CLI directly emits leader.leader_identity's 9-key
272
+ // dict (runtime.leader_identity is imported from leader). golden probe_lid.py
273
+ // keys: ok, uuid_prefix, machine_fingerprint, workspace_abspath, os_user,
274
+ // team_id, current_pane_id, last_seen_at, source. Current stub returns
275
+ // {ok:true, team:...} (wrong shape) -> RED.
276
+ #[test]
277
+ fn leader_port_leader_identity_emits_nine_key_dict() {
278
+ let ws = leader_port_ws("identity");
279
+ std::fs::create_dir_all(crate::model::paths::runtime_dir(&ws)).unwrap();
280
+ let v = super::leader_port::leader_identity(&ws, None).unwrap();
281
+ assert_eq!(v["ok"], json!(true));
282
+ let obj = v.as_object().expect("identity → JSON object");
283
+ for key in [
284
+ "ok",
285
+ "uuid_prefix",
286
+ "machine_fingerprint",
287
+ "workspace_abspath",
288
+ "os_user",
289
+ "team_id",
290
+ "current_pane_id",
291
+ "last_seen_at",
292
+ "source",
293
+ ] {
294
+ assert!(
295
+ obj.contains_key(key),
296
+ "golden identity dict must carry '{key}', got {obj:?}"
297
+ );
293
298
  }
299
+ // no override/state uuid → source is the leader-plan "derived" string.
300
+ assert_eq!(v["source"], json!("derived"));
301
+ // uuid_prefix is exactly 12 hex chars (derive[:12]).
302
+ let prefix = v["uuid_prefix"].as_str().expect("uuid_prefix str");
303
+ assert_eq!(prefix.len(), 12, "uuid_prefix == derived[:12]");
304
+ assert!(prefix.chars().all(|c| c.is_ascii_hexdigit()));
305
+ // no team registered + no TMUX_PANE/receiver → these are JSON null.
306
+ if std::env::var_os("TMUX_PANE").is_none() {
307
+ assert_eq!(v["current_pane_id"], serde_json::Value::Null);
308
+ }
309
+ assert_eq!(v["last_seen_at"], serde_json::Value::Null);
310
+ }
294
311
 
295
- // ── cmd_watch (cli/adapters.rs:58) [RED] — today a CmdResult::none() no-op ───────────────────────
296
- // Golden cmd_watch (cli/commands.py:103-109) DELEGATES to run_watch(workspace.resolve(), team) and
297
- // exits 0. Golden run_watch (watch/__init__.py:25-37) is a `while True` LIVE TAIL that streams
298
- // render_event_line output (collect_watch_lines) for the watched team. The Rust cmd_watch returns
299
- // CmdResult::none() — a no-op that never touches the watch subsystem. The watch LINE SHAPE is itself
300
- // byte-locked by coordinator::render_event_line (coordinator/tests.rs GROUP H); the cli contract here
301
- // is that cmd_watch must DELEGATE and surface those rendered lines. Seeded a result_received event ->
302
- // golden render = "result_received: <agent> -> <summary>" (render_event_line: agent_id + summary[:80]).
303
- //
304
- // RED confirmed TODAY: cmd_watch=none() returns immediately (no watch line) -> the assertion fails
305
- // (and does NOT hang). PORTER: wire cmd_watch -> coordinator::run_watch(resolved workspace, team,
306
- // interval, sink). Since golden's tail is `while True`, the cli port needs a TERMINATING / bounded
307
- // entry to be both byte-parity AND unit-testable (collect the current watch lines into the CmdResult,
308
- // or stream to stdout with a bounded test seam) — a blocking unit test is unacceptable.
309
- #[test]
310
- fn cmd_watch_delegates_and_surfaces_rendered_watch_lines_not_noop() {
311
- let ws = tmp_workspace();
312
- let logs = crate::model::paths::logs_dir(&ws);
313
- std::fs::create_dir_all(&logs).unwrap();
314
- std::fs::write(
315
- logs.join("events.jsonl"),
316
- "{\"event\":\"result_received\",\"agent_id\":\"alpha\",\"summary\":\"did the thing\"}\n",
317
- )
318
- .unwrap();
319
- let r = cmd_watch(&WatchArgs { workspace: ws.clone(), team: None })
320
- .expect("cmd_watch returns a CmdResult");
321
- let text = match &r.output {
322
- CmdOutput::Human(s) => s.clone(),
323
- CmdOutput::Json(v) => v.to_string(),
324
- CmdOutput::None => String::new(),
325
- };
326
- assert!(
312
+ // ── cmd_watch (cli/adapters.rs:58) [RED] — today a CmdResult::none() no-op ───────────────────────
313
+ // Golden cmd_watch (cli/commands.py:103-109) DELEGATES to run_watch(workspace.resolve(), team) and
314
+ // exits 0. Golden run_watch (watch/__init__.py:25-37) is a `while True` LIVE TAIL that streams
315
+ // render_event_line output (collect_watch_lines) for the watched team. The Rust cmd_watch returns
316
+ // CmdResult::none() — a no-op that never touches the watch subsystem. The watch LINE SHAPE is itself
317
+ // byte-locked by coordinator::render_event_line (coordinator/tests.rs GROUP H); the cli contract here
318
+ // is that cmd_watch must DELEGATE and surface those rendered lines. Seeded a result_received event ->
319
+ // golden render = "result_received: <agent> -> <summary>" (render_event_line: agent_id + summary[:80]).
320
+ //
321
+ // RED confirmed TODAY: cmd_watch=none() returns immediately (no watch line) -> the assertion fails
322
+ // (and does NOT hang). PORTER: wire cmd_watch -> coordinator::run_watch(resolved workspace, team,
323
+ // interval, sink). Since golden's tail is `while True`, the cli port needs a TERMINATING / bounded
324
+ // entry to be both byte-parity AND unit-testable (collect the current watch lines into the CmdResult,
325
+ // or stream to stdout with a bounded test seam) — a blocking unit test is unacceptable.
326
+ #[test]
327
+ fn cmd_watch_delegates_and_surfaces_rendered_watch_lines_not_noop() {
328
+ let ws = tmp_workspace();
329
+ let logs = crate::model::paths::logs_dir(&ws);
330
+ std::fs::create_dir_all(&logs).unwrap();
331
+ std::fs::write(
332
+ logs.join("events.jsonl"),
333
+ "{\"event\":\"result_received\",\"agent_id\":\"alpha\",\"summary\":\"did the thing\"}\n",
334
+ )
335
+ .unwrap();
336
+ let r = cmd_watch(&WatchArgs {
337
+ workspace: ws.clone(),
338
+ team: None,
339
+ })
340
+ .expect("cmd_watch returns a CmdResult");
341
+ let text = match &r.output {
342
+ CmdOutput::Human(s) => s.clone(),
343
+ CmdOutput::Json(v) => v.to_string(),
344
+ CmdOutput::None => String::new(),
345
+ };
346
+ assert!(
327
347
  text.contains("result_received: alpha -> did the thing"),
328
348
  "cmd_watch must DELEGATE to the watch subsystem (run_watch -> render_event_line) and surface the \
329
349
  rendered watch line; today it is a CmdResult::none() no-op. got output={:?}",
330
350
  r.output
331
351
  );
332
- }
352
+ }
333
353
 
334
- // D7 [WARN] — WAVE-2 Lane B: empty caller binds refuse as caller_pane_missing.
335
- #[test]
336
- #[serial_test::serial(env)]
337
- fn d7_lease_refusal_dict_is_golden_minimal_four_keys() {
338
- use std::sync::Mutex;
339
- static D7_ENV: Mutex<()> = Mutex::new(());
340
- let _g = D7_ENV.lock().unwrap_or_else(|p| p.into_inner());
341
- let _env = EnvGuard::set(&[
342
- ("TMUX_PANE", Some("")), // empty caller -> caller_pane_missing via the lease path
343
- ("TEAM_AGENT_LEADER_PANE_ID", None),
344
- ]);
345
- let ws = tmp_workspace();
346
- crate::state::persist::save_runtime_state(&ws, &json!({})).unwrap(); // claim_leader loads state
347
- let result = super::leader_port::claim_leader(&ws, None, false);
348
- let v = result.expect("claim_leader projection");
349
- let obj = v.as_object().expect("lease dict");
350
- assert_eq!(
351
- obj.get("reason").and_then(|r| r.as_str()),
352
- Some("caller_pane_missing"),
353
- "precondition: empty caller -> caller_pane_missing; got {v:?}"
354
- );
355
- let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
356
- assert_eq!(
357
- keys,
358
- ["ok", "status", "reason", "caller_pane_id", "caller_current_command", "hint"]
359
- .into_iter()
360
- .collect::<std::collections::BTreeSet<_>>(),
361
- "golden caller_pane_missing refusal key set"
362
- );
363
- }
354
+ // D7 [WARN] — WAVE-2 Lane B: empty caller binds refuse as caller_pane_missing.
355
+ #[test]
356
+ #[serial_test::serial(env)]
357
+ fn d7_lease_refusal_dict_is_golden_minimal_four_keys() {
358
+ use std::sync::Mutex;
359
+ static D7_ENV: Mutex<()> = Mutex::new(());
360
+ let _g = D7_ENV.lock().unwrap_or_else(|p| p.into_inner());
361
+ let _env = EnvGuard::set(&[
362
+ ("TMUX_PANE", Some("")), // empty caller -> caller_pane_missing via the lease path
363
+ ("TEAM_AGENT_LEADER_PANE_ID", None),
364
+ ]);
365
+ let ws = tmp_workspace();
366
+ crate::state::persist::save_runtime_state(&ws, &json!({})).unwrap(); // claim_leader loads state
367
+ let result = super::leader_port::claim_leader(&ws, None, false);
368
+ let v = result.expect("claim_leader projection");
369
+ let obj = v.as_object().expect("lease dict");
370
+ assert_eq!(
371
+ obj.get("reason").and_then(|r| r.as_str()),
372
+ Some("caller_pane_missing"),
373
+ "precondition: empty caller -> caller_pane_missing; got {v:?}"
374
+ );
375
+ let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
376
+ assert_eq!(
377
+ keys,
378
+ [
379
+ "ok",
380
+ "status",
381
+ "reason",
382
+ "caller_pane_id",
383
+ "caller_current_command",
384
+ "hint"
385
+ ]
386
+ .into_iter()
387
+ .collect::<std::collections::BTreeSet<_>>(),
388
+ "golden caller_pane_missing refusal key set"
389
+ );
390
+ }
364
391
 
365
- struct EnvGuard {
366
- previous: Vec<(&'static str, Option<String>)>,
367
- }
392
+ struct EnvGuard {
393
+ previous: Vec<(&'static str, Option<String>)>,
394
+ }
368
395
 
369
- impl EnvGuard {
370
- fn set(values: &[(&'static str, Option<&'static str>)]) -> Self {
371
- let previous = values
372
- .iter()
373
- .map(|(key, _)| (*key, std::env::var(key).ok()))
374
- .collect::<Vec<_>>();
375
- for (key, value) in values {
376
- unsafe {
377
- if let Some(value) = value {
378
- std::env::set_var(key, value);
379
- } else {
380
- std::env::remove_var(key);
381
- }
396
+ impl EnvGuard {
397
+ fn set(values: &[(&'static str, Option<&'static str>)]) -> Self {
398
+ let previous = values
399
+ .iter()
400
+ .map(|(key, _)| (*key, std::env::var(key).ok()))
401
+ .collect::<Vec<_>>();
402
+ for (key, value) in values {
403
+ unsafe {
404
+ if let Some(value) = value {
405
+ std::env::set_var(key, value);
406
+ } else {
407
+ std::env::remove_var(key);
382
408
  }
383
409
  }
384
- Self { previous }
385
410
  }
411
+ Self { previous }
386
412
  }
413
+ }
387
414
 
388
- impl Drop for EnvGuard {
389
- fn drop(&mut self) {
390
- for (key, value) in self.previous.drain(..).rev() {
391
- unsafe {
392
- if let Some(value) = value {
393
- std::env::set_var(key, value);
394
- } else {
395
- std::env::remove_var(key);
396
- }
415
+ impl Drop for EnvGuard {
416
+ fn drop(&mut self) {
417
+ for (key, value) in self.previous.drain(..).rev() {
418
+ unsafe {
419
+ if let Some(value) = value {
420
+ std::env::set_var(key, value);
421
+ } else {
422
+ std::env::remove_var(key);
397
423
  }
398
424
  }
399
425
  }
400
426
  }
427
+ }