@team-agent/installer 0.3.14 → 0.3.15
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 +1 -0
- package/crates/team-agent/src/cli/emit.rs +92 -11
- package/crates/team-agent/src/cli/mod.rs +60 -10
- package/crates/team-agent/src/cli/send.rs +252 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +159 -2
- package/crates/team-agent/src/cli/types.rs +30 -1
- package/crates/team-agent/src/compiler/tests.rs +2 -2
- package/crates/team-agent/src/compiler.rs +1 -1
- package/crates/team-agent/src/fake_worker.rs +32 -145
- package/crates/team-agent/src/lifecycle/display.rs +3 -3
- package/crates/team-agent/src/lifecycle/launch.rs +560 -69
- package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
- package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +72 -1
- package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +668 -8
- package/crates/team-agent/src/lifecycle/types.rs +5 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
- package/crates/team-agent/src/mcp_server/wire.rs +153 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
- package/crates/team-agent/src/messaging/mod.rs +6 -3
- package/crates/team-agent/src/messaging/results.rs +240 -158
- package/crates/team-agent/src/messaging/send.rs +3 -2
- package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
- package/crates/team-agent/src/os_probe.rs +11 -0
- package/crates/team-agent/src/state/persist.rs +6 -0
- package/crates/team-agent/src/tmux_backend.rs +85 -0
- package/crates/team-agent/src/transport/test_support.rs +46 -1
- package/crates/team-agent/src/transport.rs +27 -0
- package/package.json +4 -4
- package/skills/team-agent/references/recovery-runbook.md +277 -0
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -107,6 +107,7 @@ pub fn cmd_quick_start(args: &QuickStartArgs) -> Result<CmdResult, CliError> {
|
|
|
107
107
|
args.team_id.as_deref(),
|
|
108
108
|
args.yes,
|
|
109
109
|
args.fresh,
|
|
110
|
+
!args.no_display,
|
|
110
111
|
)?;
|
|
111
112
|
let readiness = value.get("readiness").and_then(Value::as_object);
|
|
112
113
|
let all_resumable_have_session = readiness
|
|
@@ -77,6 +77,12 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
|
|
|
77
77
|
"quick-start" => cmd_quick_start(&quick_start_args(args, cwd)?).map(emit_result),
|
|
78
78
|
"compile" => cmd_compile(&compile_args(args, cwd)?).map(emit_result),
|
|
79
79
|
"send" => cmd_send(&send_args(args, cwd)?).map(emit_result),
|
|
80
|
+
"fallback-send-leader" => {
|
|
81
|
+
cmd_fallback_send_leader(&fallback_send_leader_args(args, cwd)?).map(emit_result)
|
|
82
|
+
}
|
|
83
|
+
"fallback-report-result" => {
|
|
84
|
+
cmd_fallback_report_result(&fallback_report_result_args(args, cwd)?).map(emit_result)
|
|
85
|
+
}
|
|
80
86
|
"allow-peer-talk" => {
|
|
81
87
|
cmd_allow_peer_talk(&allow_peer_talk_args(args, cwd)?).map(emit_result)
|
|
82
88
|
}
|
|
@@ -136,6 +142,8 @@ const DISPATCH_COMMANDS: &[&str] = &[
|
|
|
136
142
|
"quick-start",
|
|
137
143
|
"compile",
|
|
138
144
|
"send",
|
|
145
|
+
"fallback-send-leader",
|
|
146
|
+
"fallback-report-result",
|
|
139
147
|
"allow-peer-talk",
|
|
140
148
|
"status",
|
|
141
149
|
"stop",
|
|
@@ -202,10 +210,12 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
202
210
|
)
|
|
203
211
|
}
|
|
204
212
|
Some("init") => "usage: team-agent init [--workspace WORKSPACE] [--force] [--json]".to_string(),
|
|
205
|
-
Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--fresh] [--json]\n\ndefaults: display_backend=
|
|
213
|
+
Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--fresh] [--no-display] [--json]\n\ndefaults: display_backend=adaptive; set display_backend: none in TEAM.md or pass --no-display to use one worker window per agent.".to_string(),
|
|
206
214
|
Some("start") => "usage: team-agent start [TEAMDIR] [--yes] [--fresh] [--json]".to_string(),
|
|
207
215
|
Some("compile") => "usage: team-agent compile --team TEAM [--out FILE] [--json]".to_string(),
|
|
208
216
|
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(),
|
|
217
|
+
Some("fallback-send-leader") => "usage: team-agent fallback-send-leader --workspace WORKSPACE --team TEAM --sender AGENT --message-id ID --content TEXT --primary-error ERROR [--task TASK] [--json]\n\nEmergency one-shot fallback only after a leader-bound MCP send transport failure; restart-agent after use.".to_string(),
|
|
218
|
+
Some("fallback-report-result") => "usage: team-agent fallback-report-result --workspace WORKSPACE --team TEAM --agent-id AGENT --task-id TASK --result-json JSON --primary-error ERROR [--json]\n\nEmergency one-shot fallback only after a report_result MCP transport failure; persists the result DB row, then restart-agent after use.".to_string(),
|
|
209
219
|
Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
|
|
210
220
|
Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]".to_string(),
|
|
211
221
|
Some("stop") => "usage: team-agent stop [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
|
|
@@ -347,7 +357,9 @@ fn install_skill_args(args: &[String]) -> Result<InstallSkillArgs, CliError> {
|
|
|
347
357
|
|
|
348
358
|
/// 取 `--flag <value>` 的值(用于 install-skill 的 --source/--dest,parse_args 不覆盖的旗标)。
|
|
349
359
|
fn flag_value(args: &[String], flag: &str) -> Option<String> {
|
|
350
|
-
args.iter()
|
|
360
|
+
args.iter()
|
|
361
|
+
.position(|a| a == flag)
|
|
362
|
+
.and_then(|i| args.get(i + 1).cloned())
|
|
351
363
|
}
|
|
352
364
|
|
|
353
365
|
/// `team-agent install-skill`(RED-1 单源):repo `skills/team-agent` → `~/.codex|.claude|.copilot`。
|
|
@@ -355,7 +367,9 @@ fn flag_value(args: &[String], flag: &str) -> Option<String> {
|
|
|
355
367
|
fn cmd_install_skill(args: &InstallSkillArgs) -> Result<CmdResult, CliError> {
|
|
356
368
|
// 卸载分支(单源:走同一 SkillTarget 表的 dest_dir;all → SINGLE_TARGETS 全集)。
|
|
357
369
|
if args.uninstall {
|
|
358
|
-
let home = std::env::var_os("HOME")
|
|
370
|
+
let home = std::env::var_os("HOME")
|
|
371
|
+
.map(PathBuf::from)
|
|
372
|
+
.unwrap_or_else(|| PathBuf::from("."));
|
|
359
373
|
let targets: Vec<crate::packaging::SkillTarget> = match args.target {
|
|
360
374
|
crate::packaging::SkillTarget::All => {
|
|
361
375
|
crate::packaging::SkillTarget::SINGLE_TARGETS.to_vec()
|
|
@@ -367,7 +381,8 @@ fn cmd_install_skill(args: &InstallSkillArgs) -> Result<CmdResult, CliError> {
|
|
|
367
381
|
if let Some(dest) = t.dest_dir(&home) {
|
|
368
382
|
let existed = dest.0.exists();
|
|
369
383
|
if existed && !args.dry_run {
|
|
370
|
-
std::fs::remove_dir_all(&dest.0)
|
|
384
|
+
std::fs::remove_dir_all(&dest.0)
|
|
385
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
371
386
|
}
|
|
372
387
|
removed.push(serde_json::json!({
|
|
373
388
|
"target": t,
|
|
@@ -386,13 +401,14 @@ fn cmd_install_skill(args: &InstallSkillArgs) -> Result<CmdResult, CliError> {
|
|
|
386
401
|
.source
|
|
387
402
|
.clone()
|
|
388
403
|
.ok_or_else(|| CliError::Usage("missing --source <skill dir>".to_string()))?;
|
|
389
|
-
let outcomes =
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
404
|
+
let outcomes =
|
|
405
|
+
crate::packaging::install::install_skill(&crate::packaging::SkillInstallOptions {
|
|
406
|
+
target: args.target,
|
|
407
|
+
dest: args.dest.clone(),
|
|
408
|
+
dry_run: args.dry_run,
|
|
409
|
+
source,
|
|
410
|
+
})
|
|
411
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
396
412
|
let installed: Vec<serde_json::Value> = outcomes
|
|
397
413
|
.iter()
|
|
398
414
|
.map(|o| {
|
|
@@ -643,6 +659,11 @@ struct ParsedArgs {
|
|
|
643
659
|
pane: Option<String>,
|
|
644
660
|
provider: Option<String>,
|
|
645
661
|
message_id: Option<String>,
|
|
662
|
+
content: Option<String>,
|
|
663
|
+
primary_error: Option<String>,
|
|
664
|
+
agent_id: Option<String>,
|
|
665
|
+
task_id: Option<String>,
|
|
666
|
+
result_json: Option<String>,
|
|
646
667
|
}
|
|
647
668
|
|
|
648
669
|
fn parse_args(args: &[String]) -> ParsedArgs {
|
|
@@ -664,7 +685,9 @@ fn parse_args(args: &[String]) -> ParsedArgs {
|
|
|
664
685
|
"--team-id" => parsed.team_id = next_arg(args, &mut i),
|
|
665
686
|
"--targets" | "--target" | "--to" => parsed.targets = next_arg(args, &mut i),
|
|
666
687
|
"--task" => parsed.task = next_arg(args, &mut i),
|
|
688
|
+
"--task-id" => parsed.task_id = next_arg(args, &mut i),
|
|
667
689
|
"--sender" => parsed.sender = next_arg(args, &mut i),
|
|
690
|
+
"--agent-id" => parsed.agent_id = next_arg(args, &mut i),
|
|
668
691
|
"--watch-result" => parsed.watch_result = true,
|
|
669
692
|
"--requires-ack" => parsed.requires_ack = true,
|
|
670
693
|
"--no-ack" => parsed.no_ack = true,
|
|
@@ -719,6 +742,9 @@ fn parse_args(args: &[String]) -> ParsedArgs {
|
|
|
719
742
|
"--pane" => parsed.pane = next_arg(args, &mut i),
|
|
720
743
|
"--provider" => parsed.provider = next_arg(args, &mut i),
|
|
721
744
|
"--message-id" => parsed.message_id = next_arg(args, &mut i),
|
|
745
|
+
"--content" => parsed.content = next_arg(args, &mut i),
|
|
746
|
+
"--primary-error" => parsed.primary_error = next_arg(args, &mut i),
|
|
747
|
+
"--result-json" => parsed.result_json = next_arg(args, &mut i),
|
|
722
748
|
"-h" | "--help" => {}
|
|
723
749
|
other if other.starts_with("--team=") => {
|
|
724
750
|
parsed.team = Some(other.trim_start_matches("--team=").to_string());
|
|
@@ -792,6 +818,7 @@ fn quick_start_args(args: &[String], cwd: &Path) -> Result<QuickStartArgs, CliEr
|
|
|
792
818
|
team_id: parsed.team_id.or(parsed.team),
|
|
793
819
|
yes: parsed.yes,
|
|
794
820
|
fresh: parsed.fresh,
|
|
821
|
+
no_display: parsed.no_display,
|
|
795
822
|
json: parsed.json,
|
|
796
823
|
})
|
|
797
824
|
}
|
|
@@ -862,6 +889,60 @@ fn send_args(args: &[String], cwd: &Path) -> Result<SendArgs, CliError> {
|
|
|
862
889
|
})
|
|
863
890
|
}
|
|
864
891
|
|
|
892
|
+
fn fallback_send_leader_args(
|
|
893
|
+
args: &[String],
|
|
894
|
+
cwd: &Path,
|
|
895
|
+
) -> Result<FallbackSendLeaderArgs, CliError> {
|
|
896
|
+
let parsed = parse_args(args);
|
|
897
|
+
Ok(FallbackSendLeaderArgs {
|
|
898
|
+
workspace: workspace(&parsed, cwd),
|
|
899
|
+
team: parsed.team,
|
|
900
|
+
sender: parsed
|
|
901
|
+
.sender
|
|
902
|
+
.or(parsed.agent_id)
|
|
903
|
+
.ok_or_else(|| CliError::Usage("missing --sender <agent>".to_string()))?,
|
|
904
|
+
task: parsed.task.or(parsed.task_id),
|
|
905
|
+
message_id: parsed
|
|
906
|
+
.message_id
|
|
907
|
+
.ok_or_else(|| CliError::Usage("missing --message-id <id>".to_string()))?,
|
|
908
|
+
content: parsed
|
|
909
|
+
.content
|
|
910
|
+
.or_else(|| (!parsed.positionals.is_empty()).then(|| parsed.positionals.join(" ")))
|
|
911
|
+
.ok_or_else(|| CliError::Usage("missing --content <text>".to_string()))?,
|
|
912
|
+
primary_error: parsed
|
|
913
|
+
.primary_error
|
|
914
|
+
.ok_or_else(|| CliError::Usage("missing --primary-error <error>".to_string()))?,
|
|
915
|
+
json: parsed.json,
|
|
916
|
+
})
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
fn fallback_report_result_args(
|
|
920
|
+
args: &[String],
|
|
921
|
+
cwd: &Path,
|
|
922
|
+
) -> Result<FallbackReportResultArgs, CliError> {
|
|
923
|
+
let parsed = parse_args(args);
|
|
924
|
+
Ok(FallbackReportResultArgs {
|
|
925
|
+
workspace: workspace(&parsed, cwd),
|
|
926
|
+
team: parsed.team,
|
|
927
|
+
agent_id: parsed
|
|
928
|
+
.agent_id
|
|
929
|
+
.or(parsed.sender)
|
|
930
|
+
.ok_or_else(|| CliError::Usage("missing --agent-id <agent>".to_string()))?,
|
|
931
|
+
task_id: parsed
|
|
932
|
+
.task_id
|
|
933
|
+
.or(parsed.task)
|
|
934
|
+
.ok_or_else(|| CliError::Usage("missing --task-id <task>".to_string()))?,
|
|
935
|
+
result_json: parsed
|
|
936
|
+
.result_json
|
|
937
|
+
.or_else(|| parsed.result)
|
|
938
|
+
.ok_or_else(|| CliError::Usage("missing --result-json <json>".to_string()))?,
|
|
939
|
+
primary_error: parsed
|
|
940
|
+
.primary_error
|
|
941
|
+
.ok_or_else(|| CliError::Usage("missing --primary-error <error>".to_string()))?,
|
|
942
|
+
json: parsed.json,
|
|
943
|
+
})
|
|
944
|
+
}
|
|
945
|
+
|
|
865
946
|
fn allow_peer_talk_args(args: &[String], cwd: &Path) -> Result<AllowPeerTalkArgs, CliError> {
|
|
866
947
|
let parsed = parse_args(args);
|
|
867
948
|
Ok(AllowPeerTalkArgs {
|
|
@@ -115,9 +115,16 @@ pub mod lifecycle_port {
|
|
|
115
115
|
team_id: Option<&str>,
|
|
116
116
|
yes: bool,
|
|
117
117
|
fresh: bool,
|
|
118
|
+
open_display: bool,
|
|
118
119
|
) -> Result<Value, CliError> {
|
|
119
|
-
match crate::lifecycle::
|
|
120
|
-
workspace,
|
|
120
|
+
match crate::lifecycle::quick_start_in_workspace_with_display(
|
|
121
|
+
workspace,
|
|
122
|
+
agents_dir,
|
|
123
|
+
name,
|
|
124
|
+
yes,
|
|
125
|
+
fresh,
|
|
126
|
+
team_id,
|
|
127
|
+
open_display,
|
|
121
128
|
) {
|
|
122
129
|
Ok(report) => Ok(quick_start_value(report)),
|
|
123
130
|
Err(e) => Ok(error_value(e)),
|
|
@@ -169,7 +176,7 @@ pub mod lifecycle_port {
|
|
|
169
176
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
170
177
|
let state = shutdown_state_for_team(&run_ws, team)?;
|
|
171
178
|
let transport = if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
|
|
172
|
-
|
|
179
|
+
shutdown_transport_for_endpoint(endpoint)
|
|
173
180
|
} else {
|
|
174
181
|
shutdown_workspace_transport(&run_ws)
|
|
175
182
|
};
|
|
@@ -278,6 +285,33 @@ pub mod lifecycle_port {
|
|
|
278
285
|
let anchor_sessions = anchor_sessions_from_state(state, &pane_targets, event_log);
|
|
279
286
|
match sessions_to_kill(&sessions, &anchor_sessions) {
|
|
280
287
|
KillDecision::KillServerExclusive => {
|
|
288
|
+
if state
|
|
289
|
+
.get("tmux_socket_source")
|
|
290
|
+
.and_then(Value::as_str)
|
|
291
|
+
== Some("leader_env")
|
|
292
|
+
{
|
|
293
|
+
let _ = event_log.write(
|
|
294
|
+
"shutdown.kill_server_skipped_shared_socket",
|
|
295
|
+
json!({
|
|
296
|
+
"reason": "leader_env_tmux_socket",
|
|
297
|
+
"spared_sessions": [],
|
|
298
|
+
"killed_sessions": sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
let mut error = None;
|
|
302
|
+
for session in &sessions {
|
|
303
|
+
if let Err(err) = transport.kill_session(session) {
|
|
304
|
+
if !tmux_absent_error(&err.to_string()) {
|
|
305
|
+
error.get_or_insert_with(|| err.to_string());
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return ShutdownSocketCleanup {
|
|
310
|
+
killed_sessions: sessions,
|
|
311
|
+
spared_sessions: Vec::new(),
|
|
312
|
+
error,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
281
315
|
let error = transport.kill_server().err().map(|error| error.to_string());
|
|
282
316
|
ShutdownSocketCleanup {
|
|
283
317
|
killed_sessions: sessions,
|
|
@@ -466,9 +500,17 @@ pub mod lifecycle_port {
|
|
|
466
500
|
None
|
|
467
501
|
};
|
|
468
502
|
let probe_timeout = crate::os_probe::probe_timeout();
|
|
469
|
-
|
|
470
|
-
//
|
|
471
|
-
|
|
503
|
+
let probe_timeout_kind = probe_timeout.as_ref().map(|timeout| timeout.probe);
|
|
504
|
+
// swallow batch 1: a failed ps probe degrades cleanup truthfully — the
|
|
505
|
+
// empty table must never read as a clean "no residual processes". A slow
|
|
506
|
+
// per-process cwd probe is diagnostic only once session/process residuals
|
|
507
|
+
// are otherwise clean.
|
|
508
|
+
let cleanup_truth_degraded = probe_degraded || probe_timeout_kind == Some("ps_table");
|
|
509
|
+
let diagnostic_probe_degraded = probe_timeout_kind == Some("lsof_cwd");
|
|
510
|
+
let other_probe_timeout_degraded =
|
|
511
|
+
probe_timeout.is_some() && !cleanup_truth_degraded && !diagnostic_probe_degraded;
|
|
512
|
+
let verification_degraded =
|
|
513
|
+
cleanup_truth_degraded || diagnostic_probe_degraded || other_probe_timeout_degraded;
|
|
472
514
|
let session_killed = !killed_sessions.is_empty()
|
|
473
515
|
&& kill_error.is_none()
|
|
474
516
|
&& session_residuals.is_empty()
|
|
@@ -500,13 +542,13 @@ pub mod lifecycle_port {
|
|
|
500
542
|
&& kill_error.is_none()
|
|
501
543
|
&& session_residuals.is_empty()
|
|
502
544
|
&& process_residuals.is_empty()
|
|
503
|
-
&& !
|
|
545
|
+
&& !cleanup_truth_degraded
|
|
504
546
|
&& !coordinator_timeout;
|
|
505
547
|
let status = if ok {
|
|
506
548
|
"ok"
|
|
507
549
|
} else if coordinator_timeout {
|
|
508
550
|
"timeout"
|
|
509
|
-
} else if
|
|
551
|
+
} else if cleanup_truth_degraded {
|
|
510
552
|
"partial"
|
|
511
553
|
} else if kill_error.is_some() {
|
|
512
554
|
"failed"
|
|
@@ -515,12 +557,11 @@ pub mod lifecycle_port {
|
|
|
515
557
|
};
|
|
516
558
|
let phase = if coordinator_timeout {
|
|
517
559
|
Some("stop_coordinator")
|
|
518
|
-
} else if
|
|
560
|
+
} else if cleanup_truth_degraded {
|
|
519
561
|
Some("os_probe")
|
|
520
562
|
} else {
|
|
521
563
|
None
|
|
522
564
|
};
|
|
523
|
-
let probe_timeout_kind = probe_timeout.as_ref().map(|timeout| timeout.probe);
|
|
524
565
|
let probe_timeout_value = probe_timeout.as_ref().map(|timeout| {
|
|
525
566
|
json!({
|
|
526
567
|
"probe": timeout.probe,
|
|
@@ -652,10 +693,19 @@ pub mod lifecycle_port {
|
|
|
652
693
|
crate::tmux_backend::TmuxBackend::for_workspace(workspace)
|
|
653
694
|
}
|
|
654
695
|
|
|
696
|
+
fn shutdown_transport_for_endpoint(endpoint: &str) -> crate::tmux_backend::TmuxBackend {
|
|
697
|
+
if Path::new(endpoint).is_absolute() {
|
|
698
|
+
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
|
|
699
|
+
} else {
|
|
700
|
+
crate::tmux_backend::TmuxBackend::for_socket_name(endpoint)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
655
704
|
fn legacy_worker_tmux_endpoint(state: &Value) -> Option<&str> {
|
|
656
705
|
state
|
|
657
706
|
.get("tmux_endpoint")
|
|
658
707
|
.and_then(Value::as_str)
|
|
708
|
+
.or_else(|| state.get("tmux_socket").and_then(Value::as_str))
|
|
659
709
|
.filter(|endpoint| !endpoint.is_empty())
|
|
660
710
|
}
|
|
661
711
|
|
|
@@ -37,6 +37,120 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
37
37
|
Ok(CmdResult::from_json(value, args.json))
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
pub fn cmd_fallback_send_leader(args: &FallbackSendLeaderArgs) -> Result<CmdResult, CliError> {
|
|
41
|
+
if let Some(value) = fallback_business_refusal(&args.primary_error, args.json) {
|
|
42
|
+
return Ok(value);
|
|
43
|
+
}
|
|
44
|
+
let selected = crate::state::selector::resolve_active_team(
|
|
45
|
+
&args.workspace,
|
|
46
|
+
args.team.as_deref(),
|
|
47
|
+
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
48
|
+
)?;
|
|
49
|
+
let target = MessageTarget::Single("leader".to_string());
|
|
50
|
+
let opts = SendOptions {
|
|
51
|
+
task_id: args.task.as_ref().map(|task| TaskId::new(task.clone())),
|
|
52
|
+
route_task_id: false,
|
|
53
|
+
sender: args.sender.clone(),
|
|
54
|
+
requires_ack: false,
|
|
55
|
+
wait_visible: false,
|
|
56
|
+
block_until_delivered: false,
|
|
57
|
+
team: Some(TeamKey::new(selected.team_key.clone())),
|
|
58
|
+
message_id: Some(args.message_id.clone()),
|
|
59
|
+
..SendOptions::default()
|
|
60
|
+
};
|
|
61
|
+
let primary = messaging::send_message(&selected.run_workspace, &target, &args.content, &opts);
|
|
62
|
+
let message_id = match &primary {
|
|
63
|
+
Ok(outcome) => outcome
|
|
64
|
+
.message_id
|
|
65
|
+
.clone()
|
|
66
|
+
.unwrap_or_else(|| args.message_id.clone()),
|
|
67
|
+
Err(_) => args.message_id.clone(),
|
|
68
|
+
};
|
|
69
|
+
if let Ok(outcome) = &primary {
|
|
70
|
+
if is_business_refusal_outcome(outcome) {
|
|
71
|
+
let value = json!({
|
|
72
|
+
"ok": false,
|
|
73
|
+
"status": "refused",
|
|
74
|
+
"reason": "business_reject",
|
|
75
|
+
"primary_error": args.primary_error,
|
|
76
|
+
"message_id": outcome.message_id,
|
|
77
|
+
"action": "N38 fallback refused: business rule refusals must not use fallback pane delivery",
|
|
78
|
+
});
|
|
79
|
+
return Ok(CmdResult::from_json(value, args.json));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let state = selected_state_with_active_key(&selected);
|
|
84
|
+
let event_log = crate::event_log::EventLog::new(&selected.run_workspace);
|
|
85
|
+
let primary_error = match primary {
|
|
86
|
+
Ok(outcome) if primary_delivery_succeeded(outcome.status) => {
|
|
87
|
+
let mut value = delivery_outcome_json(&outcome, &target, &args.content, &opts);
|
|
88
|
+
if let Some(obj) = value.as_object_mut() {
|
|
89
|
+
obj.insert("fallback_used".to_string(), json!(false));
|
|
90
|
+
obj.insert("primary_error".to_string(), json!(args.primary_error));
|
|
91
|
+
}
|
|
92
|
+
return Ok(CmdResult::from_json(value, args.json));
|
|
93
|
+
}
|
|
94
|
+
Ok(outcome) => format!(
|
|
95
|
+
"{}; fallback_cli_primary_status={}",
|
|
96
|
+
args.primary_error,
|
|
97
|
+
delivery_status_wire(outcome.status)
|
|
98
|
+
),
|
|
99
|
+
Err(error) => format!("{}; fallback_cli_primary_error={error}", args.primary_error),
|
|
100
|
+
};
|
|
101
|
+
let outcome = messaging::deliver_to_leader_fallback_pane(
|
|
102
|
+
&selected.run_workspace,
|
|
103
|
+
&state,
|
|
104
|
+
&message_id,
|
|
105
|
+
None,
|
|
106
|
+
&args.content,
|
|
107
|
+
false,
|
|
108
|
+
Some(&primary_error),
|
|
109
|
+
&event_log,
|
|
110
|
+
)?;
|
|
111
|
+
let mut value = delivery_outcome_json(&outcome, &target, &args.content, &opts);
|
|
112
|
+
if let Some(obj) = value.as_object_mut() {
|
|
113
|
+
obj.insert("primary_error".to_string(), json!(args.primary_error));
|
|
114
|
+
obj.insert("delivered_via".to_string(), json!("fallback_pane"));
|
|
115
|
+
obj.insert(
|
|
116
|
+
"next_action".to_string(),
|
|
117
|
+
json!("run team-agent restart-agent to refresh the worker MCP transport"),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
Ok(CmdResult::from_json(value, args.json))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pub fn cmd_fallback_report_result(args: &FallbackReportResultArgs) -> Result<CmdResult, CliError> {
|
|
124
|
+
if let Some(value) = fallback_business_refusal(&args.primary_error, args.json) {
|
|
125
|
+
return Ok(value);
|
|
126
|
+
}
|
|
127
|
+
let selected = crate::state::selector::resolve_active_team(
|
|
128
|
+
&args.workspace,
|
|
129
|
+
args.team.as_deref(),
|
|
130
|
+
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
131
|
+
)?;
|
|
132
|
+
let envelope = fallback_result_envelope(args)?;
|
|
133
|
+
let value = messaging::report_result_for_owner_team_with_primary_error(
|
|
134
|
+
&selected.run_workspace,
|
|
135
|
+
&envelope,
|
|
136
|
+
Some(&selected.team_key),
|
|
137
|
+
Some(&args.primary_error),
|
|
138
|
+
)?;
|
|
139
|
+
let mut value = value;
|
|
140
|
+
if let Some(obj) = value.as_object_mut() {
|
|
141
|
+
obj.insert("primary_error".to_string(), json!(args.primary_error));
|
|
142
|
+
obj.insert(
|
|
143
|
+
"fallback_protocol".to_string(),
|
|
144
|
+
json!("fallback-report-result"),
|
|
145
|
+
);
|
|
146
|
+
obj.insert(
|
|
147
|
+
"next_action".to_string(),
|
|
148
|
+
json!("run team-agent restart-agent to refresh the worker MCP transport"),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
Ok(CmdResult::from_json(value, args.json))
|
|
152
|
+
}
|
|
153
|
+
|
|
40
154
|
fn routing_ambiguous_value(
|
|
41
155
|
workspace: &Path,
|
|
42
156
|
args: &SendArgs,
|
|
@@ -77,6 +191,101 @@ fn routing_ambiguous_value(
|
|
|
77
191
|
}))
|
|
78
192
|
}
|
|
79
193
|
|
|
194
|
+
fn selected_state_with_active_key(selected: &crate::state::selector::SelectedTeam) -> Value {
|
|
195
|
+
let mut state = selected.state.clone();
|
|
196
|
+
if let Some(obj) = state.as_object_mut() {
|
|
197
|
+
obj.insert(
|
|
198
|
+
"active_team_key".to_string(),
|
|
199
|
+
Value::String(selected.team_key.clone()),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
state
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fn fallback_result_envelope(args: &FallbackReportResultArgs) -> Result<Value, CliError> {
|
|
206
|
+
let mut envelope: Value = serde_json::from_str(&args.result_json)?;
|
|
207
|
+
let Some(obj) = envelope.as_object_mut() else {
|
|
208
|
+
return Err(CliError::Usage(
|
|
209
|
+
"--result-json must be a JSON object".to_string(),
|
|
210
|
+
));
|
|
211
|
+
};
|
|
212
|
+
obj.entry("schema_version".to_string())
|
|
213
|
+
.or_insert_with(|| json!("result_envelope_v1"));
|
|
214
|
+
obj.entry("task_id".to_string())
|
|
215
|
+
.or_insert_with(|| json!(args.task_id));
|
|
216
|
+
obj.entry("agent_id".to_string())
|
|
217
|
+
.or_insert_with(|| json!(args.agent_id));
|
|
218
|
+
obj.entry("status".to_string())
|
|
219
|
+
.or_insert_with(|| json!("success"));
|
|
220
|
+
obj.entry("summary".to_string())
|
|
221
|
+
.or_insert_with(|| json!("completed"));
|
|
222
|
+
for key in ["changes", "tests", "risks", "artifacts", "next_actions"] {
|
|
223
|
+
obj.entry(key.to_string()).or_insert_with(|| json!([]));
|
|
224
|
+
}
|
|
225
|
+
Ok(envelope)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fn fallback_business_refusal(primary_error: &str, as_json: bool) -> Option<CmdResult> {
|
|
229
|
+
is_business_reject_text(primary_error).then(|| {
|
|
230
|
+
CmdResult::from_json(
|
|
231
|
+
json!({
|
|
232
|
+
"ok": false,
|
|
233
|
+
"status": "refused",
|
|
234
|
+
"reason": "business_reject",
|
|
235
|
+
"primary_error": primary_error,
|
|
236
|
+
"action": "N38 fallback refused: business rule refusals must not use fallback pane delivery",
|
|
237
|
+
}),
|
|
238
|
+
as_json,
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
fn is_business_refusal_outcome(outcome: &DeliveryOutcome) -> bool {
|
|
244
|
+
matches!(
|
|
245
|
+
outcome.reason,
|
|
246
|
+
Some(
|
|
247
|
+
DeliveryRefusal::TargetNotInTeam
|
|
248
|
+
| DeliveryRefusal::HumanConfirmationRequired
|
|
249
|
+
| DeliveryRefusal::MissingPermissions
|
|
250
|
+
| DeliveryRefusal::UnknownRecipient
|
|
251
|
+
| DeliveryRefusal::TeamOwnerMismatch
|
|
252
|
+
| DeliveryRefusal::Ambiguous
|
|
253
|
+
| DeliveryRefusal::RecipientPaneInNonInputMode
|
|
254
|
+
| DeliveryRefusal::SessionDrift
|
|
255
|
+
| DeliveryRefusal::RoutingAmbiguous
|
|
256
|
+
| DeliveryRefusal::EmptyTargetList
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
fn primary_delivery_succeeded(status: DeliveryStatus) -> bool {
|
|
262
|
+
matches!(
|
|
263
|
+
status,
|
|
264
|
+
DeliveryStatus::Delivered | DeliveryStatus::AlreadyDelivered
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fn is_business_reject_text(error: &str) -> bool {
|
|
269
|
+
let lower = error.to_ascii_lowercase();
|
|
270
|
+
[
|
|
271
|
+
"peer_not_in_scope",
|
|
272
|
+
"target_not_in_team",
|
|
273
|
+
"permission denied",
|
|
274
|
+
"missing_permissions",
|
|
275
|
+
"human_confirmation_required",
|
|
276
|
+
"unknown_recipient",
|
|
277
|
+
"routing_ambiguous",
|
|
278
|
+
"quota",
|
|
279
|
+
"rate limit",
|
|
280
|
+
"rate_limit",
|
|
281
|
+
"blacklist",
|
|
282
|
+
"blacklisted",
|
|
283
|
+
"forbidden",
|
|
284
|
+
]
|
|
285
|
+
.iter()
|
|
286
|
+
.any(|needle| lower.contains(needle))
|
|
287
|
+
}
|
|
288
|
+
|
|
80
289
|
/// `_send_target`(`commands.py:181-184`):`--to` comma-split fanout / `target` 单值 / None。
|
|
81
290
|
pub fn send_target(targets: Option<&str>, target: Option<&str>) -> MessageTarget {
|
|
82
291
|
if let Some(targets) = targets.filter(|s| !s.is_empty()) {
|
|
@@ -221,3 +430,46 @@ fn delivery_stage_wire(stage: DeliveryStage) -> &'static str {
|
|
|
221
430
|
DeliveryStage::VisibleCheck => "visible_check",
|
|
222
431
|
}
|
|
223
432
|
}
|
|
433
|
+
|
|
434
|
+
#[cfg(test)]
|
|
435
|
+
mod e23_tests {
|
|
436
|
+
use super::*;
|
|
437
|
+
|
|
438
|
+
#[test]
|
|
439
|
+
fn fallback_error_classifier_allows_transport_and_primary_bugs() {
|
|
440
|
+
for error in [
|
|
441
|
+
"Transport closed",
|
|
442
|
+
"Connection refused",
|
|
443
|
+
"Broken pipe",
|
|
444
|
+
"EOF on transport",
|
|
445
|
+
"MCP timeout after 5s",
|
|
446
|
+
"internal assertion failed: unwrap on Err",
|
|
447
|
+
"primary_delivery_error: serialize failed",
|
|
448
|
+
] {
|
|
449
|
+
assert!(
|
|
450
|
+
!is_business_reject_text(error),
|
|
451
|
+
"failure should be fallback-eligible, not classified as a business refusal: {error}"
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
#[test]
|
|
457
|
+
fn fallback_error_classifier_blocks_business_refusals() {
|
|
458
|
+
for error in [
|
|
459
|
+
"peer_not_in_scope",
|
|
460
|
+
"target_not_in_team",
|
|
461
|
+
"permission denied",
|
|
462
|
+
"missing_permissions",
|
|
463
|
+
"human_confirmation_required",
|
|
464
|
+
"unknown_recipient",
|
|
465
|
+
"quota exceeded",
|
|
466
|
+
"rate_limit",
|
|
467
|
+
"blacklisted target",
|
|
468
|
+
] {
|
|
469
|
+
assert!(
|
|
470
|
+
is_business_reject_text(error),
|
|
471
|
+
"business refusal must not use fallback pane delivery: {error}"
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
@@ -206,6 +206,7 @@ fn current_uid() -> Option<String> {
|
|
|
206
206
|
team_id: None,
|
|
207
207
|
yes: true,
|
|
208
208
|
fresh: false,
|
|
209
|
+
no_display: false,
|
|
209
210
|
json: true,
|
|
210
211
|
};
|
|
211
212
|
let _ = cmd_quick_start(&args); // real quick_start compiles the spec before any coordinator/launch step
|
|
@@ -240,6 +241,7 @@ fn current_uid() -> Option<String> {
|
|
|
240
241
|
team_id: None,
|
|
241
242
|
yes: false,
|
|
242
243
|
fresh: false,
|
|
244
|
+
no_display: false,
|
|
243
245
|
json: true,
|
|
244
246
|
};
|
|
245
247
|
let text = outcome_text(cmd_quick_start(&args));
|
|
@@ -275,15 +277,25 @@ fn current_uid() -> Option<String> {
|
|
|
275
277
|
);
|
|
276
278
|
}
|
|
277
279
|
|
|
278
|
-
// 4 [P1] — cmd_add_agent with a DUPLICATE agent id surfaces the REAL
|
|
279
|
-
// "agent id already exists" error
|
|
280
|
-
//
|
|
280
|
+
// 4 [P1] — cmd_add_agent with a DUPLICATE runtime agent id surfaces the REAL
|
|
281
|
+
// crate::lifecycle::add_agent "agent id already exists" error. E25: role docs/spec files are
|
|
282
|
+
// inputs, not membership; duplicate membership is runtime-state based.
|
|
281
283
|
#[test]
|
|
282
284
|
fn cli_add_agent_duplicate_id_surfaces_real_error() {
|
|
283
285
|
let team = deleg_uniq_dir("addagent");
|
|
284
286
|
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
285
287
|
std::fs::write(team.join("TEAM.md"), DELEG_TEAM_MD).unwrap();
|
|
286
288
|
std::fs::write(team.join("agents").join("implementer.md"), DELEG_VALID_ROLE).unwrap(); // 'implementer' already exists
|
|
289
|
+
crate::state::persist::save_runtime_state(
|
|
290
|
+
&team,
|
|
291
|
+
&json!({
|
|
292
|
+
"session_name": "team-deleg-addagent",
|
|
293
|
+
"agents": {
|
|
294
|
+
"implementer": {"status": "running", "provider": "codex", "window": "implementer"}
|
|
295
|
+
}
|
|
296
|
+
}),
|
|
297
|
+
)
|
|
298
|
+
.unwrap();
|
|
287
299
|
let dup_role = team.join("dup-role.md");
|
|
288
300
|
std::fs::write(&dup_role, DELEG_VALID_ROLE).unwrap(); // role file must exist
|
|
289
301
|
let args = AddAgentArgs {
|