@team-agent/installer 0.5.105 → 0.5.107

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 (46) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +5 -3
  4. package/README.zh.md +4 -3
  5. package/crates/team-agent/src/cli/adapters.rs +15 -1
  6. package/crates/team-agent/src/cli/emit.rs +33 -13
  7. package/crates/team-agent/src/cli/mod.rs +148 -21
  8. package/crates/team-agent/src/cli/status_port/brief.rs +62 -3
  9. package/crates/team-agent/src/cli/tests/base.rs +112 -225
  10. package/crates/team-agent/src/cli/tests/run_delegation.rs +4 -2
  11. package/crates/team-agent/src/cli/tests/status_send.rs +2 -2
  12. package/crates/team-agent/src/compiler/tests.rs +46 -59
  13. package/crates/team-agent/src/leader/tests/byte_findings.rs +1 -14
  14. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +57 -173
  15. package/crates/team-agent/src/lifecycle/launch/pi_mcp_extension.js +226 -0
  16. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +0 -37
  17. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -3
  18. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -0
  19. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +24 -17
  20. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +22 -9
  21. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_native_mcp_host.mjs +71 -0
  22. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +43 -66
  23. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +71 -392
  24. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +0 -5
  25. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +2 -361
  26. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +1 -16
  27. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +20 -112
  28. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1 -12
  29. package/crates/team-agent/src/lifecycle/tests/pi_native_mcp_capability_red.rs +546 -0
  30. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +6 -2
  31. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  32. package/crates/team-agent/src/lifecycle/worker_command_context.rs +11 -54
  33. package/crates/team-agent/src/messaging/tests/wave2.rs +2 -302
  34. package/crates/team-agent/src/model/name_similarity.rs +52 -67
  35. package/crates/team-agent/src/model/task_graph.rs +37 -124
  36. package/crates/team-agent/src/model/yaml/tests.rs +46 -165
  37. package/crates/team-agent/src/provider/tests/adapter.rs +0 -20
  38. package/crates/team-agent/src/provider/tests/classify.rs +106 -198
  39. package/crates/team-agent/src/state/projection.rs +15 -0
  40. package/crates/team-agent/src/tmux_backend/tests.rs +315 -501
  41. package/crates/team-agent/src/tmux_backend.rs +110 -0
  42. package/crates/team-agent/src/transport/test_support.rs +35 -0
  43. package/crates/team-agent/src/transport/tests/wire.rs +32 -58
  44. package/crates/team-agent/src/transport.rs +26 -12
  45. package/package.json +4 -4
  46. package/skills/team-agent/references/recovery-runbook.md +2 -2
@@ -373,6 +373,8 @@ pub(crate) fn start_agent_at_paths(
373
373
  };
374
374
  let into_existing_session =
375
375
  session_live_or_default(transport, &session_name, session_name_present(&state));
376
+ let inherited_generation =
377
+ crate::state::projection::session_generation(&state).map(str::to_string);
376
378
  let safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn_json(
377
379
  &agent,
378
380
  provider,
@@ -441,6 +443,24 @@ pub(crate) fn start_agent_at_paths(
441
443
  &safety,
442
444
  start_mode,
443
445
  )?;
446
+ let generation = if into_existing_session {
447
+ inherited_generation.as_deref()
448
+ } else {
449
+ Some(spawn.spawned_at.as_str())
450
+ };
451
+ if let Some(generation) = generation {
452
+ state["generation"] = serde_json::json!(generation);
453
+ if !into_existing_session {
454
+ transport
455
+ .set_session_owner_with_generation(
456
+ &session_name,
457
+ workspace,
458
+ &resolved_team_key,
459
+ generation,
460
+ )
461
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?;
462
+ }
463
+ }
444
464
  // 0.5.66 bypass 单源 §2.7:启动即带 bypass → 审计留痕。
445
465
  if safety.enabled {
446
466
  let state_agent = state
@@ -1156,6 +1176,30 @@ pub(super) fn stop_agent_at_paths(
1156
1176
  || !same_role_panes.is_empty()
1157
1177
  || (pane_id.is_none() && window_exists(transport, &session_name, &window));
1158
1178
  if stopped {
1179
+ let stop_targets =
1180
+ if let Some(pane) = pane_id.as_ref().filter(|_| stored_pane_live) {
1181
+ vec![Target::Pane(pane.clone())]
1182
+ } else if !same_role_panes.is_empty() {
1183
+ same_role_panes
1184
+ .iter()
1185
+ .map(|pane| Target::Pane(pane.pane_id.clone()))
1186
+ .collect()
1187
+ } else {
1188
+ vec![Target::SessionWindow {
1189
+ session: session_name.clone(),
1190
+ window: WindowName::new(&window),
1191
+ }]
1192
+ };
1193
+ // Explicit kill-pane/kill-window ignores remain-on-exit on the worker.
1194
+ // Preserve the last window BEFORE killing it, or the coordinator sees
1195
+ // a missing session and stops while the seat is merely being stopped.
1196
+ transport
1197
+ .preserve_session_before_stop(&session_name, &stop_targets)
1198
+ .map_err(|error| {
1199
+ LifecycleError::Transport(format!(
1200
+ "failed to preserve session before stopping agent {agent_id}: {error}"
1201
+ ))
1202
+ })?;
1159
1203
  // golden operations.py:84-86: a non-zero kill-window raises
1160
1204
  // RuntimeError(f"failed to stop agent {agent_id}: {proc.stderr.strip()}").
1161
1205
  //
@@ -509,6 +509,8 @@ fn restart_with_selected_team_and_transport(
509
509
  let live_worker_session_deferred_teardown =
510
510
  session_live_or_default(transport, &session_name, false)
511
511
  && !collect_live_agents_from_state(&state).is_empty();
512
+ let inherited_generation =
513
+ crate::state::projection::session_generation(&state).map(str::to_string);
512
514
  let same_role_cohort_targets =
513
515
  same_role_cohort_targets_from_decisions(&state, spec_workspace, &plan.decisions);
514
516
  let pre_spawn_pane_ids = if live_worker_session_deferred_teardown {
@@ -998,6 +1000,25 @@ fn restart_with_selected_team_and_transport(
998
1000
  }
999
1001
  }
1000
1002
  }
1003
+ if !fatal_resume_failure && !successful_agents.is_empty() {
1004
+ let generation = if live_worker_session_deferred_teardown {
1005
+ inherited_generation
1006
+ } else {
1007
+ // A recreated session gets the first successful NEW worker's birth,
1008
+ // not an arbitrary old stopped worker still present in the roster.
1009
+ successful_agents.first().and_then(|agent| {
1010
+ state
1011
+ .get("agents")?
1012
+ .get(agent.agent_id.as_str())?
1013
+ .get("spawned_at")?
1014
+ .as_str()
1015
+ .map(str::to_string)
1016
+ })
1017
+ };
1018
+ if let Some(generation) = generation {
1019
+ state["generation"] = serde_json::json!(generation);
1020
+ }
1021
+ }
1001
1022
  save_restart_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
1002
1023
  &selected.run_workspace,
1003
1024
  &mut state,
@@ -1040,23 +1061,9 @@ fn restart_with_selected_team_and_transport(
1040
1061
  attach_commands,
1041
1062
  });
1042
1063
  }
1043
- // Respawn changes the generation used by shutdown's ownership check, even
1044
- // when build-before-destroy keeps the tmux session itself alive. Stamp only
1045
- // the selected session, after its replacement state is durable.
1046
- if let Some(generation) = state
1047
- .get("generation")
1048
- .and_then(serde_json::Value::as_str)
1049
- .or_else(|| {
1050
- state
1051
- .get("agents")
1052
- .and_then(serde_json::Value::as_object)
1053
- .and_then(|agents| {
1054
- agents.values().find_map(|agent| {
1055
- agent.get("spawned_at").and_then(serde_json::Value::as_str)
1056
- })
1057
- })
1058
- })
1059
- {
1064
+ // Stamp the physical session birth after durable state. Replacing workers
1065
+ // in a retained session does not create a new session generation.
1066
+ if let Some(generation) = crate::state::projection::session_generation(&state) {
1060
1067
  transport
1061
1068
  .set_session_owner_with_generation(
1062
1069
  &session_name,
@@ -365,15 +365,6 @@ fn add_agent_joins_w2_into_running_roster_and_existing_session() {
365
365
  );
366
366
  }
367
367
 
368
- // REAL-MACHINE e2e boundary (rt-host-a verifies): after `add-agent w2`, the coordinator delivers
369
- // `send w2` and w2 reports a result (full round-trip). #[ignore] — needs a live tmux session + worker.
370
- #[test]
371
- #[ignore = "real-machine: add-agent then send w2 round-trips"]
372
- fn add_agent_then_send_w2_round_trips() {
373
- // The framework asserts: add-agent w2 -> w2 in the live session + roster -> `send w2 <msg>` ->
374
- // coordinator delivers -> w2 emits a result_envelope (results row). Not unit-testable in-process.
375
- }
376
-
377
368
  #[derive(Clone)]
378
369
  struct SocketRecordingRunner {
379
370
  recorded: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
@@ -505,6 +496,7 @@ session="${TEAM_AGENT_E27_SESSION_NAME}"
505
496
  pane="${TEAM_AGENT_E27_PANE_ID}"
506
497
  killed_marker="${log}.killed"
507
498
  spawned_marker="${log}.spawned"
499
+ anchor_marker="${log}.anchor"
508
500
  track=0
509
501
  case "$*" in
510
502
  *"$expected"*|*"$session"*|*"$pane"*) track=1 ;;
@@ -544,6 +536,23 @@ case "$*" in
544
536
  ;;
545
537
  esac
546
538
  case "$*" in
539
+ *"list-panes -s -t =$session -F"*)
540
+ if [ -f "$anchor_marker" ]; then
541
+ printf '%%9299\t[team-agent keepalive]\n'
542
+ fi
543
+ if [ ! -f "$killed_marker" ]; then
544
+ printf '%s\talpha\n' "$pane"
545
+ fi
546
+ if [ -f "$spawned_marker" ]; then
547
+ printf '%%9288\talpha\n'
548
+ fi
549
+ exit 0
550
+ ;;
551
+ *"new-window "*"[team-agent keepalive]"*)
552
+ : > "$anchor_marker"
553
+ printf '%%9299\n'
554
+ exit 0
555
+ ;;
547
556
  *"new-window "*|*"new-session "*)
548
557
  : > "$spawned_marker"
549
558
  printf '%%9288\n'
@@ -651,6 +660,10 @@ fn e27_stop_agent_uses_attached_explicit_state_socket() {
651
660
  "attached explicit-socket worker should be stopped"
652
661
  );
653
662
  assert_only_expected_socket_used(&shim.log, endpoint);
663
+ let raw = std::fs::read_to_string(&shim.log).unwrap();
664
+ assert!(raw.contains(&format!("list-panes -s -t ={session_name}")));
665
+ assert!(raw.contains("set-window-option -t %9299 remain-on-exit on"));
666
+ assert!(raw.contains("respawn-pane -k -t %9299 exit 0"));
654
667
  let state = crate::state::persist::load_runtime_state(&ws).unwrap();
655
668
  assert_eq!(
656
669
  state
@@ -0,0 +1,71 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ const [wrapperPath, receiptPath, cwd] = process.argv.slice(2);
5
+ if (!wrapperPath || !receiptPath || !cwd) throw new Error("expected wrapper, receipt, and cwd");
6
+
7
+ const registrations = [];
8
+ const unregistrations = [];
9
+ const handlers = new Map();
10
+ const extensionCommands = [{
11
+ name: "mcp",
12
+ source: "extension",
13
+ sourceInfo: { path: "builtin:mcp", source: "builtin", scope: "user", origin: "top-level" },
14
+ }];
15
+ const pi = {
16
+ on(event, handler) {
17
+ const list = handlers.get(event) ?? [];
18
+ list.push(handler);
19
+ handlers.set(event, list);
20
+ },
21
+ getCommands() {
22
+ return extensionCommands;
23
+ },
24
+ getMcpServers() {
25
+ return registrations.map(({ name, config }) => ({ name, config, extensionPath: "<native-n1-fixture>" }));
26
+ },
27
+ getAllTools() {
28
+ return registrations.flatMap(({ name, config }) => Object.entries(config.toolExposure ?? {})
29
+ .map(([tool, exposure]) => ({ name: `mcp__${name}__${tool}`, namespace: { name: `mcp__${name}` }, exposure })));
30
+ },
31
+ getActiveTools() {
32
+ return this.getAllTools().filter((tool) => tool.exposure !== "hidden").map((tool) => tool.name);
33
+ },
34
+ registerMcpServer(name, config) {
35
+ registrations.push({ name, config });
36
+ },
37
+ unregisterMcpServer(name) {
38
+ unregistrations.push(name);
39
+ },
40
+ };
41
+
42
+ const extension = await import(pathToFileURL(wrapperPath).href);
43
+ if (typeof extension.default !== "function") throw new Error("generated wrapper has no default extension factory");
44
+ await extension.default(pi);
45
+ const context = {
46
+ cwd,
47
+ sessionManager: {
48
+ getSessionFile: () => undefined,
49
+ getLeafId: () => undefined,
50
+ getEntries: () => [],
51
+ },
52
+ };
53
+ for (const handler of handlers.get("session_start") ?? []) {
54
+ await handler({ type: "session_start" }, context);
55
+ }
56
+ if (registrations.length !== 1) throw new Error(`expected one native MCP registration, saw ${registrations.length}`);
57
+ const expectedTools = Object.keys(registrations[0].config.toolExposure ?? {})
58
+ .map((tool) => `mcp__${registrations[0].name}__${tool}`);
59
+ const deadline = Date.now() + 3_000;
60
+ while (!expectedTools.every((name) => pi.getActiveTools().includes(name))) {
61
+ if (Date.now() >= deadline) throw new Error(`native MCP tools never became active: ${pi.getActiveTools()}`);
62
+ await new Promise((resolve) => setTimeout(resolve, 10));
63
+ }
64
+ const beforeAgentStart = handlers.get("before_agent_start")?.[0];
65
+ if (typeof beforeAgentStart !== "function") throw new Error("generated wrapper omitted before_agent_start wire binding");
66
+ const binding = await beforeAgentStart({ systemPrompt: "N1 fixture" }, context);
67
+ const wirePrompt = binding?.systemPrompt;
68
+ if (typeof wirePrompt !== "string" || !expectedTools.every((name) => wirePrompt.includes(name))) {
69
+ throw new Error(`native Team tool wire binding is incomplete: ${wirePrompt}`);
70
+ }
71
+ await writeFile(receiptPath, `${JSON.stringify({ registrations, unregistrations, activeTools: pi.getActiveTools(), expectedTools, wirePrompt }, null, 2)}\n`, "utf8");
@@ -73,6 +73,8 @@ pub(super) struct LaneTransport {
73
73
  killed: LaneKills,
74
74
  spawns: LaneSpawns,
75
75
  spawn_observed_at: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
76
+ stop_preservation: std::sync::Mutex<Vec<(Vec<crate::transport::Target>, Vec<String>)>>,
77
+ fail_stop_preservation: bool,
76
78
  }
77
79
  impl LaneTransport {
78
80
  pub(super) fn new(session: &str, windows: &[&str]) -> Self {
@@ -85,6 +87,8 @@ impl LaneTransport {
85
87
  killed: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
86
88
  spawns: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
87
89
  spawn_observed_at: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
90
+ stop_preservation: std::sync::Mutex::new(Vec::new()),
91
+ fail_stop_preservation: false,
88
92
  }
89
93
  }
90
94
  pub(super) fn killed(&self) -> Vec<String> {
@@ -297,6 +301,21 @@ impl crate::transport::Transport for LaneTransport {
297
301
  ) -> Result<crate::transport::SetEnvOutcome, crate::transport::TransportError> {
298
302
  Ok(crate::transport::SetEnvOutcome::Applied)
299
303
  }
304
+ fn preserve_session_before_stop(
305
+ &self,
306
+ session: &crate::transport::SessionName,
307
+ targets: &[crate::transport::Target],
308
+ ) -> Result<(), crate::transport::TransportError> {
309
+ self.stop_preservation
310
+ .lock()
311
+ .unwrap()
312
+ .push((targets.to_vec(), self.killed()));
313
+ if self.fail_stop_preservation {
314
+ Err(crate::transport::TransportError::TargetNotFound { target: session.as_str().to_string() })
315
+ } else {
316
+ Ok(())
317
+ }
318
+ }
300
319
  fn kill_session(
301
320
  &self,
302
321
  _s: &crate::transport::SessionName,
@@ -408,6 +427,30 @@ fn lanea_one_agent_ws(alpha_status: &str) -> PathBuf {
408
427
  ws
409
428
  }
410
429
 
430
+ #[test]
431
+ fn stop_session_preservation_runs_before_kill_and_failure_preserves_worker_state() {
432
+ for fail in [false, true] {
433
+ let ws = lanea_one_agent_ws("running");
434
+ let mut tx = LaneTransport::new("team-laneateam", &["alpha"]);
435
+ tx.fail_stop_preservation = fail;
436
+ let result = stop_agent_with_transport(&ws, &aid("alpha"), None, &tx);
437
+ let calls = tx.stop_preservation.lock().unwrap();
438
+ assert_eq!(calls.len(), 1);
439
+ assert_eq!(calls[0].1, Vec::<String>::new(), "preserve must precede all worker kills");
440
+ assert_eq!(calls[0].0, vec![crate::transport::Target::Pane(crate::transport::PaneId::new("%alpha"))]);
441
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
442
+ if fail {
443
+ assert!(result.is_err());
444
+ assert!(tx.killed().is_empty());
445
+ assert_eq!(state["agents"]["alpha"]["status"], "running");
446
+ } else {
447
+ assert!(result.unwrap().stopped);
448
+ assert_eq!(tx.killed(), vec!["%alpha".to_string()]);
449
+ assert_eq!(state["agents"]["alpha"]["status"], "stopped");
450
+ }
451
+ }
452
+ }
453
+
411
454
  fn selected_spec_path(ws: &std::path::Path) -> std::path::PathBuf {
412
455
  crate::state::selector::resolve_active_team(
413
456
  ws,
@@ -1278,33 +1321,6 @@ fn lanea_stop_window_present_kills_and_stopped_true() {
1278
1321
  );
1279
1322
  }
1280
1323
 
1281
- // ── STOP #2 (stop-display-noop-2) [RED] — close ghostty_workspace slot: persist display.status/pane_title
1282
- // Golden operations.py:88-92 -> display/close.py:84-85 relabels the slot: display["status"]="stopped",
1283
- // display["pane_title"]=f"stopped: {agent_id}", written back into the persisted agent entry. Rust never
1284
- // touches the display (mark_agent_stopped leaves it as-is) and hardcodes display_closed:false. RED: the
1285
- // persisted display.status/pane_title are the in-process observable.
1286
- #[test]
1287
- #[ignore = "legacy external display contract removed; runtime is silent tmux only"]
1288
- fn lanea_stop_ghostty_workspace_relabels_slot_to_stopped() {
1289
- let ws = lanea_ws_agents(json!({
1290
- "alpha": { "status": "running", "provider": "codex", "window": "alpha",
1291
- "display": { "backend": "ghostty_workspace", "pane_id": "%5", "linked_session": "disp-alpha", "status": "running", "pane_title": "alpha" } }
1292
- }));
1293
- let tx = LaneTransport::new("team-laneateam", &["alpha"]);
1294
- let _ = stop_agent_with_transport(&ws, &aid("alpha"), None, &tx).expect("stop ok");
1295
- let state = crate::state::persist::load_runtime_state(&ws).expect("load state");
1296
- assert_eq!(
1297
- state.pointer("/agents/alpha/display/status").and_then(serde_json::Value::as_str),
1298
- Some("stopped"),
1299
- "stop must relabel a ghostty_workspace slot: display.status='stopped' (close.py:84); Rust leaves it 'running'"
1300
- );
1301
- assert_eq!(
1302
- state.pointer("/agents/alpha/display/pane_title").and_then(serde_json::Value::as_str),
1303
- Some("stopped: alpha"),
1304
- "stop must set display.pane_title='stopped: <id>' (close.py:85); Rust never touches the display"
1305
- );
1306
- }
1307
-
1308
1324
  // ── RESET #3 (reset-paused-restart-2) [RED] — reset of a PAUSED agent returns ok=true (NOT an Err) ───
1309
1325
  // Golden operations.py:126-140: after discard, reset re-spawns via start_agent(force,allow_fresh).
1310
1326
  // discard does NOT clear `paused`, so start_agent returns the refusal-shaped {ok:False,status:paused,
@@ -1808,42 +1824,3 @@ fn fresh_start_persists_boundary_from_before_transport_spawn() {
1808
1824
  spawned_at={spawned_at}, transport_observed_at={transport_observed_at}"
1809
1825
  );
1810
1826
  }
1811
-
1812
- // ── REMOVE #6/#12 (remove-rollback-no-agent-health-3 / remove-rollback-health-1) [SEAM #[ignore]] ────
1813
- // Golden _RemoveRollback captures `self.health = copy.deepcopy(store.agent_health().get(agent_id))`
1814
- // (agents.py:185) and restore() re-upserts it via _restore_agent_health (agents.py:215-218,268-278). The
1815
- // Rust RemoveRollback has NO health field and never restores it (restart.rs:972-1067). This is only
1816
- // observable when a step AFTER the agent_health delete fails — but Rust's only post-delete step is the
1817
- // snapshot, which golden runs OUTSIDE the rollback-protected region (agents.py:135). Exercising it
1818
- // golden-faithfully needs a production failure-injection seam at an in-try step after the delete (mirror
1819
- // the coordinator SaveHook). PORTER: add `health: Option<Value>` to RemoveRollback (capture the row
1820
- // before delete; restore re-upserts status||"IDLE"/last_output_at/context_usage_pct/current_task_id, or
1821
- // deletes if None) AND move save_team_runtime_snapshot OUTSIDE the rollback region (golden agents.py:135).
1822
- #[test]
1823
- #[ignore = "seam: agent_health rollback restore needs a failure-injection hook (post-delete, in-try) to \
1824
- exercise in-process; golden agents.py:185/215-218/268-278. Porter adds RemoveRollback.health + \
1825
- moves save_team_runtime_snapshot outside the rollback region."]
1826
- fn lanea_remove_rollback_restores_agent_health() {
1827
- // Golden contract (verified by reading agents.py): on a mid-remove failure after the agent_health
1828
- // row is deleted, rollback re-upserts the captured row so the health history is not lost.
1829
- }
1830
-
1831
- // ── FORK (fork-incomplete-rollback) [SEAM #[ignore]] — post-spawn rollback arms ─────────────────────
1832
- // Golden operations.py:384-394 wraps spec-mutation..start_coordinator in try/except; on ANY failure it
1833
- // (1) kills the spawned tmux window if present, (2) adapter.cleanup_mcp, (3) restores old spec text, and
1834
- // (4) restores prior state. Rust only restores the spec on the spawn_into arm (launch.rs:481); the
1835
- // save_runtime_state (486-487) and start_coordinator (488-493) failure arms leave the spec mutated, the
1836
- // already-spawned window un-killed, and the state un-rolled-back; install_mcp/cleanup_mcp are absent.
1837
- // The adapter.fork_plan arm IS covered HARD above (lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm).
1838
- // The post-SPAWN arms need a failure-injection seam after spawn_into (codex+subscription forks past
1839
- // adapter.fork_plan, so the spawn succeeds and there is no in-process way to fail save/coordinator cleanly).
1840
- // PORTER: a Drop guard armed after the spec write, disarmed on success — kills the window, restores spec
1841
- // + state, runs cleanup_mcp on every post-write error arm.
1842
- #[test]
1843
- #[ignore = "seam: fork post-spawn rollback arms (save_runtime_state / start_coordinator failure) need a \
1844
- failure-injection hook after spawn_into; golden operations.py:384-394. Porter wires a Drop \
1845
- guard (kill window + restore spec/state + cleanup_mcp) armed after the spec write."]
1846
- fn lanea_fork_rollback_complete_on_post_spawn_failure() {
1847
- // Golden contract (operations.py:384-394): a post-spawn failure kills the spawned window, restores
1848
- // the old spec text + prior state, and runs cleanup_mcp before re-raising.
1849
- }