@team-agent/installer 0.5.118 → 0.5.120

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 (72) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +46 -1
  4. package/README.zh.md +46 -1
  5. package/crates/team-agent/src/cli/adapters.rs +11 -0
  6. package/crates/team-agent/src/cli/arguments.rs +401 -0
  7. package/crates/team-agent/src/cli/emit.rs +241 -142
  8. package/crates/team-agent/src/cli/intent/runtime.rs +1139 -0
  9. package/crates/team-agent/src/cli/intent/runtime_tests.rs +584 -0
  10. package/crates/team-agent/src/cli/intent/tests.rs +654 -0
  11. package/crates/team-agent/src/cli/intent.rs +642 -0
  12. package/crates/team-agent/src/cli/mod.rs +49 -5
  13. package/crates/team-agent/src/cli/named_address.rs +10 -1
  14. package/crates/team-agent/src/cli/spec.rs +22 -10
  15. package/crates/team-agent/src/cli/status_port/brief.rs +117 -16
  16. package/crates/team-agent/src/cli/tests/base.rs +6 -1
  17. package/crates/team-agent/src/cli/tests/parser_safety.rs +383 -0
  18. package/crates/team-agent/src/cli/tests/run_delegation.rs +63 -19
  19. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +3 -2
  20. package/crates/team-agent/src/cli/tests/verb_profile.rs +3 -1
  21. package/crates/team-agent/src/contract_runtime/backend.rs +1 -1
  22. package/crates/team-agent/src/db/message_store.rs +10 -3
  23. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +166 -77
  24. package/crates/team-agent/src/lifecycle/launch.rs +28 -0
  25. package/crates/team-agent/src/lifecycle/restart.rs +17 -0
  26. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -3
  27. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +5 -15
  28. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -3
  29. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +2 -3
  30. package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +10 -2
  31. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +8 -1
  32. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +3 -2
  33. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +6 -9
  34. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +7 -5
  35. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +4 -2
  36. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +5 -2
  37. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +4 -3
  38. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +2 -4
  39. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +3 -4
  40. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +5 -4
  41. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +2 -4
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +78 -36
  43. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +8 -4
  44. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +6 -12
  45. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +4 -0
  46. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1 -3
  47. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +7 -0
  48. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -3
  49. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -1
  50. package/crates/team-agent/src/lifecycle/types.rs +10 -0
  51. package/crates/team-agent/src/messaging/delivery.rs +132 -39
  52. package/crates/team-agent/src/messaging/delivery_consumption_tests.rs +223 -0
  53. package/crates/team-agent/src/messaging/enter_rescue.rs +182 -154
  54. package/crates/team-agent/src/messaging/enter_rescue_tests.rs +344 -277
  55. package/crates/team-agent/src/messaging/mod.rs +1 -1
  56. package/crates/team-agent/src/tmux_backend/enter_rescue.rs +325 -0
  57. package/crates/team-agent/src/tmux_backend/enter_rescue_tests.rs +432 -0
  58. package/crates/team-agent/src/tmux_backend/native_input.rs +19 -0
  59. package/crates/team-agent/src/tmux_backend/tests.rs +37 -0
  60. package/crates/team-agent/src/tmux_backend.rs +35 -1
  61. package/crates/team-agent/src/transport/enter_rescue.rs +166 -0
  62. package/crates/team-agent/src/transport/native_input.rs +5 -1
  63. package/crates/team-agent/src/transport/test_support.rs +25 -0
  64. package/crates/team-agent/src/transport.rs +34 -0
  65. package/package.json +4 -4
  66. package/skills/team-agent/SKILL.md +71 -54
  67. package/skills/team-agent/command-coverage.json +283 -13
  68. package/skills/team-agent/docs/reference/team-agent-operator.md +220 -0
  69. package/skills/team-agent/references/bug-as-artifact-flow.md +1 -1
  70. package/skills/team-agent/references/recovery-runbook.md +61 -212
  71. package/skills/team-agent/references/team-agent-operator.md +181 -48
  72. package/skills/team-agent/references/team-in-team.md +47 -80
@@ -944,52 +944,56 @@ fn preflight_fresh_leader_identity(
944
944
  .ok()
945
945
  .filter(|pane| !pane.is_empty());
946
946
  let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
947
- match crate::layout::worker_env::caller_provider_resolution(
947
+ let refusal = |stage: &str, reason: &str, action: &str| {
948
+ Some((
949
+ format!("quick-start refused before spawn: {}", reason.replace('_', " ")),
950
+ vec![format!("stage={stage} reason={reason}")],
951
+ vec![action.to_string()],
952
+ ))
953
+ };
954
+ let resolution = crate::layout::worker_env::caller_provider_resolution(
948
955
  pane.as_deref(),
949
956
  current_endpoint.as_deref(),
950
957
  transport.tmux_endpoint().as_deref(),
951
- ) {
952
- crate::layout::worker_env::CallerProviderResolution::Invalid => Some((
953
- "quick-start refused before spawn: invalid caller tuple".to_string(),
954
- vec!["stage=strict_provider reason=invalid_caller_tuple".to_string()],
955
- vec![
956
- "fix conflicting CALLER_* / TEAM_AGENT_LEADER_* identity; do not run claim-leader"
957
- .to_string(),
958
- ],
959
- )),
960
- crate::layout::worker_env::CallerProviderResolution::Valid(_) => None,
961
- crate::layout::worker_env::CallerProviderResolution::Absent => {
962
- let Some(pane) = pane else {
963
- return None;
964
- };
965
- let command =
966
- query_caller_pane_command(transport, &PaneId::new(pane)).unwrap_or_default();
967
- let explicit = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
968
- .ok()
969
- .filter(|provider| !provider.is_empty());
970
- if command.trim().is_empty() {
971
- return Some((
972
- "quick-start refused before spawn: command unobservable".to_string(),
973
- vec!["stage=command_observation reason=command_unobservable".to_string()],
974
- vec!["run from a live provider pane; do not run claim-leader".to_string()],
975
- ));
976
- }
977
- if crate::leader::owner_bind::strict_owner_bind_provider(explicit.as_deref(), &command)
978
- .is_some()
979
- {
980
- None
981
- } else {
982
- Some((
983
- "quick-start refused before spawn: provider unresolved".to_string(),
984
- vec!["stage=strict_provider reason=provider_unresolved".to_string()],
985
- vec![
986
- "set TEAM_AGENT_LEADER_PROVIDER or run from a provider pane; do not run claim-leader"
987
- .to_string(),
988
- ],
989
- ))
990
- }
958
+ );
959
+ if matches!(resolution, crate::layout::worker_env::CallerProviderResolution::Invalid) {
960
+ return refusal("strict_provider", "invalid_caller_tuple",
961
+ "fix conflicting CALLER_* / TEAM_AGENT_LEADER_* identity; do not run claim-leader");
962
+ }
963
+ let Some(pane) = pane else {
964
+ return refusal("caller_pane", "caller_pane_missing",
965
+ "run start from the intended live Leader provider pane; no workers or coordinator were started");
966
+ };
967
+ let pane = PaneId::new(pane);
968
+ let command = query_caller_pane_command(transport, &pane).unwrap_or_default();
969
+ if command.trim().is_empty() {
970
+ return refusal("command_observation", "command_unobservable",
971
+ "run from a live provider pane; do not run claim-leader");
972
+ }
973
+ if matches!(resolution, crate::layout::worker_env::CallerProviderResolution::Absent) {
974
+ let explicit = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
975
+ .ok().filter(|provider| !provider.is_empty());
976
+ if crate::leader::owner_bind::strict_owner_bind_provider(explicit.as_deref(), &command).is_none() {
977
+ return refusal("strict_provider", "provider_unresolved",
978
+ "set TEAM_AGENT_LEADER_PROVIDER or run from a provider pane; do not run claim-leader");
991
979
  }
992
980
  }
981
+ // A typed caller tuple is identity evidence, not proof that its pane still
982
+ // exists. Use the caller's endpoint, never the worker server's same %id.
983
+ let targets = match list_caller_targets(transport) {
984
+ Ok(targets) => targets,
985
+ Err(_) => return refusal("caller_pane", "caller_pane_unobservable",
986
+ "inspect the intended Leader endpoint; no workers or coordinator were started"),
987
+ };
988
+ let Some(target) = targets.iter().find(|target| target.pane_id == pane) else {
989
+ return refusal("caller_pane", "caller_pane_not_live",
990
+ "run from the intended live Leader pane; no workers or coordinator were started");
991
+ };
992
+ if target.current_path.as_ref().is_none_or(|path| path.as_os_str().is_empty()) {
993
+ return refusal("caller_pane", "caller_cwd_unobservable",
994
+ "inspect the intended Leader pane workspace before retrying start");
995
+ }
996
+ None
993
997
  }
994
998
 
995
999
  fn bind_fresh_quick_start_leader(
@@ -1151,6 +1155,12 @@ pub fn quick_start_in_workspace_with_backend(
1151
1155
  // team_key. When quick-start's downstream code runs, state.json
1152
1156
  // still has whatever it had before (empty on fresh launch), so
1153
1157
  // the spec-compile + worker-spawn path runs normally.
1158
+ let caller_transport = quick_start_tmux_backend(&workspace);
1159
+ if let Some((summary, blockers, next_actions)) = preflight_fresh_leader_identity(&caller_transport) {
1160
+ return Ok(QuickStartReport::PreflightBlocked {
1161
+ summary, blockers, next_actions, attach_commands: Vec::new(),
1162
+ });
1163
+ }
1154
1164
  let run_ws = crate::coordinator::WorkspacePath::new(workspace.clone());
1155
1165
  let start_report =
1156
1166
  crate::coordinator::health::start_coordinator_with_team(&run_ws, Some(team_key_str))
@@ -1508,46 +1518,53 @@ pub(crate) fn quick_start_with_transport_in_workspace_pi_preflight(
1508
1518
  let quick_start_phase_timer = crate::lifecycle::restart::RestartPhaseTimer::start();
1509
1519
  let mut launch =
1510
1520
  launch_with_transport_in_workspace(&workspace, &spec_path, false, yes, true, transport)?;
1511
- annotate_persisted_team_depth(
1512
- &workspace,
1513
- &state_team_key,
1514
- team_depth.parent_team_key.as_deref(),
1515
- team_depth.team_depth,
1516
- )?;
1517
- // Fresh initialization owns this one fail-closed bind attempt. It is
1518
- // independent of worker layout, so receiver binding is always attempted
1519
- // binding. Readiness receives true only after canonical registry readback.
1520
- let _ = take_fresh_bind_refusal();
1521
- launch.leader_receiver_attached = bind_fresh_quick_start_leader(
1522
- &workspace,
1523
- &state_team_key,
1524
- seeded_owner.as_ref(),
1525
- seeded_receiver.as_ref(),
1526
- transport,
1527
- )?;
1528
- if !launch.leader_receiver_attached {
1529
- if let Some((stage, reason)) = take_fresh_bind_refusal() {
1530
- launch.leader_bind_stage = Some(stage.to_string());
1531
- launch.leader_bind_reason = Some(reason.to_string());
1521
+ let mut coordinator_state = "not_attempted";
1522
+ let completion = (|| -> Result<(), LifecycleError> {
1523
+ annotate_persisted_team_depth(
1524
+ &workspace, &state_team_key,
1525
+ team_depth.parent_team_key.as_deref(), team_depth.team_depth,
1526
+ )?;
1527
+ // Recheck at the original bind/commit point: preflight is not a lease
1528
+ // over the caller pane. A lost caller must not start a coordinator.
1529
+ let _ = take_fresh_bind_refusal();
1530
+ launch.leader_receiver_attached = bind_fresh_quick_start_leader(
1531
+ &workspace, &state_team_key, seeded_owner.as_ref(),
1532
+ seeded_receiver.as_ref(), transport,
1533
+ )?;
1534
+ if !launch.leader_receiver_attached {
1535
+ if let Some((stage, reason)) = take_fresh_bind_refusal() {
1536
+ launch.leader_bind_stage = Some(stage.to_string());
1537
+ launch.leader_bind_reason = Some(reason.to_string());
1538
+ }
1539
+ return Err(LifecycleError::RequirementUnmet(format!(
1540
+ "Leader binding failed after worker launch: {}",
1541
+ launch.leader_bind_reason.as_deref().unwrap_or("unknown")
1542
+ )));
1532
1543
  }
1533
- }
1534
- launch.session_capture_incomplete_agents =
1535
- quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
1536
- let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
1537
- let coordinator_report = crate::coordinator::start_coordinator(&coordinator_workspace)
1538
- .map_err(|error| {
1539
- LifecycleError::StatePersist(format!(
1544
+ launch.session_capture_incomplete_agents =
1545
+ quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
1546
+ let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
1547
+ coordinator_state = "attempted_unknown";
1548
+ let coordinator_report = crate::coordinator::start_coordinator(&coordinator_workspace)
1549
+ .map_err(|error| LifecycleError::StatePersist(format!(
1540
1550
  "coordinator start failed for {}: {error}; inspect {} before retrying",
1541
1551
  workspace.display(),
1542
1552
  crate::coordinator::coordinator_log_path(&coordinator_workspace).display()
1543
- ))
1544
- })?;
1545
- quick_start_phase_timer.emit(
1546
- &workspace,
1547
- "launch.phase",
1548
- crate::lifecycle::restart::LifecyclePhase::CoordinatorStart,
1549
- );
1550
- require_quick_start_coordinator_started(&coordinator_report, &workspace)?;
1553
+ )))?;
1554
+ quick_start_phase_timer.emit(
1555
+ &workspace, "launch.phase",
1556
+ crate::lifecycle::restart::LifecyclePhase::CoordinatorStart,
1557
+ );
1558
+ require_quick_start_coordinator_started(&coordinator_report, &workspace)?;
1559
+ coordinator_state = "running_confirmed";
1560
+ Ok(())
1561
+ })();
1562
+ if let Err(error) = completion {
1563
+ return Ok(QuickStartReport::Partial {
1564
+ workspace, session_name, launch: Box::new(launch), team: state_team_key,
1565
+ error: error.to_string(), coordinator_state: coordinator_state.to_string(),
1566
+ });
1567
+ }
1551
1568
  let coordinator_action = "coordinator started";
1552
1569
  // BUG-7: build an honest readiness verdict from the post-spawn runtime state.
1553
1570
  // - If persist_spawn_agent_state (BUG-2 fix) marked any agent non-running, the
@@ -1701,6 +1718,78 @@ mod fresh_quick_start_leader_binding_tests {
1701
1718
 
1702
1719
  static SEQUENCE: AtomicU64 = AtomicU64::new(0);
1703
1720
 
1721
+ #[test]
1722
+ #[serial_test::serial(env)]
1723
+ fn issue337_missing_caller_is_refused_before_any_launch_effect() {
1724
+ let env = HermeticTestEnv::enter("issue337-no-caller");
1725
+ env.scrub_tmux();
1726
+ env.assert_no_real_tmux();
1727
+ let transport = OfflineTransport::new();
1728
+ let refusal = preflight_fresh_leader_identity(&transport)
1729
+ .expect("a missing caller must not reach worker/coordinator spawn");
1730
+ assert!(refusal.1.iter().any(|reason| reason.contains("caller_pane_missing")));
1731
+ assert!(!env.home().join(".team").exists());
1732
+ }
1733
+
1734
+ #[test]
1735
+ #[serial_test::serial(env)]
1736
+ fn issue337_typed_caller_still_requires_a_current_live_pane_observation() {
1737
+ let env = HermeticTestEnv::enter("issue337-unobservable-caller");
1738
+ env.scrub_tmux();
1739
+ env.assert_no_real_tmux();
1740
+ std::env::set_var("TMUX", "/private/tmp/issue337/default,1,0");
1741
+ std::env::set_var("TMUX_PANE", "%42");
1742
+ std::env::set_var(CALLER_PANE_ENV, "%42");
1743
+ std::env::set_var(CALLER_PROVIDER_ENV, "pi");
1744
+ std::env::set_var(CALLER_ENDPOINT_ENV, "/private/tmp/issue337/default");
1745
+ let transport = OfflineTransport::new().with_tmux_endpoint("/private/tmp/issue337/default");
1746
+ let refusal = preflight_fresh_leader_identity(&transport)
1747
+ .expect("typed identity is not a live pane observation");
1748
+ assert!(refusal.1.iter().any(|reason| reason.contains("unobservable") || reason.contains("not_live")));
1749
+ assert!(!env.home().join(".team").exists());
1750
+ }
1751
+
1752
+ #[test]
1753
+ #[serial_test::serial(env)]
1754
+ fn issue337_fresh_start_without_caller_creates_no_runtime_or_worker() {
1755
+ let env = HermeticTestEnv::enter("issue337-zero-effects");
1756
+ env.scrub_tmux();
1757
+ env.assert_no_real_tmux();
1758
+ let workspace = env.workspace("ws");
1759
+ let team = workspace.join("roles");
1760
+ std::fs::create_dir_all(team.join("agents")).unwrap();
1761
+ std::fs::write(team.join("TEAM.md"), "---\nname: fresh\nobjective: test\nprovider: codex\n---\nTeam.\n").unwrap();
1762
+ std::fs::write(team.join("agents/worker.md"), "---\nname: worker\nrole: Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ndangerously_skip_permissions: false\n---\nTest.\n").unwrap();
1763
+ let transport = OfflineTransport::new();
1764
+ let report = quick_start_with_transport_in_workspace(
1765
+ &workspace, &team, None, true, Some("fresh"), &transport,
1766
+ ).unwrap();
1767
+ assert!(matches!(report, QuickStartReport::PreflightBlocked { .. }));
1768
+ assert!(transport.spawn_records().is_empty());
1769
+ assert!(!workspace.join(".team/team.db").exists());
1770
+ assert!(!crate::state::persist::runtime_state_path(&workspace).exists());
1771
+ assert!(!crate::model::paths::runtime_spec_path(&workspace, "fresh").exists());
1772
+ }
1773
+
1774
+ #[test]
1775
+ #[serial_test::serial(env)]
1776
+ fn issue337_exact_live_typed_caller_passes_readonly_preflight() {
1777
+ let env = HermeticTestEnv::enter("issue337-positive");
1778
+ env.scrub_tmux();
1779
+ env.assert_no_real_tmux();
1780
+ let _tmux = env.with_env("TMUX", "/private/tmp/issue337/default,1,0");
1781
+ let _pane = env.with_env("TMUX_PANE", "%1");
1782
+ let _provider = env.with_env(CALLER_PROVIDER_ENV, "pi");
1783
+ let _caller = env.with_env(CALLER_PANE_ENV, "%1");
1784
+ let _endpoint = env.with_env(CALLER_ENDPOINT_ENV, "/private/tmp/issue337/default");
1785
+ let transport = OfflineTransport::new()
1786
+ .with_tmux_endpoint("/private/tmp/issue337/default")
1787
+ .with_pane_current_command("%1", "bash")
1788
+ .with_targets(vec![caller_target(env.workspace("caller"), None)]);
1789
+ assert!(preflight_fresh_leader_identity(&transport).is_none());
1790
+ assert!(transport.spawn_records().is_empty());
1791
+ }
1792
+
1704
1793
  #[test]
1705
1794
  fn bootstrap_binding_is_preserved_without_epoch_reset_or_takeover() {
1706
1795
  let bootstrap = json!({
@@ -36,6 +36,26 @@ use super::*;
36
36
 
37
37
  // ── lifecycle::launch —— 冷启 / quick-start / 危险审批探测 ──────────────────
38
38
 
39
+ /// White-box producer/worker-engine fixture, not a public start admission path.
40
+ /// Keep unbound producer assertions separate from public caller admission.
41
+ #[cfg(test)]
42
+ pub(crate) fn materialize_unbound_state_for_test(
43
+ workspace: &Path, team_dir: &Path, team_key: &str,
44
+ transport: &crate::transport::test_support::OfflineTransport,
45
+ ) -> Result<LaunchReport, LifecycleError> {
46
+ let spec = crate::compiler::compile_team(team_dir)
47
+ .map_err(|error| LifecycleError::Compile(error.to_string()))?;
48
+ let path = crate::model::paths::runtime_spec_path(workspace, team_key);
49
+ spec_state::write_spec_atomic(&path, &spec)?;
50
+ let path = path.canonicalize().map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
51
+ let mut state = spec_state::initial_runtime_state(
52
+ &spec, &path, workspace, team_dir, team_key, transport.tmux_endpoint().as_deref(),
53
+ );
54
+ quick_start_transport::annotate_runtime_transport(&mut state, transport, workspace, None);
55
+ state_projection::save_launched_team_state_for_key(workspace, &state, Some(team_key), None)?;
56
+ launch_with_transport_in_workspace(workspace, &path, false, true, true, transport)
57
+ }
58
+
39
59
  /// ---
40
60
  /// purpose: 冷启全队的默认入口,按 spec 所在目录绑定 per-team tmux socket 后转 launch_with_transport
41
61
  /// params:
@@ -384,6 +404,14 @@ pub use worker_env::{apply_cursor_subscription_proxy_env, CursorProxyPresence};
384
404
  mod identity;
385
405
  pub(super) use identity::*;
386
406
 
407
+ // Read-only CLI surface facades; predicate bodies and launch behavior stay unchanged.
408
+ pub(crate) fn surface_has_runtime_team(state: &serde_json::Value, team: &str) -> bool {
409
+ identity::runtime_state_has_quick_start_team(state, team)
410
+ }
411
+ pub(crate) fn surface_is_leader_bootstrap(state: &serde_json::Value) -> bool {
412
+ identity::is_quick_start_leader_bootstrap(state)
413
+ }
414
+
387
415
  // Test-only seam for the real leader producer -> unchanged bootstrap classifier.
388
416
  #[cfg(test)]
389
417
  pub(crate) fn quick_start_has_runtime_for_test(state: &serde_json::Value, team: &str) -> bool {
@@ -110,6 +110,23 @@ pub(crate) use rebuild::{
110
110
  pub(crate) use remove::remove_agent_with_transport;
111
111
  pub use remove::{remove_agent, remove_agent_flag_requirements};
112
112
  pub use selection::decide_start_mode;
113
+ pub(crate) use selection::restart_agent_never_captured;
114
+
115
+ /// Read-only CLI preflight facade. Reuses the original provider-specific probe;
116
+ /// does not capture a session, write state, or authorize a future spawn.
117
+ pub(crate) fn surface_resume_backing_probe(
118
+ workspace: &Path,
119
+ agent_id: &AgentId,
120
+ agent: &serde_json::Value,
121
+ provider: Provider,
122
+ session: &SessionId,
123
+ rollout: Option<&RolloutPath>,
124
+ ) -> (bool, Vec<std::path::PathBuf>) {
125
+ let probe = common::resume_backing_probe_for_agent(
126
+ workspace, agent_id, agent, provider, session, rollout,
127
+ );
128
+ (probe.exists, probe.checked_paths)
129
+ }
113
130
  pub(crate) use selection::{classify_first_send_at, classify_restart_plan, python_type_name};
114
131
  // Layer 2 (leader follow-up 2026-06-22): test-visible workspace-aware
115
132
  // classification so lifecycle/tests/restart.rs can exercise the
@@ -147,9 +147,7 @@ gate — N35/MUST-17); found {hits} occurrence(s)"
147
147
  #[test]
148
148
  #[serial(env)]
149
149
  fn b2_quick_start_ready_has_attach_commands_and_socket_miss_is_observable() {
150
- let hermetic = hermetic_guard::HermeticTestEnv::enter("acceptance-b-b2-quick-start");
151
- hermetic.scrub_tmux();
152
- hermetic.assert_no_real_tmux();
150
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("acceptance-b-b2-quick-start");
153
151
  let _g = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
154
152
  let ws = tmp_ws("b2-attach");
155
153
  let team_dir = write_min_team(&ws, "b2team");
@@ -54,9 +54,7 @@ const NEUTRAL_ANCESTRY: &str = "[\"/bin/zsh\"]";
54
54
  #[test]
55
55
  #[serial(env)]
56
56
  fn d1_d2_d3_codex_argv_matches_python_golden_escape_timeout_profile() {
57
- let hermetic = hermetic_guard::HermeticTestEnv::enter("behavioral-d123-codex");
58
- hermetic.scrub_tmux();
59
- hermetic.assert_no_real_tmux();
57
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("behavioral-d123-codex");
60
58
  let _ancestry = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
61
59
  let ws = tmp_dir("d123-codex-argv");
62
60
  let team_dir = write_team(
@@ -181,9 +179,7 @@ fn d1_d2_d3_codex_argv_matches_python_golden_escape_timeout_profile() {
181
179
  #[test]
182
180
  #[serial(env)]
183
181
  fn d4_worker_shell_env_sets_team_agent_id_to_worker_self() {
184
- let hermetic = hermetic_guard::HermeticTestEnv::enter("behavioral-d4-shell-env");
185
- hermetic.scrub_tmux();
186
- hermetic.assert_no_real_tmux();
182
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("behavioral-d4-shell-env");
187
183
  let _guard = EnvGuard::set(&[
188
184
  (ANCESTRY_KEY, NEUTRAL_ANCESTRY),
189
185
  ("TEAM_AGENT_ID", "stale-mcp-caller"),
@@ -224,9 +220,7 @@ fn d4_worker_shell_env_sets_team_agent_id_to_worker_self() {
224
220
  #[test]
225
221
  #[serial(env)]
226
222
  fn d5_fresh_launch_spawn_cwd_and_state_use_workspace() {
227
- let hermetic = hermetic_guard::HermeticTestEnv::enter("behavioral-d5-spawn-cwd");
228
- hermetic.scrub_tmux();
229
- hermetic.assert_no_real_tmux();
223
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("behavioral-d5-spawn-cwd");
230
224
  let _ancestry = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
231
225
  let ws = tmp_dir("d5-spawn-cwd");
232
226
  let team_dir = write_team(&ws, &simple_codex_team("d5team", "worker_a", "Cwd Worker"));
@@ -268,9 +262,7 @@ fn d5_fresh_launch_spawn_cwd_and_state_use_workspace() {
268
262
  #[test]
269
263
  #[serial(env)]
270
264
  fn d6_compiled_system_prompt_first_section_is_identity() {
271
- let hermetic = hermetic_guard::HermeticTestEnv::enter("behavioral-d6-prompt");
272
- hermetic.scrub_tmux();
273
- hermetic.assert_no_real_tmux();
265
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("behavioral-d6-prompt");
274
266
  let _ancestry = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
275
267
  let ws = tmp_dir("d6-prompt-order");
276
268
  let team_dir = write_team(
@@ -323,9 +315,7 @@ chunks=[identity, TEAMMATE_SYSTEM_PROMPT, ...]; live ps confirms identity-first)
323
315
  #[test]
324
316
  #[serial(env)]
325
317
  fn d7_runtime_fast_sends_codex_fast_mode_toggle() {
326
- let hermetic = hermetic_guard::HermeticTestEnv::enter("behavioral-d7-fast-mode");
327
- hermetic.scrub_tmux();
328
- hermetic.assert_no_real_tmux();
318
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("behavioral-d7-fast-mode");
329
319
  let _ancestry = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
330
320
  let ws = tmp_dir("d7-fast-mode");
331
321
  let mut fixture = simple_codex_team("d7team", "worker_a", "Fast Worker");
@@ -32,10 +32,9 @@ use team_agent::transport::{
32
32
  #[test]
33
33
  #[serial(env)]
34
34
  fn compatible_api_profile_writes_claude_settings_and_project_state_with_mcp_servers() {
35
- let hermetic = hermetic_guard::HermeticTestEnv::enter("claude-compatible-config");
36
- hermetic.scrub_tmux();
37
- hermetic.assert_no_real_tmux();
35
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("claude-compatible-config");
38
36
  let ws = tmp_dir("compatible-config-files");
37
+ super::launch_spawn::seed_healthy_coordinator(&ws);
39
38
  let team = write_claude_profile_team(&ws, "cfgteam", "clauder");
40
39
  let transport = RecordingTransport::default();
41
40
 
@@ -34,12 +34,11 @@ use team_agent::transport::{
34
34
  #[test]
35
35
  #[serial(env)]
36
36
  fn compatible_api_profile_quick_start_spawns_claude_with_profile_env_and_state_metadata() {
37
- let hermetic = hermetic_guard::HermeticTestEnv::enter("claude-profile-quick-start");
38
- hermetic.scrub_tmux();
39
- hermetic.assert_no_real_tmux();
37
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("claude-profile-quick-start");
40
38
  assert_s0_profile_launch_shape();
41
39
 
42
40
  let ws = tmp_dir("profile-launch");
41
+ super::launch_spawn::seed_healthy_coordinator(&ws);
43
42
  let team = write_claude_profile_team(&ws, "claudeprof", "clauder");
44
43
  let transport = RecordingTransport::default();
45
44
 
@@ -46,7 +46,7 @@ struct Fixture {
46
46
  }
47
47
  impl Fixture {
48
48
  fn new(tag: &str) -> Self {
49
- let env = hermetic::HermeticTestEnv::enter(tag);
49
+ let env = hermetic::HermeticTestEnv::enter_with_leader(tag);
50
50
  let ws = env.workspace("workspace");
51
51
  let team = ws.join("roles");
52
52
  fs::create_dir_all(team.join("agents")).unwrap();
@@ -68,7 +68,12 @@ impl Fixture {
68
68
  let real_pi = env.root().join("pi-real");
69
69
  executable(&real_pi, &script);
70
70
  std::os::unix::fs::symlink(&real_pi, bin.join("pi")).unwrap();
71
- executable(&bin.join("tmux"), "#!/bin/sh\ncase \"$*\" in\n*-V*) echo 'tmux 3.4';;\n*has-session*) exit 1;;\n*list-sessions*|*list-windows*|*list-panes*) exit 0;;\n*) exit 0;;\nesac\n");
71
+ let caller = std::env::var("TMUX").expect("owned fixture caller").split(',').next().unwrap().to_string();
72
+ let native_tmux = hermetic::fixture_tmux_binary();
73
+ executable(&bin.join("tmux"), &format!(
74
+ "#!/bin/sh\nwant_socket=0\nfor argument in \"$@\"; do\n if [ \"$want_socket\" = 1 ]; then\n if [ \"$argument\" = {} ]; then exec {} \"$@\"; fi\n break\n fi\n if [ \"$argument\" = -S ]; then want_socket=1; fi\ndone\ncase \"$*\" in\n*-V*) echo 'tmux 3.4';;\n*has-session*) exit 1;;\n*list-sessions*|*list-windows*|*list-panes*) exit 0;;\n*) exit 0;;\nesac\n",
75
+ crate::cli::shell_quote(&caller), crate::cli::shell_quote(&native_tmux.to_string_lossy()),
76
+ ));
72
77
  let guards = vec![
73
78
  env.with_env("PATH", &format!("{}:/usr/bin:/bin", bin.display())),
74
79
  env.with_env(SWITCH, "off"),
@@ -1219,6 +1224,9 @@ fn a13_materializer_version_catalog_and_nonagent_command_builders_are_not_routed
1219
1224
  #[serial(env)]
1220
1225
  fn a07_attach_existing_never_opens_enabled_invalid_route_config() {
1221
1226
  let f = Fixture::new("argv-attach");
1227
+ // This planner-only case explicitly models attach from outside tmux,
1228
+ // not the owned caller used by positive worker-start fixtures.
1229
+ f.env.scrub_tmux();
1222
1230
  f.raw("invalid JSON");
1223
1231
  f.switch(Some("on"));
1224
1232
  let plan = f.leader(Provider::Pi, true, true).unwrap();
@@ -54,7 +54,7 @@ struct Case {
54
54
  impl Case {
55
55
  fn start() -> Self {
56
56
  ensure_team_agent_cli();
57
- let env = HermeticTestEnv::enter("g1ct");
57
+ let env = HermeticTestEnv::enter_with_leader("g1ct");
58
58
  let workspace = env.workspace("ws");
59
59
  write_team_docs(&workspace);
60
60
  Self { env, workspace }
@@ -134,6 +134,12 @@ impl Case {
134
134
  }
135
135
  }
136
136
 
137
+ impl Drop for Case {
138
+ fn drop(&mut self) {
139
+ self.shutdown();
140
+ }
141
+ }
142
+
137
143
  /// `cargo test --lib` does not build `bin/team-agent`. Hermetic CLI
138
144
  /// lookup then panics and impl_check.sh sees 1 FAILED. Build the bin
139
145
  /// into the same CARGO_TARGET_DIR the lib test already uses.
@@ -338,6 +344,7 @@ fn clone_ok(case: &Case, source: &str, dest: &str, phase: &str) {
338
344
 
339
345
  /// clone 后分身 role 保留源席 tools;编译 spec 不投影 tools。
340
346
  #[test]
347
+ #[serial_test::serial(env)]
341
348
  fn clone_agent_preserves_source_tools() {
342
349
  let case = Case::start();
343
350
  let qs = case.run(&[
@@ -168,7 +168,7 @@ impl Case {
168
168
  /// product's existing test seam so the derived approval policy is a
169
169
  /// function of the FIXTURE, never of the host process tree.
170
170
  fn start_with_ancestry(tag: &str, ancestry_argv: &[&str]) -> Self {
171
- let env = HermeticTestEnv::enter(tag);
171
+ let env = HermeticTestEnv::enter_with_leader(tag);
172
172
  let workspace = env.workspace("ws");
173
173
  write_team_docs(&workspace, "claude");
174
174
  let shim_dir = write_claude_backing_shim(&workspace);
@@ -500,8 +500,9 @@ fn r8_copilot_fork_is_no_longer_capability_refused() {
500
500
  // the capability gate — a CLI-shaped R8 could never be RED. An in-process
501
501
  // RecordingTransport stands the team up without a live copilot TUI (no
502
502
  // ready-screen hang).
503
- let env = HermeticTestEnv::enter("cf-r8");
503
+ let env = HermeticTestEnv::enter_with_leader("cf-r8");
504
504
  let workspace = env.workspace("ws");
505
+ super::launch_spawn::seed_healthy_coordinator(&workspace);
505
506
  let team_dir = write_copilot_team_docs(&workspace);
506
507
  // Session ABSENT at quick-start so it can create the team session; spawn_first
507
508
  // flips session_present true, so the later fork sees a live session. (Starting
@@ -31,10 +31,9 @@ const CONTRACT_HEADING_PREFIX: &str = "# Team Agent communication contract: ";
31
31
  #[test]
32
32
  #[serial_test::serial(env)]
33
33
  fn t06_each_official_mode_selects_one_runtime_contract_in_real_spawn_prompt() {
34
- let hermetic = hermetic_guard::HermeticTestEnv::enter("communication-mode-official");
35
- hermetic.scrub_tmux();
36
- hermetic.assert_no_real_tmux();
34
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("communication-mode-official");
37
35
  let root = fixture("official", None);
36
+ super::launch_spawn::seed_healthy_coordinator(&root);
38
37
  let transport = RecordingTransport::default();
39
38
  quick_start_with_transport_in_workspace(
40
39
  &root,
@@ -87,10 +86,9 @@ fn t06_each_official_mode_selects_one_runtime_contract_in_real_spawn_prompt() {
87
86
  #[test]
88
87
  #[serial_test::serial(env)]
89
88
  fn t06_selected_templates_project_the_two_signed_communication_boundaries() {
90
- let hermetic = hermetic_guard::HermeticTestEnv::enter("communication-mode-semantics");
91
- hermetic.scrub_tmux();
92
- hermetic.assert_no_real_tmux();
89
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("communication-mode-semantics");
93
90
  let root = fixture("semantics", None);
91
+ super::launch_spawn::seed_healthy_coordinator(&root);
94
92
  let transport = RecordingTransport::default();
95
93
  quick_start_with_transport_in_workspace(
96
94
  &root,
@@ -141,10 +139,9 @@ fn t06_selected_templates_project_the_two_signed_communication_boundaries() {
141
139
  #[test]
142
140
  #[serial_test::serial(env)]
143
141
  fn t07_final_spawn_prompt_does_not_leak_leader_centric_obligations_into_orchestrated() {
144
- let hermetic = hermetic_guard::HermeticTestEnv::enter("communication-mode-negative-boundary");
145
- hermetic.scrub_tmux();
146
- hermetic.assert_no_real_tmux();
142
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("communication-mode-negative-boundary");
147
143
  let root = fixture("negative-boundary", None);
144
+ super::launch_spawn::seed_healthy_coordinator(&root);
148
145
  let transport = RecordingTransport::default();
149
146
  quick_start_with_transport_in_workspace(
150
147
  &root,
@@ -1,4 +1,4 @@
1
- //! Copilot provider contracts (command-substitution tier, zero real sessions).
1
+ //! Copilot provider contracts (zero model sessions; owned physical caller fixture).
2
2
  //!
3
3
  //! Basis docs (sole truth sources — where the in-tree implementation deviates, these
4
4
  //! contracts stay red):
@@ -32,10 +32,7 @@ mod hermetic_guard;
32
32
  fn _hermetic_boundary_marker(_: &hermetic_guard::HermeticTestEnv) {}
33
33
 
34
34
  fn enter_hermetic(tag: &str) -> hermetic_guard::HermeticTestEnv {
35
- let env = hermetic_guard::HermeticTestEnv::enter(tag);
36
- env.scrub_tmux();
37
- env.assert_no_real_tmux();
38
- env
35
+ hermetic_guard::HermeticTestEnv::enter_with_leader(tag)
39
36
  }
40
37
 
41
38
  use std::collections::{BTreeMap, BTreeSet};
@@ -875,6 +872,8 @@ fn copilot_mcp_residual_scan_unavailable_degrades_honestly_no_false_positive() {
875
872
  let home = tmp_ws("residual-unavail-home");
876
873
  std::fs::create_dir_all(home.join(".copilot")).unwrap();
877
874
  let empty_bin = tmp_ws("no-copilot-bin");
875
+ #[cfg(unix)]
876
+ std::os::unix::fs::symlink(hermetic_guard::fixture_tmux_binary(), empty_bin.join("tmux")).unwrap();
878
877
  let _guard = EnvGuard::set(&[
879
878
  (ANCESTRY_KEY, NEUTRAL_ANCESTRY),
880
879
  (
@@ -1942,6 +1941,9 @@ fn copilot_empty_mcp_list_shim(tag: &str) -> TempWorkspace {
1942
1941
  #[cfg(unix)]
1943
1942
  {
1944
1943
  use std::os::unix::fs::PermissionsExt;
1944
+ // Only the explicit owned caller needs tmux; no provider is exposed by
1945
+ // this symlink and the worker transport remains the recording fixture.
1946
+ std::os::unix::fs::symlink(hermetic_guard::fixture_tmux_binary(), bin.join("tmux")).unwrap();
1945
1947
  // coordinator health needs the seeded test PID's real non-zombie status.
1946
1948
  // Keep PATH isolated and refuse any broader process inspection.
1947
1949
  std::fs::write(
@@ -291,8 +291,9 @@ fn cursor_mcp_enable_child_env_applies_direct_profile_unsets() {
291
291
  #[test]
292
292
  #[serial(env)]
293
293
  fn cursor_spawn_writes_identity_into_project_mcp_json() {
294
- let _hermetic = hermetic_guard::HermeticTestEnv::enter("cursor-mcp-spawn");
294
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("cursor-mcp-spawn");
295
295
  let ws = tmp_dir("cursor-mcp-spawn");
296
+ super::launch_spawn::seed_healthy_coordinator(&ws);
296
297
  let team = write_cursor_team(&ws, "cursortm", "cursor_writer");
297
298
  let selected = cursor_mcp_json_path(&ws, "cursor_writer").unwrap();
298
299
  assert!(
@@ -331,11 +332,12 @@ fn cursor_spawn_writes_identity_into_project_mcp_json() {
331
332
  #[test]
332
333
  #[serial(env)]
333
334
  fn cursor_spawn_isolation_off_writes_workspace_mcp_json() {
334
- let _hermetic = hermetic_guard::HermeticTestEnv::enter("cursor-mcp-spawn-legacy");
335
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("cursor-mcp-spawn-legacy");
335
336
  let key = "TEAM_AGENT_CURSOR_MCP_ISOLATION";
336
337
  let prev = std::env::var(key).ok();
337
338
  std::env::set_var(key, "0");
338
339
  let ws = tmp_dir("cursor-mcp-spawn-legacy");
340
+ super::launch_spawn::seed_healthy_coordinator(&ws);
339
341
  let team = write_cursor_team(&ws, "cursortm", "cursor_writer");
340
342
  let result = quick_start_with_transport_in_workspace(
341
343
  &ws,