@team-agent/installer 0.5.75 → 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 (30) 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/leader/lease.rs +46 -8
  16. package/crates/team-agent/src/leader/types.rs +4 -0
  17. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +81 -4
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +11 -4
  19. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
  20. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +1 -0
  21. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  22. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +24 -28
  23. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
  24. package/crates/team-agent/src/lifecycle/types.rs +4 -0
  25. package/crates/team-agent/src/messaging/watchers.rs +48 -4
  26. package/crates/team-agent/src/state/paths.rs +17 -2
  27. package/crates/team-agent/src/state/projection.rs +18 -2
  28. package/crates/team-agent/src/state/selector.rs +23 -3
  29. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +122 -0
  30. package/package.json +4 -4
@@ -345,7 +345,7 @@ fn status_compact_reports_identity_slot_health_on_grok_identity_slot_ok() {
345
345
  }
346
346
 
347
347
  #[test]
348
- fn cmd_status_human_appends_harness_reminder() {
348
+ fn cmd_status_human_is_seven_field_brief_without_diagnostics() {
349
349
  let ws = seed_status_workspace();
350
350
  let args = StatusArgs {
351
351
  agent: None,
@@ -362,7 +362,13 @@ fn cmd_status_human_appends_harness_reminder() {
362
362
  other => panic!("expected human status output, got {other:?}"),
363
363
  };
364
364
 
365
- assert!(text.ends_with(crate::cli::STATUS_REMINDER), "{text}");
365
+ assert!(
366
+ text.contains(
367
+ "name: a1 provider: unknown runtime_status: unknown activity: unknown health: unknown session_name: null tmux_command: null"
368
+ ),
369
+ "{text}"
370
+ );
371
+ assert!(!text.contains(crate::cli::STATUS_REMINDER));
366
372
  let _ = std::fs::remove_dir_all(&ws);
367
373
  }
368
374
 
@@ -32,6 +32,11 @@ pub(crate) struct AppliedLeaderGrant {
32
32
  pub(crate) receiver: Value,
33
33
  }
34
34
 
35
+ #[derive(Debug, Default)]
36
+ struct AttachRecovery {
37
+ blocked_counts: crate::message_store::BlockedLeaderRequeueCounts,
38
+ }
39
+
35
40
  #[derive(Debug)]
36
41
  pub(crate) struct AttachLeaderError {
37
42
  source: LeaderError,
@@ -126,8 +131,11 @@ pub fn attach_leader(
126
131
  if let Some(endpoint) = target.endpoint.as_deref() {
127
132
  quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
128
133
  }
129
- let _ =
134
+ let recovery =
130
135
  requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
136
+ let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
137
+ let attach_window_failures =
138
+ attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
131
139
  return Ok(LeaseResult {
132
140
  ok: true,
133
141
  status: LeaseStatus::Claimed,
@@ -141,6 +149,7 @@ pub fn attach_leader(
141
149
  .map(str::to_string),
142
150
  bound_pane_id: Some(pane_id),
143
151
  topology_convergence: None,
152
+ attach_window_failures,
144
153
  });
145
154
  }
146
155
  event_log.write(
@@ -165,7 +174,10 @@ pub fn attach_leader(
165
174
  if let Some(endpoint) = target.endpoint.as_deref() {
166
175
  quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
167
176
  }
168
- let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
177
+ let recovery = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
178
+ let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
179
+ let attach_window_failures =
180
+ attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
169
181
  return Ok(LeaseResult {
170
182
  ok: true,
171
183
  status: LeaseStatus::AlreadyBound,
@@ -176,6 +188,7 @@ pub fn attach_leader(
176
188
  action: None,
177
189
  bound_pane_id: Some(pane_id),
178
190
  topology_convergence: None,
191
+ attach_window_failures,
179
192
  });
180
193
  }
181
194
  let identity = leader_identity_context(workspace, None, Some(&state))?;
@@ -192,7 +205,10 @@ pub fn attach_leader(
192
205
  if let Some(endpoint) = target.endpoint.as_deref() {
193
206
  quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
194
207
  }
195
- let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
208
+ let recovery = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
209
+ let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
210
+ let attach_window_failures =
211
+ attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
196
212
  Ok(LeaseResult {
197
213
  ok: true,
198
214
  status: LeaseStatus::Claimed,
@@ -203,6 +219,7 @@ pub fn attach_leader(
203
219
  action: None,
204
220
  bound_pane_id: Some(pane_id),
205
221
  topology_convergence: None,
222
+ attach_window_failures,
206
223
  })
207
224
  }
208
225
 
@@ -441,17 +458,34 @@ fn requeue_exhausted_watchers_after_attach(
441
458
  state: &Value,
442
459
  event_log: &crate::event_log::EventLog,
443
460
  pane_id: &PaneId,
444
- ) -> Result<Vec<crate::messaging::WatcherNotice>, LeaderError> {
461
+ ) -> Result<AttachRecovery, LeaderError> {
445
462
  let store = MessageStore::open(workspace)?;
446
463
  let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
447
- let notices = crate::messaging::requeue_delivery_exhausted_watchers(
448
- workspace, &store, event_log, &team_id, pane_id,
449
- )?;
464
+ let (notices, blocked_counts) =
465
+ crate::messaging::watchers::requeue_delivery_exhausted_watchers_with_counts(
466
+ workspace, &store, event_log, &team_id, pane_id,
467
+ )?;
450
468
  event_log.write(
451
469
  super::LeaderEvent::ReceiverRequeuedExhaustedWatchers.name(),
452
470
  requeued_exhausted_watchers_event_payload(pane_id, &team_id, &notices),
453
471
  )?;
454
- Ok(notices)
472
+ Ok(AttachRecovery { blocked_counts })
473
+ }
474
+
475
+ fn attach_window_failure_value(
476
+ counts: crate::message_store::BlockedLeaderRequeueCounts,
477
+ team_id: &TeamKey,
478
+ pane_id: &PaneId,
479
+ ) -> Option<Value> {
480
+ (counts.blocked_leader_unbound > 0).then(|| {
481
+ json!({
482
+ "team_id": team_id.as_str(),
483
+ "pane_id": pane_id.as_str(),
484
+ "reason": "leader_not_attached",
485
+ "count": counts.blocked_leader_unbound,
486
+ "status": "requeued_pending_physical_retry",
487
+ })
488
+ })
455
489
  }
456
490
 
457
491
  /// R8 D4 (decoupled for offline byte-lock — c-lite): build the `leader_receiver.requeued_exhausted_watchers`
@@ -949,6 +983,7 @@ fn claim_lease_no_incident_with_target(
949
983
  action: None,
950
984
  bound_pane_id: Some(caller_pane.clone()),
951
985
  topology_convergence,
986
+ attach_window_failures: None,
952
987
  });
953
988
  }
954
989
  let owner_live = bound_pane_id.as_deref().is_some_and(|pane| {
@@ -1194,6 +1229,7 @@ fn claim_lease_no_incident_with_target(
1194
1229
  action: None,
1195
1230
  bound_pane_id: Some(caller_pane.clone()),
1196
1231
  topology_convergence,
1232
+ attach_window_failures: None,
1197
1233
  })
1198
1234
  }
1199
1235
 
@@ -1452,6 +1488,7 @@ fn convergence_persistence_refusal(
1452
1488
  ),
1453
1489
  bound_pane_id,
1454
1490
  topology_convergence,
1491
+ attach_window_failures: None,
1455
1492
  }
1456
1493
  }
1457
1494
 
@@ -1475,6 +1512,7 @@ fn refused(
1475
1512
  },
1476
1513
  bound_pane_id,
1477
1514
  topology_convergence: None,
1515
+ attach_window_failures: None,
1478
1516
  }
1479
1517
  }
1480
1518
 
@@ -419,6 +419,10 @@ pub struct LeaseResult {
419
419
  /// paths so existing lease JSON stays stable unless a convergence decision
420
420
  /// was actually evaluated.
421
421
  pub topology_convergence: Option<Value>,
422
+ /// Counts returned by the same attach operation when blocked leader
423
+ /// messages were requeued. Kept typed and team/pane-bound; omitted when
424
+ /// no attach-window debt was observed.
425
+ pub attach_window_failures: Option<Value>,
422
426
  }
423
427
 
424
428
  /// Family A 正源 owner 绑定结果(`bind_owner_from_caller_pane` 返回 dict 的 typed 版)。
@@ -16,6 +16,8 @@
16
16
  use std::collections::BTreeSet;
17
17
  use std::fs::OpenOptions;
18
18
  use std::io::{Read, Write};
19
+ #[cfg(test)]
20
+ use std::cell::RefCell;
19
21
  use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
20
22
  use std::path::{Path, PathBuf};
21
23
  use std::process::{Command, Stdio};
@@ -588,6 +590,58 @@ fn executable_file_type(path: &Path) -> Result<PiExecutableFileType, ProviderErr
588
590
  }
589
591
  }
590
592
 
593
+ #[cfg(test)]
594
+ #[derive(Debug, Default)]
595
+ pub(crate) struct PiCatalogTestObservation {
596
+ pub(crate) spawn_count: usize,
597
+ pub(crate) argv: Vec<String>,
598
+ pub(crate) parent_pid: Option<u32>,
599
+ pub(crate) parent_exit_success: Option<bool>,
600
+ pub(crate) parent_exit_code: Option<i32>,
601
+ pub(crate) reader_timeout: bool,
602
+ }
603
+
604
+ #[cfg(test)]
605
+ struct PiCatalogTestContext {
606
+ receipt: PathBuf,
607
+ helper_mode: Option<String>,
608
+ observation: PiCatalogTestObservation,
609
+ }
610
+
611
+ #[cfg(test)]
612
+ thread_local! {
613
+ static PI_CATALOG_TEST_CONTEXT: RefCell<Option<PiCatalogTestContext>> = RefCell::new(None);
614
+ }
615
+
616
+ #[cfg(test)]
617
+ pub(crate) fn with_pi_catalog_test_observation<T>(
618
+ receipt: &Path,
619
+ helper_mode: Option<&str>,
620
+ action: impl FnOnce() -> T,
621
+ ) -> (T, PiCatalogTestObservation) {
622
+ PI_CATALOG_TEST_CONTEXT.with(|slot| {
623
+ assert!(slot
624
+ .replace(Some(PiCatalogTestContext {
625
+ receipt: receipt.to_path_buf(),
626
+ helper_mode: helper_mode.map(str::to_owned),
627
+ observation: PiCatalogTestObservation::default(),
628
+ }))
629
+ .is_none());
630
+ let result = action();
631
+ let context = slot.take().expect("Pi catalog test context");
632
+ (result, context.observation)
633
+ })
634
+ }
635
+
636
+ #[cfg(test)]
637
+ fn observe_pi_catalog_test(f: impl FnOnce(&mut PiCatalogTestContext)) {
638
+ PI_CATALOG_TEST_CONTEXT.with(|slot| {
639
+ if let Some(context) = slot.borrow_mut().as_mut() {
640
+ f(context);
641
+ }
642
+ });
643
+ }
644
+
591
645
  /// Discover candidates with one PATH-first catalog observation. This deliberately
592
646
  /// does not resolve adapters or scan wrapper chains; callers use it only for
593
647
  /// rejecting an unqualified role model before lifecycle mutation.
@@ -596,8 +650,18 @@ pub(crate) fn run_pi_catalog(
596
650
  timeout: Duration,
597
651
  max_bytes: u64,
598
652
  ) -> Result<Vec<u8>, String> {
599
- let mut child = Command::new(executable)
600
- .arg("--list-models")
653
+ let mut command = Command::new(executable);
654
+ command.arg("--list-models");
655
+ #[cfg(test)]
656
+ observe_pi_catalog_test(|context| {
657
+ context.observation.spawn_count += 1;
658
+ context.observation.argv.push("--list-models".to_string());
659
+ command.env("TEAM_AGENT_MODELS_TIMEOUT_RECEIPT", &context.receipt);
660
+ if let Some(mode) = context.helper_mode.as_deref() {
661
+ command.env("TEAM_AGENT_MODELS_TIMEOUT_MODE", mode);
662
+ }
663
+ });
664
+ let mut child = command
601
665
  .stdin(Stdio::null())
602
666
  .stdout(Stdio::piped())
603
667
  .stderr(Stdio::null())
@@ -614,6 +678,8 @@ pub(crate) fn run_pi_catalog(
614
678
  let _ = error;
615
679
  "Pi executable is unavailable on PATH".to_string()
616
680
  })?;
681
+ #[cfg(test)]
682
+ observe_pi_catalog_test(|context| context.observation.parent_pid = Some(child.id()));
617
683
  let stdout = child.stdout.take().ok_or_else(|| {
618
684
  let _ = child.kill();
619
685
  let _ = child.wait();
@@ -629,8 +695,19 @@ pub(crate) fn run_pi_catalog(
629
695
  loop {
630
696
  match child.try_wait() {
631
697
  Ok(Some(status)) => {
632
- let (result, bytes) = receiver.recv_timeout(timeout.saturating_sub(start.elapsed()))
633
- .map_err(|_| "Pi model catalog command timed out".to_string())?;
698
+ #[cfg(test)]
699
+ observe_pi_catalog_test(|context| {
700
+ context.observation.parent_exit_success = Some(status.success());
701
+ context.observation.parent_exit_code = status.code();
702
+ });
703
+ let (result, bytes) = match receiver.recv_timeout(timeout.saturating_sub(start.elapsed())) {
704
+ Ok(value) => value,
705
+ Err(_) => {
706
+ #[cfg(test)]
707
+ observe_pi_catalog_test(|context| context.observation.reader_timeout = true);
708
+ return Err("Pi model catalog command timed out".to_string());
709
+ }
710
+ };
634
711
  if !status.success() { return Err("Pi model catalog command failed".into()); }
635
712
  result.map_err(|_| "Pi model catalog could not be read".to_string())?;
636
713
  if bytes.len() as u64 > max_bytes { return Err("Pi model catalog exceeds the bounded output limit".into()); }
@@ -1303,7 +1303,7 @@ fn restart_with_selected_team_and_transport(
1303
1303
  )?;
1304
1304
  // 0.3.30 Bug 1: auto-attach on partial restart too — workers that did
1305
1305
  // come up still need a leader_receiver pane to deliver report_result.
1306
- try_autobind_leader_after_restart(
1306
+ let attach_window_failures = try_autobind_leader_after_restart(
1307
1307
  &selected.run_workspace,
1308
1308
  Some(selected.team_key.as_str()),
1309
1309
  &state,
@@ -1316,6 +1316,7 @@ fn restart_with_selected_team_and_transport(
1316
1316
  coordinator,
1317
1317
  next_actions,
1318
1318
  attach_commands,
1319
+ attach_window_failures,
1319
1320
  });
1320
1321
  }
1321
1322
  phase_timer.emit(&selected.run_workspace, "restart.phase", "completed");
@@ -1331,7 +1332,8 @@ fn restart_with_selected_team_and_transport(
1331
1332
  // invoked from a tmux pane should bind that pane as leader_receiver,
1332
1333
  // restoring the worker→leader delivery path. Failure is non-fatal — the
1333
1334
  // user can still run `team-agent attach-leader` manually.
1334
- try_autobind_leader_after_restart(&selected.run_workspace, Some(&selected.team_key), &state);
1335
+ let attach_window_failures =
1336
+ try_autobind_leader_after_restart(&selected.run_workspace, Some(&selected.team_key), &state);
1335
1337
  if let Ok(probe) = crate::lifecycle::display::probe_display_capabilities(&selected.run_workspace)
1336
1338
  {
1337
1339
  let _ = crate::lifecycle::display::rebuild_adaptive_display_after_rebind(
@@ -1352,6 +1354,7 @@ fn restart_with_selected_team_and_transport(
1352
1354
  coordinator,
1353
1355
  next_actions,
1354
1356
  attach_commands,
1357
+ attach_window_failures,
1355
1358
  })
1356
1359
  }
1357
1360
 
@@ -1825,9 +1828,9 @@ fn try_autobind_leader_after_restart(
1825
1828
  workspace: &std::path::Path,
1826
1829
  team: Option<&str>,
1827
1830
  state: &serde_json::Value,
1828
- ) {
1831
+ ) -> Option<serde_json::Value> {
1829
1832
  if std::env::var_os("TMUX_PANE").is_none() {
1830
- return;
1833
+ return None;
1831
1834
  }
1832
1835
  // Provider: prefer the existing team_owner.provider (if rebind),
1833
1836
  // else leader_receiver.provider (stale, but still informative),
@@ -1854,6 +1857,7 @@ fn try_autobind_leader_after_restart(
1854
1857
  let team_str = team;
1855
1858
  match crate::leader::attach_leader(workspace, team_str, None, provider) {
1856
1859
  Ok(result) if result.ok => {
1860
+ let debt = result.attach_window_failures.clone();
1857
1861
  let _ = crate::leader::registry::register_binding_from_state_best_effort(
1858
1862
  workspace,
1859
1863
  team_str,
@@ -1864,18 +1868,21 @@ fn try_autobind_leader_after_restart(
1864
1868
  result.bound_pane_id.as_ref().map(|p| p.as_str()),
1865
1869
  team_str,
1866
1870
  );
1871
+ debt
1867
1872
  }
1868
1873
  Ok(result) => {
1869
1874
  eprintln!(
1870
1875
  "team_agent::restart auto_attach_leader skipped reason={:?} team={:?}",
1871
1876
  result.reason, team_str,
1872
1877
  );
1878
+ None
1873
1879
  }
1874
1880
  Err(error) => {
1875
1881
  eprintln!(
1876
1882
  "team_agent::restart auto_attach_leader failed error={error} team={team_str:?} \
1877
1883
  (run `team-agent attach-leader` from your tmux pane to bind manually)",
1878
1884
  );
1885
+ None
1879
1886
  }
1880
1887
  }
1881
1888
  }
@@ -18,6 +18,8 @@ mod hermetic_guard;
18
18
 
19
19
  #[path = "../../../tests/support/composite_source.rs"]
20
20
  mod composite_source;
21
+ #[path = "../../../tests/support/brief_probe.rs"]
22
+ mod brief_probe;
21
23
  use std::path::{Path, PathBuf};
22
24
 
23
25
  use serde_json::{json, Value};
@@ -97,6 +99,18 @@ fn stale_snapshot_cannot_flip_status_or_diagnose_ok_readiness() {
97
99
  let case = B0HideOneCase::new("status-diagnose-hide-one");
98
100
  let root = case.current_state();
99
101
  save_runtime_state(&case.workspace, &root).expect("seed current root state");
102
+ let probe = brief_probe::install_trusted_nodeprobe(
103
+ &case.workspace.join("probe"),
104
+ NEW_ENDPOINT,
105
+ SESSION,
106
+ WORKER,
107
+ "%new",
108
+ "fake",
109
+ );
110
+ let _path = case.env.with_env(
111
+ "PATH",
112
+ &brief_probe::path_with_probe(&probe, std::env::var_os("PATH").as_deref()),
113
+ );
100
114
 
101
115
  let status_before = case.run_json(&[
102
116
  "status",
@@ -136,10 +150,20 @@ fn stale_snapshot_cannot_flip_status_or_diagnose_ok_readiness() {
136
150
  "--json",
137
151
  ]);
138
152
 
139
- assert_eq!(
140
- status_after.pointer("/agents/worker/pane_id").and_then(Value::as_str),
141
- Some("%new"),
142
- "F0-3 RED3: status must not display the stale snapshot pane as the worker authority; before={status_before} after={status_after}"
153
+ let node = brief_node(&status_after).unwrap_or_else(|| {
154
+ panic!("F0-3 RED3: status must retain the worker in the seven-field projection; before={status_before} after={status_after}")
155
+ });
156
+ assert_eq!(node.get("name").and_then(Value::as_str), Some(WORKER));
157
+ assert_eq!(node.get("provider").and_then(Value::as_str), Some("fake"));
158
+ assert_eq!(node.get("runtime_status").and_then(Value::as_str), Some("running"));
159
+ assert_eq!(node.get("activity").and_then(Value::as_str), Some("idle"));
160
+ assert_eq!(node.get("health").and_then(Value::as_str), Some("normal"));
161
+ assert_eq!(node.get("session_name").and_then(Value::as_str), Some(SESSION));
162
+ assert!(
163
+ node.get("tmux_command")
164
+ .and_then(Value::as_str)
165
+ .is_some_and(|command| command.contains(NEW_ENDPOINT) && command.contains("team-b0-hideone:worker.%new")),
166
+ "F0-3 RED3: trusted root probe must produce a non-empty exact endpoint command; node={node}"
143
167
  );
144
168
  assert_eq!(
145
169
  worker_status_tuple(&status_before),
@@ -333,18 +357,25 @@ impl B0HideOneCase {
333
357
  }
334
358
  }
335
359
 
360
+ fn brief_node(status: &Value) -> Option<&Value> {
361
+ status
362
+ .get("nodes")
363
+ .and_then(Value::as_array)
364
+ .and_then(|nodes| nodes.iter().find(|node| {
365
+ node.get("name").and_then(Value::as_str) == Some(WORKER)
366
+ }))
367
+ }
368
+
336
369
  fn worker_status_tuple(status: &Value) -> Value {
337
- let worker = status
338
- .get("agents")
339
- .and_then(Value::as_object)
340
- .and_then(|agents| agents.get(WORKER))
341
- .cloned()
342
- .unwrap_or(Value::Null);
370
+ let worker = brief_node(status).cloned().unwrap_or(Value::Null);
343
371
  json!({
344
- "ok": status.get("ok").cloned().unwrap_or(Value::Null),
345
- "pane_id": worker.get("pane_id").cloned().unwrap_or(Value::Null),
346
- "status": worker.get("status").cloned().unwrap_or(Value::Null),
347
- "readiness": status.get("readiness").cloned().unwrap_or(Value::Null),
372
+ "name": worker.get("name").cloned().unwrap_or(Value::Null),
373
+ "provider": worker.get("provider").cloned().unwrap_or(Value::Null),
374
+ "runtime_status": worker.get("runtime_status").cloned().unwrap_or(Value::Null),
375
+ "activity": worker.get("activity").cloned().unwrap_or(Value::Null),
376
+ "health": worker.get("health").cloned().unwrap_or(Value::Null),
377
+ "session_name": worker.get("session_name").cloned().unwrap_or(Value::Null),
378
+ "tmux_command": worker.get("tmux_command").cloned().unwrap_or(Value::Null),
348
379
  })
349
380
  }
350
381
 
@@ -234,6 +234,7 @@ fn clone_ok(case: &Case, source: &str, dest: &str) {
234
234
 
235
235
  /// clone 后分身 role + spec tools 必须等于源席;三件套源不得被扩成六件。
236
236
  #[test]
237
+ #[serial_test::serial(env)]
237
238
  fn clone_agent_preserves_source_tools() {
238
239
  let case = Case::start();
239
240
  let qs = case.run(&[
@@ -425,7 +425,7 @@ check behind it = false green); it must reflect an actual doctor result"
425
425
  /// Mutates the process-global PATH/HOME — must not race host_cotenant's ps/tmux
426
426
  /// shims (P0 pid-probe isolation), hence `#[serial(pid_probe)]`.
427
427
  #[test]
428
- #[serial(pid_probe)]
428
+ #[serial(env, pid_probe)]
429
429
  fn t3_claude_auth_hint_parse_failure_is_not_present() {
430
430
  let home = tmp_ws("t3-auth-home");
431
431
  let shim_dir = home.join("bin");
@@ -1,8 +1,8 @@
1
1
  use super::launch_spawn::seed_healthy_coordinator;
2
2
  use super::*;
3
3
  use crate::cli::{
4
- cmd_collect, cmd_send, cmd_status, lifecycle_port, CmdOutput, CmdResult, CollectArgs, SendArgs,
5
- StatusArgs,
4
+ cmd_collect, cmd_diagnose, cmd_send, cmd_status, lifecycle_port, CmdOutput, CmdResult,
5
+ CollectArgs, DiagnoseArgs, SendArgs, StatusArgs,
6
6
  };
7
7
  use crate::transport::test_support::OfflineTransport;
8
8
  use crate::transport::WindowName;
@@ -176,29 +176,28 @@ fn phase_f_golden_events_state_status_zero_drift() {
176
176
  #[test]
177
177
  #[serial_test::serial(env)]
178
178
  fn phase_golden_unbound_not_ready_requires_claim_leader() {
179
- // 已废除的行为:旧实现只说不 ready、不说怎么恢复,把人晾在原地。此断言证明它确实没了。
180
- // Independent of golden.json so a later regenerate cannot erase it.
181
- // Companion status keys grok_slot ← 77bddb95 / 0c319cca; next_action ← 080903a2.
182
- let compact = run_compact_status_after_quick_start();
183
- let not_ready = compact
184
- .get("not_ready")
185
- .and_then(Value::as_object)
186
- .unwrap_or_else(|| panic!("compact must carry not_ready; got {compact}"));
187
- let reasons = not_ready
188
- .get("reasons")
179
+ // Unbound recovery guidance belongs to the diagnose/quick-start refusal
180
+ // surface, not to the seven-field status brief.
181
+ let diagnose = run_diagnose_after_quick_start();
182
+ let issues = diagnose
183
+ .get("issues")
189
184
  .and_then(Value::as_array)
190
185
  .cloned()
191
186
  .unwrap_or_default();
192
187
  assert!(
193
- reasons
194
- .iter()
195
- .any(|reason| reason.as_str() == Some("leader_receiver_unbound")),
196
- "tombstone fixture must still produce leader_receiver_unbound; not_ready={not_ready:?}"
188
+ issues.iter().any(|issue| {
189
+ issue.as_str() == Some("leader_not_attached")
190
+ || issue.get("id").and_then(Value::as_str) == Some("leader_channel_unbound")
191
+ }),
192
+ "tombstone fixture must still diagnose leader_receiver_unbound; diagnose={diagnose}"
197
193
  );
198
- assert_eq!(
199
- not_ready.get("next_action").and_then(Value::as_str),
200
- Some("claim-leader"),
201
- "when not_ready.reasons contains leader_receiver_unbound, next_action must be claim-leader; not_ready={not_ready:?}"
194
+ let repairs = diagnose
195
+ .get("suggested_repairs")
196
+ .map(Value::to_string)
197
+ .unwrap_or_default();
198
+ assert!(
199
+ repairs.contains("claim-leader"),
200
+ "diagnose must retain claim-leader recovery guidance; diagnose={diagnose}"
202
201
  );
203
202
  }
204
203
 
@@ -474,7 +473,7 @@ fn strip_full_line_json_comments(text: &str) -> String {
474
473
  .join("\n")
475
474
  }
476
475
 
477
- fn run_compact_status_after_quick_start() -> Value {
476
+ fn run_diagnose_after_quick_start() -> Value {
478
477
  let hermetic = HermeticTestEnv::enter("tombstone-claim-leader");
479
478
  let _permission_mode = EnvVarGuard::set(ANCESTRY_ENV, "[]");
480
479
  let team = two_worker_team_dir(&hermetic);
@@ -491,20 +490,17 @@ fn run_compact_status_after_quick_start() -> Value {
491
490
  false,
492
491
  )
493
492
  .expect("tombstone fixture must quick-start");
494
- let status = cmd_status(&StatusArgs {
495
- agent: None,
493
+ let diagnose = cmd_diagnose(&DiagnoseArgs {
496
494
  workspace,
497
- detail: false,
498
- summary: false,
499
495
  json: true,
500
496
  team: Some("teamdir".to_string()),
501
497
  });
502
- match cmd_value(status) {
498
+ match cmd_value(diagnose) {
503
499
  Value::Object(map) => map
504
500
  .get("output")
505
501
  .cloned()
506
- .unwrap_or_else(|| panic!("compact status missing output")),
507
- other => panic!("compact status must be a JSON object, got {other}"),
502
+ .unwrap_or_else(|| panic!("diagnose missing output")),
503
+ other => panic!("diagnose must be a JSON object, got {other}"),
508
504
  }
509
505
  }
510
506
 
@@ -72,12 +72,19 @@ fn upgrade_0211_state_team_key_runtime_key_not_spec_name() {
72
72
  },
73
73
  Some(TEAM_KEY),
74
74
  ));
75
+ let node_names = status
76
+ .get("nodes")
77
+ .and_then(Value::as_array)
78
+ .expect("status brief must expose nodes")
79
+ .iter()
80
+ .filter_map(|node| node.get("name").and_then(Value::as_str))
81
+ .collect::<Vec<_>>();
75
82
  assert!(
76
- status.pointer("/agents/upgrade_worker").is_some(),
83
+ node_names.contains(&"upgrade_worker"),
77
84
  "status --team upgrade-key must project the runtime-key team; status={status}"
78
85
  );
79
86
  assert!(
80
- status.pointer("/agents/sibling_worker").is_none(),
87
+ !node_names.contains(&"sibling_worker"),
81
88
  "status --team upgrade-key must not collapse to sibling/spec-name state; status={status}"
82
89
  );
83
90
  }
@@ -216,15 +223,20 @@ fn upgrade_status_collect_scope_by_selected_team_key() {
216
223
  },
217
224
  Some(TEAM_KEY),
218
225
  ));
219
- assert_eq!(
220
- status.pointer("/results/total").and_then(Value::as_i64),
221
- Some(2),
222
- "status --team upgrade-key must count only upgrade-key results and exclude sibling rows; status={status}"
226
+ let node_names = status
227
+ .get("nodes")
228
+ .and_then(Value::as_array)
229
+ .expect("status brief must expose nodes")
230
+ .iter()
231
+ .filter_map(|node| node.get("name").and_then(Value::as_str))
232
+ .collect::<Vec<_>>();
233
+ assert!(
234
+ node_names.contains(&"upgrade_worker"),
235
+ "status --team upgrade-key must retain the selected runtime-key node; status={status}"
223
236
  );
224
- assert_eq!(
225
- status.pointer("/messages/accepted").and_then(Value::as_i64),
226
- Some(1),
227
- "status --team upgrade-key must count only upgrade-key messages; status={status}"
237
+ assert!(
238
+ !node_names.contains(&"sibling_worker"),
239
+ "status --team upgrade-key must not expose sibling rows; status={status}"
228
240
  );
229
241
 
230
242
  let collect = json_output(cmd_collect_for_team(
@@ -797,6 +797,8 @@ pub enum RestartReport {
797
797
  coordinator: CoordinatorStartSummary,
798
798
  next_actions: Vec<String>,
799
799
  attach_commands: Vec<String>,
800
+ /// Debt returned by this restart's best-effort auto-attach, if any.
801
+ attach_window_failures: Option<serde_json::Value>,
800
802
  },
801
803
  /// At least one worker failed during live spawn, but other workers were isolated
802
804
  /// and restarted. The CLI reports `status=partial` and exits non-zero.
@@ -811,6 +813,8 @@ pub enum RestartReport {
811
813
  coordinator: CoordinatorStartSummary,
812
814
  next_actions: Vec<String>,
813
815
  attach_commands: Vec<String>,
816
+ /// Debt returned by this restart's best-effort auto-attach, if any.
817
+ attach_window_failures: Option<serde_json::Value>,
814
818
  },
815
819
  /// All workers failed during live spawn. No worker is reported as restarted.
816
820
  Failed {