@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.
Files changed (93) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -3
  4. package/crates/team-agent/src/cli/emit.rs +2 -1
  5. package/crates/team-agent/src/cli/mod.rs +32 -14
  6. package/crates/team-agent/src/cli/named_address.rs +46 -115
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +44 -1169
  13. package/crates/team-agent/src/cli/spec.rs +1 -1
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  25. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
  26. package/crates/team-agent/src/cli/types.rs +1 -0
  27. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  29. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  30. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  31. package/crates/team-agent/src/db/message_store.rs +154 -44
  32. package/crates/team-agent/src/event_log.rs +73 -0
  33. package/crates/team-agent/src/leader/lease.rs +202 -16
  34. package/crates/team-agent/src/leader/start.rs +28 -4
  35. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  36. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
  37. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  38. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  39. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  41. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  42. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  43. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  44. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  45. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  46. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  48. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  49. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  50. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  51. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  52. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  53. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  54. package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
  55. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  56. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
  57. package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
  58. package/crates/team-agent/src/lifecycle/restart.rs +26 -2
  59. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
  60. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
  61. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  62. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  63. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  64. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  65. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  66. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  67. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  68. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  69. package/crates/team-agent/src/messaging/activity.rs +4 -2
  70. package/crates/team-agent/src/messaging/address.rs +86 -0
  71. package/crates/team-agent/src/messaging/delivery.rs +288 -183
  72. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  73. package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
  74. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  75. package/crates/team-agent/src/messaging/mod.rs +15 -2
  76. package/crates/team-agent/src/messaging/persist.rs +309 -0
  77. package/crates/team-agent/src/messaging/results.rs +16 -24
  78. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  79. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  80. package/crates/team-agent/src/messaging/send.rs +101 -49
  81. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +284 -21
  88. package/crates/team-agent/src/state/projection.rs +245 -30
  89. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  90. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  91. package/crates/team-agent/src/state/repository.rs +94 -27
  92. package/crates/team-agent/src/state/selector.rs +9 -18
  93. 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
- Ok(self
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
- format!("{}:{}", session.as_str(), window.as_str()),
188
- Some(window.as_str()),
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 Some(window) = window {
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
- let ws = temp_ws().join("laneav2");
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
- // The OTHER side of the gate: when the window exists, golden kills `<session>:<window>` and returns
367
- // stopped:true. GREEN today (Rust kills unconditionally), so this LOCKS the present-branch against the
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!["team-laneateam:alpha".to_string()],
384
- "stop must kill exactly the golden target <session>:<window>; killed={:?}",
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(&"team-laneateam:alpha".to_string()),
575
- "precondition: the running worker was force-stopped (window killed) before the failure; killed={:?}",
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