@team-agent/installer 0.5.104 → 0.5.106

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 (38) 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 +17 -1
  6. package/crates/team-agent/src/cli/emit.rs +135 -18
  7. package/crates/team-agent/src/cli/mod.rs +156 -35
  8. package/crates/team-agent/src/cli/spec.rs +2 -2
  9. package/crates/team-agent/src/cli/status_port/brief.rs +62 -3
  10. package/crates/team-agent/src/cli/tests/run_delegation.rs +5 -2
  11. package/crates/team-agent/src/cli/tests/status_send.rs +2 -2
  12. package/crates/team-agent/src/cli/types.rs +3 -1
  13. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +6 -2
  14. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +6 -4
  15. package/crates/team-agent/src/lifecycle/launch/role_source.rs +1 -1
  16. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +7 -2
  17. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -3
  18. package/crates/team-agent/src/lifecycle/launch.rs +4 -1
  19. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  20. package/crates/team-agent/src/lifecycle/restart/agent.rs +75 -0
  21. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +24 -17
  22. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  23. package/crates/team-agent/src/lifecycle/role_config.rs +1123 -0
  24. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +22 -0
  25. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +43 -0
  26. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +71 -0
  27. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +6 -2
  28. package/crates/team-agent/src/state/projection.rs +15 -0
  29. package/crates/team-agent/src/state/repository.rs +2 -1
  30. package/crates/team-agent/src/tmux_backend/tests.rs +137 -0
  31. package/crates/team-agent/src/tmux_backend.rs +110 -0
  32. package/crates/team-agent/src/transport/test_support.rs +35 -0
  33. package/crates/team-agent/src/transport/tests/wire.rs +32 -3
  34. package/crates/team-agent/src/transport.rs +26 -12
  35. package/package.json +4 -4
  36. package/skills/team-agent/SKILL.md +55 -20
  37. package/skills/team-agent/references/recovery-runbook.md +2 -2
  38. package/skills/team-agent/references/team-agent-operator.md +14 -6
@@ -505,6 +505,7 @@ session="${TEAM_AGENT_E27_SESSION_NAME}"
505
505
  pane="${TEAM_AGENT_E27_PANE_ID}"
506
506
  killed_marker="${log}.killed"
507
507
  spawned_marker="${log}.spawned"
508
+ anchor_marker="${log}.anchor"
508
509
  track=0
509
510
  case "$*" in
510
511
  *"$expected"*|*"$session"*|*"$pane"*) track=1 ;;
@@ -544,6 +545,23 @@ case "$*" in
544
545
  ;;
545
546
  esac
546
547
  case "$*" in
548
+ *"list-panes -s -t =$session -F"*)
549
+ if [ -f "$anchor_marker" ]; then
550
+ printf '%%9299\t[team-agent keepalive]\n'
551
+ fi
552
+ if [ ! -f "$killed_marker" ]; then
553
+ printf '%s\talpha\n' "$pane"
554
+ fi
555
+ if [ -f "$spawned_marker" ]; then
556
+ printf '%%9288\talpha\n'
557
+ fi
558
+ exit 0
559
+ ;;
560
+ *"new-window "*"[team-agent keepalive]"*)
561
+ : > "$anchor_marker"
562
+ printf '%%9299\n'
563
+ exit 0
564
+ ;;
547
565
  *"new-window "*|*"new-session "*)
548
566
  : > "$spawned_marker"
549
567
  printf '%%9288\n'
@@ -651,6 +669,10 @@ fn e27_stop_agent_uses_attached_explicit_state_socket() {
651
669
  "attached explicit-socket worker should be stopped"
652
670
  );
653
671
  assert_only_expected_socket_used(&shim.log, endpoint);
672
+ let raw = std::fs::read_to_string(&shim.log).unwrap();
673
+ assert!(raw.contains(&format!("list-panes -s -t ={session_name}")));
674
+ assert!(raw.contains("set-window-option -t %9299 remain-on-exit on"));
675
+ assert!(raw.contains("respawn-pane -k -t %9299 exit 0"));
654
676
  let state = crate::state::persist::load_runtime_state(&ws).unwrap();
655
677
  assert_eq!(
656
678
  state
@@ -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,
@@ -4,6 +4,7 @@ mod hermetic_guard;
4
4
  use super::*;
5
5
  use crate::tmux_backend::TmuxBackend;
6
6
  use crate::transport::test_support::OfflineTransport;
7
+ use crate::transport::Transport;
7
8
  use serde_json::json;
8
9
  use serial_test::serial;
9
10
 
@@ -1231,6 +1232,64 @@ fn launch_with_transport_records_one_spawn_per_agent_carrying_build_command() {
1231
1232
  "LaunchReport.started must list the compiled agent; got {:?}",
1232
1233
  report.started
1233
1234
  );
1235
+ let workspace = crate::model::paths::team_workspace(&team).unwrap();
1236
+ let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
1237
+ let generation = report.started[0].spawned_at.as_str();
1238
+ assert_eq!(state["generation"], generation);
1239
+ let team_key = crate::state::projection::team_state_key(&state);
1240
+ assert_eq!(state["teams"][&team_key]["generation"], generation);
1241
+ let owner = transport.session_owner(&SessionName::new("team-quickteam")).unwrap().unwrap();
1242
+ assert_eq!(owner.generation, generation);
1243
+ }
1244
+
1245
+ #[test]
1246
+ #[serial(env)]
1247
+ fn start_agent_session_generation_inherits_birth_or_stamps_recreated_session() {
1248
+ for canonical_state in [false, true] {
1249
+ for existing_session in [true, false] {
1250
+ for explicit_generation in [true, false] {
1251
+ let ws = restart_ws_one_resumable_worker();
1252
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
1253
+ let birth = "2026-07-14T00:00:00+00:00";
1254
+ state["agents"]["alpha"]["status"] = json!("stopped");
1255
+ state["agents"]["alpha"]["spawned_at"] = json!(birth);
1256
+ if explicit_generation {
1257
+ state["generation"] = json!("explicit-session-birth");
1258
+ }
1259
+ let team_key = crate::state::projection::team_state_key(&state);
1260
+ if canonical_state {
1261
+ state["team_key"] = json!(team_key);
1262
+ state["active_team_key"] = json!(team_key);
1263
+ state["teams"] = json!({});
1264
+ state["teams"][&team_key] = crate::state::projection::compact_team_state(&state);
1265
+ }
1266
+ let inherited = if explicit_generation { "explicit-session-birth" } else { birth };
1267
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
1268
+ let transport = OfflineTransport::new()
1269
+ .with_session_present(existing_session)
1270
+ .with_session_owner(&ws, &team_key, inherited);
1271
+ let result = start_agent_with_transport(
1272
+ &ws, &aid("alpha"), false, false, false, None, &transport,
1273
+ ).unwrap();
1274
+ assert!(matches!(result, StartAgentOutcome::Running { .. }), "{result:?}");
1275
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
1276
+ let spawned_at = state["agents"]["alpha"]["spawned_at"].as_str().unwrap();
1277
+ assert_ne!(spawned_at, birth);
1278
+ let expected = if existing_session { inherited } else { spawned_at };
1279
+ assert_eq!(state["generation"], expected);
1280
+ if canonical_state {
1281
+ assert_eq!(state["teams"][&team_key]["generation"], expected);
1282
+ } else {
1283
+ // Legacy flat state stays flat; generation persistence does
1284
+ // not implicitly migrate it to a canonical teams table.
1285
+ assert!(state.get("teams").is_none());
1286
+ }
1287
+ let owner = transport.session_owner(&SessionName::new("team-restartone")).unwrap().unwrap();
1288
+ assert_eq!(owner.generation, expected);
1289
+ assert_eq!(transport.calls().contains(&"set_session_owner_with_generation"), !existing_session);
1290
+ }
1291
+ }
1292
+ }
1234
1293
  }
1235
1294
 
1236
1295
  // ═════════════════════════════════════════════════════════════════════════
@@ -1704,6 +1763,9 @@ fn restart_ws_one_resumable_worker() -> PathBuf {
1704
1763
  #[test]
1705
1764
  fn restart_with_transport_spawns_resumable_workers_not_stub() {
1706
1765
  let ws = restart_ws_two_resumable_workers();
1766
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
1767
+ state["generation"] = json!("old-destroyed-session-birth");
1768
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
1707
1769
  let transport = OfflineTransport::new();
1708
1770
 
1709
1771
  let result = restart_with_transport(&ws, false, None, &transport);
@@ -1731,6 +1793,13 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
1731
1793
  matches!(result, Ok(RestartReport::Restarted { coordinator_started: true, .. })),
1732
1794
  "restart must reach RestartReport::Restarted with coordinator_started=true (AlreadyRunning, seeded); got {result:?}"
1733
1795
  );
1796
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
1797
+ let generation = state["agents"]["alpha"]["spawned_at"].as_str().unwrap();
1798
+ assert_ne!(generation, "old-destroyed-session-birth");
1799
+ assert_eq!(state["generation"], generation);
1800
+ assert!(state.get("teams").is_none(), "legacy flat state must remain flat");
1801
+ let owner = transport.session_owner(&SessionName::new("team-restartteam")).unwrap().unwrap();
1802
+ assert_eq!(owner.generation, generation);
1734
1803
  let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
1735
1804
  let completed = events
1736
1805
  .iter()
@@ -3071,6 +3140,8 @@ fn quick_start_state_seeds_spec_path_workspace_leader() {
3071
3140
  // `ResolvedTransport.source` through the launch call site.
3072
3141
  "transport",
3073
3142
  "active_team_key",
3143
+ // Persist physical session birth independently of worker respawns.
3144
+ "generation",
3074
3145
  "teams",
3075
3146
  ],
3076
3147
  "state.json top-level key order must match golden launch/core.py:62-71 \
@@ -180,8 +180,10 @@ fn scoped_shutdown_after_restart_refreshes_owner_and_preserves_sibling() {
180
180
  let report = restart_with_transport(&case.workspace, true, Some(TEAM), &transport).unwrap();
181
181
  assert!(matches!(report, RestartReport::Restarted { .. }), "{report:?}");
182
182
  let state = case.read_state();
183
- let expected_generation = state["teams"][TEAM]["agents"]["w1"]["spawned_at"].as_str().unwrap();
184
- assert_ne!(expected_generation, "2026-07-14T00:00:00+00:00");
183
+ let expected_generation = "2026-07-14T00:00:00+00:00";
184
+ assert_ne!(state["teams"][TEAM]["agents"]["w1"]["spawned_at"], expected_generation);
185
+ assert_eq!(state["generation"], expected_generation);
186
+ assert_eq!(state["teams"][TEAM]["generation"], expected_generation);
185
187
  let owner = transport.session_owner(&session).unwrap().unwrap();
186
188
  assert_eq!(owner.workspace, case.workspace.canonicalize().unwrap().to_string_lossy());
187
189
  assert_eq!(owner.team, TEAM);
@@ -213,8 +215,10 @@ fn failed_replacement_preserves_original_session_owner() {
213
215
  &session, &case.workspace, TEAM, "2026-07-14T00:00:00+00:00",
214
216
  ).unwrap();
215
217
  let owner_before = transport.session_owner(&session).unwrap();
218
+ let generation_before = case.read_state().get("generation").cloned();
216
219
  let report = restart_with_transport(&case.workspace, true, Some(TEAM), &transport).unwrap();
217
220
  assert_restart_did_not_succeed(&report);
221
+ assert_eq!(case.read_state().get("generation").cloned(), generation_before);
218
222
  assert_eq!(transport.session_owner(&session).unwrap(), owner_before);
219
223
  assert_eq!(transport.ops().iter().filter(|op| op.starts_with("set_session_owner:")).count(), 1);
220
224
  }
@@ -26,6 +26,21 @@ use crate::state::persist::{
26
26
 
27
27
  pub(crate) const CURRENT_TEAM_ALIAS: &str = "current";
28
28
 
29
+ /// Physical tmux session birth identity, independent of later worker spawns.
30
+ /// Legacy launches did not persist it: freeze their fallback BEFORE respawn.
31
+ pub fn session_generation(state: &Value) -> Option<&str> {
32
+ state.get("generation").and_then(Value::as_str).or_else(|| {
33
+ state
34
+ .get("agents")
35
+ .and_then(Value::as_object)
36
+ .and_then(|agents| {
37
+ agents.values().find_map(|agent| {
38
+ agent.get("spawned_at").and_then(Value::as_str)
39
+ })
40
+ })
41
+ })
42
+ }
43
+
29
44
  ///
30
45
  /// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
31
46
  /// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。
@@ -162,7 +162,8 @@ impl<'a> StateRepository<'a> {
162
162
  test_support::before_commit();
163
163
  let team_key = match intent {
164
164
  StateWriteIntent::ResultCollection { owner_team_id } => owner_team_id,
165
- StateWriteIntent::ForkAgent { team_key, .. } => Some(team_key),
165
+ StateWriteIntent::ForkAgent { team_key, .. }
166
+ | StateWriteIntent::StartAgent { team_key, .. } => Some(team_key),
166
167
  _ => {
167
168
  return Err(StateError::SaveFailed(
168
169
  "intent does not accept a bounded Team delta".into(),
@@ -355,6 +355,21 @@ fn backend_with(default: MockResp, queued: Vec<MockResp>) -> (TmuxBackend, Recor
355
355
  (TmuxBackend::with_runner(Box::new(runner)), recorded)
356
356
  }
357
357
 
358
+ #[test]
359
+ fn pane_dead_observation_requires_native_boolean_receipt() {
360
+ for (text, expected) in [("1\n", Some(true)), ("0\n", Some(false)), ("", None), ("dead", None), ("2", None)] {
361
+ let (backend, recorded) = backend_with(MockResp::Out(ok(text)), Vec::new());
362
+ assert_eq!(backend.pane_is_dead(&PaneId::new("%47")).unwrap(), expected);
363
+ assert_eq!(recorded.lock().unwrap()[0], svec(&[
364
+ "tmux", "display-message", "-p", "-t", "%47", "#{pane_dead}",
365
+ ]));
366
+ }
367
+ let (backend, _) = backend_with(MockResp::Out(fail(1, "pane unavailable")), Vec::new());
368
+ assert_eq!(backend.pane_is_dead(&PaneId::new("%47")).unwrap(), None);
369
+ let (backend, _) = backend_with(MockResp::Io(std::io::ErrorKind::TimedOut), Vec::new());
370
+ assert!(backend.pane_is_dead(&PaneId::new("%47")).is_err());
371
+ }
372
+
358
373
  #[test]
359
374
  fn provider_exit_receipt_accepts_only_shell_exit_status() {
360
375
  for (text, expected) in [
@@ -2202,6 +2217,128 @@ fn kill_session_and_kill_window_argv() {
2202
2217
  );
2203
2218
  }
2204
2219
 
2220
+ #[test]
2221
+ fn stop_session_preservation_creates_processless_anchor_by_exact_pane_id() {
2222
+ let (be, rec) = backend_with(
2223
+ MockResp::Out(ok("")),
2224
+ vec![MockResp::Out(ok("%7\tworker\n")), MockResp::Out(ok("%8\n"))],
2225
+ );
2226
+ be.preserve_session_before_stop(
2227
+ &SessionName::new("sess"),
2228
+ &[Target::Pane(PaneId::new("%7"))],
2229
+ ).unwrap();
2230
+ let calls = rec.lock().unwrap();
2231
+ assert_eq!(calls.len(), 4);
2232
+ assert_eq!(calls[0], svec(&[
2233
+ "tmux", "list-panes", "-s", "-t", "=sess", "-F", "#{pane_id}\t#{window_name}",
2234
+ ]));
2235
+ assert_eq!(calls[1], tmux_spawn_argv(
2236
+ &SessionName::new("=sess"), &WindowName::new("[team-agent keepalive]"),
2237
+ "exec sleep 86400", false,
2238
+ ));
2239
+ assert_eq!(calls[2], svec(&[
2240
+ "tmux", "set-window-option", "-t", "%8", "remain-on-exit", "on",
2241
+ ]));
2242
+ assert_eq!(calls[3], svec(&["tmux", "respawn-pane", "-k", "-t", "%8", "exit 0"]));
2243
+ }
2244
+
2245
+ #[test]
2246
+ fn stop_session_preservation_skips_surviving_workers_leaders_and_anchors() {
2247
+ for survivor in ["other-worker", "leader", "[team-agent keepalive]", "worker"] {
2248
+ let (be, rec) = backend_with(
2249
+ MockResp::Out(ok(&format!("%7\tworker\n%8\t{survivor}\n"))), vec![],
2250
+ );
2251
+ be.preserve_session_before_stop(
2252
+ &SessionName::new("sess"), &[Target::Pane(PaneId::new("%7"))],
2253
+ ).unwrap();
2254
+ assert_eq!(rec.lock().unwrap().len(), 1, "survivor={survivor}");
2255
+ }
2256
+ }
2257
+
2258
+ #[test]
2259
+ fn stop_session_preservation_covers_duplicate_panes_and_legacy_window_targets() {
2260
+ for targets in [
2261
+ vec![Target::Pane(PaneId::new("%7")), Target::Pane(PaneId::new("%8"))],
2262
+ vec![Target::SessionWindow { session: SessionName::new("sess"), window: WindowName::new("worker") }],
2263
+ ] {
2264
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![
2265
+ MockResp::Out(ok("%7\tworker\n%8\tworker\n")), MockResp::Out(ok("%9\n")),
2266
+ ]);
2267
+ be.preserve_session_before_stop(&SessionName::new("sess"), &targets).unwrap();
2268
+ assert_eq!(rec.lock().unwrap().len(), 4);
2269
+ }
2270
+ }
2271
+
2272
+ #[test]
2273
+ fn stop_session_preservation_refuses_unknown_listing_and_rolls_back_failed_anchor() {
2274
+ for response in [fail(1, "listing failed"), ok(""), ok("malformed")] {
2275
+ let (be, rec) = backend_with(MockResp::Out(response), vec![]);
2276
+ assert!(be.preserve_session_before_stop(
2277
+ &SessionName::new("sess"), &[Target::Pane(PaneId::new("%7"))],
2278
+ ).is_err());
2279
+ assert_eq!(rec.lock().unwrap().len(), 1);
2280
+ }
2281
+ for failed_step in ["new-window", "set-window-option", "respawn-pane"] {
2282
+ let mut queued = vec![MockResp::Out(ok("%7\tworker\n"))];
2283
+ if failed_step != "new-window" { queued.push(MockResp::Out(ok("%8\n"))); }
2284
+ if failed_step == "respawn-pane" { queued.push(MockResp::Out(ok(""))); }
2285
+ queued.push(MockResp::Out(fail(1, "anchor failed")));
2286
+ let (be, rec) = backend_with(MockResp::Out(ok("")), queued);
2287
+ assert!(be.preserve_session_before_stop(
2288
+ &SessionName::new("sess"), &[Target::Pane(PaneId::new("%7"))],
2289
+ ).is_err());
2290
+ let calls = rec.lock().unwrap();
2291
+ assert!(!calls.iter().any(|argv| argv.get(1).is_some_and(|op| op == "kill-window")));
2292
+ if failed_step != "new-window" {
2293
+ assert_eq!(calls.last().unwrap(), &svec(&["tmux", "kill-pane", "-t", "%8"]));
2294
+ }
2295
+ assert!(!calls.iter().any(|argv| argv.get(1).is_some_and(|op| op == "kill-pane") && argv.contains(&"%7".to_string())));
2296
+ }
2297
+ }
2298
+
2299
+ #[test]
2300
+ #[ignore = "isolated native tmux: executed explicitly on the remote test host"]
2301
+ fn stop_session_preservation_native_tmux_stop_start_and_shutdown() {
2302
+ let tmp = std::env::temp_dir().join(format!(
2303
+ "ta-preserve-{}-{}", std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap(),
2304
+ ));
2305
+ std::fs::create_dir(&tmp).unwrap();
2306
+ let endpoint = tmp.join("tmux.sock");
2307
+ let be = TmuxBackend::with_runner_for_tmux_endpoint(Box::new(RealCommandRunner), endpoint.to_str().unwrap());
2308
+ let session = SessionName::new("preserve-test");
2309
+ struct Cleanup<'a>(&'a TmuxBackend, &'a SessionName, &'a Path);
2310
+ impl Drop for Cleanup<'_> {
2311
+ fn drop(&mut self) {
2312
+ let _ = self.0.kill_session(self.1);
2313
+ let _ = std::fs::remove_dir_all(self.2);
2314
+ }
2315
+ }
2316
+ let _cleanup = Cleanup(&be, &session, &tmp);
2317
+ let argv = svec(&["sleep", "86400"]);
2318
+ let env = BTreeMap::new();
2319
+ let mut worker = be.spawn_first(&session, &WindowName::new("worker"), &argv, &tmp, &env).unwrap();
2320
+ for _ in 0..3 {
2321
+ be.preserve_session_before_stop(&session, &[Target::Pane(worker.pane_id.clone())]).unwrap();
2322
+ be.kill_pane(&worker.pane_id).unwrap();
2323
+ assert!(be.has_session(&session).unwrap());
2324
+ assert_eq!(be.has_pane(&worker.pane_id).unwrap(), Some(false));
2325
+ assert_eq!(be.list_windows(&session).unwrap(), vec![WindowName::new("[team-agent keepalive]")]);
2326
+ let anchor = be.list_targets().unwrap().into_iter().find(|pane| pane.session == session).unwrap();
2327
+ let deadline = Instant::now() + Duration::from_secs(2);
2328
+ loop {
2329
+ let dead = be.runner.run(&be.tmux_argv(&svec(&[
2330
+ "tmux", "display-message", "-p", "-t", anchor.pane_id.as_str(), "#{pane_dead}",
2331
+ ]))).unwrap();
2332
+ if dead.success && dead.stdout.trim() == "1" { break; }
2333
+ assert!(Instant::now() < deadline, "keepalive must have no running process: {dead:?}");
2334
+ std::thread::sleep(Duration::from_millis(20));
2335
+ }
2336
+ worker = be.spawn_into(&session, &WindowName::new("worker"), &argv, &tmp, &env).unwrap();
2337
+ }
2338
+ be.kill_session(&session).unwrap();
2339
+ assert!(!be.has_session(&session).unwrap());
2340
+ }
2341
+
2205
2342
  // ── 8. ERROR MAPPING: non-zero tmux exit -> TransportError::Subprocess; runner io::Error -> Err ──
2206
2343
  #[test]
2207
2344
  fn error_paths_map_to_transport_error_not_panic() {
@@ -74,6 +74,7 @@ use crate::transport::{
74
74
  };
75
75
 
76
76
  pub const PANE_BINDING_NONCE_METADATA_KEY: &str = "TEAM_AGENT_PANE_BINDING_NONCE";
77
+ pub(crate) const KEEPALIVE_WINDOW_NAME: &str = "[team-agent keepalive]";
77
78
  const TMUX_PANE_BINDING_NONCE_OPTION: &str = "@team_agent_pane_binding_nonce";
78
79
  const TMUX_SESSION_OWNER_WORKSPACE_OPTION: &str = "@team_agent_owner_workspace";
79
80
  const TMUX_SESSION_OWNER_TEAM_OPTION: &str = "@team_agent_owner_team";
@@ -3875,6 +3876,26 @@ impl Transport for TmuxBackend {
3875
3876
  Ok(output.stdout.trim().parse::<u8>().ok())
3876
3877
  }
3877
3878
 
3879
+ fn pane_is_dead(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
3880
+ let argv = self.tmux_argv(&[
3881
+ "tmux".to_string(),
3882
+ "display-message".to_string(),
3883
+ "-p".to_string(),
3884
+ "-t".to_string(),
3885
+ pane.as_str().to_string(),
3886
+ "#{pane_dead}".to_string(),
3887
+ ]);
3888
+ let output = self.runner.run(&argv)?;
3889
+ if !output.success {
3890
+ return Ok(None);
3891
+ }
3892
+ Ok(match output.stdout.trim() {
3893
+ "1" => Some(true),
3894
+ "0" => Some(false),
3895
+ _ => None,
3896
+ })
3897
+ }
3898
+
3878
3899
  fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
3879
3900
  let argv = self.tmux_argv(&[
3880
3901
  "tmux".to_string(),
@@ -4087,6 +4108,95 @@ impl Transport for TmuxBackend {
4087
4108
  self.run_ok(&argv)
4088
4109
  }
4089
4110
 
4111
+ fn preserve_session_before_stop(
4112
+ &self,
4113
+ session: &SessionName,
4114
+ targets: &[Target],
4115
+ ) -> Result<(), TransportError> {
4116
+ let list_argv = self.tmux_argv(&[
4117
+ "tmux".to_string(),
4118
+ "list-panes".to_string(),
4119
+ "-s".to_string(),
4120
+ "-t".to_string(),
4121
+ format!("={}", session.as_str()),
4122
+ "-F".to_string(),
4123
+ "#{pane_id}\t#{window_name}".to_string(),
4124
+ ]);
4125
+ let output = self.runner.run(&list_argv)?;
4126
+ if !output.success {
4127
+ return Err(subprocess_error(list_argv, output));
4128
+ }
4129
+ for line in output.stdout.lines() {
4130
+ let Some((pane, window)) = line.split_once('\t') else {
4131
+ return Err(TransportError::Subprocess {
4132
+ argv: list_argv,
4133
+ code: output.code,
4134
+ stderr: "tmux returned an invalid session pane listing".to_string(),
4135
+ });
4136
+ };
4137
+ if !targets.iter().any(|target| match target {
4138
+ Target::Pane(id) => id.as_str() == pane,
4139
+ Target::SessionWindow {
4140
+ session: owner,
4141
+ window: name,
4142
+ } => owner == session && name.as_str() == window,
4143
+ }) {
4144
+ return Ok(());
4145
+ }
4146
+ }
4147
+ // An empty listing cannot prove that this is the last worker.
4148
+ if output.stdout.trim().is_empty() {
4149
+ return Err(TransportError::TargetNotFound {
4150
+ target: session.as_str().to_string(),
4151
+ });
4152
+ }
4153
+ // Use a separate, non-worker window: remain-on-exit does not protect a
4154
+ // pane explicitly deleted by kill-pane. Configure by the new pane ID,
4155
+ // not a window name that could resolve to another same-named window.
4156
+ let spawn_argv = tmux_spawn_argv(
4157
+ &SessionName::new(format!("={}", session.as_str())),
4158
+ &WindowName::new(KEEPALIVE_WINDOW_NAME),
4159
+ "exec sleep 86400",
4160
+ false,
4161
+ );
4162
+ let spawned = self.run_spawn(&spawn_argv)?;
4163
+ let pane = spawned.stdout.trim();
4164
+ if !pane.starts_with('%')
4165
+ || pane.len() == 1
4166
+ || !pane[1..].chars().all(|ch| ch.is_ascii_digit())
4167
+ {
4168
+ return Err(TransportError::Subprocess {
4169
+ argv: self.tmux_argv(&spawn_argv),
4170
+ code: spawned.code,
4171
+ stderr: "tmux keepalive spawn returned no valid pane id".to_string(),
4172
+ });
4173
+ }
4174
+ let result = self
4175
+ .run_ok(&[
4176
+ "tmux".to_string(),
4177
+ "set-window-option".to_string(),
4178
+ "-t".to_string(),
4179
+ pane.to_string(),
4180
+ "remain-on-exit".to_string(),
4181
+ "on".to_string(),
4182
+ ])
4183
+ .and_then(|()| {
4184
+ self.run_ok(&[
4185
+ "tmux".to_string(),
4186
+ "respawn-pane".to_string(),
4187
+ "-k".to_string(),
4188
+ "-t".to_string(),
4189
+ pane.to_string(),
4190
+ "exit 0".to_string(),
4191
+ ])
4192
+ });
4193
+ if result.is_err() {
4194
+ // The worker has not been killed yet; remove only our new anchor.
4195
+ let _ = self.kill_pane(&PaneId::new(pane));
4196
+ }
4197
+ result
4198
+ }
4199
+
4090
4200
  fn kill_server(&self) -> Result<(), TransportError> {
4091
4201
  TmuxBackend::kill_server(self);
4092
4202
  Ok(())
@@ -29,6 +29,7 @@ struct OfflineState {
29
29
  target_snapshots: VecDeque<Vec<PaneInfo>>,
30
30
  windows: Vec<WindowName>,
31
31
  pane_presence: BTreeMap<String, bool>,
32
+ dead_panes: BTreeMap<String, bool>,
32
33
  spawn_failures: BTreeMap<String, String>,
33
34
  spawned_panes_addressable: bool,
34
35
  liveness: BTreeMap<String, PaneLiveness>,
@@ -67,6 +68,7 @@ impl Default for OfflineState {
67
68
  target_snapshots: VecDeque::new(),
68
69
  windows: Vec::new(),
69
70
  pane_presence: BTreeMap::new(),
71
+ dead_panes: BTreeMap::new(),
70
72
  spawn_failures: BTreeMap::new(),
71
73
  spawned_panes_addressable: true,
72
74
  liveness: BTreeMap::new(),
@@ -144,6 +146,13 @@ impl OfflineTransport {
144
146
  self
145
147
  }
146
148
 
149
+ pub fn with_pane_dead(self, pane: impl Into<String>, dead: bool) -> Self {
150
+ self.with_state(|state| {
151
+ state.dead_panes.insert(pane.into(), dead);
152
+ });
153
+ self
154
+ }
155
+
147
156
  pub fn with_spawn_failure(self, window: impl Into<String>, error: impl Into<String>) -> Self {
148
157
  self.with_state(|state| {
149
158
  state.spawn_failures.insert(window.into(), error.into());
@@ -523,6 +532,13 @@ impl Transport for OfflineTransport {
523
532
  Ok(self.with_state(|state| state.pane_current_commands.get(&pane_id).cloned()))
524
533
  }
525
534
 
535
+ fn pane_is_dead(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
536
+ Ok(self.with_state(|state| {
537
+ state.calls.push("pane_is_dead");
538
+ state.dead_panes.get(pane.as_str()).copied()
539
+ }))
540
+ }
541
+
526
542
  fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
527
543
  Ok(self.with_state(|state| {
528
544
  state.calls.push("liveness");
@@ -582,6 +598,25 @@ impl Transport for OfflineTransport {
582
598
  Ok(self.with_state(|state| state.session_owner.clone()))
583
599
  }
584
600
 
601
+ fn set_session_owner_with_generation(
602
+ &self,
603
+ _session: &SessionName,
604
+ workspace: &Path,
605
+ team: &str,
606
+ generation: &str,
607
+ ) -> Result<(), TransportError> {
608
+ self.with_state(|state| {
609
+ state.calls.push("set_session_owner_with_generation");
610
+ state.session_owner = Some(super::SessionOwner {
611
+ workspace: workspace.canonicalize().unwrap_or_else(|_| workspace.to_path_buf())
612
+ .to_string_lossy().to_string(),
613
+ team: team.to_string(),
614
+ generation: generation.to_string(),
615
+ });
616
+ });
617
+ Ok(())
618
+ }
619
+
585
620
  fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
586
621
  Ok(self.with_state(|state| {
587
622
  state.calls.push("list_windows");