@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +178 -51
- package/crates/team-agent/src/cli/mod.rs +83 -17
- package/crates/team-agent/src/coordinator/health.rs +121 -0
- package/crates/team-agent/src/leader/lease.rs +23 -2
- package/crates/team-agent/src/leader/rediscover/tests.rs +1 -0
- package/crates/team-agent/src/leader/rediscover.rs +2 -0
- package/crates/team-agent/src/leader/tests/byte_findings.rs +9 -6
- package/crates/team-agent/src/leader/tests/idle.rs +1 -0
- package/crates/team-agent/src/leader/tests/lease_claim.rs +157 -0
- package/crates/team-agent/src/leader/types.rs +2 -0
- package/crates/team-agent/src/lifecycle/launch.rs +300 -24
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +52 -0
- package/crates/team-agent/src/lifecycle/types.rs +25 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +28 -0
- package/crates/team-agent/src/mcp_server/wire.rs +81 -1
- package/crates/team-agent/src/messaging/delivery.rs +204 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
- package/crates/team-agent/src/messaging/results.rs +18 -2
- package/crates/team-agent/src/messaging/send.rs +15 -19
- package/crates/team-agent/src/state/identity.rs +3 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
- package/crates/team-agent/src/tmux_backend.rs +58 -6
- package/npm/install.mjs +29 -7
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
-
|
|
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 =>
|
|
184
|
-
|
|
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("
|
|
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("
|
|
190
|
-
Some("
|
|
191
|
-
Some("
|
|
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
|
|
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(
|
|
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(
|
|
160
|
-
let _event = crate::event_log::EventLog::new(
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
"
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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|
|
|
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, ¤t)
|
|
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),
|