@team-agent/installer 0.5.74 → 0.5.77

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 (45) 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 +23 -96
  6. package/crates/team-agent/src/cli/emit.rs +35 -7
  7. package/crates/team-agent/src/cli/mod.rs +63 -42
  8. package/crates/team-agent/src/cli/models.rs +138 -27
  9. package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
  10. package/crates/team-agent/src/cli/status_port.rs +8 -0
  11. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
  13. package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
  14. package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
  15. package/crates/team-agent/src/layout/worker_env.rs +366 -0
  16. package/crates/team-agent/src/leader/lease.rs +274 -40
  17. package/crates/team-agent/src/leader/registry.rs +177 -19
  18. package/crates/team-agent/src/leader/types.rs +4 -0
  19. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +93 -5
  20. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1820 -60
  21. package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
  23. package/crates/team-agent/src/lifecycle/restart/common.rs +3 -0
  24. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +11 -4
  25. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
  26. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +1 -0
  27. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +24 -28
  29. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
  30. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
  31. package/crates/team-agent/src/lifecycle/types.rs +4 -0
  32. package/crates/team-agent/src/messaging/delivery.rs +200 -29
  33. package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
  34. package/crates/team-agent/src/messaging/watchers.rs +48 -4
  35. package/crates/team-agent/src/state/paths.rs +17 -2
  36. package/crates/team-agent/src/state/persist.rs +166 -6
  37. package/crates/team-agent/src/state/projection.rs +18 -2
  38. package/crates/team-agent/src/state/repository.rs +36 -0
  39. package/crates/team-agent/src/state/selector.rs +23 -3
  40. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +122 -0
  41. package/crates/team-agent/src/tmux_backend/tests.rs +350 -7
  42. package/crates/team-agent/src/tmux_backend.rs +217 -26
  43. package/crates/team-agent/src/transport/test_support.rs +18 -3
  44. package/crates/team-agent/src/transport.rs +99 -0
  45. package/package.json +4 -4
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.74"
584
+ version = "0.5.77"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.74"
12
+ version = "0.5.77"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
package/README.md CHANGED
@@ -82,6 +82,15 @@ Subagents are one-shot, fire-and-forget, and can't talk to each other. Team Agen
82
82
 
83
83
  ## Status
84
84
 
85
+ `team-agent status` prints one concise row per registered node with seven fields:
86
+ `name`, `provider`, `runtime_status`, `activity`, `health`, `session_name`, and `tmux_command`.
87
+ `--json` exposes the same projection as structured JSON. Missing or conflicting evidence is
88
+ reported as `unknown`; a non-null `tmux_command` is safe to copy for the observed socket,
89
+ session, window, and pane. `--summary` and `--detail` remain accepted for compatibility and
90
+ do not add diagnostic or history fields. The accepted nodeprobe pairing and
91
+ operator-trusted capability receipt are documented in
92
+ [`docs/status-nodeprobe.md`](docs/status-nodeprobe.md).
93
+
85
94
  **Beta.** Used daily in real projects. Rough edges possible in uncommon configurations. Issues and PRs welcome.
86
95
 
87
96
  ## License
package/README.zh.md CHANGED
@@ -82,6 +82,14 @@ subagent 是一次性的、干完即焚、彼此不通信。Team Agent 的队友
82
82
 
83
83
  ## 状态
84
84
 
85
+ `team-agent status` 为每个已登记节点输出七个简要字段:
86
+ `name`、`provider`、`runtime_status`、`activity`、`health`、`session_name`、`tmux_command`。
87
+ `--json` 与人读输出使用同一信息投影。证据缺失或冲突时显示 `unknown`;非空
88
+ `tmux_command` 可复制到已观测的 socket、session、window 和 pane。`--summary` 与
89
+ `--detail` 保留兼容性,但不会增加诊断或历史字段。已验收 nodeprobe 的配对方式、
90
+ operator-trusted 能力 receipt 与 fail-closed 边界见
91
+ [`docs/status-nodeprobe.md`](docs/status-nodeprobe.md)。
92
+
85
93
  **Beta。** 已在真实项目里日常使用,小众配置下可能有毛边。欢迎 issue 和 PR。
86
94
 
87
95
  ## License
@@ -280,8 +280,8 @@ fn warn_ignored_owner_team_id(team_dir: &std::path::Path) {
280
280
  }
281
281
  }
282
282
 
283
- /// `cmd_status`(`commands.py:90`)。三态:`--summary`(xor json,xor agent)→五行文本;
284
- /// `--json`→`status_port::status(compact=!detail)`;else→`status_port::format_status(agent)`。
283
+ /// `cmd_status`(`commands.py:90`)。CLI status 统一为只读七字段 brief;
284
+ /// `--json` 与人读路径共享 nodeprobe-backed projection;`--summary`/`--detail` 保留参数兼容性但不暴露诊断。
285
285
  #[cfg(test)]
286
286
  pub(crate) fn cmd_status(args: &StatusArgs) -> Result<CmdResult, CliError> {
287
287
  cmd_status_for_team(args, args.team.as_deref())
@@ -300,123 +300,50 @@ pub fn cmd_status_for_team(args: &StatusArgs, team: Option<&str>) -> Result<CmdR
300
300
  }
301
301
  // S4QR-001 (0.4.8): selected-team ambiguity gate. status is a selected-team
302
302
  // command: when the workspace has 2+ alive teams and no --team was passed,
303
- // refuse instead of silently defaulting to the active team. Uses the same
304
- // CommandScope::resolve helper as refuse_if_multi_alive_team_missing_scope
305
- // (cli/emit.rs:964-979) so the destructive-command ambiguity gate and the
306
- // selected-team-read ambiguity gate share a single source of truth.
303
+ // refuse instead of silently defaulting to the active team. Uses the
304
+ // read-only CommandScope helper so the gate cannot migrate/write state.
307
305
  if team.is_none() {
308
- let scope = crate::state::paths::CommandScope::resolve(&args.workspace, None);
306
+ let scope = crate::state::paths::CommandScope::resolve_readonly(&args.workspace, None);
309
307
  if scope.is_ambiguous() {
310
308
  let candidates: Vec<String> = scope.candidates().to_vec();
311
309
  let message = format!(
312
310
  "status: workspace has multiple alive teams ({}); pass `--team <key>` to choose one",
313
311
  candidates.join(", ")
314
312
  );
315
- if args.json {
316
- let payload = serde_json::json!({
317
- "ok": false,
318
- "status": "refused",
319
- "reason": "team_target_ambiguous",
320
- "candidates": candidates,
321
- "message": message,
322
- });
323
- return Ok(CmdResult::from_json(payload, args.json));
324
- }
325
313
  return Err(CliError::Usage(message));
326
314
  }
327
315
  }
328
- let selected = match crate::state::selector::resolve_active_team(
316
+ let selected = crate::state::selector::resolve_active_team_readonly(
329
317
  &args.workspace,
330
318
  team,
331
319
  crate::state::selector::SelectorMode::RuntimeOnly,
332
- ) {
333
- Ok(selected) => selected,
334
- Err(error) => {
335
- return Ok(CmdResult::from_json(
336
- status_selector_error_payload(&error.to_string(), &args.workspace),
337
- args.json,
338
- ));
320
+ )?;
321
+ if let Some(agent) = args.agent.as_deref() {
322
+ if !status_port::registered_agent_exists(&selected.state, agent) {
323
+ return Err(CliError::Runtime(format!("unknown agent id: {agent}")));
339
324
  }
340
- };
341
- if args.summary {
342
- // 0.4.x compact slimming: `--summary` renders the five-line triage
343
- // text via `format_status_summary`, which needs the full payload
344
- // fields (coordinator, leader_receiver, agent_health, queued_messages,
345
- // latest_results). The slim compact projection drops those, so the
346
- // summary path must request the full payload (compact=false).
347
- let value = status_port::status_scoped(
348
- &selected.run_workspace,
349
- &selected.state,
350
- Some(&selected.team_key),
351
- false,
352
- false,
353
- )?;
354
- return Ok(CmdResult::human(append_reminder(
355
- format_status_summary(&value),
356
- crate::cli::STATUS_REMINDER,
357
- )));
358
325
  }
326
+ // Status brief is deliberately independent of the legacy RuntimeSnapshot:
327
+ // it performs one bounded nodeprobe sample and exposes exactly seven
328
+ // fields. `--summary` and `--detail` remain parser-compatible but do not
329
+ // re-enable history, runtime diagnostics, or reminder text.
359
330
  if args.json {
360
- let value = status_port::status_scoped(
361
- &selected.run_workspace,
362
- &selected.state,
363
- Some(&selected.team_key),
364
- status_compact_flag(args.detail),
365
- args.detail,
366
- )?;
367
- return Ok(CmdResult::from_json(value, true));
331
+ return Ok(CmdResult::from_json(
332
+ status_port::status_brief_scoped(
333
+ &selected.run_workspace,
334
+ &selected.state,
335
+ args.agent.as_deref(),
336
+ ),
337
+ true,
338
+ ));
368
339
  }
369
- let mut text = status_port::format_status_scoped(
340
+ Ok(CmdResult::human(status_port::format_status_brief(
370
341
  &selected.run_workspace,
371
342
  &selected.state,
372
- Some(&selected.team_key),
373
343
  args.agent.as_deref(),
374
- )?;
375
- if args.detail {
376
- let value = status_port::status_scoped(
377
- &selected.run_workspace,
378
- &selected.state,
379
- Some(&selected.team_key),
380
- false,
381
- true,
382
- )?;
383
- if let Some(hint) = value
384
- .pointer("/runtime/hint")
385
- .and_then(serde_json::Value::as_str)
386
- .filter(|hint| !hint.is_empty())
387
- {
388
- if !text.is_empty() {
389
- text.push('\n');
390
- }
391
- text.push_str(hint);
392
- }
393
- }
394
- Ok(CmdResult::human(append_reminder(
395
- text,
396
- crate::cli::STATUS_REMINDER,
397
344
  )))
398
345
  }
399
346
 
400
- fn status_selector_error_payload(error: &str, workspace: &Path) -> Value {
401
- let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.6f");
402
- let log_path = std::env::temp_dir()
403
- .join("team-agent")
404
- .join("cli-errors")
405
- .join(format!("status-{stamp}.log"));
406
- if let Some(parent) = log_path.parent() {
407
- let _ = std::fs::create_dir_all(parent);
408
- }
409
- let _ = std::fs::write(&log_path, format!("{error}\n"));
410
- json!({
411
- "ok": false,
412
- "error": error,
413
- "action": "run `team-agent doctor` or inspect the log path shown here",
414
- "log": log_path.to_string_lossy().to_string(),
415
- "workspace": workspace.to_string_lossy().to_string(),
416
- "reminder": crate::cli::STATUS_REMINDER,
417
- })
418
- }
419
-
420
347
  /// `cmd_watch`(`commands.py:103`)。委派 `coordinator::run_watch`;KeyboardInterrupt/正常 → `SystemExit(0)`。
421
348
  /// 返回 [`CmdResult::none`](不经 emit)。
422
349
  pub fn cmd_watch(args: &WatchArgs) -> Result<CmdResult, CliError> {
@@ -85,10 +85,31 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
85
85
  }
86
86
  match dispatch(command, &argv[1..], cwd) {
87
87
  Ok(exit) => exit,
88
- Err(error) => emit_cli_error(command, &argv[1..], cwd, &error),
88
+ Err(error) => emit_cli_error_for_command(command, &argv[1..], cwd, &error),
89
89
  }
90
90
  }
91
91
 
92
+ fn emit_cli_error_for_command(
93
+ command: &str,
94
+ args: &[String],
95
+ cwd: &Path,
96
+ error: &CliError,
97
+ ) -> ExitCode {
98
+ if command == "status" {
99
+ emit_status_cli_error(error)
100
+ } else {
101
+ emit_cli_error(command, args, cwd, error)
102
+ }
103
+ }
104
+
105
+ fn emit_status_cli_error(error: &CliError) -> ExitCode {
106
+ let normalized = normalize_cli_error(error);
107
+ let payload_error = normalized.as_ref().unwrap_or(error);
108
+ let safe_error = crate::redaction::redact_external_text(&payload_error.to_string());
109
+ eprintln!("error: {safe_error}");
110
+ ExitCode::Error
111
+ }
112
+
92
113
  /// Print a handler's CmdResult to stdout (emit formats json/human), then surface its exit code.
93
114
  /// (parser.py: `print(emit(result, as_json))` then the ok→exit mapping.)
94
115
  fn emit_result(r: CmdResult) -> ExitCode {
@@ -387,7 +408,7 @@ fn command_help(command: Option<&str>) -> String {
387
408
  )
388
409
  .to_string(),
389
410
  Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
390
- Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n默认输出: worker,空闲|工作|错误;错误细分走 status --summary".to_string(),
411
+ Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出七字段:name/provider/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影。缺少可靠定位或 nodeprobe 证据时显示 unknown;tmux_command 可复制到对应目标。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
391
412
  Some("models") => "usage: team-agent models --provider pi [--search TEXT] [--json]\n\nPrints models.v1 exact role_model entries; each entry includes current=true|false. Catalog readiness is reported as auth=ok|not_ready with auth_basis=catalog_visibility.".to_string(),
392
413
  Some("stop") => compat_hidden_help("stop", "usage: team-agent stop [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]"),
393
414
  Some("shutdown") => "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
@@ -2184,11 +2205,18 @@ mod tests {
2184
2205
  }
2185
2206
 
2186
2207
  #[test]
2187
- fn status_help_mentions_summary_for_error_details() {
2188
- assert!(
2189
- command_help(Some("status")).contains("错误细分走 status --summary"),
2190
- "status help must tell users where detailed error classes live"
2191
- );
2208
+ fn status_help_describes_brief_projection_and_unknown_boundary() {
2209
+ let help = command_help(Some("status"));
2210
+ for marker in [
2211
+ "name/provider/runtime_status/activity/health/session_name/tmux_command",
2212
+ "人读与 --json 使用同一投影",
2213
+ "显示 unknown",
2214
+ "tmux_command",
2215
+ "不增加诊断字段",
2216
+ ] {
2217
+ assert!(help.contains(marker), "status help missing {marker}: {help}");
2218
+ }
2219
+ assert!(!help.contains("错误细分走 status --summary"));
2192
2220
  }
2193
2221
 
2194
2222
  #[test]
@@ -3633,17 +3633,24 @@ pub mod lifecycle_port {
3633
3633
  coordinator,
3634
3634
  next_actions,
3635
3635
  attach_commands,
3636
- } => json!({
3637
- "ok": true,
3638
- "status": "restarted",
3639
- "session_name": session_name.as_str(),
3640
- "agents": agents.iter().map(|a| a.agent_id.as_str()).collect::<Vec<_>>(),
3641
- "coordinator_started": coordinator_started,
3642
- "coordinator": crate::lifecycle::coordinator_start_summary_value(&coordinator),
3643
- "next_actions": next_actions,
3644
- "attach_commands": attach_commands,
3645
- "reminder": crate::cli::QUICK_START_REMINDER,
3646
- }),
3636
+ attach_window_failures,
3637
+ } => {
3638
+ let mut value = json!({
3639
+ "ok": true,
3640
+ "status": "restarted",
3641
+ "session_name": session_name.as_str(),
3642
+ "agents": agents.iter().map(|a| a.agent_id.as_str()).collect::<Vec<_>>(),
3643
+ "coordinator_started": coordinator_started,
3644
+ "coordinator": crate::lifecycle::coordinator_start_summary_value(&coordinator),
3645
+ "next_actions": next_actions,
3646
+ "attach_commands": attach_commands,
3647
+ "reminder": crate::cli::QUICK_START_REMINDER,
3648
+ });
3649
+ if let Some(debt) = attach_window_failures {
3650
+ value["attach_window_failures"] = debt;
3651
+ }
3652
+ value
3653
+ },
3647
3654
  crate::lifecycle::RestartReport::Partial {
3648
3655
  session_name,
3649
3656
  agents,
@@ -3652,35 +3659,42 @@ pub mod lifecycle_port {
3652
3659
  coordinator,
3653
3660
  next_actions,
3654
3661
  attach_commands,
3655
- } => json!({
3656
- "ok": false,
3657
- "status": "partial",
3658
- "reason": "restart_agent_failed",
3659
- "session_name": session_name.as_str(),
3660
- "agents": agents.iter().map(|a| a.agent_id.as_str()).collect::<Vec<_>>(),
3661
- "failed_agents": failed_agents.iter().map(|failure| json!({
3662
- "agent_id": failure.agent_id.as_str(),
3663
- "restart_mode": failure.restart_mode,
3664
- "decision": failure.decision,
3665
- "session_id": failure.session_id.as_ref().map(|session| session.as_str()),
3666
- "phase": failure.phase,
3667
- "error": failure.error,
3668
- "action": crate::lifecycle::restart_failure_action(
3669
- &failure.agent_id,
3670
- &failure.phase,
3671
- failure.session_id.as_ref(),
3672
- ),
3673
- "log": format!(
3674
- ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
3675
- failure.agent_id
3676
- ),
3677
- })).collect::<Vec<_>>(),
3678
- "coordinator_started": coordinator_started,
3679
- "coordinator": crate::lifecycle::coordinator_start_summary_value(&coordinator),
3680
- "next_actions": next_actions,
3681
- "attach_commands": attach_commands,
3682
- "reminder": crate::cli::QUICK_START_REMINDER,
3683
- }),
3662
+ attach_window_failures,
3663
+ } => {
3664
+ let mut value = json!({
3665
+ "ok": false,
3666
+ "status": "partial",
3667
+ "reason": "restart_agent_failed",
3668
+ "session_name": session_name.as_str(),
3669
+ "agents": agents.iter().map(|a| a.agent_id.as_str()).collect::<Vec<_>>(),
3670
+ "failed_agents": failed_agents.iter().map(|failure| json!({
3671
+ "agent_id": failure.agent_id.as_str(),
3672
+ "restart_mode": failure.restart_mode,
3673
+ "decision": failure.decision,
3674
+ "session_id": failure.session_id.as_ref().map(|session| session.as_str()),
3675
+ "phase": failure.phase,
3676
+ "error": failure.error,
3677
+ "action": crate::lifecycle::restart_failure_action(
3678
+ &failure.agent_id,
3679
+ &failure.phase,
3680
+ failure.session_id.as_ref(),
3681
+ ),
3682
+ "log": format!(
3683
+ ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
3684
+ failure.agent_id
3685
+ ),
3686
+ })).collect::<Vec<_>>(),
3687
+ "coordinator_started": coordinator_started,
3688
+ "coordinator": crate::lifecycle::coordinator_start_summary_value(&coordinator),
3689
+ "next_actions": next_actions,
3690
+ "attach_commands": attach_commands,
3691
+ "reminder": crate::cli::QUICK_START_REMINDER,
3692
+ });
3693
+ if let Some(debt) = attach_window_failures {
3694
+ value["attach_window_failures"] = debt;
3695
+ }
3696
+ value
3697
+ },
3684
3698
  crate::lifecycle::RestartReport::Failed {
3685
3699
  session_name,
3686
3700
  failed_agents,
@@ -5260,11 +5274,14 @@ pub mod leader_port {
5260
5274
  if let Some(topology_convergence) = result.topology_convergence {
5261
5275
  out.insert("topology_convergence".to_string(), topology_convergence);
5262
5276
  }
5277
+ if let Some(attach_window_failures) = result.attach_window_failures {
5278
+ out.insert("attach_window_failures".to_string(), attach_window_failures);
5279
+ }
5263
5280
  Value::Object(out)
5264
5281
  }
5265
5282
 
5266
5283
  fn attach_lease_value(result: crate::leader::LeaseResult, requeued: Value) -> Value {
5267
- json!({
5284
+ let mut value = json!({
5268
5285
  "ok": result.ok,
5269
5286
  "leader_receiver": result
5270
5287
  .receiver
@@ -5277,7 +5294,11 @@ pub mod leader_port {
5277
5294
  "action": result.action,
5278
5295
  },
5279
5296
  "requeued_exhausted_watchers": requeued,
5280
- })
5297
+ });
5298
+ if let Some(attach_window_failures) = result.attach_window_failures {
5299
+ value["attach_window_failures"] = attach_window_failures;
5300
+ }
5301
+ value
5281
5302
  }
5282
5303
 
5283
5304
  fn attach_requeued_exhausted_watchers(
@@ -1,6 +1,8 @@
1
1
  //! Read-only Pi model catalog discovery for `team-agent models`.
2
2
  use super::{CliError, CmdOutput, CmdResult, ExitCode, ModelsArgs};
3
3
  use serde_json::{json, Value};
4
+ #[cfg(test)]
5
+ use std::collections::BTreeMap;
4
6
  use std::path::Path;
5
7
  use std::process::Command;
6
8
  use std::time::Duration;
@@ -272,6 +274,66 @@ mod tests {
272
274
  let _ = std::fs::remove_dir_all(path.parent().unwrap());
273
275
  }
274
276
 
277
+ #[cfg(unix)]
278
+ fn native_timeout_fixture() -> std::path::PathBuf {
279
+ let path = std::path::PathBuf::from(env!("TEAM_AGENT_MODELS_TIMEOUT_FIXTURE"));
280
+ assert!(path.is_file(), "prebuilt models timeout fixture is missing");
281
+ path
282
+ }
283
+
284
+ #[cfg(unix)]
285
+ fn native_timeout_receipt() -> std::path::PathBuf {
286
+ let stamp = std::time::SystemTime::now()
287
+ .duration_since(std::time::UNIX_EPOCH)
288
+ .unwrap()
289
+ .as_nanos();
290
+ std::path::PathBuf::from("/tmp").join(format!(
291
+ "team-agent-models-timeout-{}-{stamp}.receipt",
292
+ std::process::id()
293
+ ))
294
+ }
295
+
296
+ #[cfg(unix)]
297
+ fn read_native_timeout_receipt(path: &Path) -> BTreeMap<String, String> {
298
+ let mut fields = BTreeMap::new();
299
+ for line in std::fs::read_to_string(path).unwrap().lines() {
300
+ let (key, value) = line.split_once('=').expect("receipt key/value");
301
+ assert!(fields.insert(key.to_string(), value.to_string()).is_none());
302
+ }
303
+ assert_eq!(
304
+ fields.keys().map(String::as_str).collect::<Vec<_>>(),
305
+ vec![
306
+ "child_argv_exact",
307
+ "child_pid",
308
+ "child_spawn_attempts",
309
+ "descendant_pid",
310
+ "descendant_started",
311
+ "descendant_stdout_inherited",
312
+ "parent_argv_exact",
313
+ "parent_pid",
314
+ "parent_started_once",
315
+ ]
316
+ );
317
+ fields
318
+ }
319
+
320
+ #[cfg(unix)]
321
+ fn with_native_mode<T>(
322
+ mode: &str,
323
+ action: impl FnOnce() -> T,
324
+ ) -> (T, crate::lifecycle::launch::pi_mcp::PiCatalogTestObservation) {
325
+ let receipt = native_timeout_receipt();
326
+ assert!(!receipt.exists());
327
+ let result = crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
328
+ &receipt,
329
+ Some(mode),
330
+ action,
331
+ );
332
+ assert!(!receipt.exists());
333
+ let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
334
+ result
335
+ }
336
+
275
337
  #[cfg(unix)]
276
338
  #[test]
277
339
  fn runner_invokes_exact_argv_once_and_drains() {
@@ -291,12 +353,38 @@ mod tests {
291
353
  #[cfg(unix)]
292
354
  #[test]
293
355
  fn runner_timeout_is_bounded_when_descendant_keeps_stdout() {
294
- let path = fixture("sleep 2 & exit 0");
356
+ let path = native_timeout_fixture();
357
+ let receipt = native_timeout_receipt();
358
+ assert!(!receipt.exists());
295
359
  let started = Instant::now();
296
- let result = run_catalog(&path, Duration::from_millis(40), 1024);
360
+ let (result, observation) =
361
+ crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
362
+ &receipt,
363
+ Some("descendant"),
364
+ || run_catalog(&path, Duration::from_millis(40), 1024),
365
+ );
297
366
  assert!(started.elapsed() < Duration::from_millis(500));
298
367
  assert!(result.unwrap_err().contains("timed out"));
299
- cleanup_fixture(&path);
368
+ assert_eq!(observation.spawn_count, 1);
369
+ assert_eq!(observation.argv, vec!["--list-models"]);
370
+ assert_eq!(observation.parent_exit_success, Some(true));
371
+ assert_eq!(observation.parent_exit_code, Some(0));
372
+ assert!(observation.reader_timeout);
373
+
374
+ let fields = read_native_timeout_receipt(&receipt);
375
+ assert_eq!(fields["parent_started_once"], "1");
376
+ assert_eq!(fields["parent_argv_exact"], "1");
377
+ assert_eq!(fields["child_spawn_attempts"], "1");
378
+ assert_eq!(fields["child_argv_exact"], "1");
379
+ assert_eq!(fields["descendant_started"], "1");
380
+ assert_eq!(fields["descendant_stdout_inherited"], "1");
381
+ assert_eq!(
382
+ fields["parent_pid"],
383
+ observation.parent_pid.unwrap().to_string()
384
+ );
385
+ assert_eq!(fields["child_pid"], fields["descendant_pid"]);
386
+ let _ = std::fs::remove_file(&receipt);
387
+ let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
300
388
  }
301
389
 
302
390
  #[cfg(unix)]
@@ -352,35 +440,61 @@ mod tests {
352
440
  #[cfg(unix)]
353
441
  #[test]
354
442
  fn runner_fails_closed_for_nonzero_oversize_timeout_and_unavailable() {
355
- let fail = fixture("echo sensitive-token >&2; exit 7");
443
+ let fail = native_timeout_fixture();
444
+ let (failed_result, fail_observation) = with_native_mode("exit7", || {
445
+ run_catalog(&fail, Duration::from_secs(1), 1024)
446
+ });
356
447
  assert_eq!(
357
- run_catalog(&fail, Duration::from_secs(1), 1024).unwrap_err(),
448
+ failed_result.unwrap_err(),
358
449
  "Pi model catalog command failed"
359
450
  );
360
- let failed = cmd_models_with(
361
- &ModelsArgs {
362
- provider: "pi".into(),
363
- search: None,
364
- json: false,
365
- },
366
- &fail,
367
- Duration::from_secs(1),
368
- 1024,
369
- )
370
- .unwrap();
451
+ assert_eq!(fail_observation.spawn_count, 1);
452
+ assert_eq!(fail_observation.argv, vec!["--list-models"]);
453
+ assert_eq!(fail_observation.parent_exit_success, Some(false));
454
+ assert_eq!(fail_observation.parent_exit_code, Some(7));
455
+ assert!(!fail_observation.reader_timeout);
456
+ let (failed, failed_observation) = with_native_mode("exit7", || {
457
+ cmd_models_with(
458
+ &ModelsArgs {
459
+ provider: "pi".into(),
460
+ search: None,
461
+ json: false,
462
+ },
463
+ &fail,
464
+ Duration::from_secs(1),
465
+ 1024,
466
+ )
467
+ });
468
+ let failed = failed.unwrap();
469
+ assert_eq!(failed_observation.spawn_count, 1);
470
+ assert_eq!(failed_observation.argv, vec!["--list-models"]);
471
+ assert_eq!(failed_observation.parent_exit_success, Some(false));
472
+ assert_eq!(failed_observation.parent_exit_code, Some(7));
473
+ assert!(!failed_observation.reader_timeout);
371
474
  let CmdOutput::Human(text) = failed.output else {
372
475
  panic!("expected failure projection")
373
476
  };
374
477
  assert_eq!(failed.exit, ExitCode::Error);
375
478
  assert!(!text.contains("sensitive-token"));
376
- let big = fixture("head -c 64 /dev/zero");
377
- assert!(run_catalog(&big, Duration::from_secs(1), 8)
378
- .unwrap_err()
379
- .contains("bounded output"));
380
- let slow = fixture("sleep 2");
381
- assert!(run_catalog(&slow, Duration::from_millis(20), 1024)
382
- .unwrap_err()
383
- .contains("timed out"));
479
+ let big = native_timeout_fixture();
480
+ let (big_result, big_observation) = with_native_mode("oversize", || {
481
+ run_catalog(&big, Duration::from_secs(1), 8)
482
+ });
483
+ assert!(big_result.unwrap_err().contains("bounded output"));
484
+ assert_eq!(big_observation.spawn_count, 1);
485
+ assert_eq!(big_observation.argv, vec!["--list-models"]);
486
+ assert_eq!(big_observation.parent_exit_success, Some(true));
487
+ assert_eq!(big_observation.parent_exit_code, Some(0));
488
+ assert!(!big_observation.reader_timeout);
489
+ let slow = native_timeout_fixture();
490
+ let (slow_result, slow_observation) = with_native_mode("sleep", || {
491
+ run_catalog(&slow, Duration::from_millis(20), 1024)
492
+ });
493
+ assert!(slow_result.unwrap_err().contains("timed out"));
494
+ assert_eq!(slow_observation.spawn_count, 1);
495
+ assert_eq!(slow_observation.argv, vec!["--list-models"]);
496
+ assert_eq!(slow_observation.parent_exit_success, None);
497
+ assert!(!slow_observation.reader_timeout);
384
498
  assert!(run_catalog(
385
499
  std::path::Path::new("/does/not/exist"),
386
500
  Duration::from_secs(1),
@@ -388,9 +502,6 @@ mod tests {
388
502
  )
389
503
  .unwrap_err()
390
504
  .contains("unavailable"));
391
- for path in [fail, big, slow] {
392
- cleanup_fixture(&path);
393
- }
394
505
  }
395
506
 
396
507
  #[test]