@team-agent/installer 0.3.0 → 0.3.1

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 CHANGED
@@ -555,7 +555,7 @@ dependencies = [
555
555
 
556
556
  [[package]]
557
557
  name = "team-agent"
558
- version = "0.3.0"
558
+ version = "0.3.1"
559
559
  dependencies = [
560
560
  "anyhow",
561
561
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.0"
12
+ version = "0.3.1"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -121,6 +121,50 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
121
121
  }
122
122
  }
123
123
 
124
+ const DISPATCH_COMMANDS: &[&str] = &[
125
+ "init",
126
+ "quick-start",
127
+ "compile",
128
+ "send",
129
+ "allow-peer-talk",
130
+ "status",
131
+ "stop",
132
+ "shutdown",
133
+ "restart",
134
+ "restart-agent",
135
+ "start-agent",
136
+ "stop-agent",
137
+ "reset-agent",
138
+ "add-agent",
139
+ "fork-agent",
140
+ "remove-agent",
141
+ "stuck-list",
142
+ "stuck-cancel",
143
+ "acknowledge-idle",
144
+ "takeover",
145
+ "claim-leader",
146
+ "identity",
147
+ "approvals",
148
+ "inbox",
149
+ "doctor",
150
+ "watch",
151
+ "sessions",
152
+ "validate",
153
+ "profile",
154
+ "validate-result",
155
+ "collect",
156
+ "settle",
157
+ "repair-state",
158
+ "diagnose",
159
+ "preflight",
160
+ "wait-ready",
161
+ "e2e",
162
+ "peek",
163
+ "coordinator",
164
+ ];
165
+
166
+ const SPEC_ONLY_HELP_COMMANDS: &[&str] = &["start", "purge-agent", "attach-leader"];
167
+
124
168
  fn emit_missing_subcommand_usage() -> ExitCode {
125
169
  emit_usage_error("the following arguments are required: {codex,claude,...,doctor}");
126
170
  ExitCode::Usage
@@ -131,65 +175,62 @@ fn emit_missing_subcommand_usage() -> ExitCode {
131
175
  /// Used by the `--help` short-circuit gate so unknown commands keep falling through
132
176
  /// to the argparse invalid-choice path.
133
177
  fn is_known_subcommand(command: &str) -> bool {
134
- matches!(
135
- command,
136
- "init"
137
- | "quick-start"
138
- | "compile"
139
- | "send"
140
- | "allow-peer-talk"
141
- | "status"
142
- | "start"
143
- | "stop"
144
- | "shutdown"
145
- | "restart"
146
- | "restart-agent"
147
- | "start-agent"
148
- | "stop-agent"
149
- | "reset-agent"
150
- | "add-agent"
151
- | "fork-agent"
152
- | "remove-agent"
153
- | "purge-agent"
154
- | "stuck-list"
155
- | "stuck-cancel"
156
- | "acknowledge-idle"
157
- | "takeover"
158
- | "claim-leader"
159
- | "attach-leader"
160
- | "identity"
161
- | "approvals"
162
- | "inbox"
163
- | "doctor"
164
- | "watch"
165
- | "sessions"
166
- | "validate"
167
- | "profile"
168
- | "validate-result"
169
- | "collect"
170
- | "settle"
171
- | "repair-state"
172
- | "diagnose"
173
- | "preflight"
174
- | "wait-ready"
175
- | "e2e"
176
- | "peek"
177
- | "coordinator"
178
- )
178
+ DISPATCH_COMMANDS.contains(&command) || SPEC_ONLY_HELP_COMMANDS.contains(&command)
179
179
  }
180
180
 
181
181
  fn command_help(command: Option<&str>) -> String {
182
182
  match command {
183
- None => "usage: team-agent <command> [options]\n\nCommands: quick-start, status, send, collect, start, stop, shutdown, restart, restart-agent, start-agent, stop-agent, reset-agent, add-agent, remove-agent, purge-agent".to_string(),
184
- Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--name NAME] [--team-id TEAM] [--yes] [--fresh] [--json]".to_string(),
183
+ None => {
184
+ let mut commands = vec!["codex", "claude"];
185
+ commands.extend_from_slice(DISPATCH_COMMANDS);
186
+ commands.extend_from_slice(SPEC_ONLY_HELP_COMMANDS);
187
+ format!(
188
+ "usage: team-agent <command> [options]\n\nCommands: {}\n\nRun `team-agent <command> --help` for command flags.",
189
+ commands.join(", ")
190
+ )
191
+ }
192
+ Some("init") => "usage: team-agent init [--workspace WORKSPACE] [--force] [--json]".to_string(),
193
+ Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--fresh] [--json]".to_string(),
185
194
  Some("start") => "usage: team-agent start [TEAMDIR] [--yes] [--fresh] [--json]".to_string(),
186
- Some("stop") | Some("shutdown") => "usage: team-agent stop [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
195
+ Some("compile") => "usage: team-agent compile --team TEAM [--out FILE] [--json]".to_string(),
196
+ Some("send") => "usage: team-agent send TARGET MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] [--task TASK] [--sender SENDER] [--watch-result] [--requires-ack|--no-ack] [--no-wait] [--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]".to_string(),
197
+ Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
198
+ Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--summary|--json] [--detail]".to_string(),
199
+ Some("stop") => "usage: team-agent stop [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
200
+ Some("shutdown") => "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
201
+ Some("restart") => "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--json]".to_string(),
187
202
  Some("restart-agent") => "usage: team-agent restart-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--no-display] [--json]".to_string(),
203
+ Some("reset-agent") => "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--no-display] [--json]".to_string(),
204
+ Some("start-agent") => "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--no-display] [--json]".to_string(),
205
+ Some("stop-agent") => "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
206
+ Some("add-agent") => "usage: team-agent add-agent AGENT --role-file FILE [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
207
+ Some("fork-agent") => "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
208
+ Some("remove-agent") => "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]".to_string(),
188
209
  Some("purge-agent") => "usage: team-agent purge-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--json]".to_string(),
189
- Some("restart") => "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--json]".to_string(),
190
- Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--summary|--json] [--detail]".to_string(),
191
- Some("send") => "usage: team-agent send TARGET MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
210
+ Some("stuck-list") => "usage: team-agent stuck-list [--workspace WORKSPACE] [--json]".to_string(),
211
+ Some("stuck-cancel") => "usage: team-agent stuck-cancel AGENT [--workspace WORKSPACE] [--alert-type stuck|idle_fallback|cross_worker_deadlock|all] [--json]".to_string(),
212
+ Some("acknowledge-idle") => "usage: team-agent acknowledge-idle [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
213
+ Some("takeover") => "usage: team-agent takeover [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]".to_string(),
214
+ Some("claim-leader") => "usage: team-agent claim-leader [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]".to_string(),
215
+ Some("attach-leader") => "usage: team-agent attach-leader [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]".to_string(),
216
+ Some("identity") => "usage: team-agent identity [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
217
+ Some("approvals") => "usage: team-agent approvals [AGENT] [--workspace WORKSPACE] [--json]".to_string(),
218
+ Some("inbox") => "usage: team-agent inbox AGENT [--workspace WORKSPACE] [--limit N] [--since CURSOR] [--json]".to_string(),
219
+ Some("doctor") => "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]".to_string(),
220
+ Some("watch") => "usage: team-agent watch [--workspace WORKSPACE] [--team TEAM]".to_string(),
221
+ Some("sessions") => "usage: team-agent sessions [--workspace WORKSPACE] [--json]".to_string(),
222
+ Some("validate") => "usage: team-agent validate [SPEC] [--json]".to_string(),
223
+ Some("profile") => "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--json]".to_string(),
224
+ Some("validate-result") => "usage: team-agent validate-result [ENVELOPE] [--file FILE|--result JSON] [--json]".to_string(),
192
225
  Some("collect") => "usage: team-agent collect [--workspace WORKSPACE] [--result-file FILE] [--json]".to_string(),
226
+ Some("settle") => "usage: team-agent settle [--workspace WORKSPACE] [--json]".to_string(),
227
+ Some("repair-state") => "usage: team-agent repair-state --task TASK --status STATUS [SUMMARY] [--assignee AGENT] [--workspace WORKSPACE] [--json]".to_string(),
228
+ Some("diagnose") => "usage: team-agent diagnose [--workspace WORKSPACE] [--json]".to_string(),
229
+ Some("preflight") => "usage: team-agent preflight [TEAMDIR] [--json]".to_string(),
230
+ Some("wait-ready") => "usage: team-agent wait-ready [--workspace WORKSPACE] [--timeout SECONDS] [--json]".to_string(),
231
+ Some("e2e") => "usage: team-agent e2e [--workspace WORKSPACE] [--providers LIST] [--real] [--json]".to_string(),
232
+ Some("peek") => "usage: team-agent peek AGENT [--workspace WORKSPACE] [--tail N] [--allow-raw-screen] [--json]".to_string(),
233
+ Some("coordinator") => "usage: team-agent coordinator [--workspace WORKSPACE] [--once] [--tick-interval SECONDS]".to_string(),
193
234
  Some(other) => format!("usage: team-agent {other} [options]"),
194
235
  }
195
236
  }
@@ -1056,6 +1097,92 @@ mod tests {
1056
1097
  items.iter().map(|s| (*s).to_string()).collect()
1057
1098
  }
1058
1099
 
1100
+ fn source_dispatch_commands() -> Vec<&'static str> {
1101
+ let source = include_str!("emit.rs");
1102
+ let after_start = source.split_once("fn dispatch(").unwrap().1;
1103
+ let dispatch_source = after_start.split_once("const DISPATCH_COMMANDS").unwrap().0;
1104
+ let mut commands = Vec::new();
1105
+ for line in dispatch_source.lines() {
1106
+ let line = line.trim_start();
1107
+ let Some(rest) = line.strip_prefix('"') else {
1108
+ continue;
1109
+ };
1110
+ let Some((command, after_command)) = rest.split_once('"') else {
1111
+ continue;
1112
+ };
1113
+ let after_command = after_command.trim_start();
1114
+ if (after_command.starts_with("=>") || after_command.starts_with("if "))
1115
+ && !commands.contains(&command)
1116
+ {
1117
+ commands.push(command);
1118
+ }
1119
+ }
1120
+ commands
1121
+ }
1122
+
1123
+ #[test]
1124
+ fn t0_help_catalog_tracks_dispatch_commands() {
1125
+ let source_commands = source_dispatch_commands();
1126
+ for command in &source_commands {
1127
+ assert!(
1128
+ DISPATCH_COMMANDS.contains(command),
1129
+ "dispatch command `{command}` is missing from DISPATCH_COMMANDS"
1130
+ );
1131
+ }
1132
+ for command in DISPATCH_COMMANDS {
1133
+ assert!(
1134
+ source_commands.contains(command),
1135
+ "DISPATCH_COMMANDS contains `{command}` but dispatch has no matching arm"
1136
+ );
1137
+ }
1138
+
1139
+ let top_help = command_help(None);
1140
+ for command in DISPATCH_COMMANDS {
1141
+ assert!(
1142
+ top_help.contains(command),
1143
+ "top-level --help is missing dispatch command `{command}`"
1144
+ );
1145
+ let command_help = command_help(Some(command));
1146
+ assert!(
1147
+ command_help.contains("usage: team-agent") && command_help.contains(command),
1148
+ "`team-agent {command} --help` must show command-specific usage, got {command_help:?}"
1149
+ );
1150
+ }
1151
+ for command in SPEC_ONLY_HELP_COMMANDS {
1152
+ assert!(
1153
+ top_help.contains(command),
1154
+ "top-level --help is missing spec-only help command `{command}`"
1155
+ );
1156
+ }
1157
+ }
1158
+
1159
+ #[test]
1160
+ fn t0_help_catalog_lists_command_flags() {
1161
+ for (command, flags) in [
1162
+ ("quick-start", &["--workspace", "--team-id", "--yes", "--fresh", "--json"][..]),
1163
+ ("send", &["--workspace", "--team", "--targets", "--watch-result", "--timeout", "--json"][..]),
1164
+ ("status", &["--workspace", "--summary", "--json", "--detail"][..]),
1165
+ ("shutdown", &["--workspace", "--team", "--keep-logs", "--json"][..]),
1166
+ ("restart", &["--team", "--allow-fresh", "--json"][..]),
1167
+ ("start-agent", &["--workspace", "--team", "--force", "--allow-fresh", "--no-display", "--json"][..]),
1168
+ ("reset-agent", &["--workspace", "--team", "--discard-session", "--no-display", "--json"][..]),
1169
+ ("add-agent", &["--role-file", "--workspace", "--team", "--no-display", "--json"][..]),
1170
+ ("fork-agent", &["--as", "--label", "--workspace", "--team", "--no-display", "--json"][..]),
1171
+ ("remove-agent", &["--workspace", "--team", "--from-spec", "--confirm", "--force", "--json"][..]),
1172
+ ("doctor", &["--workspace", "--team", "--gate", "--fix-schema", "--cleanup-orphans", "--json"][..]),
1173
+ ("collect", &["--workspace", "--result-file", "--json"][..]),
1174
+ ("repair-state", &["--task", "--status", "--assignee", "--workspace", "--json"][..]),
1175
+ ("wait-ready", &["--workspace", "--timeout", "--json"][..]),
1176
+ ("peek", &["--workspace", "--tail", "--allow-raw-screen", "--json"][..]),
1177
+ ("coordinator", &["--workspace", "--once", "--tick-interval"][..]),
1178
+ ] {
1179
+ let help = command_help(Some(command));
1180
+ for flag in flags {
1181
+ assert!(help.contains(flag), "`team-agent {command} --help` is missing {flag}");
1182
+ }
1183
+ }
1184
+ }
1185
+
1059
1186
  #[test]
1060
1187
  fn ux_quick_start_workspace_resolves_relative_agents_dir_inside_workspace() {
1061
1188
  let cwd = tmp_workspace();
@@ -28,6 +28,7 @@
28
28
  // §10:CLI 命令实现层禁 unwrap/expect/panic(unimplemented!() stub 不被拦);tests 子模块各自 allow。
29
29
  #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
30
30
 
31
+ use std::io::Read;
31
32
  use std::path::{Path, PathBuf};
32
33
 
33
34
  use serde::{Deserialize, Serialize};
@@ -137,10 +138,12 @@ pub mod lifecycle_port {
137
138
  team: Option<&str>,
138
139
  transport: &dyn crate::transport::Transport,
139
140
  ) -> Result<Value, CliError> {
140
- let wp = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
141
+ let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
142
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
143
+ let wp = crate::coordinator::WorkspacePath::new(run_workspace.clone());
141
144
  let stopped = crate::coordinator::stop_coordinator(&wp)
142
145
  .map_err(|e| CliError::Runtime(e.to_string()))?;
143
- let mut state = crate::state::persist::load_runtime_state(workspace)?;
146
+ let mut state = crate::state::persist::load_runtime_state(&run_workspace)?;
144
147
  let session_name = state
145
148
  .get("session_name")
146
149
  .and_then(Value::as_str)
@@ -156,8 +159,8 @@ pub mod lifecycle_port {
156
159
  false
157
160
  };
158
161
  mark_agents_stopped(&mut state);
159
- crate::state::persist::save_runtime_state(workspace, &state)?;
160
- let _event = crate::event_log::EventLog::new(workspace)
162
+ crate::state::persist::save_runtime_state(&run_workspace, &state)?;
163
+ let _event = crate::event_log::EventLog::new(&run_workspace)
161
164
  .write(
162
165
  "lifecycle.shutdown",
163
166
  json!({
@@ -459,13 +462,46 @@ pub mod lifecycle_port {
459
462
  session_name,
460
463
  launch,
461
464
  next_actions,
462
- } => json!({
463
- "ok": true,
464
- "summary": format!("quick-start ready: {}", session_name.as_str()),
465
- "session_name": session_name.as_str(),
466
- "dry_run": launch.dry_run,
467
- "next_actions": next_actions,
468
- }),
465
+ worker_readiness,
466
+ } => {
467
+ // BUG-7: never emit bare "ready" while worker tool-load is unverified.
468
+ // The summary string + a structured `worker_readiness` block tell the
469
+ // caller exactly which agents are unhealthy (Degraded) or that the
470
+ // tool-set load has not been confirmed yet (PendingToolLoad).
471
+ let (summary, ok, readiness_json) = match &worker_readiness {
472
+ crate::lifecycle::QuickStartReadiness::Degraded { unhealthy_agents } => (
473
+ format!(
474
+ "quick-start degraded: {}; unhealthy: {}",
475
+ session_name.as_str(),
476
+ unhealthy_agents.join(",")
477
+ ),
478
+ false,
479
+ json!({
480
+ "state": "degraded",
481
+ "unhealthy_agents": unhealthy_agents,
482
+ }),
483
+ ),
484
+ crate::lifecycle::QuickStartReadiness::PendingToolLoad => (
485
+ format!(
486
+ "quick-start launched (worker tool load unverified): {}",
487
+ session_name.as_str()
488
+ ),
489
+ true,
490
+ json!({
491
+ "state": "pending_tool_load",
492
+ "reason": "worker MCP tool set load not yet confirmed; run `team-agent doctor` or wait for first worker turn",
493
+ }),
494
+ ),
495
+ };
496
+ json!({
497
+ "ok": ok,
498
+ "summary": summary,
499
+ "session_name": session_name.as_str(),
500
+ "dry_run": launch.dry_run,
501
+ "next_actions": next_actions,
502
+ "worker_readiness": readiness_json,
503
+ })
504
+ }
469
505
  crate::lifecycle::QuickStartReport::ExistingRuntime {
470
506
  team,
471
507
  session_name,
@@ -595,35 +631,65 @@ pub mod diagnose_port {
595
631
 
596
632
  fn secret_scan(workspace: &Path) -> Value {
597
633
  let mut findings = Vec::new();
598
- scan_secret_dir(workspace, workspace, &mut findings);
634
+ let mut scanned = 0usize;
635
+ scan_secret_dir(workspace, workspace, 0, &mut scanned, &mut findings);
599
636
  json!({
600
637
  "ok": findings.is_empty(),
601
638
  "findings": findings,
602
639
  })
603
640
  }
604
641
 
605
- fn scan_secret_dir(root: &Path, dir: &Path, findings: &mut Vec<Value>) {
642
+ const SECRET_SCAN_MAX_DEPTH: usize = 4;
643
+ const SECRET_SCAN_MAX_ENTRIES: usize = 512;
644
+ const SECRET_SCAN_MAX_FILE_BYTES: u64 = 128 * 1024;
645
+
646
+ fn scan_secret_dir(root: &Path, dir: &Path, depth: usize, scanned: &mut usize, findings: &mut Vec<Value>) {
647
+ if depth > SECRET_SCAN_MAX_DEPTH || *scanned >= SECRET_SCAN_MAX_ENTRIES {
648
+ return;
649
+ }
606
650
  let Ok(entries) = std::fs::read_dir(dir) else {
607
651
  return;
608
652
  };
609
653
  for entry in entries.flatten() {
654
+ if *scanned >= SECRET_SCAN_MAX_ENTRIES {
655
+ return;
656
+ }
657
+ *scanned = scanned.saturating_add(1);
610
658
  let path = entry.path();
611
659
  let name = path.file_name().map(|s| s.to_string_lossy());
612
660
  if name.as_deref() == Some(".team") || name.as_deref() == Some(".git") {
613
661
  continue;
614
662
  }
615
- if path.is_dir() {
616
- scan_secret_dir(root, &path, findings);
663
+ let Ok(file_type) = entry.file_type() else {
664
+ continue;
665
+ };
666
+ if file_type.is_dir() {
667
+ scan_secret_dir(root, &path, depth.saturating_add(1), scanned, findings);
617
668
  continue;
618
669
  }
619
- scan_secret_file(root, &path, findings);
670
+ if file_type.is_file() {
671
+ scan_secret_file(root, &path, findings);
672
+ }
620
673
  }
621
674
  }
622
675
 
623
676
  fn scan_secret_file(root: &Path, path: &Path, findings: &mut Vec<Value>) {
624
- let Ok(text) = std::fs::read_to_string(path) else {
677
+ let Ok(metadata) = std::fs::metadata(path) else {
678
+ return;
679
+ };
680
+ if !metadata.is_file() || metadata.len() > SECRET_SCAN_MAX_FILE_BYTES {
681
+ return;
682
+ }
683
+ let Ok(file) = std::fs::File::open(path) else {
625
684
  return;
626
685
  };
686
+ let mut text = String::new();
687
+ if std::io::Read::take(file, SECRET_SCAN_MAX_FILE_BYTES)
688
+ .read_to_string(&mut text)
689
+ .is_err()
690
+ {
691
+ return;
692
+ }
627
693
  for (idx, line) in text.lines().enumerate() {
628
694
  if line.contains("OPENAI_API_KEY=") || line.contains("ANTHROPIC_API_KEY=") {
629
695
  let rel = path.strip_prefix(root).unwrap_or(path);
@@ -3,6 +3,7 @@
3
3
  use std::io::{Read, Seek, SeekFrom};
4
4
  use std::path::{Path, PathBuf};
5
5
  use std::process::{Command, Stdio};
6
+ use std::time::Duration;
6
7
 
7
8
  use serde_json::Value;
8
9
  use thiserror::Error;
@@ -119,6 +120,9 @@ pub fn start_coordinator(workspace: &WorkspacePath) -> Result<StartReport, Start
119
120
  pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopError> {
120
121
  let pid_path = coordinator_pid_path(workspace);
121
122
  if !pid_path.exists() {
123
+ if let Some(report) = stop_discovered_coordinators(workspace)? {
124
+ return Ok(report);
125
+ }
122
126
  return Ok(StopReport {
123
127
  ok: true,
124
128
  status: StopOutcome::Missing,
@@ -158,6 +162,123 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
158
162
  })
159
163
  }
160
164
 
165
+ fn stop_discovered_coordinators(
166
+ workspace: &WorkspacePath,
167
+ ) -> Result<Option<StopReport>, StopError> {
168
+ let pids = discover_coordinator_pids(workspace);
169
+ if pids.is_empty() {
170
+ return Ok(None);
171
+ }
172
+
173
+ let mut stopped = None;
174
+ let mut failed = None;
175
+ for pid in pids {
176
+ if terminate_pid(pid) {
177
+ stopped.get_or_insert(pid);
178
+ } else {
179
+ failed.get_or_insert(pid);
180
+ }
181
+ }
182
+ remove_file_if_exists(&coordinator_meta_path(workspace))?;
183
+
184
+ if let Some(pid) = stopped {
185
+ Ok(Some(StopReport {
186
+ ok: true,
187
+ status: StopOutcome::Stopped,
188
+ pid: Some(pid),
189
+ }))
190
+ } else {
191
+ Ok(Some(StopReport {
192
+ ok: false,
193
+ status: StopOutcome::KillFailed,
194
+ pid: failed,
195
+ }))
196
+ }
197
+ }
198
+
199
+ fn discover_coordinator_pids(workspace: &WorkspacePath) -> Vec<Pid> {
200
+ let output = match Command::new("ps")
201
+ .args(["-axo", "pid=,command="])
202
+ .output()
203
+ {
204
+ Ok(output) if output.status.success() => output,
205
+ _ => return Vec::new(),
206
+ };
207
+ let text = String::from_utf8_lossy(&output.stdout);
208
+ let candidates = workspace_match_candidates(workspace.as_path());
209
+ text.lines()
210
+ .filter_map(|line| parse_ps_command_line(line))
211
+ .filter(|(pid, command)| {
212
+ *pid != std::process::id()
213
+ && coordinator_command_matches_workspace(command, &candidates)
214
+ })
215
+ .map(|(pid, _)| Pid::new(pid))
216
+ .collect()
217
+ }
218
+
219
+ fn parse_ps_command_line(line: &str) -> Option<(u32, &str)> {
220
+ let line = line.trim_start();
221
+ let split = line
222
+ .find(char::is_whitespace)
223
+ .unwrap_or(line.len());
224
+ let pid = line.get(..split)?.trim().parse::<u32>().ok()?;
225
+ let command = line.get(split..)?.trim();
226
+ Some((pid, command))
227
+ }
228
+
229
+ fn workspace_match_candidates(workspace: &Path) -> Vec<String> {
230
+ let mut candidates = vec![workspace.to_string_lossy().to_string()];
231
+ if let Ok(canonical) = workspace.canonicalize() {
232
+ let text = canonical.to_string_lossy().to_string();
233
+ if !candidates.iter().any(|candidate| candidate == &text) {
234
+ candidates.push(text);
235
+ }
236
+ }
237
+ candidates
238
+ }
239
+
240
+ fn coordinator_command_matches_workspace(command: &str, workspaces: &[String]) -> bool {
241
+ command
242
+ .split_whitespace()
243
+ .any(|token| token == "team-agent" || token.ends_with("/team-agent"))
244
+ && command.split_whitespace().any(|token| token == "coordinator")
245
+ && command.contains("--workspace")
246
+ && workspaces.iter().any(|workspace| command.contains(workspace))
247
+ }
248
+
249
+ fn terminate_pid(pid: Pid) -> bool {
250
+ if pid_is_running(pid).ok() == Some(false) {
251
+ return true;
252
+ }
253
+ if !send_signal(pid, libc::SIGTERM) {
254
+ return false;
255
+ }
256
+ if wait_until_not_running(pid, Duration::from_millis(750)) {
257
+ return true;
258
+ }
259
+ send_signal(pid, libc::SIGKILL) && wait_until_not_running(pid, Duration::from_millis(750))
260
+ }
261
+
262
+ fn send_signal(pid: Pid, signal: libc::c_int) -> bool {
263
+ let Ok(pid_t) = libc::pid_t::try_from(pid.get()) else {
264
+ return false;
265
+ };
266
+ unsafe { libc::kill(pid_t, signal) == 0 }
267
+ }
268
+
269
+ fn wait_until_not_running(pid: Pid, timeout: Duration) -> bool {
270
+ let start = std::time::Instant::now();
271
+ loop {
272
+ if pid_is_running(pid).ok() != Some(true) {
273
+ return true;
274
+ }
275
+ if start.elapsed() >= timeout {
276
+ return false;
277
+ }
278
+ std::thread::sleep(Duration::from_millis(25));
279
+ }
280
+ }
281
+
161
282
  // ===========================================================================
162
283
  // metadata 身份原语(metadata.py)—— 自由函数面
163
284
  // ===========================================================================
@@ -424,7 +424,8 @@ fn claim_lease_no_incident_with_target(
424
424
  ));
425
425
  }
426
426
  let non_empty_caller_pane = NonEmptyPaneId::try_from_pane(caller_pane)?;
427
- if bound_pane_id.as_deref() == Some(caller_pane.as_str()) {
427
+ let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
428
+ if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
428
429
  return Ok(LeaseResult {
429
430
  ok: true,
430
431
  status: LeaseStatus::AlreadyBound,
@@ -438,7 +439,12 @@ fn claim_lease_no_incident_with_target(
438
439
  }
439
440
  let owner_live = bound_pane_id
440
441
  .as_deref()
441
- .is_some_and(|pane| liveness.liveness(pane) == PaneLiveness::Live);
442
+ .is_some_and(|pane| {
443
+ if pane == caller_pane.as_str() && !bound_endpoint_matches_caller {
444
+ return false;
445
+ }
446
+ liveness.liveness(pane) == PaneLiveness::Live
447
+ });
442
448
  if owner_live && !confirm {
443
449
  emit_lease_refusal(
444
450
  event_log,
@@ -596,6 +602,20 @@ fn bound_pane(state: &Value) -> Option<String> {
596
602
  .or_else(|| get_path_str(state, &["team_owner", "pane_id"]).filter(|v| !v.is_empty()))
597
603
  }
598
604
 
605
+ fn bound_endpoint_matches_current_process(state: &Value) -> bool {
606
+ let Some(bound) = get_path_str(state, &["leader_receiver", "tmux_socket"]).filter(|v| !v.is_empty()) else {
607
+ return true;
608
+ };
609
+ let Some(current) = crate::tmux_backend::socket_name_from_tmux_env() else {
610
+ return false;
611
+ };
612
+ tmux_endpoints_match(&bound, &current)
613
+ }
614
+
615
+ fn tmux_endpoints_match(bound: &str, current: &str) -> bool {
616
+ bound == current
617
+ }
618
+
599
619
  fn prior_provider(state: &Value) -> Provider {
600
620
  get_path_str(state, &["leader_receiver", "provider"])
601
621
  .or_else(|| get_path_str(state, &["team_owner", "provider"]))
@@ -844,6 +864,7 @@ fn make_receiver(
844
864
  pane_index: target.as_ref().and_then(|t| t.pane_index.map(|v| v.to_string())),
845
865
  pane_tty: target.as_ref().and_then(|t| t.tty.clone()),
846
866
  pane_current_command: target.as_ref().and_then(|t| t.current_command.clone()),
867
+ tmux_socket: crate::tmux_backend::socket_name_from_tmux_env(),
847
868
  fingerprint: target.as_ref().map(receiver_fingerprint),
848
869
  leader_session_uuid: Some(uuid.clone()),
849
870
  owner_epoch: Some(epoch),
@@ -31,6 +31,7 @@ fn receiver(pane: &str, uuid: &str, epoch: u64) -> LeaderReceiver {
31
31
  pane_index: None,
32
32
  pane_tty: None,
33
33
  pane_current_command: None,
34
+ tmux_socket: None,
34
35
  fingerprint: None,
35
36
  leader_session_uuid: serde_json::from_value(Value::String(uuid.to_string())).ok(),
36
37
  owner_epoch: Some(OwnerEpoch(epoch)),
@@ -1013,6 +1013,7 @@ fn receiver_from_candidate(
1013
1013
  pane_index: target.pane_index.clone(),
1014
1014
  pane_tty: target.tty.clone(),
1015
1015
  pane_current_command: target.current_command.clone(),
1016
+ tmux_socket: prior.tmux_socket.clone(),
1016
1017
  fingerprint: target.fingerprint.clone(),
1017
1018
  leader_session_uuid: uuid,
1018
1019
  owner_epoch: Some(epoch),
@@ -1035,6 +1036,7 @@ fn empty_prior(provider: Provider, epoch: OwnerEpoch) -> LeaderReceiver {
1035
1036
  pane_index: None,
1036
1037
  pane_tty: None,
1037
1038
  pane_current_command: None,
1039
+ tmux_socket: None,
1038
1040
  fingerprint: None,
1039
1041
  leader_session_uuid: None,
1040
1042
  owner_epoch: Some(epoch),