@team-agent/installer 0.3.14 → 0.3.16

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 (46) 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/leader.rs +2 -0
  6. package/crates/team-agent/src/cli/mod.rs +255 -37
  7. package/crates/team-agent/src/cli/send.rs +252 -0
  8. package/crates/team-agent/src/cli/status.rs +5 -1
  9. package/crates/team-agent/src/cli/status_port.rs +24 -0
  10. package/crates/team-agent/src/cli/tests/base.rs +21 -3
  11. package/crates/team-agent/src/cli/tests/divergence.rs +6 -6
  12. package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
  13. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +285 -2
  14. package/crates/team-agent/src/cli/tests/status_send.rs +25 -0
  15. package/crates/team-agent/src/cli/types.rs +32 -1
  16. package/crates/team-agent/src/compiler/tests.rs +2 -2
  17. package/crates/team-agent/src/compiler.rs +1 -1
  18. package/crates/team-agent/src/fake_worker.rs +32 -145
  19. package/crates/team-agent/src/leader/start.rs +272 -8
  20. package/crates/team-agent/src/leader/tests/basics.rs +1 -0
  21. package/crates/team-agent/src/leader/tests/identity.rs +121 -25
  22. package/crates/team-agent/src/leader/types.rs +9 -0
  23. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  24. package/crates/team-agent/src/lifecycle/launch.rs +631 -70
  25. package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
  27. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +79 -4
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +731 -8
  30. package/crates/team-agent/src/lifecycle/types.rs +5 -0
  31. package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
  32. package/crates/team-agent/src/mcp_server/wire.rs +153 -3
  33. package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
  34. package/crates/team-agent/src/messaging/mod.rs +6 -3
  35. package/crates/team-agent/src/messaging/results.rs +240 -158
  36. package/crates/team-agent/src/messaging/send.rs +3 -2
  37. package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
  38. package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
  39. package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
  40. package/crates/team-agent/src/os_probe.rs +11 -0
  41. package/crates/team-agent/src/state/persist.rs +6 -0
  42. package/crates/team-agent/src/tmux_backend.rs +85 -0
  43. package/crates/team-agent/src/transport/test_support.rs +46 -1
  44. package/crates/team-agent/src/transport.rs +27 -0
  45. package/package.json +4 -4
  46. package/skills/team-agent/references/recovery-runbook.md +277 -0
@@ -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
+ }
@@ -104,6 +104,10 @@ pub fn format_status_summary(data: &Value) -> String {
104
104
  ],
105
105
  "-",
106
106
  );
107
+ let topology = non_empty_str(
108
+ data.get("leader_topology").and_then(Value::as_str),
109
+ "external",
110
+ );
107
111
  let agents = data.get("agents").unwrap_or(&Value::Null);
108
112
  let health = data.get("agent_health").unwrap_or(&Value::Null);
109
113
  let counts = agent_summary_counts(agents, health);
@@ -137,7 +141,7 @@ pub fn format_status_summary(data: &Value) -> String {
137
141
  .map(format_latest_result)
138
142
  .unwrap_or_else(|| "none".to_string());
139
143
  format!(
140
- "coordinator: {coordinator} schema_ok={schema_ok} tmux={tmux}\nreceiver: {pane} cmd={cmd}\n{agent_line}\nqueued: {queued} mailbox messages awaiting delivery\nlatest result: {latest}"
144
+ "coordinator: {coordinator} schema_ok={schema_ok} tmux={tmux}\nreceiver: {pane} cmd={cmd} topology={topology}\n{agent_line}\nqueued: {queued} mailbox messages awaiting delivery\nlatest result: {latest}"
141
145
  )
142
146
  }
143
147
 
@@ -48,6 +48,22 @@ use rusqlite::params;
48
48
  .cloned()
49
49
  .unwrap_or_else(|| json!({}));
50
50
  let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
51
+ let is_external_leader = state
52
+ .get("is_external_leader")
53
+ .and_then(Value::as_bool)
54
+ .unwrap_or(true);
55
+ let leader_topology = if is_external_leader { "external" } else { "managed" };
56
+ let leader_attach_command = if is_external_leader {
57
+ None
58
+ } else {
59
+ session_name.as_str().and_then(|session| {
60
+ crate::tmux_backend::attach_command_for_workspace(
61
+ workspace,
62
+ &crate::transport::SessionName::new(session.to_string()),
63
+ "leader",
64
+ )
65
+ })
66
+ };
51
67
  let tmux_present = tmux_session_present(workspace, session_name.as_str());
52
68
  let mut readiness_state = state.clone();
53
69
  if let Some(obj) = readiness_state.as_object_mut() {
@@ -58,6 +74,10 @@ use rusqlite::params;
58
74
  "ok": true,
59
75
  "team": state.pointer("/leader/id").cloned().unwrap_or_else(|| json!("leader")),
60
76
  "session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
77
+ "leader_topology": leader_topology,
78
+ "is_external_leader": is_external_leader,
79
+ "leader_attach_command": leader_attach_command,
80
+ "leader_client": state.get("leader_client").cloned().unwrap_or(Value::Null),
61
81
  "tmux_session_present": tmux_present,
62
82
  "all_spawned": readiness.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
63
83
  "all_attached_receiver": readiness.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
@@ -553,6 +573,10 @@ use rusqlite::params;
553
573
  json!({
554
574
  "team": full.get("team").cloned().unwrap_or(Value::Null),
555
575
  "session_name": full.get("session_name").cloned().unwrap_or(Value::Null),
576
+ "leader_topology": full.get("leader_topology").cloned().unwrap_or_else(|| json!("external")),
577
+ "is_external_leader": full.get("is_external_leader").cloned().unwrap_or(Value::Bool(true)),
578
+ "leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
579
+ "leader_client": full.get("leader_client").cloned().unwrap_or(Value::Null),
556
580
  "tmux_session_present": full.get("tmux_session_present").cloned().unwrap_or(Value::Bool(false)),
557
581
  "all_spawned": full.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
558
582
  "all_attached_receiver": full.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
@@ -52,6 +52,7 @@ use super::*;
52
52
  assert!(!got.attach_existing);
53
53
  assert!(!got.confirm_attach);
54
54
  assert_eq!(got.attach_session, None);
55
+ assert!(!got.external_leader);
55
56
  }
56
57
 
57
58
  #[test]
@@ -72,6 +73,23 @@ use super::*;
72
73
  assert!(!got.confirm_attach);
73
74
  }
74
75
 
76
+ #[test]
77
+ fn leader_launcher_args_external_leader_opt_out() {
78
+ let got = leader_launcher_args(&[
79
+ "--external-leader".into(),
80
+ "--".into(),
81
+ "--model".into(),
82
+ "opus".into(),
83
+ ])
84
+ .unwrap();
85
+ assert!(got.external_leader);
86
+ assert!(!got.attach_existing);
87
+ assert_eq!(
88
+ got.provider_args,
89
+ vec!["--".to_string(), "--model".to_string(), "opus".to_string()]
90
+ );
91
+ }
92
+
75
93
  #[test]
76
94
  fn leader_launcher_args_attach_session_spaced() {
77
95
  // golden: ["--attach-session","mysess"] -> attach_session="mysess"
@@ -272,7 +290,7 @@ use super::*;
272
290
  fn format_status_summary_full_byte_lock() {
273
291
  // golden:
274
292
  // coordinator: running schema_ok=True tmux=True
275
- // receiver: %3 cmd=codex
293
+ // receiver: %3 cmd=codex topology=external
276
294
  // agents: 2 — running=1 busy=1 idle=0 stopped=0 failed=0 unknown=0
277
295
  // queued: 2 mailbox messages awaiting delivery
278
296
  // latest result: a1 -> did the thing @ -
@@ -287,7 +305,7 @@ use super::*;
287
305
  });
288
306
  let got = format_status_summary(&data);
289
307
  let expected = "coordinator: running schema_ok=true tmux=true\n\
290
- receiver: %3 cmd=codex\n\
308
+ receiver: %3 cmd=codex topology=external\n\
291
309
  agents: 2 — running=1 busy=1 idle=0 stopped=0 failed=0 unknown=0\n\
292
310
  queued: 2 mailbox messages awaiting delivery\n\
293
311
  latest result: a1 -> did the thing @ -";
@@ -299,7 +317,7 @@ latest result: a1 -> did the thing @ -";
299
317
  // golden empty data: stopped/false/false, dashes, 0 counts, none latest.
300
318
  let got = format_status_summary(&json!({}));
301
319
  let expected = "coordinator: stopped schema_ok=false tmux=false\n\
302
- receiver: - cmd=-\n\
320
+ receiver: - cmd=- topology=external\n\
303
321
  agents: 0 — running=0 busy=0 idle=0 stopped=0 failed=0 unknown=0\n\
304
322
  queued: 0 mailbox messages awaiting delivery\n\
305
323
  latest result: none";
@@ -148,35 +148,35 @@ use super::*;
148
148
 
149
149
  #[test]
150
150
  fn red_format_status_summary_empty_pane_id_is_dash() {
151
- // golden: pane_id='' -> 'receiver: - cmd=x'
151
+ // golden: pane_id='' -> 'receiver: - cmd=x topology=external'
152
152
  let line = format_status_summary(&json!({
153
153
  "leader_receiver": {"pane_id": "", "pane_current_command": "x"}
154
154
  }));
155
155
  assert_eq!(
156
156
  line.lines().nth(1).unwrap(),
157
- "receiver: - cmd=x",
157
+ "receiver: - cmd=x topology=external",
158
158
  "empty-string pane_id MUST fall back to '-' (golden `pane_id or '-'`)"
159
159
  );
160
160
  }
161
161
 
162
162
  #[test]
163
163
  fn red_format_status_summary_cmd_falls_back_to_current_command() {
164
- // golden: missing pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude'
164
+ // golden: missing pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude topology=external'
165
165
  let line_missing = format_status_summary(&json!({
166
166
  "leader_receiver": {"pane_id": "%3", "current_command": "claude"}
167
167
  }));
168
168
  assert_eq!(
169
169
  line_missing.lines().nth(1).unwrap(),
170
- "receiver: %3 cmd=claude",
170
+ "receiver: %3 cmd=claude topology=external",
171
171
  "cmd MUST fall back to current_command when pane_current_command is absent (golden line 285)"
172
172
  );
173
- // golden: empty pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude'
173
+ // golden: empty pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude topology=external'
174
174
  let line_empty = format_status_summary(&json!({
175
175
  "leader_receiver": {"pane_id": "%3", "pane_current_command": "", "current_command": "claude"}
176
176
  }));
177
177
  assert_eq!(
178
178
  line_empty.lines().nth(1).unwrap(),
179
- "receiver: %3 cmd=claude",
179
+ "receiver: %3 cmd=claude topology=external",
180
180
  "empty pane_current_command MUST fall through to current_command (golden falsy `or`)"
181
181
  );
182
182
  }
@@ -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 {