@team-agent/installer 0.5.50 → 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.
Files changed (84) 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 +11 -48
  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/run_delegation.rs +1 -0
  25. package/crates/team-agent/src/cli/types.rs +1 -0
  26. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  27. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  28. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  29. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  30. package/crates/team-agent/src/db/message_store.rs +154 -44
  31. package/crates/team-agent/src/event_log.rs +73 -0
  32. package/crates/team-agent/src/leader/start.rs +28 -4
  33. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  34. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
  35. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  36. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  37. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  38. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  39. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  40. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  41. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  42. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  43. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  44. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  45. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  46. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  47. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  48. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  49. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  50. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  51. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  52. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
  53. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  54. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
  55. package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
  56. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  57. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
  58. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
  59. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  60. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  61. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  62. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  63. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  64. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  65. package/crates/team-agent/src/messaging/activity.rs +4 -2
  66. package/crates/team-agent/src/messaging/address.rs +86 -0
  67. package/crates/team-agent/src/messaging/delivery.rs +165 -39
  68. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  69. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  70. package/crates/team-agent/src/messaging/mod.rs +10 -2
  71. package/crates/team-agent/src/messaging/persist.rs +309 -0
  72. package/crates/team-agent/src/messaging/results.rs +16 -24
  73. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  74. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  75. package/crates/team-agent/src/messaging/send.rs +101 -49
  76. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  77. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  78. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  79. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  80. package/crates/team-agent/src/redaction.rs +72 -2
  81. package/crates/team-agent/src/state/persist.rs +2 -1
  82. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  83. package/crates/team-agent/src/state/repository.rs +59 -16
  84. package/package.json +4 -4
@@ -28,7 +28,7 @@ mod common;
28
28
  mod orchestrator;
29
29
  pub mod preflight;
30
30
  mod rebuild;
31
- mod remove;
31
+ pub(crate) mod remove;
32
32
  mod selection;
33
33
  mod team_state;
34
34
 
@@ -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,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
- // 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.
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!["team-laneateam:alpha".to_string()],
384
- "stop must kill exactly the golden target <session>:<window>; killed={:?}",
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(&"team-laneateam:alpha".to_string()),
575
- "precondition: the running worker was force-stopped (window killed) before the failure; killed={:?}",
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!(