@team-agent/installer 0.5.94 → 0.5.97
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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +381 -157
- package/crates/team-agent/src/cli/diagnose.rs +40 -10
- package/crates/team-agent/src/cli/emit.rs +6 -4
- package/crates/team-agent/src/cli/mod.rs +114 -30
- package/crates/team-agent/src/cli/spec.rs +2 -2
- package/crates/team-agent/src/cli/tests/base.rs +20 -10
- package/crates/team-agent/src/cli/tests/divergence.rs +12 -21
- package/crates/team-agent/src/cli/tests/main_preserved.rs +6 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +20 -11
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +43 -0
- package/crates/team-agent/src/cli/triage.rs +31 -31
- package/crates/team-agent/src/cli/types.rs +3 -1
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +10 -4
- package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +128 -11
- package/package.json +4 -4
|
@@ -161,6 +161,19 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
|
|
|
161
161
|
}
|
|
162
162
|
issues.push(issue);
|
|
163
163
|
}
|
|
164
|
+
// Preserve distinct worker failures even when the shared session probe
|
|
165
|
+
// short-circuits pane enumeration. This keeps scope evidence without
|
|
166
|
+
// issuing another transport probe per worker.
|
|
167
|
+
if let Some(agents) = state.get("agents").and_then(Value::as_object) {
|
|
168
|
+
for agent_id in agents.keys() {
|
|
169
|
+
let issue = format!("worker_window_missing:{agent_id}");
|
|
170
|
+
issues.push(json!(issue.clone()));
|
|
171
|
+
repairs.push(json!({
|
|
172
|
+
"issue": issue,
|
|
173
|
+
"action": "team-agent restart",
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
164
177
|
return (Value::Array(issues), Value::Array(repairs));
|
|
165
178
|
}
|
|
166
179
|
|
|
@@ -289,6 +302,7 @@ pub(crate) fn diagnose_runtime_for_workspace(
|
|
|
289
302
|
state: &Value,
|
|
290
303
|
backend: &dyn Transport,
|
|
291
304
|
selected_team_key: Option<&str>,
|
|
305
|
+
coordinator_health: &crate::coordinator::HealthReport,
|
|
292
306
|
) -> (Value, Value) {
|
|
293
307
|
let (mut issues, mut repairs) = diagnose_runtime(state, backend);
|
|
294
308
|
append_live_leader_workspace_mismatch_issue(
|
|
@@ -306,7 +320,7 @@ pub(crate) fn diagnose_runtime_for_workspace(
|
|
|
306
320
|
&mut repairs,
|
|
307
321
|
);
|
|
308
322
|
append_legacy_snapshot_issue(workspace, state, &mut issues);
|
|
309
|
-
append_coordinator_health_issue(workspace, state, &mut issues, &mut repairs);
|
|
323
|
+
append_coordinator_health_issue(workspace, state, coordinator_health, &mut issues, &mut repairs);
|
|
310
324
|
append_runtime_bindings_stale_after_boot_issue(workspace, state, &mut issues, &mut repairs);
|
|
311
325
|
(issues, repairs)
|
|
312
326
|
}
|
|
@@ -322,8 +336,27 @@ pub(crate) fn workspace_has_existing_team_runtime(
|
|
|
322
336
|
workspace: &std::path::Path,
|
|
323
337
|
team_key: &str,
|
|
324
338
|
) -> bool {
|
|
325
|
-
|
|
326
|
-
|
|
339
|
+
// A source TEAM.md/team.spec.yaml is install/configuration context, not a
|
|
340
|
+
// live runtime. Only persisted runtime state/specs make topology checks
|
|
341
|
+
// applicable; an empty workspace therefore remains a healthy no-runtime
|
|
342
|
+
// report instead of a fabricated missing-session issue.
|
|
343
|
+
if let Ok(state) = crate::state::persist::load_runtime_state_without_migrations(workspace) {
|
|
344
|
+
// The workspace's top-level view may describe a stopped sibling.
|
|
345
|
+
// Preserve the top-level fallback for legacy single-team state only
|
|
346
|
+
// when there is no selected team entry.
|
|
347
|
+
let team_state = state
|
|
348
|
+
.get("teams")
|
|
349
|
+
.and_then(|teams| teams.get(team_key))
|
|
350
|
+
.unwrap_or(&state);
|
|
351
|
+
let terminal = team_state
|
|
352
|
+
.get("status")
|
|
353
|
+
.and_then(Value::as_str)
|
|
354
|
+
.is_some_and(|status| matches!(status, "stopped" | "shutdown" | "archived" | "terminal"));
|
|
355
|
+
if terminal {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
crate::model::paths::runtime_spec_path(workspace, team_key).is_file()
|
|
327
360
|
|| crate::state::persist::runtime_state_path(workspace).is_file()
|
|
328
361
|
}
|
|
329
362
|
|
|
@@ -790,21 +823,18 @@ fn append_legacy_snapshot_issue(workspace: &std::path::Path, state: &Value, issu
|
|
|
790
823
|
fn append_coordinator_health_issue(
|
|
791
824
|
workspace: &std::path::Path,
|
|
792
825
|
state: &Value,
|
|
826
|
+
health: &crate::coordinator::HealthReport,
|
|
793
827
|
issues: &mut Value,
|
|
794
828
|
repairs: &mut Value,
|
|
795
829
|
) {
|
|
796
|
-
|
|
797
|
-
// `.team/runtime/team.db`, including when the file is missing or empty.
|
|
798
|
-
let workspace = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
|
|
799
|
-
let health = crate::coordinator::coordinator_health_read_only(&workspace);
|
|
800
|
-
let Some(id) = coordinator_issue_id(state, &health) else {
|
|
830
|
+
let Some(id) = coordinator_issue_id(state, health) else {
|
|
801
831
|
return;
|
|
802
832
|
};
|
|
803
833
|
if let Some(items) = issues.as_array_mut() {
|
|
804
|
-
items.push(coordinator_issue_value(id,
|
|
834
|
+
items.push(coordinator_issue_value(id, health, workspace));
|
|
805
835
|
}
|
|
806
836
|
if let Some(items) = repairs.as_array_mut() {
|
|
807
|
-
items.push(coordinator_repair_hint(id,
|
|
837
|
+
items.push(coordinator_repair_hint(id, health));
|
|
808
838
|
}
|
|
809
839
|
}
|
|
810
840
|
|
|
@@ -225,7 +225,8 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
|
|
|
225
225
|
"profile" => cmd_profile(&profile_args(args, cwd)?).map(emit_result),
|
|
226
226
|
"results" => cmd_results(&results_args(args, cwd)?).map(emit_result),
|
|
227
227
|
"wait" => cmd_wait(&wait_args(args, cwd)?).map(emit_result),
|
|
228
|
-
|
|
228
|
+
// Compatibility spelling: parse and execute the exact doctor contract.
|
|
229
|
+
"diagnose" => cmd_doctor(&doctor_args(args, cwd)).map(emit_result),
|
|
229
230
|
"preflight" => cmd_preflight(&preflight_args(args, cwd)).map(emit_result),
|
|
230
231
|
"wait-ready" => cmd_wait_ready(&wait_ready_args(args, cwd)).map(emit_result),
|
|
231
232
|
"e2e" => cmd_e2e(&e2e_args(args, cwd)).map(emit_result),
|
|
@@ -340,7 +341,7 @@ pub(crate) fn default_help() -> String {
|
|
|
340
341
|
"reset-agent",
|
|
341
342
|
],
|
|
342
343
|
);
|
|
343
|
-
append_help_section(&mut out, "Diagnose", &["
|
|
344
|
+
append_help_section(&mut out, "Diagnose", &["doctor"]);
|
|
344
345
|
append_help_section(
|
|
345
346
|
&mut out,
|
|
346
347
|
"Guided recovery",
|
|
@@ -444,7 +445,7 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
444
445
|
Some("profile") => "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--proxy-mode direct|inherit] [--json]".to_string(),
|
|
445
446
|
Some("results") => "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
446
447
|
Some("wait") => "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]".to_string(),
|
|
447
|
-
Some("diagnose") => "usage: team-agent
|
|
448
|
+
Some("diagnose") => "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]".to_string(),
|
|
448
449
|
Some("preflight") => "usage: team-agent preflight [TEAMDIR] [--json]".to_string(),
|
|
449
450
|
Some("wait-ready") => "usage: team-agent wait-ready [--workspace WORKSPACE] [--team TEAM] [--timeout SECONDS] [--json]".to_string(),
|
|
450
451
|
Some("e2e") => "usage: team-agent e2e [--workspace WORKSPACE] [--providers LIST] [--real] [--json]".to_string(),
|
|
@@ -2172,7 +2173,8 @@ mod tests {
|
|
|
2172
2173
|
fn hidden_commands_not_in_default_help() {
|
|
2173
2174
|
let top_help = command_help(None);
|
|
2174
2175
|
let visible = visible_help_commands(&top_help);
|
|
2175
|
-
|
|
2176
|
+
assert!(visible.iter().any(|command| command == "doctor"));
|
|
2177
|
+
for command in ["leaders", "diagnose", "e2e", "peek", "coordinator"] {
|
|
2176
2178
|
assert!(
|
|
2177
2179
|
!visible.iter().any(|visible| visible == command),
|
|
2178
2180
|
"`{command}` must stay hidden from default help"
|
|
@@ -516,10 +516,11 @@ pub mod lifecycle_port {
|
|
|
516
516
|
/// (经同一帧 list_targets pane→session)。state 无任何锚 → 退命名判据 + spare_fallback event。
|
|
517
517
|
fn anchor_sessions_from_state(
|
|
518
518
|
state: &Value,
|
|
519
|
+
endpoint: Option<&str>,
|
|
519
520
|
pane_targets: &[crate::transport::PaneInfo],
|
|
520
521
|
event_log: &crate::event_log::EventLog,
|
|
521
522
|
) -> std::collections::BTreeSet<String> {
|
|
522
|
-
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state);
|
|
523
|
+
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state, endpoint);
|
|
523
524
|
if anchor_pane_ids.is_empty() {
|
|
524
525
|
// 无锚(state 损坏/未记)→ 退纯命名前缀判据(下游 sessions_to_kill 仍 spare 前缀)。
|
|
525
526
|
let _ = event_log.write(
|
|
@@ -649,7 +650,12 @@ pub mod lifecycle_port {
|
|
|
649
650
|
}
|
|
650
651
|
}
|
|
651
652
|
};
|
|
652
|
-
let protected_sessions = anchor_sessions_from_state(
|
|
653
|
+
let protected_sessions = anchor_sessions_from_state(
|
|
654
|
+
state,
|
|
655
|
+
transport.tmux_endpoint().as_deref(),
|
|
656
|
+
&targets,
|
|
657
|
+
event_log,
|
|
658
|
+
);
|
|
653
659
|
let mut result = ShutdownSocketCleanup::default();
|
|
654
660
|
for session in candidates {
|
|
655
661
|
if protected_sessions.contains(session.as_str()) {
|
|
@@ -791,6 +797,43 @@ pub mod lifecycle_port {
|
|
|
791
797
|
use crate::transport::test_support::OfflineTransport;
|
|
792
798
|
use crate::transport::{PaneId, PaneInfo, SessionName};
|
|
793
799
|
|
|
800
|
+
#[test]
|
|
801
|
+
fn leader_process_protection_does_not_alias_panes_across_endpoints() {
|
|
802
|
+
let state = json!({
|
|
803
|
+
"team_owner": {"pane_id": "%2"},
|
|
804
|
+
"leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host-only.sock"}
|
|
805
|
+
});
|
|
806
|
+
let table = [(10, 1), (20, 10), (30, 10), (31, 30)]
|
|
807
|
+
.into_iter()
|
|
808
|
+
.map(|(pid, ppid)| ProcessInfo { pid, ppid, pgid: None, session: None, command: String::new() })
|
|
809
|
+
.collect::<Vec<_>>();
|
|
810
|
+
for (endpoint, pane_pid, should_protect) in [
|
|
811
|
+
("/tmp/s4-worker-only.sock", 20, false),
|
|
812
|
+
("/tmp/s4-host-only.sock", 30, true),
|
|
813
|
+
] {
|
|
814
|
+
let transport = OfflineTransport::new()
|
|
815
|
+
.with_tmux_endpoint(endpoint)
|
|
816
|
+
.with_targets(vec![PaneInfo {
|
|
817
|
+
pane_id: PaneId::new("%2"),
|
|
818
|
+
session: SessionName::new("ordinary-session"),
|
|
819
|
+
window_index: None,
|
|
820
|
+
window_name: None,
|
|
821
|
+
pane_index: None,
|
|
822
|
+
tty: None,
|
|
823
|
+
current_command: None,
|
|
824
|
+
current_path: None,
|
|
825
|
+
active: true,
|
|
826
|
+
pane_pid: Some(pane_pid),
|
|
827
|
+
leader_env: BTreeMap::new(),
|
|
828
|
+
}]);
|
|
829
|
+
let mut protected = ShutdownProtection::default();
|
|
830
|
+
extend_protection_with_leader_panes(&mut protected, &transport, &state, &table);
|
|
831
|
+
assert_eq!(protected.pids.contains(&pane_pid), should_protect);
|
|
832
|
+
assert_eq!(protected.pids.contains(&31), should_protect, "leader descendants");
|
|
833
|
+
assert_eq!(protected.pids.contains(&10), should_protect, "leader's server");
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
794
837
|
#[test]
|
|
795
838
|
fn audit_write_failure_skips_destructive_kill() {
|
|
796
839
|
let root =
|
|
@@ -1042,7 +1085,10 @@ pub mod lifecycle_port {
|
|
|
1042
1085
|
// External and managed leaders both pass the same positive ownership
|
|
1043
1086
|
// gate; topology only decides whether a verified session is reduced
|
|
1044
1087
|
// to worker panes or removed as a whole.
|
|
1045
|
-
let leader_anchor_ids = collect_state_leader_anchor_pane_ids(
|
|
1088
|
+
let leader_anchor_ids = collect_state_leader_anchor_pane_ids(
|
|
1089
|
+
&state,
|
|
1090
|
+
transport.tmux_endpoint().as_deref(),
|
|
1091
|
+
);
|
|
1046
1092
|
let (live_targets_now, live_targets_known) = match transport.list_targets() {
|
|
1047
1093
|
Ok(targets) => (targets, true),
|
|
1048
1094
|
Err(error) => {
|
|
@@ -2059,12 +2105,13 @@ pub mod lifecycle_port {
|
|
|
2059
2105
|
/// → 任一 team 的 shutdown 都不杀任何 team 的 leader 锚 pane
|
|
2060
2106
|
pub fn collect_state_leader_anchor_pane_ids(
|
|
2061
2107
|
state: &Value,
|
|
2108
|
+
endpoint: Option<&str>,
|
|
2062
2109
|
) -> std::collections::BTreeSet<String> {
|
|
2063
2110
|
let mut out = std::collections::BTreeSet::new();
|
|
2064
|
-
push_anchor_pane_id(state, &mut out);
|
|
2111
|
+
push_anchor_pane_id(state, endpoint, &mut out);
|
|
2065
2112
|
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
2066
2113
|
for (_, team_state) in teams {
|
|
2067
|
-
push_anchor_pane_id(team_state, &mut out);
|
|
2114
|
+
push_anchor_pane_id(team_state, endpoint, &mut out);
|
|
2068
2115
|
}
|
|
2069
2116
|
}
|
|
2070
2117
|
out
|
|
@@ -2184,17 +2231,40 @@ pub mod lifecycle_port {
|
|
|
2184
2231
|
Ok(())
|
|
2185
2232
|
}
|
|
2186
2233
|
|
|
2187
|
-
///
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2234
|
+
/// Pane IDs are server-local. Exclude proven foreign-endpoint anchors, but
|
|
2235
|
+
/// retain legacy anchors when their endpoint (or the transport's) is unknown.
|
|
2236
|
+
fn push_anchor_pane_id(
|
|
2237
|
+
state: &Value,
|
|
2238
|
+
endpoint: Option<&str>,
|
|
2239
|
+
out: &mut std::collections::BTreeSet<String>,
|
|
2240
|
+
) {
|
|
2241
|
+
for (key, peer_key) in [("team_owner", "leader_receiver"), ("leader_receiver", "team_owner")] {
|
|
2242
|
+
let Some(anchor) = state.get(key) else { continue };
|
|
2243
|
+
let Some(pane_id) = anchor.get("pane_id").and_then(Value::as_str).filter(|s| !s.is_empty()) else {
|
|
2244
|
+
continue;
|
|
2245
|
+
};
|
|
2246
|
+
// team_owner often records only a pane ID; its receiver supplies
|
|
2247
|
+
// the endpoint only when it describes that exact same anchor.
|
|
2248
|
+
let anchor_endpoint = anchor.get("tmux_socket").and_then(Value::as_str)
|
|
2194
2249
|
.filter(|s| !s.is_empty())
|
|
2195
|
-
|
|
2196
|
-
|
|
2250
|
+
.or_else(|| {
|
|
2251
|
+
state.get(peer_key)
|
|
2252
|
+
.filter(|peer| peer.get("pane_id").and_then(Value::as_str) == Some(pane_id))
|
|
2253
|
+
.and_then(|peer| peer.get("tmux_socket"))
|
|
2254
|
+
.and_then(Value::as_str)
|
|
2255
|
+
.filter(|s| !s.is_empty())
|
|
2256
|
+
});
|
|
2257
|
+
if let (Some(anchor_path), Some(transport_path)) = (
|
|
2258
|
+
anchor_endpoint.and_then(socket_file_for_endpoint),
|
|
2259
|
+
endpoint.filter(|s| !s.is_empty()).and_then(socket_file_for_endpoint),
|
|
2260
|
+
) {
|
|
2261
|
+
let anchor_path = anchor_path.canonicalize().unwrap_or(anchor_path);
|
|
2262
|
+
let transport_path = transport_path.canonicalize().unwrap_or(transport_path);
|
|
2263
|
+
if anchor_path != transport_path {
|
|
2264
|
+
continue;
|
|
2265
|
+
}
|
|
2197
2266
|
}
|
|
2267
|
+
out.insert(pane_id.to_string());
|
|
2198
2268
|
}
|
|
2199
2269
|
}
|
|
2200
2270
|
|
|
@@ -2305,8 +2375,10 @@ pub mod lifecycle_port {
|
|
|
2305
2375
|
);
|
|
2306
2376
|
// Source 2: state.json team_owner / leader_receiver 真锚 pane_id(top-level +
|
|
2307
2377
|
// teams[*]),per-workspace socket 命中。
|
|
2308
|
-
let anchor_pane_ids
|
|
2309
|
-
|
|
2378
|
+
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(
|
|
2379
|
+
state,
|
|
2380
|
+
transport.tmux_endpoint().as_deref(),
|
|
2381
|
+
);
|
|
2310
2382
|
leader_pane_pids.extend(
|
|
2311
2383
|
pane_targets
|
|
2312
2384
|
.iter()
|
|
@@ -2322,6 +2394,7 @@ pub mod lifecycle_port {
|
|
|
2322
2394
|
// 的 list_targets 找 anchor pane_id → pane_pid → 进入 process_tree 保护。
|
|
2323
2395
|
// 不在 state 中的 socket 不查(MUST-17 不撒宽 / 不主动枚举全机器 sockets)。
|
|
2324
2396
|
for socket_endpoint in collect_state_recorded_tmux_sockets(state) {
|
|
2397
|
+
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state, Some(&socket_endpoint));
|
|
2325
2398
|
let cross_backend =
|
|
2326
2399
|
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket_endpoint);
|
|
2327
2400
|
let cross_panes =
|
|
@@ -4752,17 +4825,15 @@ pub mod diagnose_port {
|
|
|
4752
4825
|
&& profile_smoke_value.get("status").and_then(Value::as_str)
|
|
4753
4826
|
== Some("legacy_team_invalid");
|
|
4754
4827
|
let effective_smoke_ok = profile_smoke_ok || legacy_only_failure;
|
|
4828
|
+
// Coordinator and runtime topology are facts of the unified pipeline,
|
|
4829
|
+
// not of the static environment probe. The caller supplies the
|
|
4830
|
+
// selected runtime workspace and performs one read-only health read.
|
|
4755
4831
|
let ok = workspace_valid && (team_context || workspace_has_entries) && effective_smoke_ok;
|
|
4756
|
-
let health = crate::coordinator::coordinator_health(
|
|
4757
|
-
&crate::coordinator::WorkspacePath::new(workspace.to_path_buf()),
|
|
4758
|
-
);
|
|
4759
|
-
let coordinator_ok = health.ok;
|
|
4760
|
-
let coordinator_error = health.metadata_mismatch_reason.clone();
|
|
4761
4832
|
let state = crate::state::persist::load_runtime_state(workspace).unwrap_or(Value::Null);
|
|
4762
4833
|
let grok_slot = crate::cli::grok_slot_report(workspace, &state).to_json();
|
|
4763
4834
|
let grok_slot_ok = grok_slot.get("consistent").and_then(Value::as_bool) == Some(true)
|
|
4764
4835
|
&& grok_slot.get("readable").and_then(Value::as_bool) == Some(true);
|
|
4765
|
-
let ok = ok && grok_slot_ok
|
|
4836
|
+
let ok = ok && grok_slot_ok;
|
|
4766
4837
|
Ok(json!({
|
|
4767
4838
|
"tmux": {
|
|
4768
4839
|
"installed": tmux_installed,
|
|
@@ -4770,14 +4841,14 @@ pub mod diagnose_port {
|
|
|
4770
4841
|
},
|
|
4771
4842
|
"workspace": workspace.to_string_lossy().to_string(),
|
|
4772
4843
|
"workspace_is_git_repo": workspace.join(".git").exists(),
|
|
4773
|
-
"providers":
|
|
4844
|
+
"providers": crate::cli::diagnose::provider_doctor_checks(),
|
|
4774
4845
|
"mcp": {
|
|
4775
4846
|
"server_command": which_path("team_orchestrator"),
|
|
4776
4847
|
"local_module": true,
|
|
4777
4848
|
},
|
|
4778
4849
|
"secret_scan": secret_scan(workspace),
|
|
4779
4850
|
"profile_smoke": profile_smoke_value,
|
|
4780
|
-
"coordinator":
|
|
4851
|
+
"coordinator": coordinator_not_required_value(),
|
|
4781
4852
|
"grok_slot": grok_slot,
|
|
4782
4853
|
"ok": ok,
|
|
4783
4854
|
"error": if ok {
|
|
@@ -4789,10 +4860,6 @@ pub mod diagnose_port {
|
|
|
4789
4860
|
.unwrap_or_else(|| json!("grok_slot_mismatch"))
|
|
4790
4861
|
} else if !profile_smoke_ok && !legacy_only_failure {
|
|
4791
4862
|
json!("profile_smoke_failed")
|
|
4792
|
-
} else if !coordinator_ok {
|
|
4793
|
-
coordinator_error
|
|
4794
|
-
.map(Value::String)
|
|
4795
|
-
.unwrap_or_else(|| json!("coordinator_unavailable"))
|
|
4796
4863
|
} else if workspace_valid {
|
|
4797
4864
|
json!("workspace has no Team Agent spec or runtime context")
|
|
4798
4865
|
} else {
|
|
@@ -4984,11 +5051,12 @@ pub mod diagnose_port {
|
|
|
4984
5051
|
continue;
|
|
4985
5052
|
};
|
|
4986
5053
|
let rel = path.strip_prefix(root).unwrap_or(path);
|
|
5054
|
+
// Keep only a locating rule/path/line tuple. Never include even
|
|
5055
|
+
// redacted assignment text in the structured report.
|
|
4987
5056
|
findings.push(json!({
|
|
4988
5057
|
"path": rel.to_string_lossy().to_string(),
|
|
4989
5058
|
"line": idx.saturating_add(1),
|
|
4990
5059
|
"rule": "api_key_assignment",
|
|
4991
|
-
"match_excerpt": format!("{key}=<redacted>"),
|
|
4992
5060
|
}));
|
|
4993
5061
|
}
|
|
4994
5062
|
}
|
|
@@ -5153,7 +5221,23 @@ pub mod diagnose_port {
|
|
|
5153
5221
|
})
|
|
5154
5222
|
}
|
|
5155
5223
|
|
|
5156
|
-
fn
|
|
5224
|
+
pub(crate) fn coordinator_not_required_value() -> Value {
|
|
5225
|
+
json!({
|
|
5226
|
+
"ok": true,
|
|
5227
|
+
"status": "not_required",
|
|
5228
|
+
"pid": Value::Null,
|
|
5229
|
+
"metadata": Value::Null,
|
|
5230
|
+
"metadata_ok": true,
|
|
5231
|
+
"metadata_mismatch_reason": Value::Null,
|
|
5232
|
+
"binary_path": Value::Null,
|
|
5233
|
+
"binary_version": Value::Null,
|
|
5234
|
+
"schema_ok": true,
|
|
5235
|
+
"schema_error": Value::Null,
|
|
5236
|
+
"schema": {"message_store_schema_version": Value::Null},
|
|
5237
|
+
})
|
|
5238
|
+
}
|
|
5239
|
+
|
|
5240
|
+
pub(crate) fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
|
|
5157
5241
|
let expose_binary_drift = health.service_available && !health.metadata_ok;
|
|
5158
5242
|
let binary_identity_relation = health.binary_identity_relation.as_str();
|
|
5159
5243
|
let mut value = json!({
|
|
@@ -176,7 +176,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
|
176
176
|
CommandSpec { name: "start-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StartAgent), summary: "start an existing worker", usage: "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
177
177
|
CommandSpec { name: "stop-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StopAgent), summary: "stop a worker", usage: "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
178
178
|
CommandSpec { name: "reset-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::ResetAgent), summary: "reset a worker session", usage: "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
179
|
-
CommandSpec { name: "diagnose", tier: CommandTier::
|
|
179
|
+
CommandSpec { name: "diagnose", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::Diagnose), summary: "compatibility alias for doctor", usage: "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: Some("doctor"), sunset: None, action: None, governance: None },
|
|
180
180
|
CommandSpec { name: "claim-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::ClaimLeader), summary: "bind this pane when diagnose reports rebind_required", usage: "usage: team-agent claim-leader [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
181
181
|
CommandSpec { name: "takeover", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::Takeover), summary: "replace a live leader binding with explicit consent", usage: "usage: team-agent takeover [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
182
182
|
CommandSpec { name: "attach-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::AttachLeader), summary: "bind an external leader pane", usage: "usage: team-agent attach-leader [--workspace WORKSPACE] [--team TEAM] [--pane PANE] [--provider PROVIDER] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
@@ -196,7 +196,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
|
196
196
|
CommandSpec { name: "stuck-cancel", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::StuckCancel), summary: "legacy stuck alert suppression", usage: "usage: team-agent stuck-cancel AGENT [--workspace WORKSPACE] [--alert-type stuck|idle_fallback|cross_worker_deadlock|all] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: Some("C2"), action: Some("use the concrete notification action or `team-agent diagnose`"), governance: None },
|
|
197
197
|
CommandSpec { name: "acknowledge-idle", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::AcknowledgeIdle), summary: "legacy idle acknowledgement", usage: "usage: team-agent acknowledge-idle [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: Some("C2"), action: Some("use the concrete idle notification action or `team-agent diagnose`"), governance: None },
|
|
198
198
|
CommandSpec { name: "leaders", tier: CommandTier::Secondary, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Leaders), summary: "list live, stale, or ambiguous host leaders", usage: "usage: team-agent leaders [QUERY|--search TEXT] [--all|--stale] [--json] | --prune [--dry-run] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
199
|
-
CommandSpec { name: "doctor", tier: CommandTier::
|
|
199
|
+
CommandSpec { name: "doctor", tier: CommandTier::Core, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Doctor), summary: "inspect environment and Team runtime health", usage: "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
200
200
|
CommandSpec { name: "attach-app-server-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::AttachAppServerLeader), summary: "bind an app-server leader receiver", usage: "usage: team-agent attach-app-server-leader [--workspace WORKSPACE] [--team TEAM] --socket unix:///path.sock --thread-id THREAD_ID [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
201
201
|
CommandSpec { name: "remove-agent", tier: CommandTier::Secondary, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::RemoveAgent), summary: "remove a worker", usage: "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
202
202
|
CommandSpec { name: "fork-agent", tier: CommandTier::Secondary, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::ForkAgent), summary: "fork a worker", usage: "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
@@ -671,13 +671,12 @@ fn exit_code_numeric() {
|
|
|
671
671
|
}
|
|
672
672
|
|
|
673
673
|
// =========================================================================
|
|
674
|
-
// cmd_doctor
|
|
674
|
+
// cmd_doctor: --fix without --gate is a structured failure in both renderers.
|
|
675
675
|
// =========================================================================
|
|
676
676
|
|
|
677
677
|
#[test]
|
|
678
|
-
fn
|
|
679
|
-
|
|
680
|
-
let args = DoctorArgs {
|
|
678
|
+
fn cmd_doctor_fix_without_gate_reports_usage_failure() {
|
|
679
|
+
let mut args = DoctorArgs {
|
|
681
680
|
spec: None,
|
|
682
681
|
workspace: PathBuf::from("."),
|
|
683
682
|
gate: None,
|
|
@@ -689,12 +688,23 @@ fn cmd_doctor_fix_without_gate_is_usage_error() {
|
|
|
689
688
|
confirm: false,
|
|
690
689
|
json: false,
|
|
691
690
|
};
|
|
692
|
-
let
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
);
|
|
691
|
+
let human = cmd_doctor(&args).expect("structured usage failure");
|
|
692
|
+
assert_eq!(human.exit, ExitCode::Error);
|
|
693
|
+
let CmdOutput::Human(text) = human.output else {
|
|
694
|
+
panic!("expected human triage");
|
|
695
|
+
};
|
|
696
|
+
assert!(text.contains("fix_requires_gate"), "{text}");
|
|
697
|
+
assert!(text.contains("add --gate orphans|comms"), "{text}");
|
|
698
|
+
|
|
699
|
+
args.json = true;
|
|
700
|
+
let result = cmd_doctor(&args).expect("structured JSON usage failure");
|
|
701
|
+
assert_eq!(result.exit, ExitCode::Error);
|
|
702
|
+
let CmdOutput::Json(value) = result.output else {
|
|
703
|
+
panic!("expected JSON usage failure");
|
|
704
|
+
};
|
|
705
|
+
assert_eq!(value["ok"], false);
|
|
706
|
+
assert_eq!(value["error"], "--fix requires --gate");
|
|
707
|
+
assert_eq!(value["issues"], json!(["fix_requires_gate"]));
|
|
698
708
|
}
|
|
699
709
|
|
|
700
710
|
// =========================================================================
|
|
@@ -457,14 +457,10 @@ fn red_send_target_none_is_not_broadcast() {
|
|
|
457
457
|
assert_eq!(send_target(None, Some("*")), MessageTarget::Broadcast);
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
-
//
|
|
461
|
-
// golden: for --comms WITHOUT --json, cmd_doctor returns the STRING
|
|
462
|
-
// f"{COMMS_BOUNDARY_TEXT}\n{json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True)}".
|
|
463
|
-
// Rust always does CmdResult::from_json -> CmdOutput::Json (wrong shape).
|
|
460
|
+
// Unified doctor human output is triage only, including the comms gate.
|
|
464
461
|
#[test]
|
|
465
|
-
fn
|
|
466
|
-
|
|
467
|
-
let args = DoctorArgs {
|
|
462
|
+
fn cmd_doctor_comms_human_is_compact_triage_without_boundary_prefix() {
|
|
463
|
+
let mut args = DoctorArgs {
|
|
468
464
|
spec: None,
|
|
469
465
|
workspace: PathBuf::from("."),
|
|
470
466
|
gate: None,
|
|
@@ -479,21 +475,16 @@ fn red_cmd_doctor_comms_human_is_boundary_text_plus_sorted_json() {
|
|
|
479
475
|
let result = cmd_doctor(&args).expect("comms doctor returns CmdResult");
|
|
480
476
|
let text = match result.output {
|
|
481
477
|
CmdOutput::Human(s) => s,
|
|
482
|
-
other => panic!(
|
|
483
|
-
"comms WITHOUT --json MUST be a Human boundary-text + JSON string, got {other:?}"
|
|
484
|
-
),
|
|
478
|
+
other => panic!("comms WITHOUT --json must be human triage, got {other:?}"),
|
|
485
479
|
};
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
.unwrap();
|
|
494
|
-
let parsed: Value = serde_json::from_str(json_tail)
|
|
495
|
-
.expect("comms human tail MUST be indent=2 sort_keys JSON of the selftest result");
|
|
496
|
-
assert!(parsed.is_object(), "comms selftest JSON tail is an object");
|
|
480
|
+
args.json = true;
|
|
481
|
+
let CmdOutput::Json(report) = cmd_doctor(&args).unwrap().output else {
|
|
482
|
+
panic!("--json must preserve the structured report");
|
|
483
|
+
};
|
|
484
|
+
assert_eq!(text, crate::cli::triage::render("doctor", &report));
|
|
485
|
+
assert!(text.starts_with("doctor:"));
|
|
486
|
+
assert_eq!(text.lines().count(), 1 + report["issues"].as_array().unwrap().len()
|
|
487
|
+
+ report["suggested_repairs"].as_array().unwrap().len());
|
|
497
488
|
}
|
|
498
489
|
|
|
499
490
|
// ---- #13 / #27 (P2): run() must NOT treat 'claude_code' as a passthrough trigger ----
|
|
@@ -368,12 +368,17 @@ fn ux_doctor_secret_scan_findings_name_the_exact_trigger() {
|
|
|
368
368
|
.pointer("/secret_scan/findings/0")
|
|
369
369
|
.and_then(serde_json::Value::as_object)
|
|
370
370
|
.expect("secret-scan must report the concrete trigger");
|
|
371
|
-
for key in ["path", "line", "rule"
|
|
371
|
+
for key in ["path", "line", "rule"] {
|
|
372
372
|
assert!(
|
|
373
373
|
finding.contains_key(key),
|
|
374
374
|
"secret-scan finding missing `{key}`: {finding:?}"
|
|
375
375
|
);
|
|
376
376
|
}
|
|
377
|
+
assert_eq!(finding["path"], "leaky-role.md");
|
|
378
|
+
assert_eq!(finding["line"], 1);
|
|
379
|
+
assert_eq!(finding["rule"], "api_key_assignment");
|
|
380
|
+
assert!(!finding.contains_key("match_excerpt"));
|
|
381
|
+
assert!(!value.to_string().contains("sk-test-red-contract"));
|
|
377
382
|
let _ = std::fs::remove_dir_all(&ws);
|
|
378
383
|
}
|
|
379
384
|
#[test]
|
|
@@ -299,7 +299,7 @@ fn dispatch_routes_sessions_subcommand() {
|
|
|
299
299
|
// + NO session_name + NO agents -> issues=[] -> EXIT 0.
|
|
300
300
|
#[test]
|
|
301
301
|
#[serial(env)]
|
|
302
|
-
fn
|
|
302
|
+
fn dispatch_routes_diagnose_checks_runtime_beyond_healthy_leader() {
|
|
303
303
|
let _env = EnvUnsetGuard::unset(&[
|
|
304
304
|
"TMUX",
|
|
305
305
|
"TMUX_PANE",
|
|
@@ -332,17 +332,26 @@ fn dispatch_routes_diagnose_healthy_leader() {
|
|
|
332
332
|
pane_pid: None,
|
|
333
333
|
leader_env: Default::default(),
|
|
334
334
|
}]);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
335
|
+
crate::transport_factory::with_leader_endpoint_transport(endpoint, transport, || {
|
|
336
|
+
for command in ["doctor", "diagnose"] {
|
|
337
|
+
let code = run(
|
|
338
|
+
&cli_argv(&[command, "--workspace", &ws.to_string_lossy(), "--json"]),
|
|
339
|
+
&ws,
|
|
340
|
+
);
|
|
341
|
+
assert_eq!(code, ExitCode::Error, "healthy binding alone cannot hide missing coordinator");
|
|
342
|
+
}
|
|
343
|
+
let result = cmd_diagnose(&DiagnoseArgs {
|
|
344
|
+
workspace: ws.clone(), json: true, team: None,
|
|
345
|
+
}).expect("diagnostic report");
|
|
346
|
+
let CmdOutput::Json(report) = result.output else {
|
|
347
|
+
panic!("expected JSON diagnostic report");
|
|
348
|
+
};
|
|
349
|
+
assert_eq!(report["coordinator"]["ok"], false, "{report}");
|
|
350
|
+
let issues = report["issues"].to_string();
|
|
351
|
+
for binding_failure in ["leader_not_attached", "leader_receiver_unbound", "leader_workspace_mismatch"] {
|
|
352
|
+
assert!(!issues.contains(binding_failure), "healthy binding regressed: {report}");
|
|
353
|
+
}
|
|
340
354
|
});
|
|
341
|
-
assert_eq!(
|
|
342
|
-
code,
|
|
343
|
-
ExitCode::Ok,
|
|
344
|
-
"`diagnose` must be Ok when canonical owner/receiver, production registry, and live align; got {code:?}"
|
|
345
|
-
);
|
|
346
355
|
let _ = std::fs::remove_dir_all(&ws);
|
|
347
356
|
}
|
|
348
357
|
|
|
@@ -23,6 +23,49 @@ fn anchors(raw: &[&str]) -> BTreeSet<String> {
|
|
|
23
23
|
raw.iter().map(|s| s.to_string()).collect()
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
#[test]
|
|
27
|
+
fn leader_anchors_are_endpoint_scoped_and_unknown_anchors_stay_protected() {
|
|
28
|
+
use crate::cli::lifecycle_port::collect_state_leader_anchor_pane_ids as collect;
|
|
29
|
+
let state = json!({
|
|
30
|
+
"team_owner": {"pane_id": "%2"},
|
|
31
|
+
"leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host.sock"},
|
|
32
|
+
"teams": {
|
|
33
|
+
"local": {
|
|
34
|
+
"team_owner": {"pane_id": "%3", "tmux_socket": "/tmp/s4-worker.sock"},
|
|
35
|
+
"leader_receiver": {"pane_id": "%3"}
|
|
36
|
+
},
|
|
37
|
+
"legacy": {"team_owner": {"pane_id": "%4"}}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
assert_eq!(collect(&state, Some("/tmp/s4-worker.sock")), anchors(&["%3", "%4"]));
|
|
41
|
+
assert_eq!(collect(&state, Some("/tmp/s4-host.sock")), anchors(&["%2", "%4"]));
|
|
42
|
+
assert_eq!(collect(&state, None), anchors(&["%2", "%3", "%4"]));
|
|
43
|
+
|
|
44
|
+
// An unrelated receiver's socket is not evidence about the owner's pane.
|
|
45
|
+
let different_panes = json!({
|
|
46
|
+
"team_owner": {"pane_id": "%5"},
|
|
47
|
+
"leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host.sock"}
|
|
48
|
+
});
|
|
49
|
+
assert_eq!(collect(&different_panes, Some("/tmp/s4-worker.sock")), anchors(&["%5"]));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[test]
|
|
53
|
+
#[cfg(unix)]
|
|
54
|
+
fn leader_anchor_endpoint_aliases_preserve_same_server_protection() {
|
|
55
|
+
use crate::cli::lifecycle_port::collect_state_leader_anchor_pane_ids as collect;
|
|
56
|
+
let ws = tmp_shutdown_workspace("anchor-endpoint-alias");
|
|
57
|
+
let socket = ws.join("server.sock");
|
|
58
|
+
let alias = ws.join("alias.sock");
|
|
59
|
+
std::fs::write(&socket, b"fixture").unwrap();
|
|
60
|
+
std::os::unix::fs::symlink(&socket, &alias).unwrap();
|
|
61
|
+
let state = json!({"leader_receiver": {"pane_id": "%2", "tmux_socket": alias}});
|
|
62
|
+
assert_eq!(collect(&state, socket.to_str()), anchors(&["%2"]));
|
|
63
|
+
let short_name = format!("s4-anchor-{}", std::process::id());
|
|
64
|
+
let full_path = crate::tmux_backend::socket_path_for_name(&short_name).unwrap();
|
|
65
|
+
let state = json!({"leader_receiver": {"pane_id": "%2", "tmux_socket": short_name}});
|
|
66
|
+
assert_eq!(collect(&state, full_path.to_str()), anchors(&["%2"]));
|
|
67
|
+
}
|
|
68
|
+
|
|
26
69
|
// RC-4: even an apparently exclusive socket uses per-session cleanup.
|
|
27
70
|
#[test]
|
|
28
71
|
fn rc4_exclusive_socket_is_fail_closed() {
|