@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.
- 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/leader.rs +2 -0
- package/crates/team-agent/src/cli/mod.rs +255 -37
- package/crates/team-agent/src/cli/send.rs +252 -0
- package/crates/team-agent/src/cli/status.rs +5 -1
- package/crates/team-agent/src/cli/status_port.rs +24 -0
- package/crates/team-agent/src/cli/tests/base.rs +21 -3
- package/crates/team-agent/src/cli/tests/divergence.rs +6 -6
- package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +285 -2
- package/crates/team-agent/src/cli/tests/status_send.rs +25 -0
- package/crates/team-agent/src/cli/types.rs +32 -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/leader/start.rs +272 -8
- package/crates/team-agent/src/leader/tests/basics.rs +1 -0
- package/crates/team-agent/src/leader/tests/identity.rs +121 -25
- package/crates/team-agent/src/leader/types.rs +9 -0
- package/crates/team-agent/src/lifecycle/display.rs +3 -3
- package/crates/team-agent/src/lifecycle/launch.rs +631 -70
- 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 +79 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +731 -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
|
@@ -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
|
|
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
|
|
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 {
|