@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.
Files changed (36) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/emit.rs +92 -11
  5. package/crates/team-agent/src/cli/mod.rs +60 -10
  6. package/crates/team-agent/src/cli/send.rs +252 -0
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +159 -2
  9. package/crates/team-agent/src/cli/types.rs +30 -1
  10. package/crates/team-agent/src/compiler/tests.rs +2 -2
  11. package/crates/team-agent/src/compiler.rs +1 -1
  12. package/crates/team-agent/src/fake_worker.rs +32 -145
  13. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  14. package/crates/team-agent/src/lifecycle/launch.rs +560 -69
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
  16. package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
  17. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +72 -1
  18. package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
  19. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +668 -8
  20. package/crates/team-agent/src/lifecycle/types.rs +5 -0
  21. package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
  22. package/crates/team-agent/src/mcp_server/wire.rs +153 -3
  23. package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
  24. package/crates/team-agent/src/messaging/mod.rs +6 -3
  25. package/crates/team-agent/src/messaging/results.rs +240 -158
  26. package/crates/team-agent/src/messaging/send.rs +3 -2
  27. package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
  28. package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
  29. package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
  30. package/crates/team-agent/src/os_probe.rs +11 -0
  31. package/crates/team-agent/src/state/persist.rs +6 -0
  32. package/crates/team-agent/src/tmux_backend.rs +85 -0
  33. package/crates/team-agent/src/transport/test_support.rs +46 -1
  34. package/crates/team-agent/src/transport.rs +27 -0
  35. package/package.json +4 -4
  36. package/skills/team-agent/references/recovery-runbook.md +277 -0
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.14"
569
+ version = "0.3.15"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "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.14"
12
+ version = "0.3.15"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -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=none; set display_backend: adaptive in TEAM.md to opt in to adaptive display windows.".to_string(),
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().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned())
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").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
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).map_err(|e| CliError::Runtime(e.to_string()))?;
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 = crate::packaging::install::install_skill(&crate::packaging::SkillInstallOptions {
390
- target: args.target,
391
- dest: args.dest.clone(),
392
- dry_run: args.dry_run,
393
- source,
394
- })
395
- .map_err(|e| CliError::Runtime(e.to_string()))?;
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::quick_start_in_workspace(
120
- workspace, agents_dir, name, yes, fresh, team_id,
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
- crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
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
- // swallow batch 1: a failed ps probe degrades verification truthfully — the
470
- // empty table must never read as a clean "no residual processes".
471
- let verification_degraded = probe_timeout.is_some() || probe_degraded;
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
- && !verification_degraded
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 verification_degraded {
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 verification_degraded {
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 crate::lifecycle::add_agent
279
- // "agent id already exists" error (launch.rs:252). The placeholder returns {ok:true} -> RED.
280
- // OS-safe (the dup check fires before compile/spawn).
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 {