@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
@@ -277,6 +277,10 @@ pub(super) fn spawn_agents(
277
277
  provider,
278
278
  ),
279
279
  );
280
+ // The provider is the final typed value after profile overlays. Keep it
281
+ // separate from inherited LEADER_* identity and let the tmux invocation
282
+ // boundary add the actual pane/socket only for the provider process.
283
+ crate::layout::worker_env::inject_current_caller_provider(&mut env, provider);
280
284
  // BUG / C-1-2 / C-6-1 cr verdict — Copilot system_prompt 走 spawn env overlay +
281
285
  // per-worker AGENTS.md(B2 灵魂件降级):写
282
286
  // <workspace>/.team/runtime/copilot-instructions/<agent_id>/AGENTS.md
@@ -51,6 +51,10 @@
51
51
  //! sites can reference the phases by name. The phase fns themselves
52
52
  //! remain in launch.rs until the next batch of relocations.
53
53
 
54
+ #[cfg(test)]
55
+ #[path = "../../../tests/support/hermetic.rs"]
56
+ mod hermetic;
57
+
54
58
  use std::collections::{BTreeMap, BTreeSet};
55
59
  use std::path::{Path, PathBuf};
56
60
  use std::process::Command;
@@ -66,16 +70,14 @@ use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
66
70
  use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
67
71
 
68
72
  use super::identity::spec_display_backend;
69
- use super::leader_context::{
70
- attributed_provider_for_pane_across_tmux_sockets, caller_provider_for_seed_with_lookup,
71
- seed_unbound_launched_owner,
72
- };
73
+ use super::leader_context::{caller_provider_for_seed_with_lookup, seed_unbound_launched_owner};
73
74
  use super::worker_env::spawn_timestamp;
74
75
 
75
76
  /// ---
76
77
  /// purpose: 由 spec 生成首份 runtime state 树
77
78
  /// params:
78
79
  /// team_key: 本团队的 runtime 键
80
+ /// scoped_endpoint: 本次起队已选中的 transport tmux endpoint,与 fresh bind 同源
79
81
  /// returns: 含 spec_path、workspace、team_dir、session_name、leader、agents、tasks 与 display_backend 的 state;随后按环境种入 launched owner,种不到则种一份 unbound owner
80
82
  /// ---
81
83
  pub(super) fn initial_runtime_state(
@@ -84,6 +86,7 @@ pub(super) fn initial_runtime_state(
84
86
  workspace: &Path,
85
87
  team_dir: &Path,
86
88
  team_key: &str,
89
+ scoped_endpoint: Option<&str>,
87
90
  ) -> serde_json::Value {
88
91
  let mut agents = serde_json::Map::new();
89
92
  for agent in spec_agent_values(spec) {
@@ -144,7 +147,7 @@ pub(super) fn initial_runtime_state(
144
147
  );
145
148
  state.insert("is_external_leader".to_string(), serde_json::json!(false));
146
149
  let mut state = serde_json::Value::Object(state);
147
- if !seed_launched_owner_from_env(&mut state) {
150
+ if !seed_launched_owner_from_env(&mut state, scoped_endpoint) {
148
151
  let team_id = crate::state::projection::team_state_key(&state);
149
152
  seed_unbound_launched_owner(&mut state, &team_id);
150
153
  }
@@ -157,9 +160,12 @@ pub(super) fn initial_runtime_state(
157
160
  /// state: 待改写的 state,成功时写入 owner 与 leader receiver
158
161
  /// returns: true 表示已种入,取不到 caller 身份或无 pane 时为 false
159
162
  /// ---
160
- pub(super) fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
163
+ pub(super) fn seed_launched_owner_from_env(
164
+ state: &mut serde_json::Value,
165
+ scoped_endpoint: Option<&str>,
166
+ ) -> bool {
161
167
  let team_id = crate::state::projection::team_state_key(state);
162
- let Ok(caller) = crate::state::identity::caller_identity_from_env(
168
+ let Ok(mut caller) = crate::state::identity::caller_identity_from_env(
163
169
  Some(state),
164
170
  &crate::state::identity::SystemEnv,
165
171
  Some(&team_id),
@@ -167,11 +173,27 @@ pub(super) fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> boo
167
173
  ) else {
168
174
  return false;
169
175
  };
170
- seed_launched_owner_from_caller_with_provider_lookup(
171
- state,
172
- caller,
173
- attributed_provider_for_pane_across_tmux_sockets,
174
- )
176
+ let current_pane = std::env::var("TMUX_PANE")
177
+ .ok()
178
+ .filter(|pane| !pane.is_empty());
179
+ let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
180
+ match crate::layout::worker_env::caller_provider_resolution(
181
+ current_pane.as_deref(),
182
+ current_endpoint.as_deref(),
183
+ scoped_endpoint,
184
+ ) {
185
+ crate::layout::worker_env::CallerProviderResolution::Invalid => return false,
186
+ crate::layout::worker_env::CallerProviderResolution::Valid(provider) => {
187
+ caller.provider = crate::provider::wire::provider_wire(provider).to_string();
188
+ }
189
+ crate::layout::worker_env::CallerProviderResolution::Absent => {}
190
+ }
191
+ // Absent keeps the existing explicit/direct pane path. Do not unbind a
192
+ // caller TMUX_PANE just because launcher context and LEADER_PROVIDER are
193
+ // both missing; that is the external-leader same-id-across-sockets case.
194
+ // Invalid already returned. Do not replace a missing provider with broad
195
+ // pane/process attribution.
196
+ seed_launched_owner_from_caller_with_provider_lookup(state, caller, |_| None)
175
197
  }
176
198
 
177
199
  /// ---
@@ -619,9 +641,13 @@ pub(super) fn team_workspace(team_dir: &Path) -> PathBuf {
619
641
 
620
642
  #[cfg(test)]
621
643
  mod tests {
622
- #![allow(clippy::unwrap_used)]
644
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
623
645
  use super::*;
646
+ use crate::layout::worker_env::{CALLER_ENDPOINT_ENV, CALLER_PANE_ENV, CALLER_PROVIDER_ENV};
624
647
  use crate::model::enums::Provider;
648
+ use serde_json::json;
649
+
650
+ use super::hermetic::HermeticTestEnv;
625
651
 
626
652
  fn yaml_agent(bypass: bool) -> Value {
627
653
  crate::model::yaml::loads(&format!(
@@ -651,4 +677,103 @@ mod tests {
651
677
  assert!(!safety.enabled);
652
678
  assert_eq!(safety.source, DangerousApprovalSource::Disabled);
653
679
  }
680
+
681
+ fn seed_state() -> serde_json::Value {
682
+ json!({
683
+ "team_key": "verify031",
684
+ "workspace": "/tmp/ws",
685
+ "session_name": "team-verify031",
686
+ "agents": {}
687
+ })
688
+ }
689
+
690
+ fn owner_pane(state: &serde_json::Value) -> Option<&str> {
691
+ state
692
+ .pointer("/teams/verify031/team_owner/pane_id")
693
+ .and_then(serde_json::Value::as_str)
694
+ }
695
+
696
+ fn owner_provider(state: &serde_json::Value) -> Option<&str> {
697
+ state
698
+ .pointer("/teams/verify031/team_owner/provider")
699
+ .and_then(serde_json::Value::as_str)
700
+ }
701
+
702
+ fn receiver_pane(state: &serde_json::Value) -> Option<&str> {
703
+ state
704
+ .pointer("/teams/verify031/leader_receiver/pane_id")
705
+ .and_then(serde_json::Value::as_str)
706
+ }
707
+
708
+ #[test]
709
+ #[serial_test::serial(env)]
710
+ fn seed_absent_caller_keeps_direct_tmux_pane_without_unbinding() {
711
+ let hermetic = HermeticTestEnv::enter("seed-absent-direct-pane");
712
+ let _tmux = hermetic.with_env("TMUX", "/tmp/default-tmux-socket,123,0");
713
+ let _pane = hermetic.with_env("TMUX_PANE", "%0");
714
+ let mut state = seed_state();
715
+ assert!(
716
+ seed_launched_owner_from_env(&mut state, Some("/tmp/product-team.sock")),
717
+ "Absent caller context must still seed the caller's TMUX_PANE; state={state}"
718
+ );
719
+ assert_eq!(owner_pane(&state), Some("%0"));
720
+ assert_eq!(receiver_pane(&state), Some("%0"));
721
+ assert!(
722
+ owner_provider(&state).is_none(),
723
+ "direct pane seed must not invent a provider; state={state}"
724
+ );
725
+ }
726
+
727
+ #[test]
728
+ #[serial_test::serial(env)]
729
+ fn seed_valid_caller_uses_typed_provider_and_same_scoped_endpoint() {
730
+ let hermetic = HermeticTestEnv::enter("seed-valid-scoped");
731
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
732
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
733
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
734
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
735
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
736
+ let mut state = seed_state();
737
+ assert!(seed_launched_owner_from_env(
738
+ &mut state,
739
+ Some("/tmp/tmux.sock")
740
+ ));
741
+ assert_eq!(owner_pane(&state), Some("%1"));
742
+ assert_eq!(owner_provider(&state), Some("pi"));
743
+ assert_eq!(receiver_pane(&state), Some("%1"));
744
+ }
745
+
746
+ #[test]
747
+ #[serial_test::serial(env)]
748
+ fn seed_invalid_caller_does_not_fall_back_to_direct_pane() {
749
+ let hermetic = HermeticTestEnv::enter("seed-invalid-scoped");
750
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
751
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
752
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
753
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
754
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
755
+ let mut state = seed_state();
756
+ assert!(
757
+ !seed_launched_owner_from_env(&mut state, Some("/tmp/product.sock")),
758
+ "scoped endpoint mismatch must fail closed; state={state}"
759
+ );
760
+ assert!(owner_pane(&state).is_none(), "state={state}");
761
+ assert!(receiver_pane(&state).is_none(), "state={state}");
762
+ }
763
+
764
+ #[test]
765
+ #[serial_test::serial(env)]
766
+ fn seed_absent_keeps_explicit_leader_provider() {
767
+ let hermetic = HermeticTestEnv::enter("seed-absent-explicit");
768
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
769
+ let _pane = hermetic.with_env("TMUX_PANE", "%3");
770
+ let _leader = hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", "pi");
771
+ let mut state = seed_state();
772
+ assert!(seed_launched_owner_from_env(
773
+ &mut state,
774
+ Some("/tmp/tmux.sock")
775
+ ));
776
+ assert_eq!(owner_pane(&state), Some("%3"));
777
+ assert_eq!(owner_provider(&state), Some("pi"));
778
+ }
654
779
  }
@@ -523,6 +523,9 @@ pub(super) fn spawn_agent_window(
523
523
  provider,
524
524
  ),
525
525
  );
526
+ // Keep the launcher-known typed provider independent from inherited
527
+ // LEADER_* identity. The tmux wrapper binds pane/socket at invocation time.
528
+ crate::layout::worker_env::inject_current_caller_provider(&mut env, provider);
526
529
 
527
530
  // 0.4.6 Stage 2: write actual spawn plan event BEFORE invoking the
528
531
  // transport spawn. Mirrors `launch.rs:359-380` (the reference impl)
@@ -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(