@team-agent/installer 0.5.49 → 0.5.51
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 +6 -4
- package/crates/team-agent/src/cli/emit.rs +91 -39
- package/crates/team-agent/src/cli/mod.rs +33 -15
- package/crates/team-agent/src/cli/named_address.rs +82 -53
- 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 +103 -1308
- package/crates/team-agent/src/cli/spec.rs +2 -2
- 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/leader_watch.rs +1 -1
- package/crates/team-agent/src/cli/tests/named_address.rs +9 -7
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +17 -33
- package/crates/team-agent/src/cli/types.rs +5 -8
- 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/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 +297 -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 +44 -26
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
- package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
- package/crates/team-agent/src/lifecycle/restart.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -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 +2 -2
- package/crates/team-agent/src/mcp_server/tests/send.rs +22 -15
- package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -0
- package/crates/team-agent/src/mcp_server/tools.rs +26 -15
- package/crates/team-agent/src/mcp_server/wire.rs +2 -18
- 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 +165 -39
- package/crates/team-agent/src/messaging/helpers.rs +17 -13
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
- package/crates/team-agent/src/messaging/mod.rs +11 -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 +133 -58
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -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 +2 -1
- package/crates/team-agent/src/state/repository/tests.rs +47 -0
- package/crates/team-agent/src/state/repository.rs +59 -16
- 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,411 @@ 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
|
+
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";
|
|
438
|
+
|
|
439
|
+
struct HermeticTestEnv {
|
|
440
|
+
root: std::path::PathBuf,
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
impl HermeticTestEnv {
|
|
444
|
+
fn enter(tag: &str) -> Self {
|
|
445
|
+
let root = temp_ws().join(format!("hermetic-{tag}"));
|
|
446
|
+
std::fs::create_dir_all(&root).unwrap();
|
|
447
|
+
Self { root }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
fn workspace_root(&self) -> &std::path::Path {
|
|
451
|
+
&self.root
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
impl Drop for HermeticTestEnv {
|
|
456
|
+
fn drop(&mut self) {
|
|
457
|
+
let _ = std::fs::remove_dir_all(&self.root);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
struct ForceRecreateFailureGuard(Option<String>);
|
|
462
|
+
|
|
463
|
+
impl ForceRecreateFailureGuard {
|
|
464
|
+
fn after_spawn(reason: &str) -> Self {
|
|
465
|
+
const KEY: &str = "TEAM_AGENT_TEST_FAIL_FORCE_RECREATE_AFTER_SPAWN";
|
|
466
|
+
let previous = std::env::var(KEY).ok();
|
|
467
|
+
unsafe { std::env::set_var(KEY, reason) };
|
|
468
|
+
Self(previous)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
impl Drop for ForceRecreateFailureGuard {
|
|
473
|
+
fn drop(&mut self) {
|
|
474
|
+
const KEY: &str = "TEAM_AGENT_TEST_FAIL_FORCE_RECREATE_AFTER_SPAWN";
|
|
475
|
+
unsafe {
|
|
476
|
+
if let Some(previous) = self.0.take() {
|
|
477
|
+
std::env::set_var(KEY, previous);
|
|
478
|
+
} else {
|
|
479
|
+
std::env::remove_var(KEY);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
fn seed_agent_health(ws: &std::path::Path, team: &str, agent: &str, status: &str) {
|
|
486
|
+
let store = crate::message_store::MessageStore::open(ws).unwrap();
|
|
487
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
488
|
+
conn.execute(
|
|
489
|
+
"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')",
|
|
490
|
+
rusqlite::params![team, agent, status],
|
|
491
|
+
)
|
|
492
|
+
.unwrap();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
fn seed_charlie(ws: &std::path::Path, tx: &LaneTransport) -> std::path::PathBuf {
|
|
496
|
+
let role_dir = ws.join("role-masters");
|
|
497
|
+
std::fs::create_dir_all(&role_dir).unwrap();
|
|
498
|
+
let role = role_dir.join("charlie.md");
|
|
499
|
+
std::fs::write(&role, CHARLIE_ROLE).unwrap();
|
|
500
|
+
crate::lifecycle::add_agent_with_transport(ws, &aid("charlie"), &role, false, None, tx)
|
|
501
|
+
.unwrap();
|
|
502
|
+
role
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#[test]
|
|
506
|
+
#[serial_test::serial(env)]
|
|
507
|
+
fn force_recreate_is_one_lock_transaction_for_all_seat_quadrants_and_rolls_back_failure() {
|
|
508
|
+
for quadrant in ["state-only", "spec-only", "physical-only", "mixed"] {
|
|
509
|
+
let ws = lanea_ws_agents(json!({
|
|
510
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
511
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
512
|
+
}));
|
|
513
|
+
let tx = LaneTransport::new("team-laneateam", &[]);
|
|
514
|
+
seed_healthy_coordinator(&ws);
|
|
515
|
+
let role = seed_charlie(&ws, &tx);
|
|
516
|
+
match quadrant {
|
|
517
|
+
"state-only" => {
|
|
518
|
+
remove_agent_from_fixture_spec(&ws, "charlie");
|
|
519
|
+
crate::transport::Transport::kill_pane(
|
|
520
|
+
&tx,
|
|
521
|
+
&crate::transport::PaneId::new("%charlie"),
|
|
522
|
+
)
|
|
523
|
+
.unwrap();
|
|
524
|
+
}
|
|
525
|
+
"spec-only" => {
|
|
526
|
+
remove_agent_from_fixture_state(&ws, "charlie");
|
|
527
|
+
crate::transport::Transport::kill_pane(
|
|
528
|
+
&tx,
|
|
529
|
+
&crate::transport::PaneId::new("%charlie"),
|
|
530
|
+
)
|
|
531
|
+
.unwrap();
|
|
532
|
+
}
|
|
533
|
+
"physical-only" => {
|
|
534
|
+
remove_agent_from_fixture_spec(&ws, "charlie");
|
|
535
|
+
remove_agent_from_fixture_state(&ws, "charlie");
|
|
536
|
+
}
|
|
537
|
+
"mixed" => {
|
|
538
|
+
crate::transport::Transport::kill_pane(
|
|
539
|
+
&tx,
|
|
540
|
+
&crate::transport::PaneId::new("%charlie"),
|
|
541
|
+
)
|
|
542
|
+
.unwrap();
|
|
543
|
+
}
|
|
544
|
+
_ => unreachable!(),
|
|
545
|
+
}
|
|
546
|
+
crate::lifecycle::add_agent_with_transport_force(
|
|
547
|
+
&ws,
|
|
548
|
+
&aid("charlie"),
|
|
549
|
+
&role,
|
|
550
|
+
false,
|
|
551
|
+
None,
|
|
552
|
+
true,
|
|
553
|
+
&tx,
|
|
554
|
+
)
|
|
555
|
+
.unwrap_or_else(|error| panic!("{quadrant} force-recreate failed: {error}"));
|
|
556
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
557
|
+
assert!(
|
|
558
|
+
state["agents"].get("charlie").is_some(),
|
|
559
|
+
"{quadrant}: state restored"
|
|
560
|
+
);
|
|
561
|
+
let spec_text = std::fs::read_to_string(selected_spec_path(&ws)).unwrap();
|
|
562
|
+
assert_eq!(
|
|
563
|
+
spec_text.matches("id: \"charlie\"").count(),
|
|
564
|
+
1,
|
|
565
|
+
"{quadrant}: exactly one spec row"
|
|
566
|
+
);
|
|
567
|
+
assert_eq!(
|
|
568
|
+
crate::transport::Transport::list_targets(&tx)
|
|
569
|
+
.unwrap()
|
|
570
|
+
.iter()
|
|
571
|
+
.filter(|pane| pane.session.as_str() == "team-laneateam"
|
|
572
|
+
&& pane
|
|
573
|
+
.window_name
|
|
574
|
+
.as_ref()
|
|
575
|
+
.is_some_and(|window| window.as_str() == "charlie"))
|
|
576
|
+
.count(),
|
|
577
|
+
1,
|
|
578
|
+
"{quadrant}: exactly one live pane"
|
|
579
|
+
);
|
|
580
|
+
assert!(role.exists(), "{quadrant}: external role preserved");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
let ws = lanea_ws_agents(json!({
|
|
584
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
585
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
586
|
+
}));
|
|
587
|
+
let tx = LaneTransport::new("team-laneateam", &[]);
|
|
588
|
+
seed_healthy_coordinator(&ws);
|
|
589
|
+
let role = seed_charlie(&ws, &tx);
|
|
590
|
+
let missing = ws.join("role-masters").join("missing-charlie.md");
|
|
591
|
+
let error = crate::lifecycle::add_agent_with_transport_force(
|
|
592
|
+
&ws,
|
|
593
|
+
&aid("charlie"),
|
|
594
|
+
&missing,
|
|
595
|
+
false,
|
|
596
|
+
None,
|
|
597
|
+
true,
|
|
598
|
+
&tx,
|
|
599
|
+
)
|
|
600
|
+
.expect_err("missing replacement role must fail and roll back");
|
|
601
|
+
assert!(error.to_string().contains("role file not found"));
|
|
602
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
603
|
+
assert!(
|
|
604
|
+
state["agents"].get("charlie").is_some(),
|
|
605
|
+
"rollback restores state"
|
|
606
|
+
);
|
|
607
|
+
assert!(
|
|
608
|
+
std::fs::read_to_string(selected_spec_path(&ws))
|
|
609
|
+
.unwrap()
|
|
610
|
+
.contains("id: \"charlie\""),
|
|
611
|
+
"rollback restores spec"
|
|
612
|
+
);
|
|
613
|
+
assert!(
|
|
614
|
+
crate::transport::Transport::list_targets(&tx)
|
|
615
|
+
.unwrap()
|
|
616
|
+
.iter()
|
|
617
|
+
.any(|pane| {
|
|
618
|
+
pane.session.as_str() == "team-laneateam"
|
|
619
|
+
&& pane
|
|
620
|
+
.window_name
|
|
621
|
+
.as_ref()
|
|
622
|
+
.is_some_and(|window| window.as_str() == "charlie")
|
|
623
|
+
}),
|
|
624
|
+
"rollback restores physical seat"
|
|
625
|
+
);
|
|
626
|
+
assert!(role.exists(), "rollback never mutates external role source");
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
#[test]
|
|
630
|
+
#[serial_test::serial(env)]
|
|
631
|
+
fn force_recreate_post_consumption_failure_restores_before_tuple_and_health() {
|
|
632
|
+
let hermetic = HermeticTestEnv::enter("force-recreate-rollback");
|
|
633
|
+
let ws = lanea_ws_agents_at(
|
|
634
|
+
hermetic.workspace_root(),
|
|
635
|
+
json!({
|
|
636
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
637
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
let tx = LaneTransport::new("team-laneateam", &[]);
|
|
641
|
+
seed_healthy_coordinator(&ws);
|
|
642
|
+
let role = seed_charlie(&ws, &tx);
|
|
643
|
+
seed_agent_health(&ws, "team-laneateam", "charlie", "IDLE");
|
|
644
|
+
seed_agent_health(&ws, "team-other", "charlie", "WORKING");
|
|
645
|
+
let selected_health = crate::db::agent_health_capture::select_agent_health(
|
|
646
|
+
&ws,
|
|
647
|
+
"team-laneateam",
|
|
648
|
+
&aid("charlie"),
|
|
649
|
+
)
|
|
650
|
+
.unwrap();
|
|
651
|
+
let sibling_health =
|
|
652
|
+
crate::db::agent_health_capture::select_agent_health(&ws, "team-other", &aid("charlie"))
|
|
653
|
+
.unwrap();
|
|
654
|
+
let _failure = ForceRecreateFailureGuard::after_spawn("post-consumption seam");
|
|
655
|
+
let error = crate::lifecycle::add_agent_with_transport_force(
|
|
656
|
+
&ws,
|
|
657
|
+
&aid("charlie"),
|
|
658
|
+
&role,
|
|
659
|
+
false,
|
|
660
|
+
None,
|
|
661
|
+
true,
|
|
662
|
+
&tx,
|
|
663
|
+
)
|
|
664
|
+
.expect_err("post-spawn failure must roll back a consumed seat");
|
|
665
|
+
assert!(error.to_string().contains("post-consumption seam"));
|
|
666
|
+
assert_eq!(
|
|
667
|
+
tx.killed()
|
|
668
|
+
.iter()
|
|
669
|
+
.filter(|pane| pane.as_str() == "%charlie")
|
|
670
|
+
.count(),
|
|
671
|
+
2,
|
|
672
|
+
"rollback kills the old pane during remove and the transaction-created pane before restore"
|
|
673
|
+
);
|
|
674
|
+
assert_eq!(
|
|
675
|
+
crate::transport::Transport::list_targets(&tx)
|
|
676
|
+
.unwrap()
|
|
677
|
+
.iter()
|
|
678
|
+
.filter(|pane| pane.session.as_str() == "team-laneateam"
|
|
679
|
+
&& pane
|
|
680
|
+
.window_name
|
|
681
|
+
.as_ref()
|
|
682
|
+
.is_some_and(|window| window.as_str() == "charlie"))
|
|
683
|
+
.count(),
|
|
684
|
+
1,
|
|
685
|
+
"rollback restores exactly one physical seat; error={error}; spawns={:?}; killed={:?}",
|
|
686
|
+
tx.spawns(),
|
|
687
|
+
tx.killed(),
|
|
688
|
+
);
|
|
689
|
+
assert_eq!(
|
|
690
|
+
crate::db::agent_health_capture::select_agent_health(
|
|
691
|
+
&ws,
|
|
692
|
+
"team-laneateam",
|
|
693
|
+
&aid("charlie"),
|
|
694
|
+
)
|
|
695
|
+
.unwrap(),
|
|
696
|
+
selected_health,
|
|
697
|
+
"rollback restores the selected team's captured health row"
|
|
698
|
+
);
|
|
699
|
+
assert_eq!(
|
|
700
|
+
crate::db::agent_health_capture::select_agent_health(&ws, "team-other", &aid("charlie"),)
|
|
701
|
+
.unwrap(),
|
|
702
|
+
sibling_health,
|
|
703
|
+
"rollback never changes the sibling team's same-agent health row"
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
#[test]
|
|
708
|
+
fn force_remove_scopes_agent_health_to_canonical_team_key() {
|
|
709
|
+
let hermetic = HermeticTestEnv::enter("force-remove-health-scope");
|
|
710
|
+
let ws = lanea_ws_agents_at(
|
|
711
|
+
hermetic.workspace_root(),
|
|
712
|
+
json!({
|
|
713
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
714
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
715
|
+
}),
|
|
716
|
+
);
|
|
717
|
+
let tx = LaneTransport::new("team-laneateam", &[]);
|
|
718
|
+
seed_agent_health(&ws, "team-laneateam", "alpha", "IDLE");
|
|
719
|
+
seed_agent_health(&ws, "team-other", "alpha", "WORKING");
|
|
720
|
+
let sibling_before =
|
|
721
|
+
crate::db::agent_health_capture::select_agent_health(&ws, "team-other", &aid("alpha"))
|
|
722
|
+
.unwrap();
|
|
723
|
+
|
|
724
|
+
remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &tx).unwrap();
|
|
725
|
+
|
|
726
|
+
assert!(crate::db::agent_health_capture::select_agent_health(
|
|
727
|
+
&ws,
|
|
728
|
+
"team-laneateam",
|
|
729
|
+
&aid("alpha"),
|
|
730
|
+
)
|
|
731
|
+
.unwrap()
|
|
732
|
+
.is_none());
|
|
733
|
+
assert_eq!(
|
|
734
|
+
crate::db::agent_health_capture::select_agent_health(&ws, "team-other", &aid("alpha"),)
|
|
735
|
+
.unwrap(),
|
|
736
|
+
sibling_before,
|
|
737
|
+
"force-remove must not delete the sibling owner_team_id row"
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
271
741
|
/// Rewrite the compiled spec's `context.state_file` (default `team_state.md`) to `name`, so the
|
|
272
742
|
/// rollback team-state path divergence (capture honors spec.context.state_file; restore hardcodes
|
|
273
743
|
/// team_state.md) is observable. Asserts the rewrite took, so a format change can't silently mis-test.
|
|
@@ -363,9 +833,8 @@ fn lanea_stop_window_absent_returns_stopped_false_no_kill() {
|
|
|
363
833
|
}
|
|
364
834
|
|
|
365
835
|
// ── STOP #1 companion [LOCK] — window PRESENT => stopped=true + kill the golden target ───────────────
|
|
366
|
-
//
|
|
367
|
-
// stopped:true.
|
|
368
|
-
// window-gate fix regressing it.
|
|
836
|
+
// When the selected team's exact `(session, window, pane)` tuple exists, stop kills that pane and
|
|
837
|
+
// returns stopped:true. Pane identity avoids ambiguous same-name windows on shared endpoints.
|
|
369
838
|
#[test]
|
|
370
839
|
fn lanea_stop_window_present_kills_and_stopped_true() {
|
|
371
840
|
let ws = lanea_ws_agents(
|
|
@@ -380,8 +849,8 @@ fn lanea_stop_window_present_kills_and_stopped_true() {
|
|
|
380
849
|
);
|
|
381
850
|
assert_eq!(
|
|
382
851
|
tx.killed(),
|
|
383
|
-
vec!["
|
|
384
|
-
"stop must kill
|
|
852
|
+
vec!["%alpha".to_string()],
|
|
853
|
+
"stop must kill the selected team's exact pane tuple; killed={:?}",
|
|
385
854
|
tx.killed()
|
|
386
855
|
);
|
|
387
856
|
}
|
|
@@ -470,6 +939,95 @@ fn lanea_remove_dynamic_agent_removable_without_from_spec() {
|
|
|
470
939
|
);
|
|
471
940
|
}
|
|
472
941
|
|
|
942
|
+
#[test]
|
|
943
|
+
fn remove_preserves_external_role_source_for_both_flag_forms() {
|
|
944
|
+
for from_spec in [false, true] {
|
|
945
|
+
let ws = lanea_ws_agents(json!({
|
|
946
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
947
|
+
"bravo": { "status": "stopped", "provider": "codex", "window": "bravo" }
|
|
948
|
+
}));
|
|
949
|
+
let external_dir = ws.join("role-masters");
|
|
950
|
+
std::fs::create_dir_all(&external_dir).unwrap();
|
|
951
|
+
let external = external_dir.join("alpha.md");
|
|
952
|
+
let original = b"external alpha role\n";
|
|
953
|
+
std::fs::write(&external, original).unwrap();
|
|
954
|
+
let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
955
|
+
state["agents"]["alpha"]["dynamic_role_file"] =
|
|
956
|
+
serde_json::json!(external.to_string_lossy());
|
|
957
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
958
|
+
|
|
959
|
+
let result = remove_agent_with_transport(
|
|
960
|
+
&ws,
|
|
961
|
+
&aid("alpha"),
|
|
962
|
+
from_spec,
|
|
963
|
+
true,
|
|
964
|
+
None,
|
|
965
|
+
&LaneTransport::new("team-laneateam", &[]),
|
|
966
|
+
);
|
|
967
|
+
assert!(result.is_ok(), "remove failed: {result:?}");
|
|
968
|
+
assert_eq!(
|
|
969
|
+
std::fs::read(&external).unwrap(),
|
|
970
|
+
original,
|
|
971
|
+
"external role source changed for from_spec={from_spec}"
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
#[cfg(unix)]
|
|
977
|
+
#[test]
|
|
978
|
+
fn remove_preserves_managed_path_symlink_escape() {
|
|
979
|
+
use std::os::unix::fs::symlink;
|
|
980
|
+
|
|
981
|
+
let ws = lanea_ws_agents(json!({
|
|
982
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha", "dynamic_role_file": ".team/dynamic-role-files/alpha.md" },
|
|
983
|
+
"bravo": { "status": "stopped", "provider": "codex", "window": "bravo" }
|
|
984
|
+
}));
|
|
985
|
+
let external = ws.join("role-masters").join("alpha.md");
|
|
986
|
+
std::fs::create_dir_all(external.parent().unwrap()).unwrap();
|
|
987
|
+
std::fs::write(&external, b"external target\n").unwrap();
|
|
988
|
+
let managed = ws.join(".team").join("dynamic-role-files").join("alpha.md");
|
|
989
|
+
std::fs::create_dir_all(managed.parent().unwrap()).unwrap();
|
|
990
|
+
symlink(&external, &managed).unwrap();
|
|
991
|
+
|
|
992
|
+
let result = remove_agent_with_transport(
|
|
993
|
+
&ws,
|
|
994
|
+
&aid("alpha"),
|
|
995
|
+
false,
|
|
996
|
+
true,
|
|
997
|
+
None,
|
|
998
|
+
&LaneTransport::new("team-laneateam", &[]),
|
|
999
|
+
);
|
|
1000
|
+
assert!(result.is_ok(), "remove failed: {result:?}");
|
|
1001
|
+
assert_eq!(std::fs::read(&external).unwrap(), b"external target\n");
|
|
1002
|
+
assert!(managed.exists(), "symlink escape must be preserved");
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
#[test]
|
|
1006
|
+
fn force_remove_state_only_seat_is_idempotent() {
|
|
1007
|
+
let ws = lanea_ws_agents(json!({
|
|
1008
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
1009
|
+
"bravo": { "status": "stopped", "provider": "codex", "window": "bravo" }
|
|
1010
|
+
}));
|
|
1011
|
+
let spec_path = ws.join("team.spec.yaml");
|
|
1012
|
+
let spec = crate::model::yaml::loads(&std::fs::read_to_string(&spec_path).unwrap()).unwrap();
|
|
1013
|
+
std::fs::write(
|
|
1014
|
+
&spec_path,
|
|
1015
|
+
crate::model::yaml::dumps(&crate::lifecycle::restart::remove::spec_without_agent(
|
|
1016
|
+
&spec,
|
|
1017
|
+
&aid("alpha"),
|
|
1018
|
+
)),
|
|
1019
|
+
)
|
|
1020
|
+
.unwrap();
|
|
1021
|
+
let transport = LaneTransport::new("team-laneateam", &[]);
|
|
1022
|
+
let first = remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &transport);
|
|
1023
|
+
let second = remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &transport);
|
|
1024
|
+
assert!(first.is_ok(), "state-only force-remove failed: {first:?}");
|
|
1025
|
+
assert!(
|
|
1026
|
+
second.is_ok(),
|
|
1027
|
+
"second force-remove must be idempotent: {second:?}"
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
473
1031
|
// ── REMOVE #5 (remove-unknown-precedence-2) [RED] — unknown-worker raised BEFORE from_spec refusal ───
|
|
474
1032
|
// Golden agents.py:41-54 loads the spec and runs _find_worker (raising "unknown worker agent id: <id>")
|
|
475
1033
|
// BEFORE the from_spec/confirm refusal at :53. Rust checks `!from_spec` FIRST (restart.rs:285) and
|
|
@@ -571,8 +1129,8 @@ fn lanea_remove_rollback_restarts_force_stopped_worker() {
|
|
|
571
1129
|
after the force-stop, triggering rollback; got {result:?}"
|
|
572
1130
|
);
|
|
573
1131
|
assert!(
|
|
574
|
-
tx.killed().contains(&"
|
|
575
|
-
"precondition: the running worker
|
|
1132
|
+
tx.killed().contains(&"%alpha".to_string()),
|
|
1133
|
+
"precondition: the running worker's exact pane was killed before the failure; killed={:?}",
|
|
576
1134
|
tx.killed()
|
|
577
1135
|
);
|
|
578
1136
|
assert!(
|