@team-agent/installer 0.5.50 → 0.5.52
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 +5 -3
- package/crates/team-agent/src/cli/emit.rs +2 -1
- package/crates/team-agent/src/cli/mod.rs +32 -14
- package/crates/team-agent/src/cli/named_address.rs +46 -115
- package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
- package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
- package/crates/team-agent/src/cli/send/persist.rs +154 -0
- package/crates/team-agent/src/cli/send/presentation.rs +333 -0
- package/crates/team-agent/src/cli/send/resolve.rs +361 -0
- package/crates/team-agent/src/cli/send.rs +44 -1169
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
- package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
- package/crates/team-agent/src/cli/status_port/format.rs +145 -0
- package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
- package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
- package/crates/team-agent/src/cli/status_port/store.rs +412 -0
- package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
- package/crates/team-agent/src/cli/status_port.rs +47 -1548
- package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
- package/crates/team-agent/src/coordinator/tick.rs +37 -0
- package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
- package/crates/team-agent/src/db/message_store.rs +154 -44
- package/crates/team-agent/src/event_log.rs +73 -0
- package/crates/team-agent/src/leader/lease.rs +202 -16
- package/crates/team-agent/src/leader/start.rs +28 -4
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
- package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
- package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
- package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
- package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
- package/crates/team-agent/src/lifecycle/restart.rs +26 -2
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
- package/crates/team-agent/src/mcp_server/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
- package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
- package/crates/team-agent/src/mcp_server/tests.rs +11 -4
- package/crates/team-agent/src/mcp_server/tools.rs +14 -5
- package/crates/team-agent/src/messaging/activity.rs +4 -2
- package/crates/team-agent/src/messaging/address.rs +86 -0
- package/crates/team-agent/src/messaging/delivery.rs +288 -183
- package/crates/team-agent/src/messaging/helpers.rs +17 -13
- package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
- package/crates/team-agent/src/messaging/mod.rs +15 -2
- package/crates/team-agent/src/messaging/persist.rs +309 -0
- package/crates/team-agent/src/messaging/results.rs +16 -24
- package/crates/team-agent/src/messaging/scheduler.rs +4 -2
- package/crates/team-agent/src/messaging/selftest.rs +19 -12
- package/crates/team-agent/src/messaging/send.rs +101 -49
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/crates/team-agent/src/redaction.rs +72 -2
- package/crates/team-agent/src/state/persist.rs +284 -21
- package/crates/team-agent/src/state/projection.rs +245 -30
- package/crates/team-agent/src/state/repository/intent.rs +13 -0
- package/crates/team-agent/src/state/repository/tests.rs +47 -0
- package/crates/team-agent/src/state/repository.rs +94 -27
- package/crates/team-agent/src/state/selector.rs +9 -18
- package/package.json +4 -4
|
@@ -15,6 +15,7 @@ pub(super) type LaneSpawns = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<St
|
|
|
15
15
|
pub(super) struct LaneTransport {
|
|
16
16
|
session: String,
|
|
17
17
|
windows: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
|
|
18
|
+
foreign: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
|
|
18
19
|
killed: LaneKills,
|
|
19
20
|
spawns: LaneSpawns,
|
|
20
21
|
}
|
|
@@ -25,6 +26,7 @@ impl LaneTransport {
|
|
|
25
26
|
windows: std::sync::Arc::new(std::sync::Mutex::new(
|
|
26
27
|
windows.iter().map(|w| (*w).to_string()).collect(),
|
|
27
28
|
)),
|
|
29
|
+
foreign: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
28
30
|
killed: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
29
31
|
spawns: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
30
32
|
}
|
|
@@ -35,6 +37,12 @@ impl LaneTransport {
|
|
|
35
37
|
pub(super) fn spawns(&self) -> Vec<(String, Vec<String>)> {
|
|
36
38
|
self.spawns.lock().unwrap().clone()
|
|
37
39
|
}
|
|
40
|
+
fn add_foreign(&self, session: &str, window: &str) {
|
|
41
|
+
self.foreign
|
|
42
|
+
.lock()
|
|
43
|
+
.unwrap()
|
|
44
|
+
.push((session.to_string(), window.to_string()));
|
|
45
|
+
}
|
|
38
46
|
}
|
|
39
47
|
impl crate::transport::Transport for LaneTransport {
|
|
40
48
|
fn kind(&self) -> crate::transport::BackendKind {
|
|
@@ -52,6 +60,18 @@ impl crate::transport::Transport for LaneTransport {
|
|
|
52
60
|
.lock()
|
|
53
61
|
.unwrap()
|
|
54
62
|
.push(("spawn_first".to_string(), argv.to_vec()));
|
|
63
|
+
if !self
|
|
64
|
+
.windows
|
|
65
|
+
.lock()
|
|
66
|
+
.unwrap()
|
|
67
|
+
.iter()
|
|
68
|
+
.any(|name| name == window.as_str())
|
|
69
|
+
{
|
|
70
|
+
self.windows
|
|
71
|
+
.lock()
|
|
72
|
+
.unwrap()
|
|
73
|
+
.push(window.as_str().to_string());
|
|
74
|
+
}
|
|
55
75
|
Ok(crate::transport::SpawnResult {
|
|
56
76
|
pane_id: crate::transport::PaneId::new(format!("%{}", window.as_str())),
|
|
57
77
|
session: session.clone(),
|
|
@@ -71,6 +91,18 @@ impl crate::transport::Transport for LaneTransport {
|
|
|
71
91
|
.lock()
|
|
72
92
|
.unwrap()
|
|
73
93
|
.push(("spawn_into".to_string(), argv.to_vec()));
|
|
94
|
+
if !self
|
|
95
|
+
.windows
|
|
96
|
+
.lock()
|
|
97
|
+
.unwrap()
|
|
98
|
+
.iter()
|
|
99
|
+
.any(|name| name == window.as_str())
|
|
100
|
+
{
|
|
101
|
+
self.windows
|
|
102
|
+
.lock()
|
|
103
|
+
.unwrap()
|
|
104
|
+
.push(window.as_str().to_string());
|
|
105
|
+
}
|
|
74
106
|
Ok(crate::transport::SpawnResult {
|
|
75
107
|
pane_id: crate::transport::PaneId::new(format!("%{}", window.as_str())),
|
|
76
108
|
session: session.clone(),
|
|
@@ -120,7 +152,7 @@ impl crate::transport::Transport for LaneTransport {
|
|
|
120
152
|
fn list_targets(
|
|
121
153
|
&self,
|
|
122
154
|
) -> Result<Vec<crate::transport::PaneInfo>, crate::transport::TransportError> {
|
|
123
|
-
|
|
155
|
+
let mut panes: Vec<_> = self
|
|
124
156
|
.windows
|
|
125
157
|
.lock()
|
|
126
158
|
.unwrap()
|
|
@@ -138,7 +170,27 @@ impl crate::transport::Transport for LaneTransport {
|
|
|
138
170
|
pane_pid: None,
|
|
139
171
|
leader_env: std::collections::BTreeMap::new(),
|
|
140
172
|
})
|
|
141
|
-
.collect()
|
|
173
|
+
.collect();
|
|
174
|
+
panes.extend(
|
|
175
|
+
self.foreign
|
|
176
|
+
.lock()
|
|
177
|
+
.unwrap()
|
|
178
|
+
.iter()
|
|
179
|
+
.map(|(session, window)| crate::transport::PaneInfo {
|
|
180
|
+
pane_id: crate::transport::PaneId::new(format!("%{session}-{window}")),
|
|
181
|
+
session: crate::transport::SessionName::new(session),
|
|
182
|
+
window_index: None,
|
|
183
|
+
window_name: Some(crate::transport::WindowName::new(window)),
|
|
184
|
+
pane_index: None,
|
|
185
|
+
tty: None,
|
|
186
|
+
current_command: None,
|
|
187
|
+
current_path: None,
|
|
188
|
+
active: false,
|
|
189
|
+
pane_pid: None,
|
|
190
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
Ok(panes)
|
|
142
194
|
}
|
|
143
195
|
fn has_session(
|
|
144
196
|
&self,
|
|
@@ -182,15 +234,24 @@ impl crate::transport::Transport for LaneTransport {
|
|
|
182
234
|
) -> Result<(), crate::transport::TransportError> {
|
|
183
235
|
let (name, window) = match t {
|
|
184
236
|
crate::transport::Target::Pane(p) => (p.as_str().to_string(), None),
|
|
185
|
-
crate::transport::Target::SessionWindow { session, window } =>
|
|
186
|
-
(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
)
|
|
190
|
-
}
|
|
237
|
+
crate::transport::Target::SessionWindow { session, window } => (
|
|
238
|
+
format!("{}:{}", session.as_str(), window.as_str()),
|
|
239
|
+
Some(window.as_str()),
|
|
240
|
+
),
|
|
191
241
|
};
|
|
192
242
|
self.killed.lock().unwrap().push(name);
|
|
193
|
-
if let
|
|
243
|
+
if let crate::transport::Target::Pane(pane) = t {
|
|
244
|
+
if let Some(window) = pane.as_str().strip_prefix('%') {
|
|
245
|
+
self.windows
|
|
246
|
+
.lock()
|
|
247
|
+
.unwrap()
|
|
248
|
+
.retain(|candidate| candidate != window);
|
|
249
|
+
self.foreign
|
|
250
|
+
.lock()
|
|
251
|
+
.unwrap()
|
|
252
|
+
.retain(|(session, candidate)| format!("{session}-{candidate}") != window);
|
|
253
|
+
}
|
|
254
|
+
} else if let Some(window) = window {
|
|
194
255
|
self.windows
|
|
195
256
|
.lock()
|
|
196
257
|
.unwrap()
|
|
@@ -209,7 +270,11 @@ impl crate::transport::Transport for LaneTransport {
|
|
|
209
270
|
/// 2-agent (alpha, bravo) compiled spec + custom `state.agents` map. session_name = "team-laneateam"
|
|
210
271
|
/// (the compiled runtime.session_name). ensure_owner_allowed passes (no team_owner).
|
|
211
272
|
fn lanea_ws_agents(agents: serde_json::Value) -> PathBuf {
|
|
212
|
-
|
|
273
|
+
lanea_ws_agents_at(&temp_ws(), agents)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fn lanea_ws_agents_at(root: &std::path::Path, agents: serde_json::Value) -> PathBuf {
|
|
277
|
+
let ws = root.join("laneav2");
|
|
213
278
|
std::fs::create_dir_all(ws.join("agents")).unwrap();
|
|
214
279
|
std::fs::write(
|
|
215
280
|
ws.join("TEAM.md"),
|
|
@@ -268,6 +333,737 @@ fn lanea_one_agent_ws(alpha_status: &str) -> PathBuf {
|
|
|
268
333
|
ws
|
|
269
334
|
}
|
|
270
335
|
|
|
336
|
+
fn selected_spec_path(ws: &std::path::Path) -> std::path::PathBuf {
|
|
337
|
+
crate::state::selector::resolve_active_team(
|
|
338
|
+
ws,
|
|
339
|
+
None,
|
|
340
|
+
crate::state::selector::SelectorMode::RequireSpec,
|
|
341
|
+
)
|
|
342
|
+
.unwrap()
|
|
343
|
+
.spec_workspace
|
|
344
|
+
.unwrap_or_else(|| ws.to_path_buf())
|
|
345
|
+
.join("team.spec.yaml")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
fn remove_agent_from_fixture_spec(ws: &std::path::Path, agent_id: &str) {
|
|
349
|
+
let path = selected_spec_path(ws);
|
|
350
|
+
let spec = crate::model::yaml::loads(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
|
351
|
+
let removed = crate::lifecycle::restart::remove::spec_without_agent(&spec, &aid(agent_id));
|
|
352
|
+
std::fs::write(path, crate::model::yaml::dumps(&removed)).unwrap();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fn remove_agent_from_fixture_state(ws: &std::path::Path, agent_id: &str) {
|
|
356
|
+
let mut state = crate::state::persist::load_runtime_state(ws).unwrap();
|
|
357
|
+
state
|
|
358
|
+
.get_mut("agents")
|
|
359
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
360
|
+
.unwrap()
|
|
361
|
+
.remove(agent_id);
|
|
362
|
+
crate::state::persist::save_runtime_state_with_deleted_agents(ws, &state, &[agent_id]).unwrap();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#[test]
|
|
366
|
+
fn force_remove_reconciles_all_seat_quadrants_without_cross_team_same_name_kill() {
|
|
367
|
+
for quadrant in ["state-only", "spec-only", "physical-only", "mixed"] {
|
|
368
|
+
let ws = lanea_ws_agents(json!({
|
|
369
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
370
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
371
|
+
}));
|
|
372
|
+
let local_physical = matches!(quadrant, "physical-only");
|
|
373
|
+
let tx = LaneTransport::new(
|
|
374
|
+
"team-laneateam",
|
|
375
|
+
if local_physical { &["alpha"][..] } else { &[] },
|
|
376
|
+
);
|
|
377
|
+
tx.add_foreign("team-other", "alpha");
|
|
378
|
+
match quadrant {
|
|
379
|
+
"state-only" => remove_agent_from_fixture_spec(&ws, "alpha"),
|
|
380
|
+
"spec-only" => remove_agent_from_fixture_state(&ws, "alpha"),
|
|
381
|
+
"physical-only" => {
|
|
382
|
+
remove_agent_from_fixture_spec(&ws, "alpha");
|
|
383
|
+
remove_agent_from_fixture_state(&ws, "alpha");
|
|
384
|
+
}
|
|
385
|
+
"mixed" => {}
|
|
386
|
+
_ => unreachable!(),
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for attempt in 0..2 {
|
|
390
|
+
let result = remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &tx);
|
|
391
|
+
assert!(
|
|
392
|
+
matches!(result, Ok(RemoveAgentOutcome::Removed { .. })),
|
|
393
|
+
"{quadrant} force-remove attempt {attempt} must be idempotent: {result:?}"
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
397
|
+
assert!(
|
|
398
|
+
state["agents"].get("alpha").is_none(),
|
|
399
|
+
"{quadrant}: state absent"
|
|
400
|
+
);
|
|
401
|
+
let spec =
|
|
402
|
+
crate::model::yaml::loads(&std::fs::read_to_string(selected_spec_path(&ws)).unwrap())
|
|
403
|
+
.unwrap();
|
|
404
|
+
assert!(
|
|
405
|
+
spec.get("agents")
|
|
406
|
+
.and_then(crate::model::yaml::Value::as_list)
|
|
407
|
+
.is_none_or(|agents| agents.iter().all(|agent| {
|
|
408
|
+
agent.get("id").and_then(crate::model::yaml::Value::as_str) != Some("alpha")
|
|
409
|
+
})),
|
|
410
|
+
"{quadrant}: spec absent"
|
|
411
|
+
);
|
|
412
|
+
let live = crate::transport::Transport::list_targets(&tx).unwrap();
|
|
413
|
+
assert!(
|
|
414
|
+
live.iter()
|
|
415
|
+
.all(|pane| pane.session.as_str() != "team-laneateam"
|
|
416
|
+
|| pane
|
|
417
|
+
.window_name
|
|
418
|
+
.as_ref()
|
|
419
|
+
.is_none_or(|window| window.as_str() != "alpha")),
|
|
420
|
+
"{quadrant}: selected team physical absent"
|
|
421
|
+
);
|
|
422
|
+
assert!(
|
|
423
|
+
live.iter().any(|pane| pane.session.as_str() == "team-other"
|
|
424
|
+
&& pane
|
|
425
|
+
.window_name
|
|
426
|
+
.as_ref()
|
|
427
|
+
.is_some_and(|window| window.as_str() == "alpha")),
|
|
428
|
+
"{quadrant}: cross-team same-name pane must survive"
|
|
429
|
+
);
|
|
430
|
+
assert!(
|
|
431
|
+
!tx.killed().iter().any(|pane| pane == "%team-other-alpha"),
|
|
432
|
+
"{quadrant}: cross-team pane must never be selected"
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#[test]
|
|
438
|
+
fn remove_persists_retired_tombstone_and_explicit_readd_clears_it() {
|
|
439
|
+
let ws = lanea_ws_agents(json!({
|
|
440
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
441
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
442
|
+
}));
|
|
443
|
+
let mut seeded = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
444
|
+
seeded["team_key"] = json!("laneateam");
|
|
445
|
+
seeded["active_team_key"] = json!("laneateam");
|
|
446
|
+
let mut nested = crate::state::projection::compact_team_state(&seeded);
|
|
447
|
+
nested["status"] = json!("alive");
|
|
448
|
+
seeded["teams"] = json!({
|
|
449
|
+
"laneateam": nested,
|
|
450
|
+
"sibling": {
|
|
451
|
+
"team_key": "sibling",
|
|
452
|
+
"status": "shutdown",
|
|
453
|
+
"agents": {},
|
|
454
|
+
"agent_lifecycle": {
|
|
455
|
+
"alpha": {
|
|
456
|
+
"state": "retired",
|
|
457
|
+
"changed_at": "2026-07-20T00:00:00Z",
|
|
458
|
+
"reason": "sibling retirement"
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
crate::state::persist::save_runtime_state(&ws, &seeded).unwrap();
|
|
464
|
+
let stale_tick = crate::state::projection::select_runtime_state(&ws, Some("laneateam"))
|
|
465
|
+
.expect("capture the coordinator projection from before remove");
|
|
466
|
+
let tx = LaneTransport::new("team-laneateam", &[]);
|
|
467
|
+
let role = ws.join("agents").join("alpha.md");
|
|
468
|
+
|
|
469
|
+
remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &tx).unwrap();
|
|
470
|
+
let removed: serde_json::Value = serde_json::from_str(
|
|
471
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
472
|
+
)
|
|
473
|
+
.unwrap();
|
|
474
|
+
assert_eq!(
|
|
475
|
+
removed["teams"]["laneateam"]["agent_lifecycle"]["alpha"]["state"], "retired",
|
|
476
|
+
"the first successful remove must directly persist the canonical tombstone"
|
|
477
|
+
);
|
|
478
|
+
assert!(
|
|
479
|
+
removed["teams"]["laneateam"]["agents"]
|
|
480
|
+
.get("alpha")
|
|
481
|
+
.is_none(),
|
|
482
|
+
"the first successful remove must directly delete the canonical agent row"
|
|
483
|
+
);
|
|
484
|
+
crate::state::repository::StateRepository::new(&ws)
|
|
485
|
+
.save(
|
|
486
|
+
crate::state::repository::StateWriteIntent::CoordinatorTick {
|
|
487
|
+
team_key: "laneateam",
|
|
488
|
+
},
|
|
489
|
+
&stale_tick,
|
|
490
|
+
)
|
|
491
|
+
.unwrap();
|
|
492
|
+
let after_stale_tick: serde_json::Value = serde_json::from_str(
|
|
493
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
494
|
+
)
|
|
495
|
+
.unwrap();
|
|
496
|
+
assert_eq!(
|
|
497
|
+
after_stale_tick["teams"]["laneateam"]["agent_lifecycle"]["alpha"]["state"], "retired",
|
|
498
|
+
"a stale coordinator save must not erase the first remove's tombstone"
|
|
499
|
+
);
|
|
500
|
+
assert!(
|
|
501
|
+
after_stale_tick["teams"]["laneateam"]["agents"]
|
|
502
|
+
.get("alpha")
|
|
503
|
+
.is_none(),
|
|
504
|
+
"a stale coordinator save must not resurrect the first removed agent"
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
crate::lifecycle::add_agent_with_transport(&ws, &aid("alpha"), &role, false, None, &tx)
|
|
508
|
+
.unwrap();
|
|
509
|
+
let readded = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
510
|
+
assert!(
|
|
511
|
+
readded["teams"]["laneateam"]["agent_lifecycle"]
|
|
512
|
+
.get("alpha")
|
|
513
|
+
.is_none(),
|
|
514
|
+
"explicit re-add must consume the retired tombstone"
|
|
515
|
+
);
|
|
516
|
+
assert!(readded["teams"]["laneateam"]["agents"]
|
|
517
|
+
.get("alpha")
|
|
518
|
+
.is_some());
|
|
519
|
+
assert_eq!(
|
|
520
|
+
readded["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"], "retired",
|
|
521
|
+
"selected-team re-add must not clear a sibling team's same-id retirement"
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/// Cross-product RED (verifier, slice2b authority leak): the FAILURE dimension
|
|
526
|
+
/// (a stale writer whose incoming projection predates a sibling retirement) x the
|
|
527
|
+
/// BOUNDARY dimension (a sibling team carrying the SAME agent id). The tracked
|
|
528
|
+
/// `remove_persists_..._readd_clears_it` sibling assertion covers only the
|
|
529
|
+
/// non-stale corner (sibling tombstone already current in the add's input); it
|
|
530
|
+
/// does NOT exercise an incoming sibling projection captured BEFORE the sibling
|
|
531
|
+
/// retirement. topology-authority is keyed by bare `agent_id` with no team
|
|
532
|
+
/// dimension (repository.rs:277-283 drops team_key; persist.rs iterates every
|
|
533
|
+
/// sibling team with the same global id set), so an explicit AddAgent(alpha) in
|
|
534
|
+
/// team A exempts alpha in team B too and a stale incoming resurrects B's alpha /
|
|
535
|
+
/// erases B's retirement tombstone. Red at da11f98c: teamB.alpha lifecycle Null.
|
|
536
|
+
#[test]
|
|
537
|
+
fn add_agent_authority_must_not_cross_team_erase_sibling_same_id_retirement() {
|
|
538
|
+
// Disk latest: team A alive with alpha; team B alpha ABSENT + retired.
|
|
539
|
+
let ws = lanea_ws_agents(json!({
|
|
540
|
+
"alpha": { "status": "running", "provider": "codex", "window": "alpha" },
|
|
541
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
542
|
+
}));
|
|
543
|
+
let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
544
|
+
latest["team_key"] = json!("laneateam");
|
|
545
|
+
latest["active_team_key"] = json!("laneateam");
|
|
546
|
+
let mut nested_a = crate::state::projection::compact_team_state(&latest);
|
|
547
|
+
nested_a["status"] = json!("alive");
|
|
548
|
+
latest["teams"] = json!({
|
|
549
|
+
"laneateam": nested_a,
|
|
550
|
+
"sibling": {
|
|
551
|
+
"team_key": "sibling",
|
|
552
|
+
"status": "alive",
|
|
553
|
+
"agents": {},
|
|
554
|
+
"agent_lifecycle": {
|
|
555
|
+
"alpha": {
|
|
556
|
+
"state": "retired",
|
|
557
|
+
"changed_at": "2026-07-21T00:00:00Z",
|
|
558
|
+
"reason": "sibling retirement (newer than the stale incoming)"
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
|
|
564
|
+
|
|
565
|
+
// Incoming STALE root: captured BEFORE team B retired alpha, so its sibling
|
|
566
|
+
// entry still carries alpha ACTIVE with no tombstone. A concurrent explicit
|
|
567
|
+
// AddAgent(alpha) in team A carries this whole stale multi-team projection.
|
|
568
|
+
let mut incoming_stale = latest.clone();
|
|
569
|
+
incoming_stale["teams"]["sibling"] = json!({
|
|
570
|
+
"team_key": "sibling",
|
|
571
|
+
"status": "alive",
|
|
572
|
+
"agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
|
|
573
|
+
"agent_lifecycle": {}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
crate::state::repository::StateRepository::new(&ws)
|
|
577
|
+
.save(
|
|
578
|
+
crate::state::repository::StateWriteIntent::AddAgent {
|
|
579
|
+
team_key: "laneateam",
|
|
580
|
+
agent_id: "alpha",
|
|
581
|
+
},
|
|
582
|
+
&incoming_stale,
|
|
583
|
+
)
|
|
584
|
+
.unwrap();
|
|
585
|
+
|
|
586
|
+
let after: serde_json::Value = serde_json::from_str(
|
|
587
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
588
|
+
)
|
|
589
|
+
.unwrap();
|
|
590
|
+
// The AddAgent authority is scoped to team A's alpha ONLY; team B's newer
|
|
591
|
+
// same-id retirement must survive (the stale incoming must not resurrect it).
|
|
592
|
+
assert_eq!(
|
|
593
|
+
after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
|
|
594
|
+
json!("retired"),
|
|
595
|
+
"selected-team AddAgent authority must not cross team boundary by bare agent id: \
|
|
596
|
+
team B's newer same-id retirement was erased by team A's stale re-add"
|
|
597
|
+
);
|
|
598
|
+
assert!(
|
|
599
|
+
after["teams"]["sibling"]["agents"].get("alpha").is_none(),
|
|
600
|
+
"a stale incoming must not resurrect the sibling team's retired same-id agent"
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/// Identity-source boundary (verifier, slice2b authority fix new adjudication
|
|
605
|
+
/// face, same family as slice1 empty-current): the legacy top-level scope anchor
|
|
606
|
+
/// now prefers `active_team_key`. Verify it does not over-exempt / under-protect
|
|
607
|
+
/// when `active_team_key` is a NON-EMPTY but TERMINAL / mismatched value (points
|
|
608
|
+
/// at a team not present in `teams`). The AddAgent authority is (laneateam,
|
|
609
|
+
/// alpha); with a ghost active_team_key, the top-level filter must resolve NO
|
|
610
|
+
/// exemption for the ghost, so a real retirement anywhere is still protected —
|
|
611
|
+
/// the anchor must not silently widen authority to a bare id under a dangling
|
|
612
|
+
/// active_team_key (that would be the empty-current forgery shape at slice1).
|
|
613
|
+
#[test]
|
|
614
|
+
fn terminal_active_team_key_does_not_widen_topology_authority_to_bare_id() {
|
|
615
|
+
// Disk latest: laneateam alive with alpha; sibling alpha ABSENT + retired.
|
|
616
|
+
let ws = lanea_ws_agents(json!({
|
|
617
|
+
"alpha": { "status": "running", "provider": "codex", "window": "alpha" },
|
|
618
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
619
|
+
}));
|
|
620
|
+
let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
621
|
+
latest["team_key"] = json!("laneateam");
|
|
622
|
+
latest["active_team_key"] = json!("laneateam");
|
|
623
|
+
let mut nested_a = crate::state::projection::compact_team_state(&latest);
|
|
624
|
+
nested_a["status"] = json!("alive");
|
|
625
|
+
latest["teams"] = json!({
|
|
626
|
+
"laneateam": nested_a,
|
|
627
|
+
"sibling": {
|
|
628
|
+
"team_key": "sibling",
|
|
629
|
+
"status": "alive",
|
|
630
|
+
"agents": {},
|
|
631
|
+
"agent_lifecycle": {
|
|
632
|
+
"alpha": { "state": "retired", "changed_at": "2026-07-21T00:00:00Z", "reason": "sibling" }
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
|
|
637
|
+
|
|
638
|
+
// Incoming STALE root whose active_team_key points at a GHOST team not in
|
|
639
|
+
// `teams`, and whose sibling entry is the pre-retire (alpha active) shape.
|
|
640
|
+
let mut incoming_stale = latest.clone();
|
|
641
|
+
incoming_stale["active_team_key"] = json!("ghostteam-does-not-exist");
|
|
642
|
+
incoming_stale["teams"]["sibling"] = json!({
|
|
643
|
+
"team_key": "sibling",
|
|
644
|
+
"status": "alive",
|
|
645
|
+
"agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
|
|
646
|
+
"agent_lifecycle": {}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
crate::state::repository::StateRepository::new(&ws)
|
|
650
|
+
.save(
|
|
651
|
+
crate::state::repository::StateWriteIntent::AddAgent {
|
|
652
|
+
team_key: "laneateam",
|
|
653
|
+
agent_id: "alpha",
|
|
654
|
+
},
|
|
655
|
+
&incoming_stale,
|
|
656
|
+
)
|
|
657
|
+
.unwrap();
|
|
658
|
+
|
|
659
|
+
let after: serde_json::Value = serde_json::from_str(
|
|
660
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
661
|
+
)
|
|
662
|
+
.unwrap();
|
|
663
|
+
// A dangling active_team_key must not widen the (laneateam, alpha) authority
|
|
664
|
+
// into a bare `alpha` exemption that reaches the sibling: sibling stays
|
|
665
|
+
// retired, not resurrected.
|
|
666
|
+
assert_eq!(
|
|
667
|
+
after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
|
|
668
|
+
json!("retired"),
|
|
669
|
+
"a terminal/ghost active_team_key must not widen topology authority to a bare id \
|
|
670
|
+
and erase the sibling's same-id retirement"
|
|
671
|
+
);
|
|
672
|
+
assert!(
|
|
673
|
+
after["teams"]["sibling"]["agents"].get("alpha").is_none(),
|
|
674
|
+
"a terminal active_team_key must not let a stale incoming resurrect the sibling agent"
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/// Cross-product cell (verifier, slice2b current-alias fix): the `current`
|
|
679
|
+
/// physical alias dimension x the sibling same-id retirement dimension. The
|
|
680
|
+
/// reset fix routes `team == "current"` authority through `top_level_team`
|
|
681
|
+
/// (active_team_key). This pins that the alias branch does NOT leak that
|
|
682
|
+
/// active-team authority into a REAL sibling that merely shares the agent id:
|
|
683
|
+
/// a `current` alias entry (= the active team's physical projection) plus a
|
|
684
|
+
/// distinct sibling team both carrying `alpha`, sibling retired, stale incoming
|
|
685
|
+
/// resurrecting sibling alpha. The current-alias authority must clear only the
|
|
686
|
+
/// active team's own alpha, never the sibling's newer retirement.
|
|
687
|
+
#[test]
|
|
688
|
+
fn current_alias_authority_does_not_leak_into_real_sibling_same_id_retirement() {
|
|
689
|
+
let ws = lanea_ws_agents(json!({
|
|
690
|
+
"alpha": { "status": "running", "provider": "codex", "window": "alpha" },
|
|
691
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
692
|
+
}));
|
|
693
|
+
let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
694
|
+
latest["team_key"] = json!("laneateam");
|
|
695
|
+
latest["active_team_key"] = json!("laneateam");
|
|
696
|
+
let mut nested_a = crate::state::projection::compact_team_state(&latest);
|
|
697
|
+
nested_a["status"] = json!("alive");
|
|
698
|
+
// `current` is the physical alias of the active team (laneateam); a REAL
|
|
699
|
+
// sibling team also carries alpha, retired and newer than the stale incoming.
|
|
700
|
+
latest["teams"] = json!({
|
|
701
|
+
"laneateam": nested_a.clone(),
|
|
702
|
+
"current": nested_a,
|
|
703
|
+
"sibling": {
|
|
704
|
+
"team_key": "sibling",
|
|
705
|
+
"status": "alive",
|
|
706
|
+
"agents": {},
|
|
707
|
+
"agent_lifecycle": {
|
|
708
|
+
"alpha": { "state": "retired", "changed_at": "2026-07-21T00:00:00Z", "reason": "sibling" }
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
|
|
713
|
+
|
|
714
|
+
let mut incoming_stale = latest.clone();
|
|
715
|
+
incoming_stale["teams"]["sibling"] = json!({
|
|
716
|
+
"team_key": "sibling",
|
|
717
|
+
"status": "alive",
|
|
718
|
+
"agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
|
|
719
|
+
"agent_lifecycle": {}
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
crate::state::repository::StateRepository::new(&ws)
|
|
723
|
+
.save(
|
|
724
|
+
crate::state::repository::StateWriteIntent::AddAgent {
|
|
725
|
+
team_key: "laneateam",
|
|
726
|
+
agent_id: "alpha",
|
|
727
|
+
},
|
|
728
|
+
&incoming_stale,
|
|
729
|
+
)
|
|
730
|
+
.unwrap();
|
|
731
|
+
|
|
732
|
+
let after: serde_json::Value = serde_json::from_str(
|
|
733
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
734
|
+
)
|
|
735
|
+
.unwrap();
|
|
736
|
+
// The current-alias authority is the ACTIVE team's; it must not reach the
|
|
737
|
+
// real sibling that only shares the id.
|
|
738
|
+
assert_eq!(
|
|
739
|
+
after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
|
|
740
|
+
json!("retired"),
|
|
741
|
+
"current-alias authority must not leak into a real sibling's same-id retirement"
|
|
742
|
+
);
|
|
743
|
+
assert!(
|
|
744
|
+
after["teams"]["sibling"]["agents"].get("alpha").is_none(),
|
|
745
|
+
"a stale incoming must not resurrect the real sibling's retired same-id agent \
|
|
746
|
+
via the current-alias authority path"
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const CHARLIE_ROLE: &str = "---\nname: charlie\nrole: Charlie Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nCharlie.\n";
|
|
751
|
+
|
|
752
|
+
struct HermeticTestEnv {
|
|
753
|
+
root: std::path::PathBuf,
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
impl HermeticTestEnv {
|
|
757
|
+
fn enter(tag: &str) -> Self {
|
|
758
|
+
let root = temp_ws().join(format!("hermetic-{tag}"));
|
|
759
|
+
std::fs::create_dir_all(&root).unwrap();
|
|
760
|
+
Self { root }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
fn workspace_root(&self) -> &std::path::Path {
|
|
764
|
+
&self.root
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
impl Drop for HermeticTestEnv {
|
|
769
|
+
fn drop(&mut self) {
|
|
770
|
+
let _ = std::fs::remove_dir_all(&self.root);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
struct ForceRecreateFailureGuard(Option<String>);
|
|
775
|
+
|
|
776
|
+
impl ForceRecreateFailureGuard {
|
|
777
|
+
fn after_spawn(reason: &str) -> Self {
|
|
778
|
+
const KEY: &str = "TEAM_AGENT_TEST_FAIL_FORCE_RECREATE_AFTER_SPAWN";
|
|
779
|
+
let previous = std::env::var(KEY).ok();
|
|
780
|
+
unsafe { std::env::set_var(KEY, reason) };
|
|
781
|
+
Self(previous)
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
impl Drop for ForceRecreateFailureGuard {
|
|
786
|
+
fn drop(&mut self) {
|
|
787
|
+
const KEY: &str = "TEAM_AGENT_TEST_FAIL_FORCE_RECREATE_AFTER_SPAWN";
|
|
788
|
+
unsafe {
|
|
789
|
+
if let Some(previous) = self.0.take() {
|
|
790
|
+
std::env::set_var(KEY, previous);
|
|
791
|
+
} else {
|
|
792
|
+
std::env::remove_var(KEY);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
fn seed_agent_health(ws: &std::path::Path, team: &str, agent: &str, status: &str) {
|
|
799
|
+
let store = crate::message_store::MessageStore::open(ws).unwrap();
|
|
800
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
801
|
+
conn.execute(
|
|
802
|
+
"insert into agent_health(owner_team_id, agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at) values (?1, ?2, ?3, 'before-output', 37, 'task-before', '2026-07-19T00:00:00Z')",
|
|
803
|
+
rusqlite::params![team, agent, status],
|
|
804
|
+
)
|
|
805
|
+
.unwrap();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
fn seed_charlie(ws: &std::path::Path, tx: &LaneTransport) -> std::path::PathBuf {
|
|
809
|
+
let role_dir = ws.join("role-masters");
|
|
810
|
+
std::fs::create_dir_all(&role_dir).unwrap();
|
|
811
|
+
let role = role_dir.join("charlie.md");
|
|
812
|
+
std::fs::write(&role, CHARLIE_ROLE).unwrap();
|
|
813
|
+
crate::lifecycle::add_agent_with_transport(ws, &aid("charlie"), &role, false, None, tx)
|
|
814
|
+
.unwrap();
|
|
815
|
+
role
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
#[test]
|
|
819
|
+
#[serial_test::serial(env)]
|
|
820
|
+
fn force_recreate_is_one_lock_transaction_for_all_seat_quadrants_and_rolls_back_failure() {
|
|
821
|
+
for quadrant in ["state-only", "spec-only", "physical-only", "mixed"] {
|
|
822
|
+
let ws = lanea_ws_agents(json!({
|
|
823
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
824
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
825
|
+
}));
|
|
826
|
+
let tx = LaneTransport::new("team-laneateam", &[]);
|
|
827
|
+
seed_healthy_coordinator(&ws);
|
|
828
|
+
let role = seed_charlie(&ws, &tx);
|
|
829
|
+
match quadrant {
|
|
830
|
+
"state-only" => {
|
|
831
|
+
remove_agent_from_fixture_spec(&ws, "charlie");
|
|
832
|
+
crate::transport::Transport::kill_pane(
|
|
833
|
+
&tx,
|
|
834
|
+
&crate::transport::PaneId::new("%charlie"),
|
|
835
|
+
)
|
|
836
|
+
.unwrap();
|
|
837
|
+
}
|
|
838
|
+
"spec-only" => {
|
|
839
|
+
remove_agent_from_fixture_state(&ws, "charlie");
|
|
840
|
+
crate::transport::Transport::kill_pane(
|
|
841
|
+
&tx,
|
|
842
|
+
&crate::transport::PaneId::new("%charlie"),
|
|
843
|
+
)
|
|
844
|
+
.unwrap();
|
|
845
|
+
}
|
|
846
|
+
"physical-only" => {
|
|
847
|
+
remove_agent_from_fixture_spec(&ws, "charlie");
|
|
848
|
+
remove_agent_from_fixture_state(&ws, "charlie");
|
|
849
|
+
}
|
|
850
|
+
"mixed" => {
|
|
851
|
+
crate::transport::Transport::kill_pane(
|
|
852
|
+
&tx,
|
|
853
|
+
&crate::transport::PaneId::new("%charlie"),
|
|
854
|
+
)
|
|
855
|
+
.unwrap();
|
|
856
|
+
}
|
|
857
|
+
_ => unreachable!(),
|
|
858
|
+
}
|
|
859
|
+
crate::lifecycle::add_agent_with_transport_force(
|
|
860
|
+
&ws,
|
|
861
|
+
&aid("charlie"),
|
|
862
|
+
&role,
|
|
863
|
+
false,
|
|
864
|
+
None,
|
|
865
|
+
true,
|
|
866
|
+
&tx,
|
|
867
|
+
)
|
|
868
|
+
.unwrap_or_else(|error| panic!("{quadrant} force-recreate failed: {error}"));
|
|
869
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
870
|
+
assert!(
|
|
871
|
+
state["agents"].get("charlie").is_some(),
|
|
872
|
+
"{quadrant}: state restored"
|
|
873
|
+
);
|
|
874
|
+
assert!(
|
|
875
|
+
state["agent_lifecycle"].get("charlie").is_none(),
|
|
876
|
+
"{quadrant}: successful force-recreate consumes its interim tombstone"
|
|
877
|
+
);
|
|
878
|
+
let spec_text = std::fs::read_to_string(selected_spec_path(&ws)).unwrap();
|
|
879
|
+
assert_eq!(
|
|
880
|
+
spec_text.matches("id: \"charlie\"").count(),
|
|
881
|
+
1,
|
|
882
|
+
"{quadrant}: exactly one spec row"
|
|
883
|
+
);
|
|
884
|
+
assert_eq!(
|
|
885
|
+
crate::transport::Transport::list_targets(&tx)
|
|
886
|
+
.unwrap()
|
|
887
|
+
.iter()
|
|
888
|
+
.filter(|pane| pane.session.as_str() == "team-laneateam"
|
|
889
|
+
&& pane
|
|
890
|
+
.window_name
|
|
891
|
+
.as_ref()
|
|
892
|
+
.is_some_and(|window| window.as_str() == "charlie"))
|
|
893
|
+
.count(),
|
|
894
|
+
1,
|
|
895
|
+
"{quadrant}: exactly one live pane"
|
|
896
|
+
);
|
|
897
|
+
assert!(role.exists(), "{quadrant}: external role preserved");
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
let ws = lanea_ws_agents(json!({
|
|
901
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
902
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
903
|
+
}));
|
|
904
|
+
let tx = LaneTransport::new("team-laneateam", &[]);
|
|
905
|
+
seed_healthy_coordinator(&ws);
|
|
906
|
+
let role = seed_charlie(&ws, &tx);
|
|
907
|
+
let missing = ws.join("role-masters").join("missing-charlie.md");
|
|
908
|
+
let error = crate::lifecycle::add_agent_with_transport_force(
|
|
909
|
+
&ws,
|
|
910
|
+
&aid("charlie"),
|
|
911
|
+
&missing,
|
|
912
|
+
false,
|
|
913
|
+
None,
|
|
914
|
+
true,
|
|
915
|
+
&tx,
|
|
916
|
+
)
|
|
917
|
+
.expect_err("missing replacement role must fail and roll back");
|
|
918
|
+
assert!(error.to_string().contains("role file not found"));
|
|
919
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
920
|
+
assert!(
|
|
921
|
+
state["agents"].get("charlie").is_some(),
|
|
922
|
+
"rollback restores state"
|
|
923
|
+
);
|
|
924
|
+
assert!(
|
|
925
|
+
state["agent_lifecycle"].get("charlie").is_none(),
|
|
926
|
+
"preflight failure must not create a retirement tombstone"
|
|
927
|
+
);
|
|
928
|
+
assert!(
|
|
929
|
+
std::fs::read_to_string(selected_spec_path(&ws))
|
|
930
|
+
.unwrap()
|
|
931
|
+
.contains("id: \"charlie\""),
|
|
932
|
+
"rollback restores spec"
|
|
933
|
+
);
|
|
934
|
+
assert!(
|
|
935
|
+
crate::transport::Transport::list_targets(&tx)
|
|
936
|
+
.unwrap()
|
|
937
|
+
.iter()
|
|
938
|
+
.any(|pane| {
|
|
939
|
+
pane.session.as_str() == "team-laneateam"
|
|
940
|
+
&& pane
|
|
941
|
+
.window_name
|
|
942
|
+
.as_ref()
|
|
943
|
+
.is_some_and(|window| window.as_str() == "charlie")
|
|
944
|
+
}),
|
|
945
|
+
"rollback restores physical seat"
|
|
946
|
+
);
|
|
947
|
+
assert!(role.exists(), "rollback never mutates external role source");
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
#[test]
|
|
951
|
+
#[serial_test::serial(env)]
|
|
952
|
+
fn force_recreate_post_consumption_failure_restores_before_tuple_and_health() {
|
|
953
|
+
let hermetic = HermeticTestEnv::enter("force-recreate-rollback");
|
|
954
|
+
let ws = lanea_ws_agents_at(
|
|
955
|
+
hermetic.workspace_root(),
|
|
956
|
+
json!({
|
|
957
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
958
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
959
|
+
}),
|
|
960
|
+
);
|
|
961
|
+
let tx = LaneTransport::new("team-laneateam", &[]);
|
|
962
|
+
seed_healthy_coordinator(&ws);
|
|
963
|
+
let role = seed_charlie(&ws, &tx);
|
|
964
|
+
seed_agent_health(&ws, "team-laneateam", "charlie", "IDLE");
|
|
965
|
+
seed_agent_health(&ws, "team-other", "charlie", "WORKING");
|
|
966
|
+
let selected_health = crate::db::agent_health_capture::select_agent_health(
|
|
967
|
+
&ws,
|
|
968
|
+
"team-laneateam",
|
|
969
|
+
&aid("charlie"),
|
|
970
|
+
)
|
|
971
|
+
.unwrap();
|
|
972
|
+
let sibling_health =
|
|
973
|
+
crate::db::agent_health_capture::select_agent_health(&ws, "team-other", &aid("charlie"))
|
|
974
|
+
.unwrap();
|
|
975
|
+
let _failure = ForceRecreateFailureGuard::after_spawn("post-consumption seam");
|
|
976
|
+
let error = crate::lifecycle::add_agent_with_transport_force(
|
|
977
|
+
&ws,
|
|
978
|
+
&aid("charlie"),
|
|
979
|
+
&role,
|
|
980
|
+
false,
|
|
981
|
+
None,
|
|
982
|
+
true,
|
|
983
|
+
&tx,
|
|
984
|
+
)
|
|
985
|
+
.expect_err("post-spawn failure must roll back a consumed seat");
|
|
986
|
+
assert!(error.to_string().contains("post-consumption seam"));
|
|
987
|
+
assert_eq!(
|
|
988
|
+
tx.killed()
|
|
989
|
+
.iter()
|
|
990
|
+
.filter(|pane| pane.as_str() == "%charlie")
|
|
991
|
+
.count(),
|
|
992
|
+
2,
|
|
993
|
+
"rollback kills the old pane during remove and the transaction-created pane before restore"
|
|
994
|
+
);
|
|
995
|
+
assert_eq!(
|
|
996
|
+
crate::transport::Transport::list_targets(&tx)
|
|
997
|
+
.unwrap()
|
|
998
|
+
.iter()
|
|
999
|
+
.filter(|pane| pane.session.as_str() == "team-laneateam"
|
|
1000
|
+
&& pane
|
|
1001
|
+
.window_name
|
|
1002
|
+
.as_ref()
|
|
1003
|
+
.is_some_and(|window| window.as_str() == "charlie"))
|
|
1004
|
+
.count(),
|
|
1005
|
+
1,
|
|
1006
|
+
"rollback restores exactly one physical seat; error={error}; spawns={:?}; killed={:?}",
|
|
1007
|
+
tx.spawns(),
|
|
1008
|
+
tx.killed(),
|
|
1009
|
+
);
|
|
1010
|
+
assert_eq!(
|
|
1011
|
+
crate::db::agent_health_capture::select_agent_health(
|
|
1012
|
+
&ws,
|
|
1013
|
+
"team-laneateam",
|
|
1014
|
+
&aid("charlie"),
|
|
1015
|
+
)
|
|
1016
|
+
.unwrap(),
|
|
1017
|
+
selected_health,
|
|
1018
|
+
"rollback restores the selected team's captured health row"
|
|
1019
|
+
);
|
|
1020
|
+
assert_eq!(
|
|
1021
|
+
crate::db::agent_health_capture::select_agent_health(&ws, "team-other", &aid("charlie"),)
|
|
1022
|
+
.unwrap(),
|
|
1023
|
+
sibling_health,
|
|
1024
|
+
"rollback never changes the sibling team's same-agent health row"
|
|
1025
|
+
);
|
|
1026
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1027
|
+
assert!(
|
|
1028
|
+
state["agent_lifecycle"].get("charlie").is_none(),
|
|
1029
|
+
"post-consumption rollback restores the before-state without a new tombstone"
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
#[test]
|
|
1034
|
+
fn force_remove_scopes_agent_health_to_canonical_team_key() {
|
|
1035
|
+
let hermetic = HermeticTestEnv::enter("force-remove-health-scope");
|
|
1036
|
+
let ws = lanea_ws_agents_at(
|
|
1037
|
+
hermetic.workspace_root(),
|
|
1038
|
+
json!({
|
|
1039
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
1040
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
1041
|
+
}),
|
|
1042
|
+
);
|
|
1043
|
+
let tx = LaneTransport::new("team-laneateam", &[]);
|
|
1044
|
+
seed_agent_health(&ws, "team-laneateam", "alpha", "IDLE");
|
|
1045
|
+
seed_agent_health(&ws, "team-other", "alpha", "WORKING");
|
|
1046
|
+
let sibling_before =
|
|
1047
|
+
crate::db::agent_health_capture::select_agent_health(&ws, "team-other", &aid("alpha"))
|
|
1048
|
+
.unwrap();
|
|
1049
|
+
|
|
1050
|
+
remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &tx).unwrap();
|
|
1051
|
+
|
|
1052
|
+
assert!(crate::db::agent_health_capture::select_agent_health(
|
|
1053
|
+
&ws,
|
|
1054
|
+
"team-laneateam",
|
|
1055
|
+
&aid("alpha"),
|
|
1056
|
+
)
|
|
1057
|
+
.unwrap()
|
|
1058
|
+
.is_none());
|
|
1059
|
+
assert_eq!(
|
|
1060
|
+
crate::db::agent_health_capture::select_agent_health(&ws, "team-other", &aid("alpha"),)
|
|
1061
|
+
.unwrap(),
|
|
1062
|
+
sibling_before,
|
|
1063
|
+
"force-remove must not delete the sibling owner_team_id row"
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
271
1067
|
/// Rewrite the compiled spec's `context.state_file` (default `team_state.md`) to `name`, so the
|
|
272
1068
|
/// rollback team-state path divergence (capture honors spec.context.state_file; restore hardcodes
|
|
273
1069
|
/// team_state.md) is observable. Asserts the rewrite took, so a format change can't silently mis-test.
|
|
@@ -363,9 +1159,8 @@ fn lanea_stop_window_absent_returns_stopped_false_no_kill() {
|
|
|
363
1159
|
}
|
|
364
1160
|
|
|
365
1161
|
// ── STOP #1 companion [LOCK] — window PRESENT => stopped=true + kill the golden target ───────────────
|
|
366
|
-
//
|
|
367
|
-
// stopped:true.
|
|
368
|
-
// window-gate fix regressing it.
|
|
1162
|
+
// When the selected team's exact `(session, window, pane)` tuple exists, stop kills that pane and
|
|
1163
|
+
// returns stopped:true. Pane identity avoids ambiguous same-name windows on shared endpoints.
|
|
369
1164
|
#[test]
|
|
370
1165
|
fn lanea_stop_window_present_kills_and_stopped_true() {
|
|
371
1166
|
let ws = lanea_ws_agents(
|
|
@@ -380,8 +1175,8 @@ fn lanea_stop_window_present_kills_and_stopped_true() {
|
|
|
380
1175
|
);
|
|
381
1176
|
assert_eq!(
|
|
382
1177
|
tx.killed(),
|
|
383
|
-
vec!["
|
|
384
|
-
"stop must kill
|
|
1178
|
+
vec!["%alpha".to_string()],
|
|
1179
|
+
"stop must kill the selected team's exact pane tuple; killed={:?}",
|
|
385
1180
|
tx.killed()
|
|
386
1181
|
);
|
|
387
1182
|
}
|
|
@@ -470,6 +1265,95 @@ fn lanea_remove_dynamic_agent_removable_without_from_spec() {
|
|
|
470
1265
|
);
|
|
471
1266
|
}
|
|
472
1267
|
|
|
1268
|
+
#[test]
|
|
1269
|
+
fn remove_preserves_external_role_source_for_both_flag_forms() {
|
|
1270
|
+
for from_spec in [false, true] {
|
|
1271
|
+
let ws = lanea_ws_agents(json!({
|
|
1272
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
1273
|
+
"bravo": { "status": "stopped", "provider": "codex", "window": "bravo" }
|
|
1274
|
+
}));
|
|
1275
|
+
let external_dir = ws.join("role-masters");
|
|
1276
|
+
std::fs::create_dir_all(&external_dir).unwrap();
|
|
1277
|
+
let external = external_dir.join("alpha.md");
|
|
1278
|
+
let original = b"external alpha role\n";
|
|
1279
|
+
std::fs::write(&external, original).unwrap();
|
|
1280
|
+
let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1281
|
+
state["agents"]["alpha"]["dynamic_role_file"] =
|
|
1282
|
+
serde_json::json!(external.to_string_lossy());
|
|
1283
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
1284
|
+
|
|
1285
|
+
let result = remove_agent_with_transport(
|
|
1286
|
+
&ws,
|
|
1287
|
+
&aid("alpha"),
|
|
1288
|
+
from_spec,
|
|
1289
|
+
true,
|
|
1290
|
+
None,
|
|
1291
|
+
&LaneTransport::new("team-laneateam", &[]),
|
|
1292
|
+
);
|
|
1293
|
+
assert!(result.is_ok(), "remove failed: {result:?}");
|
|
1294
|
+
assert_eq!(
|
|
1295
|
+
std::fs::read(&external).unwrap(),
|
|
1296
|
+
original,
|
|
1297
|
+
"external role source changed for from_spec={from_spec}"
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
#[cfg(unix)]
|
|
1303
|
+
#[test]
|
|
1304
|
+
fn remove_preserves_managed_path_symlink_escape() {
|
|
1305
|
+
use std::os::unix::fs::symlink;
|
|
1306
|
+
|
|
1307
|
+
let ws = lanea_ws_agents(json!({
|
|
1308
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha", "dynamic_role_file": ".team/dynamic-role-files/alpha.md" },
|
|
1309
|
+
"bravo": { "status": "stopped", "provider": "codex", "window": "bravo" }
|
|
1310
|
+
}));
|
|
1311
|
+
let external = ws.join("role-masters").join("alpha.md");
|
|
1312
|
+
std::fs::create_dir_all(external.parent().unwrap()).unwrap();
|
|
1313
|
+
std::fs::write(&external, b"external target\n").unwrap();
|
|
1314
|
+
let managed = ws.join(".team").join("dynamic-role-files").join("alpha.md");
|
|
1315
|
+
std::fs::create_dir_all(managed.parent().unwrap()).unwrap();
|
|
1316
|
+
symlink(&external, &managed).unwrap();
|
|
1317
|
+
|
|
1318
|
+
let result = remove_agent_with_transport(
|
|
1319
|
+
&ws,
|
|
1320
|
+
&aid("alpha"),
|
|
1321
|
+
false,
|
|
1322
|
+
true,
|
|
1323
|
+
None,
|
|
1324
|
+
&LaneTransport::new("team-laneateam", &[]),
|
|
1325
|
+
);
|
|
1326
|
+
assert!(result.is_ok(), "remove failed: {result:?}");
|
|
1327
|
+
assert_eq!(std::fs::read(&external).unwrap(), b"external target\n");
|
|
1328
|
+
assert!(managed.exists(), "symlink escape must be preserved");
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
#[test]
|
|
1332
|
+
fn force_remove_state_only_seat_is_idempotent() {
|
|
1333
|
+
let ws = lanea_ws_agents(json!({
|
|
1334
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
1335
|
+
"bravo": { "status": "stopped", "provider": "codex", "window": "bravo" }
|
|
1336
|
+
}));
|
|
1337
|
+
let spec_path = ws.join("team.spec.yaml");
|
|
1338
|
+
let spec = crate::model::yaml::loads(&std::fs::read_to_string(&spec_path).unwrap()).unwrap();
|
|
1339
|
+
std::fs::write(
|
|
1340
|
+
&spec_path,
|
|
1341
|
+
crate::model::yaml::dumps(&crate::lifecycle::restart::remove::spec_without_agent(
|
|
1342
|
+
&spec,
|
|
1343
|
+
&aid("alpha"),
|
|
1344
|
+
)),
|
|
1345
|
+
)
|
|
1346
|
+
.unwrap();
|
|
1347
|
+
let transport = LaneTransport::new("team-laneateam", &[]);
|
|
1348
|
+
let first = remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &transport);
|
|
1349
|
+
let second = remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &transport);
|
|
1350
|
+
assert!(first.is_ok(), "state-only force-remove failed: {first:?}");
|
|
1351
|
+
assert!(
|
|
1352
|
+
second.is_ok(),
|
|
1353
|
+
"second force-remove must be idempotent: {second:?}"
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
473
1357
|
// ── REMOVE #5 (remove-unknown-precedence-2) [RED] — unknown-worker raised BEFORE from_spec refusal ───
|
|
474
1358
|
// Golden agents.py:41-54 loads the spec and runs _find_worker (raising "unknown worker agent id: <id>")
|
|
475
1359
|
// BEFORE the from_spec/confirm refusal at :53. Rust checks `!from_spec` FIRST (restart.rs:285) and
|
|
@@ -571,8 +1455,8 @@ fn lanea_remove_rollback_restarts_force_stopped_worker() {
|
|
|
571
1455
|
after the force-stop, triggering rollback; got {result:?}"
|
|
572
1456
|
);
|
|
573
1457
|
assert!(
|
|
574
|
-
tx.killed().contains(&"
|
|
575
|
-
"precondition: the running worker
|
|
1458
|
+
tx.killed().contains(&"%alpha".to_string()),
|
|
1459
|
+
"precondition: the running worker's exact pane was killed before the failure; killed={:?}",
|
|
576
1460
|
tx.killed()
|
|
577
1461
|
);
|
|
578
1462
|
assert!(
|
|
@@ -582,6 +1466,11 @@ fn lanea_remove_rollback_restarts_force_stopped_worker() {
|
|
|
582
1466
|
RemoveRollback::restore. spawns={:?}",
|
|
583
1467
|
tx.spawns()
|
|
584
1468
|
);
|
|
1469
|
+
let restored = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1470
|
+
assert!(
|
|
1471
|
+
restored["agent_lifecycle"].get("alpha").is_none(),
|
|
1472
|
+
"failed remove must roll back the transaction-created tombstone"
|
|
1473
|
+
);
|
|
585
1474
|
}
|
|
586
1475
|
|
|
587
1476
|
// ── REMOVE #11 (remove-dynamic-role-path-and-required-8, warn) [RED] — missing REQUIRED role file raises
|