@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
@@ -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;
@@ -134,8 +136,15 @@ fn run_catalog(program: &Path, timeout: Duration, max_bytes: u64) -> Result<Vec<
134
136
  #[cfg(test)]
135
137
  mod tests {
136
138
  use super::*;
139
+ #[cfg(unix)]
140
+ use std::io::Write;
141
+ #[cfg(unix)]
142
+ use std::sync::atomic::{AtomicU64, Ordering};
137
143
  use std::time::Instant;
138
144
 
145
+ #[cfg(unix)]
146
+ static FIXTURE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
147
+
139
148
  #[cfg(unix)]
140
149
  #[test]
141
150
  fn real_dispatcher_child_helper() {
@@ -242,12 +251,13 @@ mod tests {
242
251
  #[cfg(unix)]
243
252
  fn fixture(body: &str) -> std::path::PathBuf {
244
253
  use std::os::unix::fs::PermissionsExt;
254
+ let sequence = FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
245
255
  let root = std::env::current_exe()
246
256
  .unwrap()
247
257
  .parent()
248
258
  .unwrap()
249
259
  .join(format!(
250
- "team-agent-pi-fixture-{}-{}",
260
+ "team-agent-pi-fixture-{}-{}-{sequence}",
251
261
  std::process::id(),
252
262
  std::time::SystemTime::now()
253
263
  .duration_since(std::time::UNIX_EPOCH)
@@ -267,20 +277,209 @@ mod tests {
267
277
  path
268
278
  }
269
279
 
280
+ #[cfg(unix)]
281
+ fn fixture_with_sidecars(body: &str) -> std::path::PathBuf {
282
+ let path = fixture(body);
283
+ let script = std::fs::read_to_string(&path).unwrap();
284
+ let script = script.strip_prefix("#!/bin/sh\n").unwrap();
285
+ let script = format!(
286
+ "#!/bin/sh\nprintf '1\\n' > \"$0.entered\" || :\n{script}body_status=$?\nprintf '1\\n' > \"$0.completed\" || :\nexit \"$body_status\"\n"
287
+ );
288
+ std::fs::write(&path, script).unwrap();
289
+ path
290
+ }
291
+
270
292
  #[cfg(unix)]
271
293
  fn cleanup_fixture(path: &Path) {
272
294
  let _ = std::fs::remove_dir_all(path.parent().unwrap());
273
295
  }
274
296
 
297
+ #[cfg(unix)]
298
+ fn native_timeout_fixture() -> std::path::PathBuf {
299
+ let path = std::path::PathBuf::from(env!("TEAM_AGENT_MODELS_TIMEOUT_FIXTURE"));
300
+ assert!(path.is_file(), "prebuilt models timeout fixture is missing");
301
+ path
302
+ }
303
+
304
+ #[cfg(unix)]
305
+ fn native_timeout_receipt() -> std::path::PathBuf {
306
+ let stamp = std::time::SystemTime::now()
307
+ .duration_since(std::time::UNIX_EPOCH)
308
+ .unwrap()
309
+ .as_nanos();
310
+ std::path::PathBuf::from("/tmp").join(format!(
311
+ "team-agent-models-timeout-{}-{stamp}.receipt",
312
+ std::process::id()
313
+ ))
314
+ }
315
+
316
+ #[cfg(unix)]
317
+ fn native_timeout_stage_receipt(receipt: &Path) -> std::path::PathBuf {
318
+ receipt.with_extension("stage")
319
+ }
320
+
321
+ #[cfg(unix)]
322
+ fn parse_native_timeout_receipt(path: &Path) -> Option<BTreeMap<String, String>> {
323
+ let mut fields = BTreeMap::new();
324
+ for line in std::fs::read_to_string(path).ok()?.lines() {
325
+ let (key, value) = line.split_once('=')?;
326
+ if fields.insert(key.to_string(), value.to_string()).is_some() {
327
+ return None;
328
+ }
329
+ }
330
+ let keys = fields.keys().map(String::as_str).collect::<Vec<_>>();
331
+ (keys
332
+ == vec![
333
+ "child_argv_exact",
334
+ "child_pid",
335
+ "child_spawn_attempts",
336
+ "descendant_pid",
337
+ "descendant_started",
338
+ "descendant_stdout_inherited",
339
+ "parent_argv_exact",
340
+ "parent_pid",
341
+ "parent_started_once",
342
+ ])
343
+ .then_some(fields)
344
+ }
345
+
346
+ #[cfg(unix)]
347
+ fn wait_native_timeout_receipt(path: &Path) -> BTreeMap<String, String> {
348
+ let deadline = Instant::now() + Duration::from_millis(500);
349
+ loop {
350
+ if let Some(fields) = parse_native_timeout_receipt(path) {
351
+ return fields;
352
+ }
353
+ if Instant::now() >= deadline {
354
+ panic!("native timeout receipt did not become complete after runner return");
355
+ }
356
+ std::thread::sleep(Duration::from_millis(1));
357
+ }
358
+ }
359
+
360
+ #[cfg(unix)]
361
+ fn with_native_mode<T>(
362
+ mode: &str,
363
+ action: impl FnOnce() -> T,
364
+ ) -> (T, crate::lifecycle::launch::pi_mcp::PiCatalogTestObservation) {
365
+ let receipt = native_timeout_receipt();
366
+ assert!(!receipt.exists());
367
+ let result = crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
368
+ &receipt,
369
+ Some(mode),
370
+ action,
371
+ );
372
+ assert!(!receipt.exists());
373
+ let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
374
+ result
375
+ }
376
+
377
+ #[cfg(unix)]
378
+ fn safe_fixture_sidecar(path: &Path, expected: &str) -> &'static str {
379
+ match std::fs::read_to_string(path) {
380
+ Ok(text) if text == expected => "present_expected",
381
+ Ok(_) => "present_unexpected",
382
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => "missing",
383
+ Err(_) => "unavailable",
384
+ }
385
+ }
386
+
387
+ #[cfg(unix)]
388
+ fn safe_runner_fixture_observation(path: &Path) -> String {
389
+ format!(
390
+ "fixture_entered={} fixture_completed={} fixture_count={}",
391
+ safe_fixture_sidecar(&path.with_extension("entered"), "1\n"),
392
+ safe_fixture_sidecar(&path.with_extension("completed"), "1\n"),
393
+ safe_fixture_sidecar(&path.with_extension("count"), "1:--list-models\n"),
394
+ )
395
+ }
396
+
397
+ #[cfg(unix)]
398
+ fn safe_runner_observation(
399
+ elapsed: Duration,
400
+ observation: &crate::lifecycle::launch::pi_mcp::PiCatalogTestObservation,
401
+ ) -> String {
402
+ format!(
403
+ "elapsed_ms={} spawn_count={} argv={:?} parent_pid={:?} parent_exit_success={:?} parent_exit_code={:?} reader_timeout={}",
404
+ elapsed.as_millis(),
405
+ observation.spawn_count,
406
+ observation.argv,
407
+ observation.parent_pid,
408
+ observation.parent_exit_success,
409
+ observation.parent_exit_code,
410
+ observation.reader_timeout,
411
+ )
412
+ }
413
+
414
+ #[cfg(unix)]
415
+ fn safe_models_result_class(result: &CmdResult) -> &'static str {
416
+ let CmdOutput::Human(text) = &result.output else {
417
+ return "non_human_output";
418
+ };
419
+ let Ok(value) = serde_json::from_str::<Value>(text) else {
420
+ return "non_json_output";
421
+ };
422
+ match value.get("ok").and_then(Value::as_bool) {
423
+ Some(true) => "success_json",
424
+ Some(false) => match value.get("error").and_then(Value::as_str) {
425
+ Some(error) if error.contains("unavailable") => "runner_unavailable",
426
+ Some(error) if error.contains("timed out") => "runner_timeout",
427
+ Some(error) if error.contains("command failed") => "runner_exit",
428
+ Some(error) if error.contains("could not be read") => "reader_error",
429
+ Some(error) if error.contains("exceeds") => "reader_oversize",
430
+ Some(error) if error.contains("catalog") => "parser_catalog_error",
431
+ Some(_) => "failure_json",
432
+ None => "failure_json_missing_error",
433
+ },
434
+ None => "json_missing_ok",
435
+ }
436
+ }
437
+
438
+ #[cfg(unix)]
439
+ fn safe_models_observation(
440
+ elapsed: Duration,
441
+ result: &CmdResult,
442
+ observation: &crate::lifecycle::launch::pi_mcp::PiCatalogTestObservation,
443
+ ) -> String {
444
+ format!(
445
+ "class={} elapsed_ms={} parent_pid={:?} parent_exit_success={:?} parent_exit_code={:?} reader_timeout={}",
446
+ safe_models_result_class(result),
447
+ elapsed.as_millis(),
448
+ observation.parent_pid,
449
+ observation.parent_exit_success,
450
+ observation.parent_exit_code,
451
+ observation.reader_timeout,
452
+ )
453
+ }
454
+
275
455
  #[cfg(unix)]
276
456
  #[test]
277
457
  fn runner_invokes_exact_argv_once_and_drains() {
278
- let path = fixture(
458
+ let path = fixture_with_sidecars(
279
459
  "test \"$1\" = --list-models && echo provider model && echo openai-codex gpt-5.6-sol",
280
460
  );
281
- assert!(run_catalog(&path, Duration::from_secs(1), 1024)
282
- .unwrap()
283
- .starts_with(b"provider model"));
461
+ let receipt = native_timeout_receipt();
462
+ assert!(!receipt.exists());
463
+ let started = Instant::now();
464
+ let (result, observation) =
465
+ crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
466
+ &receipt,
467
+ None,
468
+ || run_catalog(&path, Duration::from_secs(1), 1024),
469
+ );
470
+ let elapsed = started.elapsed();
471
+ let fixture_observation = safe_runner_fixture_observation(&path);
472
+ assert!(
473
+ result
474
+ .as_ref()
475
+ .is_ok_and(|bytes| bytes.starts_with(b"provider model")),
476
+ "runner observation: {}; {}",
477
+ safe_runner_observation(elapsed, &observation),
478
+ fixture_observation
479
+ );
480
+ let _ = result.unwrap();
481
+ let _ = std::fs::remove_file(&receipt);
482
+ let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
284
483
  assert_eq!(
285
484
  std::fs::read_to_string(path.with_extension("count")).unwrap(),
286
485
  "1:--list-models\n"
@@ -288,15 +487,174 @@ mod tests {
288
487
  cleanup_fixture(&path);
289
488
  }
290
489
 
490
+ #[cfg(unix)]
491
+ const NATIVE_STAGE_KEYS: [&str; 5] = [
492
+ "parent_entry_ms",
493
+ "parent_before_child_spawn_ms",
494
+ "parent_after_child_spawn_ms",
495
+ "parent_before_exit_ms",
496
+ "test_spawn_return_ms",
497
+ ];
498
+
499
+ #[cfg(unix)]
500
+ // Match the native helper's UNIX epoch millisecond clock exactly.
501
+ fn append_native_stage(path: &Path, key: &str) {
502
+ if !NATIVE_STAGE_KEYS.contains(&key) {
503
+ return;
504
+ }
505
+ let Some(milliseconds) = std::time::SystemTime::now()
506
+ .duration_since(std::time::UNIX_EPOCH)
507
+ .ok()
508
+ .map(|duration| duration.as_millis())
509
+ else {
510
+ return;
511
+ };
512
+ let Ok(mut file) = std::fs::OpenOptions::new()
513
+ .create(true)
514
+ .append(true)
515
+ .open(path)
516
+ else {
517
+ return;
518
+ };
519
+ let _ = writeln!(file, "{key}={milliseconds}");
520
+ }
521
+
522
+ #[cfg(unix)]
523
+ fn safe_native_stage(path: &Path) -> String {
524
+ let (read_state, text) = match std::fs::read_to_string(path) {
525
+ Ok(text) => ("ok", Some(text)),
526
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => ("missing", None),
527
+ Err(_) => ("unavailable", None),
528
+ };
529
+ let mut fields = BTreeMap::new();
530
+ if let Some(ref text) = text {
531
+ for line in text.lines() {
532
+ let Some((key, value)) = line.split_once('=') else {
533
+ continue;
534
+ };
535
+ if NATIVE_STAGE_KEYS.contains(&key)
536
+ && value.len() <= 20
537
+ && value.bytes().all(|byte| byte.is_ascii_digit())
538
+ {
539
+ fields.entry(key).or_insert(value);
540
+ }
541
+ }
542
+ }
543
+ let field = |key: &str| fields.get(key).copied().unwrap_or("unknown");
544
+ format!(
545
+ "stage_read={read_state} parent_entry_ms={} parent_before_child_spawn_ms={} parent_after_child_spawn_ms={} parent_before_exit_ms={} test_spawn_return_ms={}",
546
+ field("parent_entry_ms"),
547
+ field("parent_before_child_spawn_ms"),
548
+ field("parent_after_child_spawn_ms"),
549
+ field("parent_before_exit_ms"),
550
+ field("test_spawn_return_ms"),
551
+ )
552
+ }
553
+
554
+ #[cfg(unix)]
555
+ fn wait_for_native_parent_exit(
556
+ child: &mut std::process::Child,
557
+ stage: &Path,
558
+ ) -> std::process::ExitStatus {
559
+ let deadline = Instant::now() + Duration::from_millis(500);
560
+ loop {
561
+ match child.try_wait() {
562
+ Ok(Some(status)) => return status,
563
+ Ok(None) if Instant::now() < deadline => {
564
+ std::thread::sleep(Duration::from_millis(1));
565
+ }
566
+ Ok(None) => {
567
+ let before_kill = safe_native_stage(stage);
568
+ let _ = child.kill();
569
+ let _ = child.wait();
570
+ let after_wait = safe_native_stage(stage);
571
+ panic!(
572
+ "native parent did not exit before test setup deadline; stage_before_kill={before_kill}; stage_after_wait={after_wait}"
573
+ );
574
+ }
575
+ Err(_) => {
576
+ let before_kill = safe_native_stage(stage);
577
+ let _ = child.kill();
578
+ let _ = child.wait();
579
+ let after_wait = safe_native_stage(stage);
580
+ panic!(
581
+ "native parent status could not be observed; stage_before_kill={before_kill}; stage_after_wait={after_wait}"
582
+ );
583
+ }
584
+ }
585
+ }
586
+ }
587
+
588
+ /// Read/drain seam only: public `run_catalog` spawn coverage remains in the
589
+ /// normal success and fail-closed tests.
291
590
  #[cfg(unix)]
292
591
  #[test]
293
- fn runner_timeout_is_bounded_when_descendant_keeps_stdout() {
294
- let path = fixture("sleep 2 & exit 0");
592
+ fn reader_deadline_is_bounded_after_parent_exit_with_descendant_pipe() {
593
+ let path = native_timeout_fixture();
594
+ let receipt = native_timeout_receipt();
595
+ let stage = native_timeout_stage_receipt(&receipt);
596
+ assert!(!receipt.exists());
597
+ assert!(!stage.exists());
598
+ let mut parent = std::process::Command::new(&path)
599
+ .arg("--list-models")
600
+ .env("TEAM_AGENT_MODELS_TIMEOUT_RECEIPT", &receipt)
601
+ .env("TEAM_AGENT_MODELS_TIMEOUT_STAGE_RECEIPT", &stage)
602
+ .env("TEAM_AGENT_MODELS_TIMEOUT_MODE", "descendant")
603
+ .stdin(std::process::Stdio::null())
604
+ .stdout(std::process::Stdio::piped())
605
+ .stderr(std::process::Stdio::null())
606
+ .spawn()
607
+ .unwrap();
608
+ append_native_stage(&stage, "test_spawn_return_ms");
609
+ let parent_pid = parent.id();
610
+ let parent_status = wait_for_native_parent_exit(&mut parent, &stage);
611
+ let stdout = parent.stdout.take().expect("native parent stdout pipe");
295
612
  let started = Instant::now();
296
- let result = run_catalog(&path, Duration::from_millis(40), 1024);
297
- assert!(started.elapsed() < Duration::from_millis(500));
613
+ let (result, observation) =
614
+ crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
615
+ &receipt,
616
+ Some("descendant"),
617
+ || {
618
+ crate::lifecycle::launch::pi_mcp::run_pi_catalog_after_parent_exit_for_test(
619
+ stdout,
620
+ parent_pid,
621
+ parent_status,
622
+ Duration::from_millis(40),
623
+ 1024,
624
+ )
625
+ },
626
+ );
627
+ let elapsed = started.elapsed();
628
+ assert!(
629
+ elapsed < Duration::from_millis(500),
630
+ "runner observation: {}",
631
+ safe_runner_observation(elapsed, &observation)
632
+ );
298
633
  assert!(result.unwrap_err().contains("timed out"));
299
- cleanup_fixture(&path);
634
+ assert_eq!(
635
+ observation.parent_exit_success,
636
+ Some(true),
637
+ "runner observation: {}",
638
+ safe_runner_observation(elapsed, &observation)
639
+ );
640
+ assert_eq!(observation.parent_exit_code, Some(0));
641
+ assert!(observation.reader_timeout);
642
+
643
+ let fields = wait_native_timeout_receipt(&receipt);
644
+ assert_eq!(fields["parent_started_once"], "1");
645
+ assert_eq!(fields["parent_argv_exact"], "1");
646
+ assert_eq!(fields["child_spawn_attempts"], "1");
647
+ assert_eq!(fields["child_argv_exact"], "1");
648
+ assert_eq!(fields["descendant_started"], "1");
649
+ assert_eq!(fields["descendant_stdout_inherited"], "1");
650
+ assert_eq!(
651
+ fields["parent_pid"],
652
+ observation.parent_pid.unwrap().to_string()
653
+ );
654
+ assert_eq!(fields["child_pid"], fields["descendant_pid"]);
655
+ let _ = std::fs::remove_file(&receipt);
656
+ let _ = std::fs::remove_file(&stage);
657
+ let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
300
658
  }
301
659
 
302
660
  #[cfg(unix)]
@@ -310,16 +668,72 @@ mod tests {
310
668
  search: Some("sol".into()),
311
669
  json: true,
312
670
  };
313
- let result = cmd_models_with(&args, &path, Duration::from_secs(1), 1024).unwrap();
314
- let CmdOutput::Human(text) = result.output else {
315
- panic!("expected JSON projection")
671
+ let receipt = native_timeout_receipt();
672
+ assert!(!receipt.exists());
673
+ let started = Instant::now();
674
+ let (result, observation) =
675
+ crate::lifecycle::launch::pi_mcp::with_pi_catalog_test_observation(
676
+ &receipt,
677
+ None,
678
+ || cmd_models_with(&args, &path, Duration::from_secs(1), 1024),
679
+ );
680
+ let elapsed = started.elapsed();
681
+ let result = match result {
682
+ Ok(result) => result,
683
+ Err(_) => panic!(
684
+ "command boundary returned a CLI error; class=cli_error elapsed_ms={} parent_pid={:?} parent_exit_success={:?} parent_exit_code={:?} reader_timeout={}",
685
+ elapsed.as_millis(),
686
+ observation.parent_pid,
687
+ observation.parent_exit_success,
688
+ observation.parent_exit_code,
689
+ observation.reader_timeout,
690
+ ),
316
691
  };
317
- let value: Value = serde_json::from_str(&text).unwrap();
318
- assert_eq!(value["schema_version"], "models.v1");
319
- assert_eq!(value["models"][0]["role_model"], "openai-codex/gpt-5.6-sol");
320
- assert_eq!(value["models"][0]["current"], false);
321
- assert_eq!(value["auth"], "ok");
322
- assert_eq!(value["models"].as_array().unwrap().len(), 1);
692
+ let CmdOutput::Human(text) = &result.output else {
693
+ panic!(
694
+ "expected JSON projection; {}",
695
+ safe_models_observation(elapsed, &result, &observation)
696
+ )
697
+ };
698
+ let value: Value = serde_json::from_str(text).unwrap_or_else(|_| {
699
+ panic!(
700
+ "expected valid JSON projection; {}",
701
+ safe_models_observation(elapsed, &result, &observation)
702
+ )
703
+ });
704
+ assert_eq!(
705
+ value["schema_version"],
706
+ "models.v1",
707
+ "{}",
708
+ safe_models_observation(elapsed, &result, &observation)
709
+ );
710
+ assert_eq!(
711
+ value["models"][0]["role_model"],
712
+ "openai-codex/gpt-5.6-sol",
713
+ "{}",
714
+ safe_models_observation(elapsed, &result, &observation)
715
+ );
716
+ assert_eq!(
717
+ value["models"][0]["current"],
718
+ false,
719
+ "{}",
720
+ safe_models_observation(elapsed, &result, &observation)
721
+ );
722
+ assert_eq!(
723
+ value["auth"],
724
+ "ok",
725
+ "{}",
726
+ safe_models_observation(elapsed, &result, &observation)
727
+ );
728
+ let model_count = value["models"].as_array().map(|models| models.len());
729
+ assert_eq!(
730
+ model_count,
731
+ Some(1),
732
+ "{}",
733
+ safe_models_observation(elapsed, &result, &observation)
734
+ );
735
+ let _ = std::fs::remove_file(&receipt);
736
+ let _ = std::fs::remove_file(format!("{}.sock", receipt.display()));
323
737
  let human = cmd_models_with(
324
738
  &ModelsArgs {
325
739
  provider: "pi".into(),
@@ -352,35 +766,65 @@ mod tests {
352
766
  #[cfg(unix)]
353
767
  #[test]
354
768
  fn runner_fails_closed_for_nonzero_oversize_timeout_and_unavailable() {
355
- let fail = fixture("echo sensitive-token >&2; exit 7");
769
+ let fail = native_timeout_fixture();
770
+ let started = Instant::now();
771
+ let (failed_result, fail_observation) = with_native_mode("exit7", || {
772
+ run_catalog(&fail, Duration::from_secs(1), 1024)
773
+ });
774
+ let elapsed = started.elapsed();
356
775
  assert_eq!(
357
- run_catalog(&fail, Duration::from_secs(1), 1024).unwrap_err(),
358
- "Pi model catalog command failed"
776
+ failed_result.unwrap_err(),
777
+ "Pi model catalog command failed",
778
+ "runner observation: {}",
779
+ safe_runner_observation(elapsed, &fail_observation)
359
780
  );
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();
781
+ assert_eq!(fail_observation.spawn_count, 1);
782
+ assert_eq!(fail_observation.argv, vec!["--list-models"]);
783
+ assert_eq!(fail_observation.parent_exit_success, Some(false));
784
+ assert_eq!(fail_observation.parent_exit_code, Some(7));
785
+ assert!(!fail_observation.reader_timeout);
786
+ let (failed, failed_observation) = with_native_mode("exit7", || {
787
+ cmd_models_with(
788
+ &ModelsArgs {
789
+ provider: "pi".into(),
790
+ search: None,
791
+ json: false,
792
+ },
793
+ &fail,
794
+ Duration::from_secs(1),
795
+ 1024,
796
+ )
797
+ });
798
+ let failed = failed.unwrap();
799
+ assert_eq!(failed_observation.spawn_count, 1);
800
+ assert_eq!(failed_observation.argv, vec!["--list-models"]);
801
+ assert_eq!(failed_observation.parent_exit_success, Some(false));
802
+ assert_eq!(failed_observation.parent_exit_code, Some(7));
803
+ assert!(!failed_observation.reader_timeout);
371
804
  let CmdOutput::Human(text) = failed.output else {
372
805
  panic!("expected failure projection")
373
806
  };
374
807
  assert_eq!(failed.exit, ExitCode::Error);
375
808
  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"));
809
+ let big = native_timeout_fixture();
810
+ let (big_result, big_observation) = with_native_mode("oversize", || {
811
+ run_catalog(&big, Duration::from_secs(1), 8)
812
+ });
813
+ assert!(big_result.unwrap_err().contains("bounded output"));
814
+ assert_eq!(big_observation.spawn_count, 1);
815
+ assert_eq!(big_observation.argv, vec!["--list-models"]);
816
+ assert_eq!(big_observation.parent_exit_success, Some(true));
817
+ assert_eq!(big_observation.parent_exit_code, Some(0));
818
+ assert!(!big_observation.reader_timeout);
819
+ let slow = native_timeout_fixture();
820
+ let (slow_result, slow_observation) = with_native_mode("sleep", || {
821
+ run_catalog(&slow, Duration::from_millis(20), 1024)
822
+ });
823
+ assert!(slow_result.unwrap_err().contains("timed out"));
824
+ assert_eq!(slow_observation.spawn_count, 1);
825
+ assert_eq!(slow_observation.argv, vec!["--list-models"]);
826
+ assert_eq!(slow_observation.parent_exit_success, None);
827
+ assert!(!slow_observation.reader_timeout);
384
828
  assert!(run_catalog(
385
829
  std::path::Path::new("/does/not/exist"),
386
830
  Duration::from_secs(1),
@@ -388,9 +832,6 @@ mod tests {
388
832
  )
389
833
  .unwrap_err()
390
834
  .contains("unavailable"));
391
- for path in [fail, big, slow] {
392
- cleanup_fixture(&path);
393
- }
394
835
  }
395
836
 
396
837
  #[test]
@@ -694,9 +694,23 @@ fn resolve_worker(
694
694
  }
695
695
  };
696
696
  let matches = matching_session_window(&targets, session, window);
697
- match matches.len() {
698
- 1 => {
699
- let pane = matches[0];
697
+ let selected_pane = match matches.len() {
698
+ 1 => matches.first().copied(),
699
+ n if n > 1 => {
700
+ let state_pane_id =
701
+ string_field(agent_entry, "pane_id").filter(|pane_id| !pane_id.is_empty());
702
+ let mut state_matches = matches.iter().copied().filter(|pane| {
703
+ state_pane_id.is_some_and(|pane_id| pane.pane_id.as_str() == pane_id)
704
+ });
705
+ match (state_matches.next(), state_matches.next()) {
706
+ (Some(pane), None) => Some(pane),
707
+ _ => None,
708
+ }
709
+ }
710
+ _ => None,
711
+ };
712
+ match selected_pane {
713
+ Some(pane) => {
700
714
  let state_pane_id = string_field(agent_entry, "pane_id").map(str::to_string);
701
715
  let state_pane_stale = state_pane_id
702
716
  .as_deref()
@@ -727,7 +741,7 @@ fn resolve_worker(
727
741
  warning,
728
742
  })
729
743
  }
730
- 0 => Ok(resolved_worker_without_live(
744
+ None if matches.is_empty() => Ok(resolved_worker_without_live(
731
745
  sender_workspace,
732
746
  target_workspace,
733
747
  team,
@@ -738,7 +752,7 @@ fn resolve_worker(
738
752
  window,
739
753
  transport.tmux_endpoint(),
740
754
  )),
741
- _ => Err(name_ambiguous(
755
+ None => Err(name_ambiguous(
742
756
  "multiple live panes match the same session/window",
743
757
  matches
744
758
  .into_iter()