@team-agent/installer 0.3.1 → 0.3.3

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 (79) hide show
  1. package/Cargo.lock +34 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -1
  4. package/crates/team-agent/src/cli/adapters.rs +234 -26
  5. package/crates/team-agent/src/cli/diagnose.rs +144 -10
  6. package/crates/team-agent/src/cli/emit.rs +289 -54
  7. package/crates/team-agent/src/cli/leader.rs +37 -8
  8. package/crates/team-agent/src/cli/mod.rs +1281 -196
  9. package/crates/team-agent/src/cli/status_port.rs +195 -46
  10. package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
  11. package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
  12. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
  13. package/crates/team-agent/src/cli/tests/run_delegation.rs +59 -3
  14. package/crates/team-agent/src/cli/types.rs +18 -0
  15. package/crates/team-agent/src/compiler.rs +15 -5
  16. package/crates/team-agent/src/coordinator/health.rs +95 -17
  17. package/crates/team-agent/src/coordinator/mod.rs +4 -0
  18. package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
  19. package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
  20. package/crates/team-agent/src/coordinator/tick.rs +222 -69
  21. package/crates/team-agent/src/coordinator/types.rs +15 -3
  22. package/crates/team-agent/src/db/schema.rs +37 -2
  23. package/crates/team-agent/src/diagnose/comms.rs +226 -0
  24. package/crates/team-agent/src/diagnose/mod.rs +45 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +658 -0
  26. package/crates/team-agent/src/fake_worker.rs +146 -3
  27. package/crates/team-agent/src/leader/start.rs +121 -23
  28. package/crates/team-agent/src/leader/types.rs +44 -1
  29. package/crates/team-agent/src/lib.rs +3 -0
  30. package/crates/team-agent/src/lifecycle/display.rs +645 -47
  31. package/crates/team-agent/src/lifecycle/launch.rs +1061 -146
  32. package/crates/team-agent/src/lifecycle/mod.rs +2 -0
  33. package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
  34. package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
  35. package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
  36. package/crates/team-agent/src/lifecycle/restart/common.rs +183 -24
  37. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +498 -22
  38. package/crates/team-agent/src/lifecycle/restart/remove.rs +27 -7
  39. package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
  40. package/crates/team-agent/src/lifecycle/restart.rs +24 -1
  41. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
  43. package/crates/team-agent/src/lifecycle/types.rs +19 -0
  44. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  45. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
  46. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
  47. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
  48. package/crates/team-agent/src/mcp_server/mod.rs +3 -74
  49. package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
  51. package/crates/team-agent/src/mcp_server/tools.rs +312 -111
  52. package/crates/team-agent/src/mcp_server/types.rs +6 -4
  53. package/crates/team-agent/src/mcp_server/wire.rs +19 -7
  54. package/crates/team-agent/src/message_store.rs +21 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +470 -59
  56. package/crates/team-agent/src/messaging/mod.rs +9 -6
  57. package/crates/team-agent/src/messaging/results.rs +353 -63
  58. package/crates/team-agent/src/messaging/selftest.rs +199 -12
  59. package/crates/team-agent/src/messaging/send.rs +35 -3
  60. package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
  61. package/crates/team-agent/src/messaging/types.rs +11 -3
  62. package/crates/team-agent/src/os_probe.rs +119 -0
  63. package/crates/team-agent/src/packaging/migrate.rs +10 -2
  64. package/crates/team-agent/src/packaging/tests.rs +23 -0
  65. package/crates/team-agent/src/provider/adapter.rs +564 -63
  66. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
  67. package/crates/team-agent/src/provider/classify.rs +51 -4
  68. package/crates/team-agent/src/provider/helpers.rs +10 -1
  69. package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
  70. package/crates/team-agent/src/provider/types.rs +47 -0
  71. package/crates/team-agent/src/session_capture.rs +616 -0
  72. package/crates/team-agent/src/state/persist.rs +170 -1
  73. package/crates/team-agent/src/state/projection.rs +141 -8
  74. package/crates/team-agent/src/state/selector.rs +5 -2
  75. package/crates/team-agent/src/tmux_backend.rs +161 -64
  76. package/crates/team-agent/src/transport/test_support.rs +9 -0
  77. package/crates/team-agent/src/transport/tests/wire.rs +4 -0
  78. package/crates/team-agent/src/transport.rs +13 -2
  79. package/package.json +4 -4
@@ -3,11 +3,12 @@
3
3
  use std::path::Path;
4
4
 
5
5
  use crate::model::ids::TeamKey;
6
+ use crate::message_store::MessageStore;
6
7
 
7
8
  use super::helpers::next_run_id;
8
9
  use super::{
9
- CheckEvidence, CheckKind, CheckStatus, IdleEvaluation, MessagingError, ProviderSdkCalls,
10
- SelftestCheck, SelftestReport,
10
+ CheckEvidence, CheckKind, CheckStatus, ContractSuiteCheck, IdleEvaluation, MessagingError,
11
+ ProviderSdkCalls, SelftestCheck, SelftestReport,
11
12
  };
12
13
 
13
14
  /// selftest driver (`diagnose/comms.py` `CommsSelftestDriver`):**零 token / 零 provider SDK**
@@ -29,6 +30,7 @@ pub fn run_comms_selftest(
29
30
  team: Option<&TeamKey>,
30
31
  driver: &dyn CommsSelftestDriver,
31
32
  ) -> Result<SelftestReport, MessagingError> {
33
+ let run_id = driver.run_id().unwrap_or_else(next_run_id);
32
34
  let binding = driver.receiver_binding(workspace, team);
33
35
  let mismatches = binding
34
36
  .get("mismatches")
@@ -43,29 +45,33 @@ pub fn run_comms_selftest(
43
45
  let receiver_status = if mismatches.is_empty() { CheckStatus::Pass } else { CheckStatus::Fail };
44
46
  let calls = driver.provider_sdk_calls();
45
47
  let provider_status = if calls.is_zero() { CheckStatus::Pass } else { CheckStatus::Fail };
46
- let run_id = driver.run_id().unwrap_or_else(next_run_id);
48
+ let contract_checks = run_contract_suite(workspace, team, &run_id);
49
+ let contract_status = if contract_checks.iter().all(|check| check.status == CheckStatus::Pass) {
50
+ CheckStatus::Pass
51
+ } else {
52
+ CheckStatus::Fail
53
+ };
47
54
  let receiver_binding = SelftestCheck {
48
55
  status: receiver_status,
49
56
  verifies: CheckKind::ReceiverBinding,
50
- evidence: CheckEvidence::Binding { mismatches },
57
+ evidence: CheckEvidence::Binding { mismatches, details: binding },
51
58
  };
52
59
  let contract_suite = SelftestCheck {
53
- status: CheckStatus::Deferred,
60
+ status: contract_status,
54
61
  verifies: CheckKind::ContractSuite,
55
- evidence: CheckEvidence::Deferred { reason: "contract_suite_not_shipped".to_string() },
62
+ evidence: CheckEvidence::ContractSuite { checks: contract_checks },
56
63
  };
57
64
  let provider_sdk_calls = SelftestCheck {
58
65
  status: provider_status,
59
66
  verifies: CheckKind::NoProviderSdkCalls,
60
67
  evidence: CheckEvidence::ProviderSdkCalls(calls),
61
68
  };
69
+ let ok = receiver_binding.status == CheckStatus::Pass
70
+ && contract_suite.status == CheckStatus::Pass
71
+ && provider_sdk_calls.status == CheckStatus::Pass;
62
72
  Ok(SelftestReport {
63
- ok: receiver_binding.status == CheckStatus::Pass && provider_sdk_calls.status == CheckStatus::Pass,
64
- status: if receiver_binding.status == CheckStatus::Pass && provider_sdk_calls.status == CheckStatus::Pass {
65
- CheckStatus::Pass
66
- } else {
67
- CheckStatus::Fail
68
- },
73
+ ok,
74
+ status: if ok { CheckStatus::Pass } else { CheckStatus::Fail },
69
75
  run_id,
70
76
  scope: "binding_consistency".to_string(),
71
77
  boundary: "messaging".to_string(),
@@ -75,6 +81,187 @@ pub fn run_comms_selftest(
75
81
  })
76
82
  }
77
83
 
84
+ fn run_contract_suite(
85
+ workspace: &Path,
86
+ team: Option<&TeamKey>,
87
+ run_id: &str,
88
+ ) -> Vec<ContractSuiteCheck> {
89
+ let scratch = std::env::temp_dir().join(format!(
90
+ "ta-comms-contract-{run_id}-{}",
91
+ std::process::id()
92
+ ));
93
+ let _ = std::fs::remove_dir_all(&scratch);
94
+ let mut checks = Vec::new();
95
+ let mut scratch_store = match MessageStore::open(&scratch) {
96
+ Ok(store) => {
97
+ checks.push(contract_check("message_store_schema", CheckStatus::Pass, None));
98
+ Some(store)
99
+ }
100
+ Err(error) => {
101
+ checks.push(contract_check(
102
+ "message_store_schema",
103
+ CheckStatus::Fail,
104
+ Some(error.to_string()),
105
+ ));
106
+ None
107
+ }
108
+ };
109
+
110
+ match scratch_store.as_ref().and_then(|store| {
111
+ store
112
+ .create_message(
113
+ None,
114
+ "doctor",
115
+ "worker",
116
+ "comms contract probe",
117
+ None,
118
+ false,
119
+ Some("contract-team"),
120
+ )
121
+ .ok()
122
+ }) {
123
+ Some(message_id) if message_id.starts_with("msg_") && message_id.len() > 4 => {
124
+ let rendered = format!(
125
+ "Team Agent message from doctor:\n\ncomms contract probe\n\n[team-agent-token:{message_id}]"
126
+ );
127
+ let token = format!("[team-agent-token:{message_id}]");
128
+ checks.push(contract_check(
129
+ "message_token_shape",
130
+ if rendered.ends_with(&token) { CheckStatus::Pass } else { CheckStatus::Fail },
131
+ (!rendered.ends_with(&token)).then(|| "rendered token suffix missing".to_string()),
132
+ ));
133
+ }
134
+ Some(_) => checks.push(contract_check(
135
+ "message_token_shape",
136
+ CheckStatus::Fail,
137
+ Some("message id did not use msg_ prefix".to_string()),
138
+ )),
139
+ None => checks.push(contract_check(
140
+ "message_token_shape",
141
+ CheckStatus::Fail,
142
+ Some("could not create scratch message".to_string()),
143
+ )),
144
+ }
145
+
146
+ let result_id = format!("res-comms-{run_id}");
147
+ let result_content = super::watchers::format_result_watcher_notification(&serde_json::json!({
148
+ "result_id": result_id,
149
+ "task_id": "comms-contract",
150
+ "agent_id": "doctor",
151
+ "status": "success",
152
+ "summary": "contract suite probe",
153
+ }));
154
+ let parsed_result_id = super::watchers::result_id_from_text(&result_content);
155
+ checks.push(contract_check(
156
+ "result_notification_render",
157
+ if parsed_result_id.as_deref() == Some(result_id.as_str()) {
158
+ CheckStatus::Pass
159
+ } else {
160
+ CheckStatus::Fail
161
+ },
162
+ (parsed_result_id.as_deref() != Some(result_id.as_str()))
163
+ .then(|| "result notification did not round-trip result_id".to_string()),
164
+ ));
165
+
166
+ let selected = crate::state::selector::resolve_active_team(
167
+ workspace,
168
+ team.map(TeamKey::as_str),
169
+ crate::state::selector::SelectorMode::RuntimeOnly,
170
+ );
171
+ match (scratch_store.take(), selected) {
172
+ (Some(store), Ok(selected)) => {
173
+ let owner_team = selected.team_key;
174
+ let mut state = selected.state;
175
+ if !state.is_object() {
176
+ state = serde_json::json!({});
177
+ }
178
+ if let Some(obj) = state.as_object_mut() {
179
+ obj.insert(
180
+ "active_team_key".to_string(),
181
+ serde_json::json!(owner_team.clone()),
182
+ );
183
+ }
184
+ let event_log = crate::event_log::EventLog::new(&scratch);
185
+ let outcome = super::leader_receiver::send_to_leader_receiver(
186
+ &scratch,
187
+ &state,
188
+ "leader",
189
+ "comms contract leader projection",
190
+ None,
191
+ "doctor",
192
+ false,
193
+ Some("comms-contract-result"),
194
+ &event_log,
195
+ );
196
+ match outcome {
197
+ Ok(outcome) => {
198
+ let actual_owner = outcome
199
+ .message_id
200
+ .as_deref()
201
+ .and_then(|message_id| message_owner_team(store.db_path(), message_id).ok().flatten());
202
+ checks.push(contract_check(
203
+ "leader_projection_owner_team",
204
+ if actual_owner.as_deref() == Some(owner_team.as_str()) {
205
+ CheckStatus::Pass
206
+ } else {
207
+ CheckStatus::Fail
208
+ },
209
+ (actual_owner.as_deref() != Some(owner_team.as_str())).then(|| {
210
+ format!(
211
+ "leader-bound message owner_team_id={:?}, expected={owner_team}",
212
+ actual_owner
213
+ )
214
+ }),
215
+ ));
216
+ }
217
+ Err(error) => checks.push(contract_check(
218
+ "leader_projection_owner_team",
219
+ CheckStatus::Fail,
220
+ Some(error.to_string()),
221
+ )),
222
+ }
223
+ }
224
+ (_, Err(error)) => checks.push(contract_check(
225
+ "leader_projection_owner_team",
226
+ CheckStatus::Fail,
227
+ Some(error.to_string()),
228
+ )),
229
+ (None, _) => checks.push(contract_check(
230
+ "leader_projection_owner_team",
231
+ CheckStatus::Fail,
232
+ Some("message store schema unavailable".to_string()),
233
+ )),
234
+ }
235
+
236
+ let _ = std::fs::remove_dir_all(&scratch);
237
+ checks
238
+ }
239
+
240
+ fn contract_check(
241
+ name: &str,
242
+ status: CheckStatus,
243
+ reason: Option<String>,
244
+ ) -> ContractSuiteCheck {
245
+ ContractSuiteCheck {
246
+ name: name.to_string(),
247
+ status,
248
+ reason,
249
+ }
250
+ }
251
+
252
+ fn message_owner_team(
253
+ db_path: &Path,
254
+ message_id: &str,
255
+ ) -> Result<Option<String>, MessagingError> {
256
+ let conn = crate::db::schema::open_db(db_path)?;
257
+ let owner = conn.query_row(
258
+ "select owner_team_id from messages where message_id = ?1",
259
+ rusqlite::params![message_id],
260
+ |row| row.get::<_, Option<String>>(0),
261
+ )?;
262
+ Ok(owner)
263
+ }
264
+
78
265
  /// `evaluate_idle_behavior` (`diagnose/comms.py:50`):idle 分类准确性评估。零 token,走 driver。
79
266
  pub fn evaluate_idle_behavior(
80
267
  workspace: &Path,
@@ -138,9 +138,6 @@ pub fn send_message(
138
138
  };
139
139
  // send.py:259-261 — a non-leader target that is NOT a known team agent is refused
140
140
  // (target_not_in_team), NOT persisted. Membership = the runtime state's `agents` map.
141
- if let Some(outcome) = send_owner_gate_refusal(workspace, &state, &opts.sender)? {
142
- return Ok(outcome);
143
- }
144
141
  let in_team = state
145
142
  .get("agents")
146
143
  .and_then(|a| a.as_object())
@@ -148,6 +145,19 @@ pub fn send_message(
148
145
  if !in_team {
149
146
  return Ok(refused_outcome(DeliveryRefusal::TargetNotInTeam));
150
147
  }
148
+ if let Some(outcome) = session_drift_refusal(
149
+ &state,
150
+ recipient,
151
+ "leader",
152
+ &opts.sender,
153
+ opts.task_id.as_ref(),
154
+ &event_log,
155
+ )? {
156
+ return Ok(outcome);
157
+ }
158
+ if let Some(outcome) = send_owner_gate_refusal(workspace, &state, &opts.sender)? {
159
+ return Ok(outcome);
160
+ }
151
161
  if opts.route_task_id {
152
162
  if let Some(task_id) = opts.task_id.as_ref() {
153
163
  if !task_exists(&state, task_id) {
@@ -367,6 +377,14 @@ fn owner_gate_hint_team_key(state: &serde_json::Value) -> String {
367
377
  }
368
378
 
369
379
  fn owner_pane_is_dead(state: &serde_json::Value) -> bool {
380
+ if state
381
+ .get("leader_receiver")
382
+ .and_then(|receiver| receiver.get("status"))
383
+ .and_then(serde_json::Value::as_str)
384
+ .is_some_and(|status| status == "unbound")
385
+ {
386
+ return true;
387
+ }
370
388
  let Some(pane_id) = state
371
389
  .get("team_owner")
372
390
  .and_then(|owner| owner.get("pane_id"))
@@ -375,6 +393,9 @@ fn owner_pane_is_dead(state: &serde_json::Value) -> bool {
375
393
  else {
376
394
  return false;
377
395
  };
396
+ if pane_id == "__team_agent_unbound__" {
397
+ return true;
398
+ }
378
399
  if pane_id.contains("dead") {
379
400
  return true;
380
401
  }
@@ -519,12 +540,15 @@ fn fanout_send(
519
540
  channel_label: &str,
520
541
  ) -> Result<DeliveryOutcome, MessagingError> {
521
542
  let mut last_message_id: Option<String> = None;
543
+ let mut first_failure: Option<DeliveryOutcome> = None;
522
544
  let mut any_failure = false;
523
545
  let mut delivered_count = 0usize;
546
+ let mut attempted_count = 0usize;
524
547
  for recipient in recipients {
525
548
  if recipient.is_empty() || recipient == &opts.sender {
526
549
  continue;
527
550
  }
551
+ attempted_count = attempted_count.saturating_add(1);
528
552
  let outcome = if recipient == "leader" {
529
553
  send_to_leader_receiver(
530
554
  workspace,
@@ -556,6 +580,14 @@ fn fanout_send(
556
580
  }
557
581
  } else {
558
582
  any_failure = true;
583
+ if first_failure.is_none() {
584
+ first_failure = Some(outcome);
585
+ }
586
+ }
587
+ }
588
+ if delivered_count == 0 && attempted_count == 1 {
589
+ if let Some(outcome) = first_failure {
590
+ return Ok(outcome);
559
591
  }
560
592
  }
561
593
  let status = if any_failure {
@@ -957,16 +957,31 @@ fn run_comms_selftest_nonzero_provider_sdk_fails_gate() {
957
957
  }
958
958
 
959
959
  #[test]
960
- fn run_comms_selftest_contract_suite_is_deferred() {
961
- // diagnose/comms.py:132-139 — contract_suite is always deferred (test files
962
- // not shipped) and counts as a pass for the overall gate.
960
+ fn run_comms_selftest_contract_suite_is_executable_zero_token() {
963
961
  let ws = tmp_ws("selftestdefer");
964
962
  let driver = ZeroSdkDriver {
965
963
  run_id: Some("r3".to_string()),
966
964
  calls: ProviderSdkCalls::default(),
967
965
  };
968
966
  let report = run_comms_selftest(&ws, None, &driver).unwrap();
969
- assert_eq!(report.contract_suite.status, CheckStatus::Deferred);
967
+ assert_eq!(report.contract_suite.status, CheckStatus::Pass);
968
+ match &report.contract_suite.evidence {
969
+ CheckEvidence::ContractSuite { checks } => {
970
+ assert!(
971
+ checks.iter().all(|check| check.status == CheckStatus::Pass),
972
+ "contract suite subchecks must all pass: {checks:?}"
973
+ );
974
+ let names: Vec<&str> = checks.iter().map(|check| check.name.as_str()).collect();
975
+ assert!(
976
+ names.contains(&"message_store_schema")
977
+ && names.contains(&"message_token_shape")
978
+ && names.contains(&"result_notification_render")
979
+ && names.contains(&"leader_projection_owner_team"),
980
+ "contract suite must cover schema, token, result notification, and leader projection: {names:?}"
981
+ );
982
+ }
983
+ other => panic!("expected ContractSuite evidence, got {other:?}"),
984
+ }
970
985
  }
971
986
 
972
987
  // ════════════════════════════════════════════════════════════════════════
@@ -229,9 +229,17 @@ pub enum CheckEvidence {
229
229
  /// `no_provider_sdk_calls` 的机械证据 (§84):三 SDK 调用计数。
230
230
  ProviderSdkCalls(ProviderSdkCalls),
231
231
  /// binding 一致性比对结果 (mismatch 列表)。
232
- Binding { mismatches: Vec<String> },
233
- /// contract_suite deferred 占位。
234
- Deferred { reason: String },
232
+ Binding { mismatches: Vec<String>, details: serde_json::Value },
233
+ /// executable zero-token comms contract suite evidence.
234
+ ContractSuite { checks: Vec<ContractSuiteCheck> },
235
+ }
236
+
237
+ /// One executable zero-token comms contract-suite subcheck.
238
+ #[derive(Debug, Clone, PartialEq, Eq)]
239
+ pub struct ContractSuiteCheck {
240
+ pub name: String,
241
+ pub status: CheckStatus,
242
+ pub reason: Option<String>,
235
243
  }
236
244
 
237
245
  /// **机械门** (§84/MUST-NOT-13;`diagnose/comms.py:142`):selftest 路径 provider SDK 调用
@@ -0,0 +1,119 @@
1
+ use std::cell::RefCell;
2
+ use std::fs::OpenOptions;
3
+ use std::io::{self, Read};
4
+ use std::process::{Command, ExitStatus, Stdio};
5
+ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
+
7
+ const DEFAULT_TIMEOUT: Duration = Duration::from_millis(900);
8
+
9
+ thread_local! {
10
+ static PROBE_TIMEOUT: RefCell<Option<ProbeTimeout>> = const { RefCell::new(None) };
11
+ }
12
+
13
+ #[derive(Clone, Debug)]
14
+ pub(crate) struct ProbeTimeout {
15
+ pub(crate) probe: &'static str,
16
+ pub(crate) pid: Option<u32>,
17
+ pub(crate) timeout_ms: u64,
18
+ }
19
+
20
+ #[derive(Debug)]
21
+ pub(crate) struct BoundedCommandOutput {
22
+ pub(crate) status: ExitStatus,
23
+ pub(crate) stdout: Vec<u8>,
24
+ }
25
+
26
+ pub(crate) fn clear_probe_timeout() {
27
+ PROBE_TIMEOUT.with(|timeout| *timeout.borrow_mut() = None);
28
+ }
29
+
30
+ pub(crate) fn probe_timed_out() -> bool {
31
+ PROBE_TIMEOUT.with(|timeout| timeout.borrow().is_some())
32
+ }
33
+
34
+ pub(crate) fn probe_timeout() -> Option<ProbeTimeout> {
35
+ PROBE_TIMEOUT.with(|timeout| timeout.borrow().clone())
36
+ }
37
+
38
+ pub(crate) fn bounded_command_output_with_probe(
39
+ command: &mut Command,
40
+ probe: &'static str,
41
+ pid: Option<u32>,
42
+ ) -> io::Result<BoundedCommandOutput> {
43
+ bounded_command_output_with_timeout(command, DEFAULT_TIMEOUT, probe, pid)
44
+ }
45
+
46
+ fn bounded_command_output_with_timeout(
47
+ command: &mut Command,
48
+ timeout: Duration,
49
+ probe: &'static str,
50
+ pid: Option<u32>,
51
+ ) -> io::Result<BoundedCommandOutput> {
52
+ let stdout_path = temp_output_path("stdout");
53
+ let stdout_file = OpenOptions::new()
54
+ .create_new(true)
55
+ .read(true)
56
+ .write(true)
57
+ .open(&stdout_path)?;
58
+ let child = command
59
+ .stdout(Stdio::from(stdout_file.try_clone()?))
60
+ .stderr(Stdio::null())
61
+ .spawn()?;
62
+ wait_for_bounded_child(child, stdout_file, stdout_path, timeout, probe, pid)
63
+ }
64
+
65
+ fn wait_for_bounded_child(
66
+ mut child: std::process::Child,
67
+ stdout_file: std::fs::File,
68
+ stdout_path: std::path::PathBuf,
69
+ timeout: Duration,
70
+ probe: &'static str,
71
+ pid: Option<u32>,
72
+ ) -> io::Result<BoundedCommandOutput> {
73
+ let start = Instant::now();
74
+ loop {
75
+ if let Some(status) = child.try_wait()? {
76
+ drop(stdout_file);
77
+ let stdout = read_and_remove(&stdout_path);
78
+ return Ok(BoundedCommandOutput { status, stdout });
79
+ }
80
+ if start.elapsed() >= timeout {
81
+ PROBE_TIMEOUT.with(|current| {
82
+ let mut current = current.borrow_mut();
83
+ if current.is_none() {
84
+ *current = Some(ProbeTimeout {
85
+ probe,
86
+ pid,
87
+ timeout_ms: timeout.as_millis() as u64,
88
+ });
89
+ }
90
+ });
91
+ let _ = child.kill();
92
+ let status = child.wait()?;
93
+ drop(stdout_file);
94
+ let stdout = read_and_remove(&stdout_path);
95
+ return Ok(BoundedCommandOutput { status, stdout });
96
+ }
97
+ std::thread::sleep(Duration::from_millis(10));
98
+ }
99
+ }
100
+
101
+ fn temp_output_path(kind: &str) -> std::path::PathBuf {
102
+ let nanos = SystemTime::now()
103
+ .duration_since(UNIX_EPOCH)
104
+ .map(|duration| duration.as_nanos())
105
+ .unwrap_or(0);
106
+ std::env::temp_dir().join(format!(
107
+ "team-agent-os-probe-{}-{nanos}.{kind}",
108
+ std::process::id()
109
+ ))
110
+ }
111
+
112
+ fn read_and_remove(path: &std::path::Path) -> Vec<u8> {
113
+ let mut stdout = Vec::new();
114
+ if let Ok(mut file) = std::fs::File::open(path) {
115
+ let _ = file.read_to_end(&mut stdout);
116
+ }
117
+ let _ = std::fs::remove_file(path);
118
+ stdout
119
+ }
@@ -13,8 +13,16 @@ pub fn doctor(opts: &DoctorOptions) -> Result<DoctorStatus, PackagingError> {
13
13
  if opts.fix && opts.gate.is_none() {
14
14
  return Err(PackagingError::InvalidOptions("--fix requires --gate".to_string()));
15
15
  }
16
- if opts.fix && matches!(opts.gate, Some(DoctorGate::Orphans | DoctorGate::Comms)) {
17
- return Ok(DoctorStatus::Ok);
16
+ let gate_blockers = crate::diagnose::doctor_gate_blockers(
17
+ &opts.workspace,
18
+ opts.gate,
19
+ opts.fix,
20
+ opts.confirm,
21
+ )?;
22
+ if !gate_blockers.is_empty() {
23
+ return Ok(DoctorStatus::HasBlockers {
24
+ blockers: gate_blockers,
25
+ });
18
26
  }
19
27
  let diagnosis = schema_diagnosis_workspace(&opts.workspace)?;
20
28
  if diagnosis.layout_diffs.is_empty() {
@@ -269,6 +269,29 @@ fn doctor_on_clean_workspace_no_drift_is_ok() {
269
269
  assert_eq!(status, DoctorStatus::Ok);
270
270
  }
271
271
 
272
+ #[test]
273
+ fn doctor_comms_gate_failure_maps_to_typed_blocker() {
274
+ let dir = std::env::temp_dir().join(format!("ta-doctor-comms-{}", std::process::id()));
275
+ let _ = std::fs::create_dir_all(&dir);
276
+ let mut opts = doctor_opts(&dir);
277
+ opts.gate = Some(DoctorGate::Comms);
278
+ let status = doctor(&opts).expect("comms gate should return typed blockers");
279
+ match status {
280
+ DoctorStatus::HasBlockers { blockers } => {
281
+ let blocker = blockers
282
+ .iter()
283
+ .find(|blocker| blocker.source == BlockerSource::CommsGate)
284
+ .expect("must surface CommsGate blocker");
285
+ assert!(
286
+ blocker.detail.contains("receiver_binding"),
287
+ "blocker detail must name failing check: {}",
288
+ blocker.detail
289
+ );
290
+ }
291
+ DoctorStatus::Ok => panic!("missing receiver binding must not report Ok"),
292
+ }
293
+ }
294
+
272
295
  #[test]
273
296
  fn doctor_drifted_db_emits_schema_layout_drift_blocker() {
274
297
  // STRENGTHENED (gate w59ds828k): the ONLY drift assertion previously