@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
@@ -107,186 +107,241 @@ fn seed_team_spec(ws: &std::path::Path) {
107
107
  std::fs::write(ws.join("team.spec.yaml"), spec).unwrap();
108
108
  }
109
109
 
110
- // ── ACK-CRACK [P1 byte-shape] — acknowledge_idle must write golden's TTL suppression shape ───────
111
- // golden runtime.py:680-688: manual-acknowledge persists
112
- // coordinator.idle_acknowledged[team] = {acknowledged_at, expires_at, ttl_seconds}
113
- // coordinator.suppressed_idle_alerts[team][worker].idle_fallback =
114
- // {suppressed_at, suppressed_by:"manual_acknowledge", manual_acknowledge:true, expires_at, ttl_seconds}
115
- // The clear logic does datetime.fromisoformat(entry["expires_at"]); a MISSING expires_at -> ValueError
116
- // -> "invalid_suppression_timestamp" -> immediate self-clear (latent crack once detect_idle_fallbacks
117
- // is ported). So BOTH idle_acknowledged and the entry MUST carry a non-empty expires_at.
118
- #[test]
119
- fn acknowledge_idle_writes_golden_ttl_suppression_shape() {
120
- let ws = tmp_workspace();
121
- crate::state::persist::save_runtime_state(
122
- &ws,
123
- &serde_json::json!({
124
- "active_team_key": "teamX",
125
- "agents": {"w1": {"status": "running", "provider": "codex"}}
126
- }),
127
- )
128
- .unwrap();
129
- let _ = lifecycle_port::acknowledge_idle(&ws, None).expect("acknowledge_idle ok");
130
- let state = crate::state::persist::load_runtime_state(&ws).unwrap();
131
- let ack = &state["coordinator"]["idle_acknowledged"]["teamX"];
132
- assert!(
133
- ack.get("expires_at").and_then(serde_json::Value::as_str).is_some_and(|s| !s.is_empty()),
134
- "ACK-CRACK: idle_acknowledged[team] must carry a non-empty expires_at (golden); got {ack}"
135
- );
136
- assert!(ack.get("ttl_seconds").is_some(), "idle_acknowledged[team] must carry ttl_seconds; got {ack}");
137
- let entry = &state["coordinator"]["suppressed_idle_alerts"]["teamX"]["w1"]["idle_fallback"];
138
- assert!(
110
+ // ── ACK-CRACK [P1 byte-shape] — acknowledge_idle must write golden's TTL suppression shape ───────
111
+ // golden runtime.py:680-688: manual-acknowledge persists
112
+ // coordinator.idle_acknowledged[team] = {acknowledged_at, expires_at, ttl_seconds}
113
+ // coordinator.suppressed_idle_alerts[team][worker].idle_fallback =
114
+ // {suppressed_at, suppressed_by:"manual_acknowledge", manual_acknowledge:true, expires_at, ttl_seconds}
115
+ // The clear logic does datetime.fromisoformat(entry["expires_at"]); a MISSING expires_at -> ValueError
116
+ // -> "invalid_suppression_timestamp" -> immediate self-clear (latent crack once detect_idle_fallbacks
117
+ // is ported). So BOTH idle_acknowledged and the entry MUST carry a non-empty expires_at.
118
+ #[test]
119
+ fn acknowledge_idle_writes_golden_ttl_suppression_shape() {
120
+ let ws = tmp_workspace();
121
+ crate::state::persist::save_runtime_state(
122
+ &ws,
123
+ &serde_json::json!({
124
+ "active_team_key": "teamX",
125
+ "agents": {"w1": {"status": "running", "provider": "codex"}}
126
+ }),
127
+ )
128
+ .unwrap();
129
+ let _ = lifecycle_port::acknowledge_idle(&ws, None).expect("acknowledge_idle ok");
130
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
131
+ let ack = &state["coordinator"]["idle_acknowledged"]["teamX"];
132
+ assert!(
133
+ ack.get("expires_at")
134
+ .and_then(serde_json::Value::as_str)
135
+ .is_some_and(|s| !s.is_empty()),
136
+ "ACK-CRACK: idle_acknowledged[team] must carry a non-empty expires_at (golden); got {ack}"
137
+ );
138
+ assert!(
139
+ ack.get("ttl_seconds").is_some(),
140
+ "idle_acknowledged[team] must carry ttl_seconds; got {ack}"
141
+ );
142
+ let entry = &state["coordinator"]["suppressed_idle_alerts"]["teamX"]["w1"]["idle_fallback"];
143
+ assert!(
139
144
  entry.get("expires_at").and_then(serde_json::Value::as_str).is_some_and(|s| !s.is_empty()),
140
145
  "ACK-CRACK: the manual-ack suppression entry must carry expires_at (else clear logic ValueErrors \
141
146
  -> instant self-clear); got {entry}"
142
147
  );
143
- assert_eq!(entry["suppressed_by"], serde_json::json!("manual_acknowledge"), "golden suppressed_by; got {entry}");
144
- assert_eq!(entry["manual_acknowledge"], serde_json::json!(true), "golden manual_acknowledge:true; got {entry}");
145
- }
146
- // ── ACK return-shape [P1 byte-parity] — acknowledge_idle must RETURN golden's keys ───────────────
147
- // golden runtime.py:691: return {ok, team, agent_id, acknowledged_at, expires_at, ttl_seconds}.
148
- // Rust (cli/mod.rs) returns only {ok, team, ttl_seconds} -> missing agent_id, acknowledged_at,
149
- // expires_at. RED. (acknowledged_at/expires_at are the same values written into idle_acknowledged.)
150
- #[test]
151
- fn acknowledge_idle_return_carries_golden_keys() {
152
- let ws = tmp_workspace();
153
- crate::state::persist::save_runtime_state(
148
+ assert_eq!(
149
+ entry["suppressed_by"],
150
+ serde_json::json!("manual_acknowledge"),
151
+ "golden suppressed_by; got {entry}"
152
+ );
153
+ assert_eq!(
154
+ entry["manual_acknowledge"],
155
+ serde_json::json!(true),
156
+ "golden manual_acknowledge:true; got {entry}"
157
+ );
158
+ }
159
+ // ── ACK return-shape [P1 byte-parity] — acknowledge_idle must RETURN golden's keys ───────────────
160
+ // golden runtime.py:691: return {ok, team, agent_id, acknowledged_at, expires_at, ttl_seconds}.
161
+ // Rust (cli/mod.rs) returns only {ok, team, ttl_seconds} -> missing agent_id, acknowledged_at,
162
+ // expires_at. RED. (acknowledged_at/expires_at are the same values written into idle_acknowledged.)
163
+ #[test]
164
+ fn acknowledge_idle_return_carries_golden_keys() {
165
+ let ws = tmp_workspace();
166
+ crate::state::persist::save_runtime_state(
154
167
  &ws,
155
168
  &serde_json::json!({ "active_team_key": "teamX", "agents": {"w1": {"status": "running", "provider": "codex"}} }),
156
169
  )
157
170
  .unwrap();
158
- let r = lifecycle_port::acknowledge_idle(&ws, None).expect("acknowledge_idle ok");
159
- let obj = r.as_object().expect("ack returns a dict");
160
- for key in ["ok", "team", "agent_id", "acknowledged_at", "expires_at", "ttl_seconds"] {
161
- assert!(
162
- obj.contains_key(key),
163
- "ACK return-shape: golden return carries `{key}` (runtime.py:691: ok/team/agent_id/\
164
- acknowledged_at/expires_at/ttl_seconds); Rust omits it. got keys {:?}",
165
- obj.keys().collect::<Vec<_>>()
166
- );
167
- }
171
+ let r = lifecycle_port::acknowledge_idle(&ws, None).expect("acknowledge_idle ok");
172
+ let obj = r.as_object().expect("ack returns a dict");
173
+ for key in [
174
+ "ok",
175
+ "team",
176
+ "agent_id",
177
+ "acknowledged_at",
178
+ "expires_at",
179
+ "ttl_seconds",
180
+ ] {
168
181
  assert!(
169
- obj.get("expires_at").and_then(serde_json::Value::as_str).is_some_and(|s| !s.is_empty()),
170
- "ACK return-shape: expires_at must be a non-empty timestamp; got {r}"
182
+ obj.contains_key(key),
183
+ "ACK return-shape: golden return carries `{key}` (runtime.py:691: ok/team/agent_id/\
184
+ acknowledged_at/expires_at/ttl_seconds); Rust omits it. got keys {:?}",
185
+ obj.keys().collect::<Vec<_>>()
171
186
  );
172
- let _ = std::fs::remove_dir_all(&ws);
173
187
  }
174
- // ── BUG-2 [real bug] — inbox must RETURN the stored messages, not a hardcoded []. ────────────────
175
- // Golden status/inbox.py:35-38 -> MessageStore.inbox(agent_id) (core.py:242, owner_team_id=None):
176
- // select <MESSAGE_SELECT> from messages where sender = ? or recipient = ? order by created_at desc
177
- // limit ? -> then reversed(rows) (chronological asc). At THIS call site owner_team_id is None, so
178
- // there is NO team filter a sent-and-stored message (recipient=w1, status='accepted') must show
179
- // in `inbox w1`. Rust mod.rs:144 is a stub: `let _=(workspace,limit,as_json); "messages":[]`. So
180
- // the row is in team.db but inbox always returns [] -> RED. The shape test above only proves the
181
- // empty-state envelope; THIS proves the message actually surfaces.
182
- #[test]
183
- fn inbox_returns_stored_message_for_recipient() {
184
- let ws = tmp_workspace();
185
- let store = crate::message_store::MessageStore::open(&ws).unwrap();
186
- let mid = store
187
- .create_message(None, "leader", "w1", "hello w1", None, true, None)
188
- .unwrap();
189
- let v = status_port::inbox(&ws, "w1", 20, None, true, None).expect("inbox");
190
- let messages = v["messages"].as_array().expect("messages array");
191
- assert_eq!(
188
+ assert!(
189
+ obj.get("expires_at")
190
+ .and_then(serde_json::Value::as_str)
191
+ .is_some_and(|s| !s.is_empty()),
192
+ "ACK return-shape: expires_at must be a non-empty timestamp; got {r}"
193
+ );
194
+ let _ = std::fs::remove_dir_all(&ws);
195
+ }
196
+ // ── BUG-2 [real bug] — inbox must RETURN the stored messages, not a hardcoded []. ────────────────
197
+ // Golden status/inbox.py:35-38 -> MessageStore.inbox(agent_id) (core.py:242, owner_team_id=None):
198
+ // select <MESSAGE_SELECT> from messages where sender = ? or recipient = ? order by created_at desc
199
+ // limit ? -> then reversed(rows) (chronological asc). At THIS call site owner_team_id is None, so
200
+ // there is NO team filter — a sent-and-stored message (recipient=w1, status='accepted') must show
201
+ // in `inbox w1`. Rust mod.rs:144 is a stub: `let _=(workspace,limit,as_json); "messages":[]`. So
202
+ // the row is in team.db but inbox always returns [] -> RED. The shape test above only proves the
203
+ // empty-state envelope; THIS proves the message actually surfaces.
204
+ #[test]
205
+ fn inbox_returns_stored_message_for_recipient() {
206
+ let ws = tmp_workspace();
207
+ let store = crate::message_store::MessageStore::open(&ws).unwrap();
208
+ let mid = store
209
+ .create_message(None, "leader", "w1", "hello w1", None, true, None)
210
+ .unwrap();
211
+ let v = status_port::inbox(&ws, "w1", 20, None, true, None).expect("inbox");
212
+ let messages = v["messages"].as_array().expect("messages array");
213
+ assert_eq!(
192
214
  messages.len(),
193
215
  1,
194
216
  "golden inbox(w1) must return the stored recipient=w1 row; the stub returns [] -> RED. got {v}"
195
217
  );
196
- let m = &messages[0];
197
- assert_eq!(m["message_id"], json!(mid), "the returned row is the message we stored");
198
- assert_eq!(m["recipient"], json!("w1"));
199
- assert_eq!(m["sender"], json!("leader"));
200
- assert_eq!(m["content"], json!("hello w1"));
201
- assert_eq!(m["status"], json!("accepted"), "create_message persists status='accepted'");
202
- // NULL owner_team_id semantics: status.inbox() calls MessageStore.inbox(agent) with
203
- // owner_team_id=None (no team clause), so a NULL-owner message MUST surface for its recipient.
204
- assert_eq!(m["owner_team_id"], json!(null), "the stored message's owner_team_id is NULL and still returned");
205
- // byte-faithful raw-row columns: requires_ack is the 0/1 INT; artifact_refs the literal text "[]".
206
- assert_eq!(m["requires_ack"], json!(1), "requires_ack is the 0/1 int, not a bool");
207
- assert_eq!(m["artifact_refs"], json!("[]"), "artifact_refs is the raw text column, not parsed");
208
- let _ = std::fs::remove_dir_all(&ws);
209
- }
210
- // ── BUG-2 (match scope) inbox(agent) returns rows where sender==agent OR recipient==agent, and
211
- // EXCLUDES messages for other agents. Membership+exclusion form (not strict index order) so the
212
- // test is deterministic regardless of created_at sub-second ties; golden order is chronological asc. ─
213
- #[test]
214
- fn inbox_matches_sender_or_recipient_and_excludes_others() {
215
- let ws = tmp_workspace();
216
- let store = crate::message_store::MessageStore::open(&ws).unwrap();
217
- store.create_message(None, "leader", "w1", "to w1", None, true, None).unwrap();
218
- store.create_message(None, "w1", "leader", "from w1", None, true, None).unwrap();
219
- store.create_message(None, "leader", "w2", "unrelated to w2", None, true, None).unwrap();
220
- let v = status_port::inbox(&ws, "w1", 20, None, true, None).expect("inbox");
221
- let messages = v["messages"].as_array().expect("messages array");
222
- let mut contents: Vec<String> =
223
- messages.iter().map(|m| m["content"].as_str().unwrap().to_string()).collect();
224
- contents.sort();
225
- assert_eq!(
218
+ let m = &messages[0];
219
+ assert_eq!(
220
+ m["message_id"],
221
+ json!(mid),
222
+ "the returned row is the message we stored"
223
+ );
224
+ assert_eq!(m["recipient"], json!("w1"));
225
+ assert_eq!(m["sender"], json!("leader"));
226
+ assert_eq!(m["content"], json!("hello w1"));
227
+ assert_eq!(
228
+ m["status"],
229
+ json!("accepted"),
230
+ "create_message persists status='accepted'"
231
+ );
232
+ // NULL owner_team_id semantics: status.inbox() calls MessageStore.inbox(agent) with
233
+ // owner_team_id=None (no team clause), so a NULL-owner message MUST surface for its recipient.
234
+ assert_eq!(
235
+ m["owner_team_id"],
236
+ json!(null),
237
+ "the stored message's owner_team_id is NULL and still returned"
238
+ );
239
+ // byte-faithful raw-row columns: requires_ack is the 0/1 INT; artifact_refs the literal text "[]".
240
+ assert_eq!(
241
+ m["requires_ack"],
242
+ json!(1),
243
+ "requires_ack is the 0/1 int, not a bool"
244
+ );
245
+ assert_eq!(
246
+ m["artifact_refs"],
247
+ json!("[]"),
248
+ "artifact_refs is the raw text column, not parsed"
249
+ );
250
+ let _ = std::fs::remove_dir_all(&ws);
251
+ }
252
+ // ── BUG-2 (match scope) — inbox(agent) returns rows where sender==agent OR recipient==agent, and
253
+ // EXCLUDES messages for other agents. Membership+exclusion form (not strict index order) so the
254
+ // test is deterministic regardless of created_at sub-second ties; golden order is chronological asc. ─
255
+ #[test]
256
+ fn inbox_matches_sender_or_recipient_and_excludes_others() {
257
+ let ws = tmp_workspace();
258
+ let store = crate::message_store::MessageStore::open(&ws).unwrap();
259
+ store
260
+ .create_message(None, "leader", "w1", "to w1", None, true, None)
261
+ .unwrap();
262
+ store
263
+ .create_message(None, "w1", "leader", "from w1", None, true, None)
264
+ .unwrap();
265
+ store
266
+ .create_message(None, "leader", "w2", "unrelated to w2", None, true, None)
267
+ .unwrap();
268
+ let v = status_port::inbox(&ws, "w1", 20, None, true, None).expect("inbox");
269
+ let messages = v["messages"].as_array().expect("messages array");
270
+ let mut contents: Vec<String> = messages
271
+ .iter()
272
+ .map(|m| m["content"].as_str().unwrap().to_string())
273
+ .collect();
274
+ contents.sort();
275
+ assert_eq!(
226
276
  contents,
227
277
  vec!["from w1".to_string(), "to w1".to_string()],
228
278
  "inbox(w1) must return BOTH the recipient=w1 and sender=w1 rows and EXCLUDE the w2 message; \
229
279
  the stub returns [] -> RED. got {contents:?}"
230
280
  );
231
- let _ = std::fs::remove_dir_all(&ws);
232
- }
233
- // ── BUG-4 [real bug] — peek must resolve the agent terminal via state `session:window` (golden),
234
- // NOT a stored `pane_id` field. A live worker present in state (with a `window`, on the team's `-L`
235
- // socket) must NOT be fabricated as "agent pane not found". ──────────────────────────────────────
236
- // Golden status/peek.py:35-44: agent = state["agents"][id]; window = agent.get("window", id);
237
- // if not session_name or not _tmux_window_exists(session_name, window): raise "agent terminal is
238
- // not available: <id>"; else `tmux capture-pane -t session:window`. It NEVER reads a stored pane_id.
239
- // Probed live (/tmp/probe_peek.py): a present worker whose window is absent on the socket raises
240
- // `agent terminal is not available: w1`; a missing agent raises `unknown agent id: <id>`. Rust
241
- // cmd_peek (adapters.rs:231 + agent_pane_id:279) keys off agent_state pane_id/pane/tmux_pane_id and
242
- // returns {ok:false,error:"agent pane not found"} when absent — so a NORMAL live worker (window in
243
- // state, no pane_id field) is mis-reported as not found. That is the CP-1 pane-resolution divergence.
244
- //
245
- // Deterministic without real tmux: on a host with no live session, the golden-correct peek resolves
246
- // session:window, finds the window absent on the socket, and yields "agent terminal is not available:
247
- // w1" — NOT "agent pane not found". (The window-on-socket -> real raw-screen capture positive case is
248
- // real-machine; see the #[ignore] dispatch_routes_peek_real_machine.)
249
- #[test]
250
- fn peek_resolves_live_worker_via_session_window_not_pane_id_field() {
251
- let ws = tmp_workspace();
252
- // a live worker: present in state with a `window`, session_name set, but NO stored pane_id field.
253
- // session name is unique so a real tmux session on the dev host can't accidentally satisfy it.
254
- crate::state::persist::save_runtime_state(
255
- &ws,
256
- &json!({
257
- "session_name": "team-peek-red-probe-x9q",
258
- "agents": {"w1": {"status": "running", "provider": "codex", "window": "w1"}}
259
- }),
260
- )
261
- .unwrap();
262
- let args = PeekArgs {
263
- agent: "w1".to_string(),
264
- workspace: ws.clone(),
265
- tail: 20,
266
- head: None,
267
- search: None,
268
- allow_raw_screen: true,
269
- json: true,
270
- };
271
- let text = outcome_text(cmd_peek(&args));
272
- assert!(
281
+ let _ = std::fs::remove_dir_all(&ws);
282
+ }
283
+ // ── BUG-4 [real bug] — peek must resolve the agent terminal via state `session:window` (golden),
284
+ // NOT a stored `pane_id` field. A live worker present in state (with a `window`, on the team's `-L`
285
+ // socket) must NOT be fabricated as "agent pane not found". ──────────────────────────────────────
286
+ // Golden status/peek.py:35-44: agent = state["agents"][id]; window = agent.get("window", id);
287
+ // if not session_name or not _tmux_window_exists(session_name, window): raise "agent terminal is
288
+ // not available: <id>"; else `tmux capture-pane -t session:window`. It NEVER reads a stored pane_id.
289
+ // Probed live (/tmp/probe_peek.py): a present worker whose window is absent on the socket raises
290
+ // `agent terminal is not available: w1`; a missing agent raises `unknown agent id: <id>`. Rust
291
+ // cmd_peek (adapters.rs:231 + agent_pane_id:279) keys off agent_state pane_id/pane/tmux_pane_id and
292
+ // returns {ok:false,error:"agent pane not found"} when absent — so a NORMAL live worker (window in
293
+ // state, no pane_id field) is mis-reported as not found. That is the CP-1 pane-resolution divergence.
294
+ //
295
+ // Deterministic without real tmux: on a host with no live session, the golden-correct peek resolves
296
+ // session:window, finds the window absent on the socket, and yields "agent terminal is not available:
297
+ // w1" — NOT "agent pane not found". (The window-on-socket -> real raw-screen capture positive case is
298
+ // real-machine; see the #[ignore] dispatch_routes_peek_real_machine.)
299
+ #[test]
300
+ fn peek_resolves_live_worker_via_session_window_not_pane_id_field() {
301
+ let ws = tmp_workspace();
302
+ // a live worker: present in state with a `window`, session_name set, but NO stored pane_id field.
303
+ // session name is unique so a real tmux session on the dev host can't accidentally satisfy it.
304
+ crate::state::persist::save_runtime_state(
305
+ &ws,
306
+ &json!({
307
+ "session_name": "team-peek-red-probe-x9q",
308
+ "agents": {"w1": {"status": "running", "provider": "codex", "window": "w1"}}
309
+ }),
310
+ )
311
+ .unwrap();
312
+ let args = PeekArgs {
313
+ agent: "w1".to_string(),
314
+ workspace: ws.clone(),
315
+ tail: 20,
316
+ head: None,
317
+ search: None,
318
+ allow_raw_screen: true,
319
+ json: true,
320
+ };
321
+ let text = outcome_text(cmd_peek(&args));
322
+ assert!(
273
323
  !text.contains("agent pane not found"),
274
324
  "peek keys off a stored pane_id field and fabricates 'agent pane not found' for a live worker \
275
325
  that has a `window` in state; golden resolves session:window and never reads pane_id. got: {text}"
276
326
  );
277
- assert!(
278
- text.contains("agent terminal is not available: w1"),
279
- "golden status/peek.py: a worker whose window is not on the socket yields \
327
+ assert!(
328
+ text.contains("agent terminal is not available: w1"),
329
+ "golden status/peek.py: a worker whose window is not on the socket yields \
280
330
  'agent terminal is not available: w1' (window-existence via session:window), NOT a \
281
331
  pane_id-keyed error. got: {text}"
282
- );
283
- let _ = std::fs::remove_dir_all(&ws);
284
- }
285
- #[test]
286
- fn ux_doctor_secret_scan_is_present_and_non_triggering_for_normal_paths() {
287
- let ws = tmp_workspace();
288
- std::fs::write(ws.join("normal-role.md"), "---\nname: worker\nprovider: codex\n---\nUse /tmp/team-agent.\n").unwrap();
289
- let value = json_output(cmd_doctor(&DoctorArgs {
332
+ );
333
+ let _ = std::fs::remove_dir_all(&ws);
334
+ }
335
+ #[test]
336
+ fn ux_doctor_secret_scan_is_present_and_non_triggering_for_normal_paths() {
337
+ let ws = tmp_workspace();
338
+ std::fs::write(
339
+ ws.join("normal-role.md"),
340
+ "---\nname: worker\nprovider: codex\n---\nUse /tmp/team-agent.\n",
341
+ )
342
+ .unwrap();
343
+ let value = json_output(
344
+ cmd_doctor(&DoctorArgs {
290
345
  spec: None,
291
346
  workspace: ws.clone(),
292
347
  gate: None,
@@ -297,16 +352,23 @@ fn seed_team_spec(ws: &std::path::Path) {
297
352
  cleanup_orphans: false,
298
353
  confirm: false,
299
354
  json: true,
300
- }).expect("doctor"));
301
- assert_eq!(value.pointer("/secret_scan/ok"), Some(&json!(true)));
302
- assert_eq!(value.pointer("/secret_scan/findings"), Some(&json!([])));
303
- let _ = std::fs::remove_dir_all(&ws);
304
- }
305
- #[test]
306
- fn ux_doctor_secret_scan_findings_name_the_exact_trigger() {
307
- let ws = tmp_workspace();
308
- std::fs::write(ws.join("leaky-role.md"), "OPENAI_API_KEY=sk-test-red-contract\n").unwrap();
309
- let value = json_output(cmd_doctor(&DoctorArgs {
355
+ })
356
+ .expect("doctor"),
357
+ );
358
+ assert_eq!(value.pointer("/secret_scan/ok"), Some(&json!(true)));
359
+ assert_eq!(value.pointer("/secret_scan/findings"), Some(&json!([])));
360
+ let _ = std::fs::remove_dir_all(&ws);
361
+ }
362
+ #[test]
363
+ fn ux_doctor_secret_scan_findings_name_the_exact_trigger() {
364
+ let ws = tmp_workspace();
365
+ std::fs::write(
366
+ ws.join("leaky-role.md"),
367
+ "OPENAI_API_KEY=sk-test-red-contract\n",
368
+ )
369
+ .unwrap();
370
+ let value = json_output(
371
+ cmd_doctor(&DoctorArgs {
310
372
  spec: None,
311
373
  workspace: ws.clone(),
312
374
  gate: None,
@@ -317,449 +379,495 @@ fn seed_team_spec(ws: &std::path::Path) {
317
379
  cleanup_orphans: false,
318
380
  confirm: false,
319
381
  json: true,
320
- }).expect("doctor"));
321
- let finding = value
322
- .pointer("/secret_scan/findings/0")
323
- .and_then(serde_json::Value::as_object)
324
- .expect("secret-scan must report the concrete trigger");
325
- for key in ["path", "line", "rule", "match_excerpt"] {
326
- assert!(finding.contains_key(key), "secret-scan finding missing `{key}`: {finding:?}");
327
- }
328
- let _ = std::fs::remove_dir_all(&ws);
382
+ })
383
+ .expect("doctor"),
384
+ );
385
+ let finding = value
386
+ .pointer("/secret_scan/findings/0")
387
+ .and_then(serde_json::Value::as_object)
388
+ .expect("secret-scan must report the concrete trigger");
389
+ for key in ["path", "line", "rule", "match_excerpt"] {
390
+ assert!(
391
+ finding.contains_key(key),
392
+ "secret-scan finding missing `{key}`: {finding:?}"
393
+ );
329
394
  }
330
- #[test]
331
- fn ux_wait_ready_does_not_report_ready_true_without_ready_runtime_state() {
332
- let ws = tmp_workspace();
333
- crate::state::persist::save_runtime_state(
334
- &ws,
335
- &json!({
336
- "agents": {"w1": {"status": "starting"}},
337
- "tasks": [{"id": "t1", "assignee": "w1", "status": "pending"}],
338
- "leader_receiver": {"status": "attached"},
339
- }),
340
- )
341
- .unwrap();
342
- let value = json_output(cmd_wait_ready(&WaitReadyArgs {
395
+ let _ = std::fs::remove_dir_all(&ws);
396
+ }
397
+ #[test]
398
+ fn ux_wait_ready_does_not_report_ready_true_without_ready_runtime_state() {
399
+ let ws = tmp_workspace();
400
+ crate::state::persist::save_runtime_state(
401
+ &ws,
402
+ &json!({
403
+ "agents": {"w1": {"status": "starting"}},
404
+ "tasks": [{"id": "t1", "assignee": "w1", "status": "pending"}],
405
+ "leader_receiver": {"status": "attached"},
406
+ }),
407
+ )
408
+ .unwrap();
409
+ let value = json_output(
410
+ cmd_wait_ready(&WaitReadyArgs {
343
411
  workspace: ws.clone(),
344
412
  timeout: 0.0,
345
413
  json: true,
346
414
  team: None,
347
- }).expect("wait-ready"));
348
- assert_eq!(value["ok"], json!(false), "wait-ready must not fake success before workers are ready");
349
- assert_eq!(value.pointer("/readiness/ready"), Some(&json!(false)));
350
- assert!(
351
- value["summary"].as_str().unwrap_or("").contains("not ready"),
352
- "wait-ready false state should explain not-ready status, got {value:?}"
353
- );
354
- let _ = std::fs::remove_dir_all(&ws);
355
- }
356
- #[test]
357
- fn wait_ready_fake_quick_start_counts_mcp_config_and_task_prompt_delivery() {
358
- let ws = tmp_workspace();
359
- let mcp_config = ws.join(".team").join("runtime").join("agents").join("fake_impl").join("mcp_config.json");
360
- std::fs::create_dir_all(mcp_config.parent().unwrap()).unwrap();
361
- std::fs::write(&mcp_config, r#"{"mcpServers":{"team-agent":{}}}"#).unwrap();
362
- crate::state::persist::save_runtime_state(
363
- &ws,
364
- &json!({
365
- "session_name": "team-fake-ready",
366
- "agents": {
367
- "fake_impl": {
368
- "status": "running",
369
- "provider": "fake",
370
- "mcp_config": mcp_config.to_string_lossy(),
371
- }
372
- },
373
- "tasks": [{
374
- "id": "task_impl",
375
- "assignee": "fake_impl",
376
- "status": "pending",
377
- }],
378
- "leader_receiver": {"status": "attached"},
379
- }),
415
+ })
416
+ .expect("wait-ready"),
417
+ );
418
+ assert_eq!(
419
+ value["ok"],
420
+ json!(false),
421
+ "wait-ready must not fake success before workers are ready"
422
+ );
423
+ assert_eq!(value.pointer("/readiness/ready"), Some(&json!(false)));
424
+ assert!(
425
+ value["summary"]
426
+ .as_str()
427
+ .unwrap_or("")
428
+ .contains("not ready"),
429
+ "wait-ready false state should explain not-ready status, got {value:?}"
430
+ );
431
+ let _ = std::fs::remove_dir_all(&ws);
432
+ }
433
+ #[test]
434
+ fn wait_ready_fake_quick_start_counts_mcp_config_and_task_prompt_delivery() {
435
+ let ws = tmp_workspace();
436
+ let mcp_config = ws
437
+ .join(".team")
438
+ .join("runtime")
439
+ .join("agents")
440
+ .join("fake_impl")
441
+ .join("mcp_config.json");
442
+ std::fs::create_dir_all(mcp_config.parent().unwrap()).unwrap();
443
+ std::fs::write(&mcp_config, r#"{"mcpServers":{"team-agent":{}}}"#).unwrap();
444
+ crate::state::persist::save_runtime_state(
445
+ &ws,
446
+ &json!({
447
+ "session_name": "team-fake-ready",
448
+ "agents": {
449
+ "fake_impl": {
450
+ "status": "running",
451
+ "provider": "fake",
452
+ "mcp_config": mcp_config.to_string_lossy(),
453
+ }
454
+ },
455
+ "tasks": [{
456
+ "id": "task_impl",
457
+ "assignee": "fake_impl",
458
+ "status": "pending",
459
+ }],
460
+ "leader_receiver": {"status": "attached"},
461
+ }),
462
+ )
463
+ .unwrap();
464
+ let store = crate::message_store::MessageStore::open(&ws).unwrap();
465
+ store
466
+ .create_message(
467
+ Some("task_impl"),
468
+ "leader",
469
+ "fake_impl",
470
+ "initial task prompt",
471
+ None,
472
+ true,
473
+ None,
380
474
  )
381
475
  .unwrap();
382
- let store = crate::message_store::MessageStore::open(&ws).unwrap();
383
- store
384
- .create_message(Some("task_impl"), "leader", "fake_impl", "initial task prompt", None, true, None)
385
- .unwrap();
386
- let value = json_output(cmd_wait_ready(&WaitReadyArgs {
476
+ let value = json_output(
477
+ cmd_wait_ready(&WaitReadyArgs {
387
478
  workspace: ws.clone(),
388
479
  timeout: 0.0,
389
480
  json: true,
390
481
  team: None,
391
- }).expect("wait-ready"));
392
- assert_eq!(
393
- value.pointer("/readiness/mcp_ready"),
394
- Some(&json!(true)),
395
- "fake quick-start readiness must treat an existing per-agent mcp_config file as mcp_ready"
396
- );
397
- assert_eq!(
482
+ })
483
+ .expect("wait-ready"),
484
+ );
485
+ assert_eq!(
486
+ value.pointer("/readiness/mcp_ready"),
487
+ Some(&json!(true)),
488
+ "fake quick-start readiness must treat an existing per-agent mcp_config file as mcp_ready"
489
+ );
490
+ assert_eq!(
398
491
  value.pointer("/readiness/task_prompt_delivered"),
399
492
  Some(&json!(true)),
400
493
  "fake quick-start readiness must treat message_counts>0 / persisted initial task prompt as task_prompt_delivered"
401
494
  );
402
- assert_eq!(
495
+ assert_eq!(
403
496
  value.pointer("/readiness/ready"),
404
497
  Some(&json!(true)),
405
498
  "process_started + cli_prompt_ready alone is incomplete; mcp_ready and task_prompt_delivered must also be satisfied"
406
499
  );
407
- let _ = std::fs::remove_dir_all(&ws);
408
- }
409
- fn valid_result_envelope() -> serde_json::Value {
410
- json!({
411
- "schema_version": "result_envelope_v1",
412
- "task_id": "task_impl",
413
- "agent_id": "fake_impl",
414
- "status": "success",
415
- "summary": "done",
416
- "artifacts": [],
417
- "changes": [],
418
- "tests": [{"command": "cargo test", "status": "passed"}],
419
- "risks": [],
420
- "next_actions": []
421
- })
422
- }
423
- fn seed_collect_state(ws: &std::path::Path) {
424
- seed_team_spec(ws);
425
- crate::state::persist::save_runtime_state(
426
- ws,
427
- &json!({
428
- "agents": {"fake_impl": {"status": "idle"}},
429
- "tasks": [{
430
- "id": "task_impl",
431
- "title": "Fake implementation",
432
- "type": "implementation",
433
- "assignee": "fake_impl",
434
- "deps": [],
435
- "acceptance": ["fake result collected"],
436
- "status": "pending",
437
- "requires_tools": [],
438
- "files": [],
439
- "risk": "low"
440
- }],
441
- "session_name": Value::Null,
442
- "active_team_key": Value::Null,
443
- "spec_path": ws.join("team.spec.yaml").to_string_lossy()
444
- }),
445
- )
446
- .unwrap();
447
- }
448
- fn seed_uncollected_result(ws: &std::path::Path, result_id: &str) {
449
- let store = crate::message_store::MessageStore::open(ws).unwrap();
450
- let conn = crate::db::schema::open_db(store.db_path()).unwrap();
451
- conn.execute(
500
+ let _ = std::fs::remove_dir_all(&ws);
501
+ }
502
+ fn valid_result_envelope() -> serde_json::Value {
503
+ json!({
504
+ "schema_version": "result_envelope_v1",
505
+ "task_id": "task_impl",
506
+ "agent_id": "fake_impl",
507
+ "status": "success",
508
+ "summary": "done",
509
+ "artifacts": [],
510
+ "changes": [],
511
+ "tests": [{"command": "cargo test", "status": "passed"}],
512
+ "risks": [],
513
+ "next_actions": []
514
+ })
515
+ }
516
+ fn seed_collect_state(ws: &std::path::Path) {
517
+ seed_team_spec(ws);
518
+ crate::state::persist::save_runtime_state(
519
+ ws,
520
+ &json!({
521
+ "agents": {"fake_impl": {"status": "idle"}},
522
+ "tasks": [{
523
+ "id": "task_impl",
524
+ "title": "Fake implementation",
525
+ "type": "implementation",
526
+ "assignee": "fake_impl",
527
+ "deps": [],
528
+ "acceptance": ["fake result collected"],
529
+ "status": "pending",
530
+ "requires_tools": [],
531
+ "files": [],
532
+ "risk": "low"
533
+ }],
534
+ "session_name": Value::Null,
535
+ "active_team_key": Value::Null,
536
+ "spec_path": ws.join("team.spec.yaml").to_string_lossy()
537
+ }),
538
+ )
539
+ .unwrap();
540
+ }
541
+ fn seed_uncollected_result(ws: &std::path::Path, result_id: &str) {
542
+ let store = crate::message_store::MessageStore::open(ws).unwrap();
543
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
544
+ conn.execute(
452
545
  "insert into results(
453
546
  result_id, owner_team_id, task_id, agent_id, envelope, status, created_at
454
547
  ) values (?1, null, 'task_impl', 'fake_impl', ?2, 'success', '2026-06-02T10:00:00+00:00')",
455
548
  rusqlite::params![result_id, valid_result_envelope().to_string()],
456
549
  )
457
550
  .unwrap();
551
+ }
552
+ fn read_state(ws: &std::path::Path) -> serde_json::Value {
553
+ serde_json::from_str(
554
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(ws)).unwrap(),
555
+ )
556
+ .unwrap()
557
+ }
558
+ fn read_events(ws: &std::path::Path) -> Vec<serde_json::Value> {
559
+ crate::event_log::EventLog::new(ws).tail(50).unwrap()
560
+ }
561
+ fn seeded_team_key(ws: &std::path::Path) -> String {
562
+ ws.file_name().unwrap().to_string_lossy().to_string()
563
+ }
564
+ fn json_output(result: CmdResult) -> serde_json::Value {
565
+ match result.output {
566
+ CmdOutput::Json(v) => v,
567
+ other => panic!("expected JSON output, got {other:?}"),
458
568
  }
459
- fn read_state(ws: &std::path::Path) -> serde_json::Value {
460
- serde_json::from_str(
461
- &std::fs::read_to_string(crate::state::persist::runtime_state_path(ws)).unwrap(),
462
- )
463
- .unwrap()
464
- }
465
- fn read_events(ws: &std::path::Path) -> Vec<serde_json::Value> {
466
- crate::event_log::EventLog::new(ws).tail(50).unwrap()
467
- }
468
- fn seeded_team_key(ws: &std::path::Path) -> String {
469
- ws.file_name().unwrap().to_string_lossy().to_string()
470
- }
471
- fn json_output(result: CmdResult) -> serde_json::Value {
472
- match result.output {
473
- CmdOutput::Json(v) => v,
474
- other => panic!("expected JSON output, got {other:?}"),
475
- }
476
- }
477
- fn seed_remove_agent_workspace(ws: &std::path::Path, status: &str) {
478
- seed_team_spec(ws);
479
- crate::state::persist::save_runtime_state(
480
- ws,
481
- &json!({
482
- "session_name": "team-agent-fake-e2e",
483
- "agents": {
484
- "fake_impl": {
485
- "status": status,
486
- "provider": "fake",
487
- "window": "fake_impl"
488
- }
489
- },
490
- "spec_path": ws.join("team.spec.yaml").to_string_lossy()
491
- }),
492
- )
493
- .unwrap();
494
- }
495
- #[test]
496
- fn remove_agent_spec_running_refusal_lists_all_required_flags_once() {
497
- let ws = tmp_workspace();
498
- seed_remove_agent_workspace(&ws, "running");
499
- let out = json_output(
500
- cmd_remove_agent(&RemoveAgentArgs {
501
- agent: "fake_impl".to_string(),
502
- workspace: ws.clone(),
503
- team: None,
504
- from_spec: false,
505
- confirm: false,
506
- force: false,
507
- json: true,
508
- })
509
- .unwrap(),
510
- );
511
- assert_eq!(out["ok"], json!(false));
512
- assert_eq!(out["status"], json!("refused"));
513
- assert_eq!(out["reason"], json!("remove_agent_flags_required"));
514
- for flag in ["--from-spec", "--confirm", "--force"] {
515
- assert!(
516
- out["error"].as_str().is_some_and(|s| s.contains(flag))
517
- && out["action"].as_str().is_some_and(|s| s.contains(flag))
518
- && out["command"].as_str().is_some_and(|s| s.contains(flag)),
519
- "refusal must mention {flag} in error/action/copyable command; got {out}"
520
- );
521
- }
522
- let with_confirm = json_output(
523
- cmd_remove_agent(&RemoveAgentArgs {
524
- agent: "fake_impl".to_string(),
525
- workspace: ws.clone(),
526
- team: None,
527
- from_spec: false,
528
- confirm: true,
529
- force: false,
530
- json: true,
531
- })
532
- .unwrap(),
569
+ }
570
+ fn seed_remove_agent_workspace(ws: &std::path::Path, status: &str) {
571
+ seed_team_spec(ws);
572
+ crate::state::persist::save_runtime_state(
573
+ ws,
574
+ &json!({
575
+ "session_name": "team-agent-fake-e2e",
576
+ "agents": {
577
+ "fake_impl": {
578
+ "status": status,
579
+ "provider": "fake",
580
+ "window": "fake_impl"
581
+ }
582
+ },
583
+ "spec_path": ws.join("team.spec.yaml").to_string_lossy()
584
+ }),
585
+ )
586
+ .unwrap();
587
+ }
588
+ #[test]
589
+ fn remove_agent_spec_running_refusal_lists_all_required_flags_once() {
590
+ let ws = tmp_workspace();
591
+ seed_remove_agent_workspace(&ws, "running");
592
+ let out = json_output(
593
+ cmd_remove_agent(&RemoveAgentArgs {
594
+ agent: "fake_impl".to_string(),
595
+ workspace: ws.clone(),
596
+ team: None,
597
+ from_spec: false,
598
+ confirm: false,
599
+ force: false,
600
+ json: true,
601
+ })
602
+ .unwrap(),
603
+ );
604
+ assert_eq!(out["ok"], json!(false));
605
+ assert_eq!(out["status"], json!("refused"));
606
+ assert_eq!(out["reason"], json!("remove_agent_flags_required"));
607
+ for flag in ["--from-spec", "--confirm", "--force"] {
608
+ assert!(
609
+ out["error"].as_str().is_some_and(|s| s.contains(flag))
610
+ && out["action"].as_str().is_some_and(|s| s.contains(flag))
611
+ && out["command"].as_str().is_some_and(|s| s.contains(flag)),
612
+ "refusal must mention {flag} in error/action/copyable command; got {out}"
533
613
  );
534
- assert_eq!(with_confirm["reason"], json!("remove_agent_flags_required"));
535
- for flag in ["--from-spec", "--confirm", "--force"] {
536
- assert!(
614
+ }
615
+ let with_confirm = json_output(
616
+ cmd_remove_agent(&RemoveAgentArgs {
617
+ agent: "fake_impl".to_string(),
618
+ workspace: ws.clone(),
619
+ team: None,
620
+ from_spec: false,
621
+ confirm: true,
622
+ force: false,
623
+ json: true,
624
+ })
625
+ .unwrap(),
626
+ );
627
+ assert_eq!(with_confirm["reason"], json!("remove_agent_flags_required"));
628
+ for flag in ["--from-spec", "--confirm", "--force"] {
629
+ assert!(
537
630
  with_confirm["error"].as_str().is_some_and(|s| s.contains(flag))
538
631
  && with_confirm["action"].as_str().is_some_and(|s| s.contains(flag))
539
632
  && with_confirm["command"].as_str().is_some_and(|s| s.contains(flag)),
540
633
  "refusal with --confirm must still give the full command including {flag}; got {with_confirm}"
541
634
  );
542
- }
543
- let state = crate::state::persist::load_runtime_state(&ws).unwrap();
544
- assert!(
545
- state["agents"].get("fake_impl").is_some(),
546
- "refused remove-agent must not delete the spec-defined running agent"
547
- );
548
- }
549
- #[test]
550
- fn remove_agent_running_refusal_is_not_success_envelope() {
551
- let ws = tmp_workspace();
552
- seed_remove_agent_workspace(&ws, "running");
553
- let out = json_output(
554
- cmd_remove_agent(&RemoveAgentArgs {
555
- agent: "fake_impl".to_string(),
556
- workspace: ws.clone(),
557
- team: None,
558
- from_spec: true,
559
- confirm: true,
560
- force: false,
561
- json: true,
562
- })
563
- .unwrap(),
564
- );
565
- assert_eq!(out["ok"], json!(false));
566
- assert_eq!(out["status"], json!("refused"));
567
- assert_eq!(out["reason"], json!("force_required"));
568
- let state = crate::state::persist::load_runtime_state(&ws).unwrap();
569
- assert!(
570
- state["agents"].get("fake_impl").is_some(),
571
- "refused remove-agent must not delete the running agent"
572
- );
573
- }
574
- #[test]
575
- fn remove_agent_from_spec_refusal_is_not_success_envelope() {
576
- let ws = tmp_workspace();
577
- seed_remove_agent_workspace(&ws, "stopped");
578
- let out = json_output(
579
- cmd_remove_agent(&RemoveAgentArgs {
580
- agent: "fake_impl".to_string(),
581
- workspace: ws.clone(),
582
- team: None,
583
- from_spec: false,
584
- confirm: true,
585
- force: false,
586
- json: true,
587
- })
588
- .unwrap(),
589
- );
590
- assert_eq!(out["ok"], json!(false));
591
- assert_eq!(out["status"], json!("refused"));
592
- assert_eq!(out["reason"], json!("from_spec_confirm_required"));
593
- let state = crate::state::persist::load_runtime_state(&ws).unwrap();
594
- assert!(
595
- state["agents"].get("fake_impl").is_some(),
596
- "refused remove-agent must not delete the spec-defined agent"
597
- );
598
- }
599
- #[test]
600
- fn collect_uncollected_result_marks_db_and_outputs_result() {
601
- let ws = tmp_workspace();
602
- seed_collect_state(&ws);
603
- seed_uncollected_result(&ws, "res_collect_red");
604
- let out = json_output(
605
- cmd_collect(&CollectArgs {
606
- workspace: ws.clone(),
607
- result_file: None,
608
- json: true,
609
- team: None,
610
- })
611
- .unwrap(),
612
- );
613
- assert_eq!(out["ok"], json!(true));
614
- assert_eq!(out["collected_results"][0]["result_id"], json!("res_collect_red"));
615
- assert_eq!(out["collected_results"][0]["scope"], json!("task"));
616
- assert_eq!(
617
- out["results"],
618
- json!({"total": 1, "uncollected": 0, "collected": 1, "invalid": 0, "by_status": {}})
619
- );
620
- let store = crate::message_store::MessageStore::open(&ws).unwrap();
621
- let conn = crate::db::schema::open_db(store.db_path()).unwrap();
622
- let status: String = conn
623
- .query_row(
624
- "select status from results where result_id = 'res_collect_red'",
625
- [],
626
- |row| row.get(0),
627
- )
628
- .unwrap();
629
- assert_eq!(status, "collected");
630
- let state = read_state(&ws);
631
- assert_eq!(state["tasks"][0]["status"], json!("done"));
632
- assert_eq!(state["tasks"][0]["accepted_result_id"], json!("res_collect_red"));
633
- assert!(
634
- read_events(&ws)
635
- .iter()
636
- .any(|e| e["event"] == json!("collect.result") && e["result_id"] == json!("res_collect_red")),
637
- "collect must emit collect.result for the stored result"
638
- );
639
- }
640
- #[test]
641
- fn stuck_cancel_persists_suppression_and_stuck_list_reads_state() {
642
- let ws = tmp_workspace();
643
- seed_collect_state(&ws);
644
- let out = json_output(
645
- cmd_stuck_cancel(&StuckCancelArgs {
646
- agent: "fake_impl".to_string(),
647
- workspace: ws.clone(),
648
- alert_type: None,
649
- json: true,
650
- team: None,
651
- })
652
- .unwrap(),
653
- );
654
- let team_key = seeded_team_key(&ws);
655
- assert_eq!(out["ok"], json!(true));
656
- assert_eq!(out["alert_types"], json!(["cross_worker_deadlock", "idle_fallback", "stuck"]));
657
- assert!(out["suppressed"]["idle_fallback"]["snapshot"]["assigned_task_ids"]
658
- .as_array()
659
- .unwrap()
660
- .contains(&json!("task_impl")));
661
- let state = read_state(&ws);
662
- assert_eq!(
663
- state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]["suppressed_by"],
664
- json!("leader")
665
- );
666
- assert!(
667
- read_events(&ws)
668
- .iter()
669
- .any(|e| e["event"] == json!("coordinator.idle_alert_suppressed")
670
- && e["agent_id"] == json!("fake_impl")),
671
- "stuck_cancel must write coordinator.idle_alert_suppressed"
672
- );
673
- let listed = json_output(
674
- cmd_stuck_list(&StuckListArgs {
675
- workspace: ws.clone(),
676
- json: true,
677
- team: None,
678
- })
679
- .unwrap(),
680
- );
681
- assert_eq!(
682
- listed["suppressed_idle_alerts"]["fake_impl"]["stuck"]["suppressed_by"],
683
- json!("leader"),
684
- "stuck-list must read the persisted state mirror, not return a hard-coded empty list"
685
- );
686
635
  }
687
-
688
- #[test]
689
- fn stuck_cancel_explicit_team_is_rejected_until_backend_is_scoped() {
690
- let ws = tmp_workspace();
691
- seed_collect_state(&ws);
692
- let err = cmd_stuck_cancel(&StuckCancelArgs {
636
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
637
+ assert!(
638
+ state["agents"].get("fake_impl").is_some(),
639
+ "refused remove-agent must not delete the spec-defined running agent"
640
+ );
641
+ }
642
+ #[test]
643
+ fn remove_agent_running_refusal_is_not_success_envelope() {
644
+ let ws = tmp_workspace();
645
+ seed_remove_agent_workspace(&ws, "running");
646
+ let out = json_output(
647
+ cmd_remove_agent(&RemoveAgentArgs {
648
+ agent: "fake_impl".to_string(),
649
+ workspace: ws.clone(),
650
+ team: None,
651
+ from_spec: true,
652
+ confirm: true,
653
+ force: false,
654
+ json: true,
655
+ })
656
+ .unwrap(),
657
+ );
658
+ assert_eq!(out["ok"], json!(false));
659
+ assert_eq!(out["status"], json!("refused"));
660
+ assert_eq!(out["reason"], json!("force_required"));
661
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
662
+ assert!(
663
+ state["agents"].get("fake_impl").is_some(),
664
+ "refused remove-agent must not delete the running agent"
665
+ );
666
+ }
667
+ #[test]
668
+ fn remove_agent_from_spec_refusal_is_not_success_envelope() {
669
+ let ws = tmp_workspace();
670
+ seed_remove_agent_workspace(&ws, "stopped");
671
+ let out = json_output(
672
+ cmd_remove_agent(&RemoveAgentArgs {
673
+ agent: "fake_impl".to_string(),
674
+ workspace: ws.clone(),
675
+ team: None,
676
+ from_spec: false,
677
+ confirm: true,
678
+ force: false,
679
+ json: true,
680
+ })
681
+ .unwrap(),
682
+ );
683
+ assert_eq!(out["ok"], json!(false));
684
+ assert_eq!(out["status"], json!("refused"));
685
+ assert_eq!(out["reason"], json!("from_spec_confirm_required"));
686
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
687
+ assert!(
688
+ state["agents"].get("fake_impl").is_some(),
689
+ "refused remove-agent must not delete the spec-defined agent"
690
+ );
691
+ }
692
+ #[test]
693
+ fn collect_uncollected_result_marks_db_and_outputs_result() {
694
+ let ws = tmp_workspace();
695
+ seed_collect_state(&ws);
696
+ seed_uncollected_result(&ws, "res_collect_red");
697
+ let out = json_output(
698
+ cmd_collect(&CollectArgs {
699
+ workspace: ws.clone(),
700
+ result_file: None,
701
+ json: true,
702
+ team: None,
703
+ })
704
+ .unwrap(),
705
+ );
706
+ assert_eq!(out["ok"], json!(true));
707
+ assert_eq!(
708
+ out["collected_results"][0]["result_id"],
709
+ json!("res_collect_red")
710
+ );
711
+ assert_eq!(out["collected_results"][0]["scope"], json!("task"));
712
+ assert_eq!(
713
+ out["results"],
714
+ json!({"total": 1, "uncollected": 0, "collected": 1, "invalid": 0, "by_status": {}})
715
+ );
716
+ let store = crate::message_store::MessageStore::open(&ws).unwrap();
717
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
718
+ let status: String = conn
719
+ .query_row(
720
+ "select status from results where result_id = 'res_collect_red'",
721
+ [],
722
+ |row| row.get(0),
723
+ )
724
+ .unwrap();
725
+ assert_eq!(status, "collected");
726
+ let state = read_state(&ws);
727
+ assert_eq!(state["tasks"][0]["status"], json!("done"));
728
+ assert_eq!(
729
+ state["tasks"][0]["accepted_result_id"],
730
+ json!("res_collect_red")
731
+ );
732
+ assert!(
733
+ read_events(&ws)
734
+ .iter()
735
+ .any(|e| e["event"] == json!("collect.result")
736
+ && e["result_id"] == json!("res_collect_red")),
737
+ "collect must emit collect.result for the stored result"
738
+ );
739
+ }
740
+ #[test]
741
+ fn stuck_cancel_persists_suppression_and_stuck_list_reads_state() {
742
+ let ws = tmp_workspace();
743
+ seed_collect_state(&ws);
744
+ let out = json_output(
745
+ cmd_stuck_cancel(&StuckCancelArgs {
693
746
  agent: "fake_impl".to_string(),
694
747
  workspace: ws.clone(),
695
748
  alert_type: None,
696
749
  json: true,
697
- team: Some("current".to_string()),
750
+ team: None,
698
751
  })
699
- .expect_err("explicit --team must not silently write global stuck suppression");
700
- assert!(
752
+ .unwrap(),
753
+ );
754
+ let team_key = seeded_team_key(&ws);
755
+ assert_eq!(out["ok"], json!(true));
756
+ assert_eq!(
757
+ out["alert_types"],
758
+ json!(["cross_worker_deadlock", "idle_fallback", "stuck"])
759
+ );
760
+ assert!(
761
+ out["suppressed"]["idle_fallback"]["snapshot"]["assigned_task_ids"]
762
+ .as_array()
763
+ .unwrap()
764
+ .contains(&json!("task_impl"))
765
+ );
766
+ let state = read_state(&ws);
767
+ assert_eq!(
768
+ state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]
769
+ ["suppressed_by"],
770
+ json!("leader")
771
+ );
772
+ assert!(
773
+ read_events(&ws)
774
+ .iter()
775
+ .any(|e| e["event"] == json!("coordinator.idle_alert_suppressed")
776
+ && e["agent_id"] == json!("fake_impl")),
777
+ "stuck_cancel must write coordinator.idle_alert_suppressed"
778
+ );
779
+ let listed = json_output(
780
+ cmd_stuck_list(&StuckListArgs {
781
+ workspace: ws.clone(),
782
+ json: true,
783
+ team: None,
784
+ })
785
+ .unwrap(),
786
+ );
787
+ assert_eq!(
788
+ listed["suppressed_idle_alerts"]["fake_impl"]["stuck"]["suppressed_by"],
789
+ json!("leader"),
790
+ "stuck-list must read the persisted state mirror, not return a hard-coded empty list"
791
+ );
792
+ }
793
+
794
+ #[test]
795
+ fn stuck_cancel_explicit_team_is_rejected_until_backend_is_scoped() {
796
+ let ws = tmp_workspace();
797
+ seed_collect_state(&ws);
798
+ let err = cmd_stuck_cancel(&StuckCancelArgs {
799
+ agent: "fake_impl".to_string(),
800
+ workspace: ws.clone(),
801
+ alert_type: None,
802
+ json: true,
803
+ team: Some("current".to_string()),
804
+ })
805
+ .expect_err("explicit --team must not silently write global stuck suppression");
806
+ assert!(
701
807
  err.to_string().contains("not supported yet"),
702
808
  "stuck-cancel --team must be an explicit refusal until backend supports scoped writes; got {err}"
703
809
  );
704
- let _ = std::fs::remove_dir_all(&ws);
705
- }
810
+ let _ = std::fs::remove_dir_all(&ws);
811
+ }
706
812
 
707
- #[test]
708
- fn stuck_cancel_invalid_alert_type_is_rejected() {
709
- let ws = tmp_workspace();
710
- seed_collect_state(&ws);
711
- let code = run(
712
- &cli_argv(&[
713
- "stuck-cancel",
714
- "fake_impl",
715
- "--workspace",
716
- &ws.to_string_lossy(),
717
- "--alert-type",
718
- "bogus",
719
- "--json",
720
- ]),
721
- &ws,
722
- );
723
- assert_eq!(
813
+ #[test]
814
+ fn stuck_cancel_invalid_alert_type_is_rejected() {
815
+ let ws = tmp_workspace();
816
+ seed_collect_state(&ws);
817
+ let code = run(
818
+ &cli_argv(&[
819
+ "stuck-cancel",
820
+ "fake_impl",
821
+ "--workspace",
822
+ &ws.to_string_lossy(),
823
+ "--alert-type",
824
+ "bogus",
825
+ "--json",
826
+ ]),
827
+ &ws,
828
+ );
829
+ assert_eq!(
724
830
  code,
725
831
  ExitCode::Error,
726
832
  "Python rejects alert_type outside stuck/idle_fallback/cross_worker_deadlock/all; Rust must not silently coerce bogus to stuck"
727
833
  );
728
- }
729
- #[test]
730
- fn acknowledge_idle_records_manual_idle_fallback_suppression_and_event() {
731
- let ws = tmp_workspace();
732
- seed_collect_state(&ws);
733
- let out = json_output(
734
- cmd_acknowledge_idle(&AcknowledgeIdleArgs {
735
- team: None,
736
- workspace: ws.clone(),
737
- json: true,
738
- })
739
- .unwrap(),
740
- );
741
- let team_key = seeded_team_key(&ws);
742
- assert_eq!(out["ok"], json!(true));
743
- assert_eq!(out["team"], json!(team_key));
744
- assert_eq!(out["ttl_seconds"], json!(1800));
745
- let state = read_state(&ws);
746
- let ack = &state["coordinator"]["idle_acknowledged"][&team_key];
747
- assert_eq!(ack["ttl_seconds"], json!(1800));
748
- assert!(ack["acknowledged_at"].as_str().is_some());
749
- assert_eq!(
750
- state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]["suppressed_by"],
751
- json!("manual_acknowledge")
752
- );
753
- assert_eq!(
754
- state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]["manual_acknowledge"],
755
- json!(true)
756
- );
757
- assert!(
758
- read_events(&ws)
759
- .iter()
760
- .any(|e| e["event"] == json!("coordinator.idle_acknowledged")
761
- && e["team"] == json!(team_key)
762
- && e["ttl_seconds"] == json!(1800)),
763
- "acknowledge-idle must emit coordinator.idle_acknowledged"
764
- );
765
- }
834
+ }
835
+ #[test]
836
+ fn acknowledge_idle_records_manual_idle_fallback_suppression_and_event() {
837
+ let ws = tmp_workspace();
838
+ seed_collect_state(&ws);
839
+ let out = json_output(
840
+ cmd_acknowledge_idle(&AcknowledgeIdleArgs {
841
+ team: None,
842
+ workspace: ws.clone(),
843
+ json: true,
844
+ })
845
+ .unwrap(),
846
+ );
847
+ let team_key = seeded_team_key(&ws);
848
+ assert_eq!(out["ok"], json!(true));
849
+ assert_eq!(out["team"], json!(team_key));
850
+ assert_eq!(out["ttl_seconds"], json!(1800));
851
+ let state = read_state(&ws);
852
+ let ack = &state["coordinator"]["idle_acknowledged"][&team_key];
853
+ assert_eq!(ack["ttl_seconds"], json!(1800));
854
+ assert!(ack["acknowledged_at"].as_str().is_some());
855
+ assert_eq!(
856
+ state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]
857
+ ["suppressed_by"],
858
+ json!("manual_acknowledge")
859
+ );
860
+ assert_eq!(
861
+ state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]
862
+ ["manual_acknowledge"],
863
+ json!(true)
864
+ );
865
+ assert!(
866
+ read_events(&ws)
867
+ .iter()
868
+ .any(|e| e["event"] == json!("coordinator.idle_acknowledged")
869
+ && e["team"] == json!(team_key)
870
+ && e["ttl_seconds"] == json!(1800)),
871
+ "acknowledge-idle must emit coordinator.idle_acknowledged"
872
+ );
873
+ }