@team-agent/installer 0.5.75 → 0.5.78

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 (98) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -0
  4. package/README.zh.md +8 -0
  5. package/crates/team-agent/src/cli/adapters.rs +184 -125
  6. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  7. package/crates/team-agent/src/cli/emit.rs +59 -15
  8. package/crates/team-agent/src/cli/mod.rs +600 -134
  9. package/crates/team-agent/src/cli/models.rs +485 -44
  10. package/crates/team-agent/src/cli/named_address.rs +19 -5
  11. package/crates/team-agent/src/cli/profile.rs +35 -4
  12. package/crates/team-agent/src/cli/spec.rs +7 -2
  13. package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
  14. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  15. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  16. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  17. package/crates/team-agent/src/cli/status_port.rs +8 -0
  18. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  19. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  20. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  21. package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
  22. package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
  23. package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
  24. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  25. package/crates/team-agent/src/cli/types.rs +1 -0
  26. package/crates/team-agent/src/leader/lease.rs +538 -41
  27. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  28. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  29. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  30. package/crates/team-agent/src/leader/start.rs +151 -14
  31. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  32. package/crates/team-agent/src/leader/types.rs +4 -0
  33. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  34. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  35. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  36. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  37. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +138 -14
  38. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  39. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  40. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  41. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  42. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  43. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  44. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  45. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  46. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  47. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +1002 -65
  48. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  49. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  50. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
  51. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  52. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  53. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  54. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -3
  55. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  56. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  57. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  58. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  59. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  60. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  61. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  62. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  63. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  64. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  65. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  66. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +4 -3
  67. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  68. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  69. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  70. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +69 -28
  71. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  72. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  73. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  74. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
  75. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  76. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  77. package/crates/team-agent/src/lifecycle/types.rs +12 -0
  78. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  79. package/crates/team-agent/src/messaging/delivery.rs +67 -8
  80. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  81. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  82. package/crates/team-agent/src/messaging/watchers.rs +48 -4
  83. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  84. package/crates/team-agent/src/state/paths.rs +17 -2
  85. package/crates/team-agent/src/state/persist.rs +97 -1
  86. package/crates/team-agent/src/state/projection.rs +18 -2
  87. package/crates/team-agent/src/state/repository.rs +29 -0
  88. package/crates/team-agent/src/state/selector.rs +23 -3
  89. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +144 -0
  90. package/crates/team-agent/src/tmux_backend/tests.rs +121 -4
  91. package/crates/team-agent/src/tmux_backend.rs +208 -118
  92. package/crates/team-agent/src/transport.rs +72 -0
  93. package/crates/team-agent/src/transport_factory.rs +53 -0
  94. package/package.json +4 -5
  95. package/skills/team-agent/SKILL.md +45 -17
  96. package/skills/team-agent/command-coverage.json +4 -29
  97. package/npm/check-skill-links.mjs +0 -88
  98. package/skills/team-agent/references/team-agent-operator.md +0 -672
@@ -0,0 +1,691 @@
1
+ use super::*;
2
+ use serde_json::{json, Value};
3
+ use serial_test::serial;
4
+ use std::collections::BTreeMap;
5
+ use std::path::{Path, PathBuf};
6
+ #[cfg(unix)]
7
+ use std::time::{Duration, Instant};
8
+
9
+ fn brief_workspace(tag: &str) -> PathBuf {
10
+ let dir = std::env::temp_dir().join(format!(
11
+ "ta-status-brief-{}-{}-{}",
12
+ tag,
13
+ std::process::id(),
14
+ std::time::SystemTime::now()
15
+ .duration_since(std::time::UNIX_EPOCH)
16
+ .unwrap()
17
+ .as_nanos()
18
+ ));
19
+ std::fs::create_dir_all(dir.join(".team").join("runtime")).unwrap();
20
+ dir
21
+ }
22
+
23
+ fn write_state(ws: &Path, state: &Value) {
24
+ std::fs::write(
25
+ ws.join(".team").join("runtime").join("state.json"),
26
+ serde_json::to_vec_pretty(state).unwrap(),
27
+ )
28
+ .unwrap();
29
+ }
30
+
31
+ fn production_agent(id: &str, pane: &str) -> Value {
32
+ json!({
33
+ "status": "running",
34
+ "provider": "pi",
35
+ "agent_id": id,
36
+ "window": id,
37
+ "layout_window": id,
38
+ "pane_id": pane,
39
+ "display": {
40
+ "backend": "adaptive",
41
+ "status": "opened",
42
+ "window": id,
43
+ "pane_id": pane,
44
+ "target_worker_session": "team-demo",
45
+ "linked_session": null,
46
+ "display_session": null
47
+ }
48
+ })
49
+ }
50
+
51
+ fn production_state(agents: Value) -> Value {
52
+ json!({
53
+ "session_name": "team-demo",
54
+ "tmux_endpoint": "/tmp/ta-status-brief.sock",
55
+ "tmux_socket": "/tmp/ta-status-brief.sock",
56
+ "active_team_key": "demo",
57
+ "agents": agents
58
+ })
59
+ }
60
+
61
+ fn status_args(ws: &Path, json_out: bool, agent: Option<&str>, team: Option<&str>) -> StatusArgs {
62
+ StatusArgs {
63
+ agent: agent.map(str::to_string),
64
+ workspace: ws.to_path_buf(),
65
+ detail: false,
66
+ summary: false,
67
+ json: json_out,
68
+ team: team.map(str::to_string),
69
+ }
70
+ }
71
+
72
+ fn json_nodes(result: CmdResult) -> Vec<Value> {
73
+ match result.output {
74
+ CmdOutput::Json(value) => value
75
+ .get("nodes")
76
+ .and_then(Value::as_array)
77
+ .cloned()
78
+ .unwrap_or_default(),
79
+ other => panic!("expected JSON nodes, got {other:?}"),
80
+ }
81
+ }
82
+
83
+ fn snapshot_tree(root: &Path) -> BTreeMap<String, Vec<u8>> {
84
+ let mut out = BTreeMap::new();
85
+ fn walk(dir: &Path, root: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
86
+ let Ok(entries) = std::fs::read_dir(dir) else {
87
+ return;
88
+ };
89
+ for entry in entries.flatten() {
90
+ let path = entry.path();
91
+ let rel = path
92
+ .strip_prefix(root)
93
+ .unwrap()
94
+ .to_string_lossy()
95
+ .replace('\\', "/");
96
+ if path.is_dir() {
97
+ walk(&path, root, out);
98
+ } else if let Ok(bytes) = std::fs::read(&path) {
99
+ out.insert(rel, bytes);
100
+ }
101
+ }
102
+ }
103
+ walk(root, root, &mut out);
104
+ out
105
+ }
106
+
107
+ #[cfg(unix)]
108
+ fn producer_report(socket: &str, window: &str, pane: &str, method: &str) -> String {
109
+ format!(
110
+ r#"{{"schema_version":1,"socket":"{socket}","sampled_at":"2026-01-01T00:00:00Z","nodes":[{{"socket":"{socket}","workspace_path":"/ws","project_name":"demo","session":"team-demo","window_index":1,"window_name":"{window}","pane_id":"{pane}","name":"{window}","provider":"pi","state":"idle","activity":"idle","session_name":"pi-session","health":"normal","background_tasks":{{"running":0}},"evidence":{{"method":"{method}","detail":"channel"}}}}]}}"#
111
+ )
112
+ }
113
+
114
+ #[cfg(unix)]
115
+ fn write_nodeprobe(dir: &Path, body: &str) -> PathBuf {
116
+ use std::os::unix::fs::PermissionsExt;
117
+ std::fs::create_dir_all(dir).unwrap();
118
+ let path = dir.join("nodeprobe");
119
+ std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
120
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
121
+ path
122
+ }
123
+
124
+ #[cfg(unix)]
125
+ fn make_fifo(path: &Path) {
126
+ use std::ffi::CString;
127
+ use std::os::unix::ffi::OsStrExt;
128
+ let raw = CString::new(path.as_os_str().as_bytes()).unwrap();
129
+ let result = unsafe { libc::mkfifo(raw.as_ptr(), 0o600) };
130
+ assert_eq!(result, 0, "mkfifo {}: {}", path.display(), std::io::Error::last_os_error());
131
+ }
132
+
133
+ fn status_run(ws: &Path, extra: &[&str]) -> ExitCode {
134
+ let mut argv = vec![
135
+ "status".to_string(),
136
+ "--workspace".to_string(),
137
+ ws.to_string_lossy().to_string(),
138
+ ];
139
+ argv.extend(extra.iter().map(|item| item.to_string()));
140
+ run(&argv, Path::new("."))
141
+ }
142
+
143
+ #[cfg(unix)]
144
+ #[test]
145
+ #[serial(status_brief)]
146
+ fn cmd_status_from_production_registration_and_report() {
147
+ let ws = brief_workspace("prod");
148
+ write_state(
149
+ &ws,
150
+ &production_state(json!({ "worker": production_agent("worker", "%7") })),
151
+ );
152
+ let scratch = ws.join("probe");
153
+ let report = producer_report("/tmp/ta-status-brief.sock", "worker", "%7", "pi_activity_channel");
154
+ let log = scratch.join("calls.log");
155
+ let bin = write_nodeprobe(
156
+ &scratch,
157
+ &format!(
158
+ "printf '%s %s\\n' \"$1\" \"$2\" >> '{}'\ncat <<'EOF'\n{}\nEOF\n",
159
+ log.display(),
160
+ report
161
+ ),
162
+ );
163
+ let result = status_port::with_test_nodeprobe(bin, || {
164
+ cmd_status(&status_args(&ws, true, None, None)).expect("status")
165
+ });
166
+ let nodes = json_nodes(result);
167
+ assert_eq!(nodes.len(), 1);
168
+ assert_eq!(nodes[0]["name"], "worker");
169
+ assert_eq!(nodes[0]["provider"], "pi");
170
+ assert_eq!(nodes[0]["runtime_status"], "running");
171
+ assert_eq!(nodes[0]["activity"], "idle");
172
+ assert_eq!(nodes[0]["health"], "normal");
173
+ assert_eq!(nodes[0]["session_name"], "pi-session");
174
+ assert_eq!(
175
+ nodes[0]["tmux_command"],
176
+ "tmux -S '/tmp/ta-status-brief.sock' attach -t 'team-demo:worker.%7'"
177
+ );
178
+ let calls = std::fs::read_to_string(&log).unwrap();
179
+ assert_eq!(calls.lines().count(), 1);
180
+ assert!(calls.contains("-S /tmp/ta-status-brief.sock"), "{calls}");
181
+ let _ = std::fs::remove_dir_all(&ws);
182
+ }
183
+
184
+ #[cfg(unix)]
185
+ #[test]
186
+ #[serial(status_brief)]
187
+ fn cmd_status_dedupes_same_endpoint_across_agents() {
188
+ let ws = brief_workspace("dedupe");
189
+ write_state(
190
+ &ws,
191
+ &production_state(json!({
192
+ "alpha": production_agent("alpha", "%7"),
193
+ "beta": production_agent("beta", "%8"),
194
+ })),
195
+ );
196
+ let scratch = ws.join("probe");
197
+ let log = scratch.join("calls.log");
198
+ let report_alpha = producer_report("/tmp/ta-status-brief.sock", "alpha", "%7", "pi_activity_channel");
199
+ let bin = write_nodeprobe(
200
+ &scratch,
201
+ &format!(
202
+ "printf '%s %s\\n' \"$1\" \"$2\" >> '{}'\ncat <<'EOF'\n{}\nEOF\n",
203
+ log.display(),
204
+ report_alpha
205
+ ),
206
+ );
207
+ let nodes = status_port::with_test_nodeprobe(bin, || {
208
+ json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"))
209
+ });
210
+ assert_eq!(nodes.len(), 2);
211
+ assert_eq!(std::fs::read_to_string(&log).unwrap().lines().count(), 1);
212
+ let _ = std::fs::remove_dir_all(&ws);
213
+ }
214
+
215
+ #[test]
216
+ #[serial(status_brief)]
217
+ fn cmd_status_is_readonly_on_legacy_and_multi_team_state() {
218
+ let ws = brief_workspace("readonly");
219
+ let legacy = json!({
220
+ "session_name": "sess",
221
+ "team_dir": format!("{}/.team/tk", ws.display()),
222
+ "agents": {
223
+ "w1": {
224
+ "agent_id": "w1",
225
+ "status": "running",
226
+ "provider": "pi"
227
+ }
228
+ },
229
+ "team_owner": {"pane_id": "%1", "machine_fingerprint": "fp"}
230
+ });
231
+ write_state(&ws, &legacy);
232
+ let before = snapshot_tree(&ws);
233
+ let result = cmd_status(&status_args(&ws, true, None, None)).expect("status");
234
+ let after = snapshot_tree(&ws);
235
+ assert_eq!(before, after, "status must not migrate or write runtime state");
236
+ assert_eq!(json_nodes(result).len(), 1);
237
+ let pid_path = crate::coordinator::coordinator_pid_path(&crate::coordinator::WorkspacePath::new(
238
+ ws.clone(),
239
+ ));
240
+ assert!(!pid_path.exists(), "status must not spawn a coordinator");
241
+
242
+ let multi = brief_workspace("readonly-multi");
243
+ write_state(
244
+ &multi,
245
+ &json!({
246
+ "teams": {
247
+ "alpha": {"status": "alive", "agents": {"a": {"status": "running", "provider": "pi"}}},
248
+ "beta": {"status": "alive", "agents": {"b": {"status": "running", "provider": "pi"}}}
249
+ }
250
+ }),
251
+ );
252
+ let before_multi = snapshot_tree(&multi);
253
+ let err = cmd_status(&status_args(&multi, true, None, None)).unwrap_err();
254
+ assert!(
255
+ err.to_string().contains("multiple alive teams"),
256
+ "{err}"
257
+ );
258
+ assert_eq!(before_multi, snapshot_tree(&multi));
259
+ let chosen = cmd_status(&status_args(&multi, true, None, Some("alpha"))).expect("team");
260
+ assert_eq!(json_nodes(chosen).len(), 1);
261
+ assert_eq!(before_multi, snapshot_tree(&multi));
262
+ let _ = std::fs::remove_dir_all(&ws);
263
+ let _ = std::fs::remove_dir_all(&multi);
264
+ }
265
+
266
+ #[test]
267
+ #[serial(status_brief)]
268
+ fn cmd_status_human_and_json_fail_on_selector_errors() {
269
+ let multi = brief_workspace("ambiguous");
270
+ write_state(
271
+ &multi,
272
+ &json!({
273
+ "teams": {
274
+ "alpha": {"status": "alive", "agents": {"a": {"status": "running", "provider": "pi"}}},
275
+ "beta": {"status": "alive", "agents": {"b": {"status": "running", "provider": "pi"}}}
276
+ }
277
+ }),
278
+ );
279
+ for json_out in [false, true] {
280
+ let err = cmd_status(&status_args(&multi, json_out, None, None)).unwrap_err();
281
+ assert!(err.to_string().contains("multiple alive teams"), "{err}");
282
+ }
283
+ assert_ne!(status_run(&multi, &[]), ExitCode::Ok);
284
+ assert_ne!(status_run(&multi, &["--json"]), ExitCode::Ok);
285
+
286
+ let one = brief_workspace("one");
287
+ write_state(
288
+ &one,
289
+ &production_state(json!({ "worker": production_agent("worker", "%7") })),
290
+ );
291
+ for json_out in [false, true] {
292
+ let err = cmd_status(&status_args(&one, json_out, Some("ghost"), None)).unwrap_err();
293
+ assert!(err.to_string().contains("unknown agent id: ghost"), "{err}");
294
+ let err = cmd_status(&status_args(&one, json_out, None, Some("missing"))).unwrap_err();
295
+ assert!(
296
+ err.to_string().contains("not found") || err.to_string().contains("missing"),
297
+ "{err}"
298
+ );
299
+ }
300
+ assert_ne!(status_run(&one, &["ghost"]), ExitCode::Ok);
301
+ assert_ne!(status_run(&one, &["ghost", "--json"]), ExitCode::Ok);
302
+
303
+ let bad = brief_workspace("bad-state");
304
+ std::fs::write(
305
+ bad.join(".team").join("runtime").join("state.json"),
306
+ b"{not-json",
307
+ )
308
+ .unwrap();
309
+ for json_out in [false, true] {
310
+ assert!(cmd_status(&status_args(&bad, json_out, None, None)).is_err());
311
+ }
312
+ let before_bad = snapshot_tree(&bad);
313
+ assert_ne!(status_run(&bad, &[]), ExitCode::Ok);
314
+ assert_eq!(before_bad, snapshot_tree(&bad));
315
+ assert_ne!(status_run(&bad, &["--json"]), ExitCode::Ok);
316
+ assert_eq!(before_bad, snapshot_tree(&bad));
317
+
318
+ let missing = std::env::temp_dir()
319
+ .join(format!(
320
+ "ta-status-brief-absent-{}-{}",
321
+ std::process::id(),
322
+ std::time::SystemTime::now()
323
+ .duration_since(std::time::UNIX_EPOCH)
324
+ .unwrap()
325
+ .as_nanos()
326
+ ))
327
+ .join("nested");
328
+ for json_out in [false, true] {
329
+ let err = cmd_status(&status_args(&missing, json_out, None, None)).unwrap_err();
330
+ assert!(
331
+ err.to_string().contains("invalid workspace"),
332
+ "{err}"
333
+ );
334
+ }
335
+ assert_ne!(status_run(&missing, &[]), ExitCode::Ok);
336
+ assert_ne!(status_run(&missing, &["--json"]), ExitCode::Ok);
337
+ assert!(!missing.exists(), "status errors must not create workspace paths");
338
+
339
+ let _ = std::fs::remove_dir_all(&multi);
340
+ let _ = std::fs::remove_dir_all(&one);
341
+ let _ = std::fs::remove_dir_all(&bad);
342
+ }
343
+
344
+ #[cfg(unix)]
345
+ #[test]
346
+ #[serial(status_brief)]
347
+ fn cmd_status_does_not_spawn_unbound_or_mismatched_nodeprobe() {
348
+ let cases = [
349
+ ("missing-receipt", None, None),
350
+ ("wrong-hash", Some(("binary_sha256", json!("0".repeat(64)))), None),
351
+ ("wrong-source", Some(("source_commit", json!("0".repeat(40)))), None),
352
+ ("wrong-target", Some(("target", json!("unknown-target"))), None),
353
+ (
354
+ "wrong-capability",
355
+ Some(("capabilities", json!(["tmux.capture-pane"]))),
356
+ None,
357
+ ),
358
+ ("replaced-binary", None, Some("replace")),
359
+ ];
360
+ for (tag, mutation, replacement) in cases {
361
+ let ws = brief_workspace(tag);
362
+ write_state(
363
+ &ws,
364
+ &production_state(json!({ "worker": production_agent("worker", "%7") })),
365
+ );
366
+ let scratch = ws.join("probe");
367
+ let log = scratch.join("spawned.log");
368
+ let bin = write_nodeprobe(
369
+ &scratch,
370
+ &format!("printf spawned >> '{}'\n", log.display()),
371
+ );
372
+ let run = || {
373
+ let nodes = json_nodes(
374
+ cmd_status(&status_args(&ws, true, None, None)).expect("status"),
375
+ );
376
+ assert_eq!(nodes[0]["runtime_status"], "unknown", "{tag}");
377
+ assert!(!log.exists(), "{tag} must not spawn an unbound probe");
378
+ };
379
+ if mutation.is_none() && replacement.is_none() {
380
+ status_port::with_test_nodeprobe_unbound(bin, run);
381
+ } else {
382
+ status_port::with_test_nodeprobe(bin.clone(), || {
383
+ if let Some((field, value)) = mutation {
384
+ let receipt = scratch.join("nodeprobe.capability.json");
385
+ let mut body: Value = serde_json::from_slice(
386
+ &std::fs::read(&receipt).unwrap(),
387
+ )
388
+ .unwrap();
389
+ body[field] = value;
390
+ std::fs::write(&receipt, serde_json::to_vec(&body).unwrap()).unwrap();
391
+ }
392
+ if replacement.is_some() {
393
+ use std::io::Write;
394
+ std::fs::OpenOptions::new()
395
+ .append(true)
396
+ .open(&bin)
397
+ .unwrap()
398
+ .write_all(b"# replaced\n")
399
+ .unwrap();
400
+ }
401
+ run();
402
+ });
403
+ }
404
+ let _ = std::fs::remove_dir_all(&ws);
405
+ }
406
+ }
407
+
408
+ #[cfg(unix)]
409
+ #[test]
410
+ #[serial(status_brief)]
411
+ fn cmd_status_resolver_rejects_fifo_and_oversized_receipts_without_spawn() {
412
+ let cases = ["fifo-receipt", "oversized-receipt"];
413
+ for tag in cases {
414
+ let ws = brief_workspace(tag);
415
+ write_state(
416
+ &ws,
417
+ &production_state(json!({ "worker": production_agent("worker", "%7") })),
418
+ );
419
+ let scratch = ws.join("probe");
420
+ let log = scratch.join("spawned.log");
421
+ let bin = write_nodeprobe(
422
+ &scratch.join("candidate"),
423
+ &format!("printf spawned >> '{}'\n", log.display()),
424
+ );
425
+ let started = Instant::now();
426
+ status_port::with_test_nodeprobe(bin.clone(), || {
427
+ let receipt = scratch.join("candidate/nodeprobe.capability.json");
428
+ if tag == "fifo-receipt" {
429
+ std::fs::remove_file(&receipt).unwrap();
430
+ make_fifo(&receipt);
431
+ } else {
432
+ std::fs::write(&receipt, vec![b'x'; 65 * 1024]).unwrap();
433
+ }
434
+ let nodes = json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"));
435
+ assert_eq!(nodes[0]["runtime_status"], "unknown", "{tag}");
436
+ });
437
+ assert!(!log.exists(), "{tag} must not spawn the candidate");
438
+ assert!(
439
+ started.elapsed() < Duration::from_secs(1),
440
+ "{tag} resolver exceeded bounded wall clock: {:?}",
441
+ started.elapsed()
442
+ );
443
+ let _ = std::fs::remove_dir_all(&ws);
444
+ }
445
+
446
+ let ws = brief_workspace("lazy-candidate");
447
+ write_state(
448
+ &ws,
449
+ &production_state(json!({ "worker": production_agent("worker", "%7") })),
450
+ );
451
+ let scratch = ws.join("probe");
452
+ let first_log = scratch.join("first.log");
453
+ let second_log = scratch.join("second.log");
454
+ let first = write_nodeprobe(
455
+ &scratch.join("first"),
456
+ &format!("printf first >> '{}'\n", first_log.display()),
457
+ );
458
+ let second = write_nodeprobe(
459
+ &scratch.join("second"),
460
+ &format!("printf second >> '{}'\n", second_log.display()),
461
+ );
462
+ let started = Instant::now();
463
+ status_port::with_test_nodeprobe_candidates(vec![first, second.clone()], || {
464
+ let second_receipt = scratch.join("second/nodeprobe.capability.json");
465
+ std::fs::remove_file(&second_receipt).unwrap();
466
+ make_fifo(&second_receipt);
467
+ let nodes = json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"));
468
+ assert_eq!(nodes[0]["runtime_status"], "unknown");
469
+ });
470
+ assert!(first_log.exists(), "valid first candidate must be spawned");
471
+ assert!(!second_log.exists(), "later blocking candidate must not be scanned");
472
+ assert!(
473
+ started.elapsed() < Duration::from_secs(1),
474
+ "lazy resolver exceeded bounded wall clock: {:?}",
475
+ started.elapsed()
476
+ );
477
+ let _ = std::fs::remove_dir_all(&ws);
478
+ }
479
+
480
+ #[cfg(unix)]
481
+ #[test]
482
+ #[serial(status_brief)]
483
+ fn cmd_status_resolver_late_result_cannot_spawn_after_deadline() {
484
+ let ws = brief_workspace("resolver-late-result");
485
+ write_state(
486
+ &ws,
487
+ &production_state(json!({ "worker": production_agent("worker", "%7") })),
488
+ );
489
+ let scratch = ws.join("probe");
490
+ let log = scratch.join("spawned.log");
491
+ let bin = write_nodeprobe(
492
+ &scratch,
493
+ &format!("printf spawned >> '{}'\n", log.display()),
494
+ );
495
+ status_port::with_test_nodeprobe(bin, || {
496
+ let caller_started = Instant::now();
497
+ let signal = status_port::with_test_nodeprobe_resolver_delay(
498
+ Duration::from_millis(2500),
499
+ |signal| {
500
+ let result = cmd_status(&status_args(&ws, true, None, None)).expect("status");
501
+ let caller_elapsed = caller_started.elapsed();
502
+ assert!(
503
+ caller_elapsed < Duration::from_millis(2400),
504
+ "resolver delay blocked status caller: {:?}",
505
+ caller_elapsed
506
+ );
507
+ let nodes = json_nodes(result);
508
+ assert_eq!(nodes[0]["runtime_status"], "unknown");
509
+ signal.clone()
510
+ },
511
+ );
512
+ let entered_deadline = Instant::now() + Duration::from_secs(1);
513
+ while !signal.entered() && Instant::now() < entered_deadline {
514
+ std::thread::yield_now();
515
+ }
516
+ assert!(signal.entered(), "resolver delay seam was not entered");
517
+ let finished_deadline = Instant::now() + Duration::from_secs(3);
518
+ while !signal.finished() && Instant::now() < finished_deadline {
519
+ std::thread::yield_now();
520
+ }
521
+ assert!(signal.finished(), "resolver worker did not release the gate");
522
+ assert!(!log.exists(), "late resolver result must not spawn probe");
523
+
524
+ let nodes = json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"));
525
+ assert_eq!(nodes[0]["runtime_status"], "unknown");
526
+ assert!(
527
+ log.exists(),
528
+ "one normal selection must prove resolver gate release"
529
+ );
530
+ });
531
+ let _ = std::fs::remove_dir_all(&ws);
532
+ }
533
+
534
+ #[cfg(unix)]
535
+ #[test]
536
+ #[serial(status_brief)]
537
+ fn cmd_status_rejects_producer_error_and_wrong_socket_as_unknown() {
538
+ let cases = [
539
+ (
540
+ "exit0-error-nodes",
541
+ 0,
542
+ r#"{"schema_version":1,"socket":"/tmp/ta-status-brief.sock","sampled_at":"2026-01-01T00:00:00Z","nodes":[{"socket":"/tmp/ta-status-brief.sock","session":"team-demo","window_name":"worker","pane_id":"%7","provider":"pi","activity":"idle","health":"normal","evidence":{"method":"pi_activity_channel"}}],"error":{"kind":"corpus_error","message":"providers corpus failed"}}"#,
543
+ ),
544
+ (
545
+ "exit1-error-empty",
546
+ 1,
547
+ r#"{"schema_version":1,"socket":"/tmp/ta-status-brief.sock","sampled_at":"2026-01-01T00:00:00Z","nodes":[],"error":{"kind":"tmux_inventory","message":"missing"}}"#,
548
+ ),
549
+ (
550
+ "exit1-valid",
551
+ 1,
552
+ r#"{"schema_version":1,"socket":"/tmp/ta-status-brief.sock","sampled_at":"2026-01-01T00:00:00Z","nodes":[{"socket":"/tmp/ta-status-brief.sock","session":"team-demo","window_name":"worker","pane_id":"%7","provider":"pi","activity":"idle","health":"normal","evidence":{"method":"pi_activity_channel"}}]}"#,
553
+ ),
554
+ (
555
+ "wrong-socket",
556
+ 0,
557
+ r#"{"schema_version":1,"socket":"/tmp/other.sock","sampled_at":"2026-01-01T00:00:00Z","nodes":[{"socket":"/tmp/other.sock","session":"team-demo","window_name":"worker","pane_id":"%7","provider":"pi","activity":"idle","health":"normal","evidence":{"method":"pi_activity_channel"}}]}"#,
558
+ ),
559
+ ];
560
+ for (tag, code, payload) in cases {
561
+ let ws = brief_workspace(tag);
562
+ write_state(
563
+ &ws,
564
+ &production_state(json!({ "worker": production_agent("worker", "%7") })),
565
+ );
566
+ let scratch = ws.join("probe");
567
+ let bin = write_nodeprobe(
568
+ &scratch,
569
+ &format!("cat <<'EOF'\n{payload}\nEOF\nexit {code}\n"),
570
+ );
571
+ let nodes = status_port::with_test_nodeprobe(bin, || {
572
+ json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"))
573
+ });
574
+ assert_eq!(nodes[0]["runtime_status"], "unknown", "{tag}");
575
+ assert_eq!(nodes[0]["activity"], "unknown", "{tag}");
576
+ assert!(nodes[0]["tmux_command"].is_null(), "{tag}");
577
+ let _ = std::fs::remove_dir_all(&ws);
578
+ }
579
+ }
580
+
581
+ #[cfg(unix)]
582
+ #[test]
583
+ #[serial(status_brief)]
584
+ fn cmd_status_zero_match_and_stopped_conflict_stay_unknown() {
585
+ let ws = brief_workspace("zero-match");
586
+ write_state(
587
+ &ws,
588
+ &production_state(json!({ "worker": production_agent("worker", "%7") })),
589
+ );
590
+ let miss = producer_report("/tmp/ta-status-brief.sock", "worker", "%9", "pi_activity_channel");
591
+ let bin = write_nodeprobe(&ws.join("probe"), &format!("cat <<'EOF'\n{miss}\nEOF\n"));
592
+ let nodes = status_port::with_test_nodeprobe(bin, || {
593
+ json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"))
594
+ });
595
+ assert_eq!(nodes[0]["runtime_status"], "unknown");
596
+
597
+ let conflict = brief_workspace("stopped-live");
598
+ let mut agent = production_agent("worker", "%7");
599
+ agent["status"] = json!("stopped");
600
+ write_state(&conflict, &production_state(json!({ "worker": agent })));
601
+ let live = producer_report(
602
+ "/tmp/ta-status-brief.sock",
603
+ "worker",
604
+ "%7",
605
+ "pi_activity_channel",
606
+ );
607
+ let bin = write_nodeprobe(&conflict.join("probe"), &format!("cat <<'EOF'\n{live}\nEOF\n"));
608
+ let nodes = status_port::with_test_nodeprobe(bin, || {
609
+ json_nodes(cmd_status(&status_args(&conflict, true, None, None)).expect("status"))
610
+ });
611
+ assert_eq!(nodes[0]["runtime_status"], "unknown");
612
+ let _ = std::fs::remove_dir_all(&ws);
613
+ let _ = std::fs::remove_dir_all(&conflict);
614
+ }
615
+
616
+ #[cfg(unix)]
617
+ #[test]
618
+ #[serial(status_brief)]
619
+ fn cmd_status_shared_deadline_reaps_inherited_stdout_child() {
620
+ let ws = brief_workspace("hang");
621
+ write_state(
622
+ &ws,
623
+ &production_state(json!({ "worker": production_agent("worker", "%7") })),
624
+ );
625
+ let pid_file = ws.join("probe").join("child.pid");
626
+ let bin = write_nodeprobe(
627
+ &ws.join("probe"),
628
+ &format!(
629
+ "/bin/sleep 12 &\nprintf '%s\\n' \"$!\" > '{}'\nexit 0\n",
630
+ pid_file.display()
631
+ ),
632
+ );
633
+ let started = Instant::now();
634
+ let nodes = status_port::with_test_nodeprobe(bin, || {
635
+ json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"))
636
+ });
637
+ let elapsed = started.elapsed();
638
+ assert_eq!(nodes[0]["runtime_status"], "unknown");
639
+ assert!(
640
+ elapsed < Duration::from_secs(3),
641
+ "shared deadline exceeded: {elapsed:?}"
642
+ );
643
+ let pid: u32 = std::fs::read_to_string(&pid_file)
644
+ .unwrap()
645
+ .trim()
646
+ .parse()
647
+ .unwrap();
648
+ let deadline = Instant::now() + Duration::from_millis(200);
649
+ while Instant::now() < deadline && pid_alive(pid) {
650
+ std::thread::sleep(Duration::from_millis(10));
651
+ }
652
+ assert!(!pid_alive(pid), "inherited stdout child {pid} was not reaped");
653
+ let _ = std::fs::remove_dir_all(&ws);
654
+ }
655
+
656
+ #[cfg(unix)]
657
+ #[test]
658
+ #[serial(status_brief)]
659
+ fn cmd_status_multi_endpoint_uses_total_budget() {
660
+ let ws = brief_workspace("budget");
661
+ let mut beta = production_agent("beta", "%8");
662
+ beta["tmux_endpoint"] = json!("/tmp/ta-status-brief-b.sock");
663
+ write_state(
664
+ &ws,
665
+ &json!({
666
+ "session_name": "team-demo",
667
+ "tmux_endpoint": "/tmp/ta-status-brief.sock",
668
+ "agents": {
669
+ "alpha": production_agent("alpha", "%7"),
670
+ "beta": beta
671
+ }
672
+ }),
673
+ );
674
+ let bin = write_nodeprobe(&ws.join("probe"), "/bin/sleep 5\nexit 0\n");
675
+ let started = Instant::now();
676
+ let nodes = status_port::with_test_nodeprobe(bin, || {
677
+ json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("status"))
678
+ });
679
+ let elapsed = started.elapsed();
680
+ assert_eq!(nodes.len(), 2);
681
+ assert!(
682
+ elapsed < Duration::from_secs(3),
683
+ "total probe budget exceeded: {elapsed:?}"
684
+ );
685
+ let _ = std::fs::remove_dir_all(&ws);
686
+ }
687
+
688
+ #[cfg(unix)]
689
+ fn pid_alive(pid: u32) -> bool {
690
+ unsafe { libc::kill(pid as i32, 0) == 0 }
691
+ }