@team-agent/installer 0.5.38 → 0.5.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.38"
578
+ version = "0.5.40"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.38"
12
+ version = "0.5.40"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -3,6 +3,22 @@ use super::*;
3
3
  use crate::provider::wire::{command_name, parse_provider, provider_wire};
4
4
  use crate::transport::Transport;
5
5
 
6
+ /// 0.5.39 Slice 1 (tmux-server-death-locate §11.1 B): classify a tmux
7
+ /// transport error string into a diagnose issue id. When the underlying
8
+ /// tmux subprocess stderr contains `server exited unexpectedly`, the
9
+ /// tmux server itself crashed (upstream tmux 3.6a control-mode +
10
+ /// broadcast attach/detach jitter is one known trigger) — the physical
11
+ /// layer that disappeared is the whole server, not just this team's
12
+ /// session. Otherwise, callers keep the legacy `tmux_session_missing`
13
+ /// classification.
14
+ pub(crate) fn classify_tmux_server_error(error_text: &str) -> &'static str {
15
+ if error_text.contains("server exited unexpectedly") {
16
+ "tmux_server_crashed"
17
+ } else {
18
+ "tmux_session_missing"
19
+ }
20
+ }
21
+
6
22
  pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value, Value) {
7
23
  let mut issues = Vec::new();
8
24
  let mut repairs = Vec::new();
@@ -31,11 +47,20 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
31
47
  ));
32
48
  }
33
49
  Err(error) => {
34
- issues.push(json!("tmux_session_missing"));
50
+ // 0.5.39 Slice 1 (tmux-server-death-locate §11.1 B):
51
+ // when the transport error stderr matches "server exited
52
+ // unexpectedly", the physical layer that disappeared is
53
+ // the tmux server itself — not just this team's session.
54
+ // Surface `tmux_server_crashed` so the user's next
55
+ // action is a coordinator/host-level recovery, not a
56
+ // per-agent `restart <agent>`.
57
+ let error_str = error.to_string();
58
+ let issue_id = classify_tmux_server_error(&error_str);
59
+ issues.push(json!(issue_id));
35
60
  let mut hint =
36
- recovery_hint(session_name, "tmux_session_missing", "team-agent restart");
61
+ recovery_hint(session_name, issue_id, "team-agent diagnose");
37
62
  if let Some(obj) = hint.as_object_mut() {
38
- obj.insert("reason".to_string(), Value::String(error.to_string()));
63
+ obj.insert("reason".to_string(), Value::String(error_str));
39
64
  }
40
65
  repairs.push(hint);
41
66
  }
@@ -3339,6 +3339,30 @@ pub mod lifecycle_port {
3339
3339
  "invalid": invalid.iter().map(|w| w.worker_id.as_str()).collect::<Vec<_>>(),
3340
3340
  "reminder": crate::cli::QUICK_START_REMINDER,
3341
3341
  }),
3342
+ crate::lifecycle::RestartReport::RefusedBuildBeforeDestroyRequired {
3343
+ session_name,
3344
+ live_agents,
3345
+ error,
3346
+ } => {
3347
+ let repair_team = team
3348
+ .filter(|team| !team.is_empty())
3349
+ .unwrap_or(session_name.as_str());
3350
+ let shutdown_scoped =
3351
+ format!("team-agent shutdown --team {repair_team}");
3352
+ json!({
3353
+ "ok": false,
3354
+ "status": "refused_build_before_destroy",
3355
+ "reason": "build_before_destroy_required",
3356
+ "session_name": session_name,
3357
+ "live_agents": live_agents,
3358
+ "error": error,
3359
+ "next_actions": [
3360
+ shutdown_scoped,
3361
+ "team-agent restart",
3362
+ ],
3363
+ "reminder": crate::cli::QUICK_START_REMINDER,
3364
+ })
3365
+ }
3342
3366
  crate::lifecycle::RestartReport::RefusedDirtyTopology {
3343
3367
  session_name,
3344
3368
  reason,
@@ -1,10 +1,19 @@
1
1
  //! lifecycle::display —— 能力门 / 后端解析 / 开关 / rebind 后重建。
2
+ //!
3
+ //! 0.5.39 Slice 1 (tmux-server-death-locate §7 Slice 1): all tmux
4
+ //! session/window/pane operations here MUST go through the workspace's
5
+ //! scoped `TmuxBackend` — raw `Command::new("tmux")` / ambient
6
+ //! `run_tmux(...)` helpers in this file inherit ambient `$TMUX` and can
7
+ //! kill sessions on the wrong socket. Ambient leader-pane env probes
8
+ //! (`display-message` reading $TMUX to discover which session invoked
9
+ //! us) live in `tmux_backend::probe_ambient_leader_pane_info` — that
10
+ //! probe is definitionally ambient (its whole job is "which session am I
11
+ //! in") and stays outside this module.
2
12
 
3
13
  use std::collections::BTreeMap;
4
14
  use std::path::Path;
5
- use std::process::Command;
6
15
 
7
- use crate::transport::{PaneId, SessionName, WindowName};
16
+ use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
8
17
 
9
18
  use super::*;
10
19
 
@@ -239,6 +248,12 @@ fn close_adaptive_displays(
239
248
  orphans_cleaned: Vec::new(),
240
249
  });
241
250
  };
251
+ // 0.5.39 Slice 1: build workspace-scoped tmux transport once and
252
+ // route every destructive tmux op below through it (no ambient
253
+ // $TMUX). The kill_* Transport methods carry the workspace socket
254
+ // (`tmux -L <socket>`) so display cleanup cannot cross-kill a session
255
+ // on the user's default tmux server.
256
+ let transport = crate::transport_factory::tmux_workspace_transport(workspace);
242
257
  let mut closed = Vec::new();
243
258
  let mut orphans_cleaned = Vec::new();
244
259
  let mut seen = std::collections::BTreeSet::new();
@@ -258,13 +273,13 @@ fn close_adaptive_displays(
258
273
  }
259
274
  for target in display_identifiers(display, agent, agent_id, session_name, &state) {
260
275
  if seen.insert(target.clone()) {
261
- kill_display_target(&target);
276
+ kill_display_target(&transport, &target);
262
277
  closed.push(target);
263
278
  }
264
279
  }
265
280
  }
266
281
  if let Some(leader_session) = adaptive_leader_session(&state) {
267
- for target in close_adaptive_windows(leader_session.as_str(), session_name.as_str()) {
282
+ for target in close_adaptive_windows(&transport, leader_session.as_str(), session_name.as_str()) {
268
283
  orphans_cleaned.push(target.clone());
269
284
  if seen.insert(target.clone()) {
270
285
  closed.push(target);
@@ -351,13 +366,6 @@ struct LeaderTmuxInfo {
351
366
  pane: Option<String>,
352
367
  }
353
368
 
354
- #[derive(Debug)]
355
- struct TmuxOutput {
356
- ok: bool,
357
- stdout: String,
358
- stderr: String,
359
- }
360
-
361
369
  fn in_wsl() -> bool {
362
370
  std::env::var("WSL_DISTRO_NAME").is_ok_and(|value| !value.is_empty())
363
371
  || std::env::var("WSL_INTEROP").is_ok_and(|value| !value.is_empty())
@@ -369,52 +377,11 @@ fn running_inside_tmux() -> bool {
369
377
  }
370
378
 
371
379
  fn current_leader_tmux_info() -> Option<LeaderTmuxInfo> {
372
- let pane = std::env::var("TMUX_PANE")
373
- .ok()
374
- .filter(|value| !value.is_empty());
375
- let mut commands = Vec::new();
376
- if let Some(pane) = pane.as_deref() {
377
- commands.push(vec![
378
- "display-message".to_string(),
379
- "-p".to_string(),
380
- "-t".to_string(),
381
- pane.to_string(),
382
- "-F".to_string(),
383
- "#{session_name}\t#{pane_id}".to_string(),
384
- ]);
385
- commands.push(vec![
386
- "display-message".to_string(),
387
- "-p".to_string(),
388
- "-t".to_string(),
389
- pane.to_string(),
390
- "-F".to_string(),
391
- "#{session_name}".to_string(),
392
- ]);
393
- }
394
- if std::env::var("TMUX").is_ok_and(|value| !value.is_empty()) {
395
- commands.push(vec![
396
- "display-message".to_string(),
397
- "-p".to_string(),
398
- "-F".to_string(),
399
- "#{session_name}\t#{pane_id}".to_string(),
400
- ]);
401
- commands.push(vec![
402
- "display-message".to_string(),
403
- "-p".to_string(),
404
- "-F".to_string(),
405
- "#{session_name}".to_string(),
406
- ]);
407
- }
408
- for command in commands {
409
- let args = command.iter().map(String::as_str).collect::<Vec<_>>();
410
- if let Some(parsed) = run_tmux(&args)
411
- .ok()
412
- .and_then(|out| parse_tmux_info(&out.stdout))
413
- {
414
- return Some(parsed);
415
- }
416
- }
417
- None
380
+ // 0.5.39 Slice 1: ambient probe lives in `tmux_backend` — the single
381
+ // controlled exception to the "all tmux ops through socket-scoped
382
+ // transport" rule (its whole job is "which session am I in?").
383
+ crate::tmux_backend::probe_ambient_leader_pane_info()
384
+ .map(|(session, pane)| LeaderTmuxInfo { session, pane })
418
385
  }
419
386
 
420
387
  fn env_leader_tmux_info() -> Option<LeaderTmuxInfo> {
@@ -428,44 +395,29 @@ fn env_leader_tmux_info() -> Option<LeaderTmuxInfo> {
428
395
  Some(LeaderTmuxInfo { session, pane })
429
396
  }
430
397
 
431
- fn parse_tmux_info(stdout: &str) -> Option<LeaderTmuxInfo> {
432
- let line = stdout.lines().find(|line| !line.trim().is_empty())?.trim();
433
- let parts = line.split('\t').collect::<Vec<_>>();
434
- match parts.as_slice() {
435
- [session, pane, ..] if !session.is_empty() && !session.starts_with('%') => {
436
- Some(LeaderTmuxInfo {
437
- session: (*session).to_string(),
438
- pane: (!pane.is_empty()).then(|| (*pane).to_string()),
439
- })
440
- }
441
- [pane, session, ..] if pane.starts_with('%') && !session.is_empty() => {
442
- Some(LeaderTmuxInfo {
443
- session: (*session).to_string(),
444
- pane: Some((*pane).to_string()),
445
- })
446
- }
447
- [session] if !session.is_empty() && !session.starts_with('%') => Some(LeaderTmuxInfo {
448
- session: (*session).to_string(),
449
- pane: None,
450
- }),
451
- _ => None,
452
- }
453
- }
454
-
455
- fn close_adaptive_windows(leader_session: &str, session_name: &str) -> Vec<String> {
398
+ fn close_adaptive_windows(
399
+ transport: &dyn Transport,
400
+ leader_session: &str,
401
+ session_name: &str,
402
+ ) -> Vec<String> {
456
403
  let prefix = format!("team-agent:{session_name}:overview");
457
- let Ok(output) = run_tmux(&["list-windows", "-t", leader_session, "-F", "#{window_name}"])
458
- else {
404
+ // 0.5.39 Slice 1: `Transport::list_windows` uses the workspace socket
405
+ // — if `leader_session` doesn't exist on that socket, list_windows
406
+ // returns Err/empty and we bail without touching another server.
407
+ let Ok(windows) = transport.list_windows(&SessionName::new(leader_session.to_string())) else {
459
408
  return Vec::new();
460
409
  };
461
- output
462
- .stdout
463
- .lines()
464
- .filter_map(|line| {
465
- let window = line.trim();
410
+ windows
411
+ .into_iter()
412
+ .filter_map(|window| {
413
+ let window = window.as_str().to_string();
466
414
  if window == prefix || window.starts_with(&format!("{prefix}-")) {
467
- let target = format!("{leader_session}:{window}");
468
- kill_adaptive_window(&target).then_some(target)
415
+ let target_string = format!("{leader_session}:{window}");
416
+ let target = Target::SessionWindow {
417
+ session: SessionName::new(leader_session.to_string()),
418
+ window: WindowName::new(window),
419
+ };
420
+ transport.kill_window(&target).ok().map(|_| target_string)
469
421
  } else {
470
422
  None
471
423
  }
@@ -473,18 +425,23 @@ fn close_adaptive_windows(leader_session: &str, session_name: &str) -> Vec<Strin
473
425
  .collect()
474
426
  }
475
427
 
476
- fn kill_adaptive_window(target: &str) -> bool {
477
- run_tmux(&["kill-window", "-t", target]).is_ok()
478
- }
479
-
480
- fn kill_display_target(target: &str) {
481
- if target.contains(':') {
482
- let _ = run_tmux(&["kill-window", "-t", target]);
428
+ fn kill_display_target(transport: &dyn Transport, target: &str) {
429
+ // 0.5.39 Slice 1: only kill window/pane never fall back to
430
+ // kill-session on an ambiguous string shape. Locate §7 Slice 1:
431
+ // "Remove the string-shape `kill-session` fallback unless target is a
432
+ // proven display-only session." The old code parsed any bare
433
+ // non-`%`/non-`:` target as a session and issued `kill-session`,
434
+ // which is exactly the unbounded blast radius we're closing.
435
+ if let Some((session, window)) = target.split_once(':') {
436
+ let _ = transport.kill_window(&Target::SessionWindow {
437
+ session: SessionName::new(session.to_string()),
438
+ window: WindowName::new(window.to_string()),
439
+ });
483
440
  } else if target.starts_with('%') {
484
- let _ = run_tmux(&["kill-pane", "-t", target]);
485
- } else {
486
- let _ = run_tmux(&["kill-session", "-t", target]);
441
+ let _ = transport.kill_pane(&PaneId::new(target.to_string()));
487
442
  }
443
+ // Bare session-name form is intentionally a no-op: display cleanup
444
+ // must not kill a whole session on shape guesswork.
488
445
  }
489
446
 
490
447
  fn adaptive_leader_session(state: &serde_json::Value) -> Option<String> {
@@ -525,23 +482,3 @@ fn string_field(display: &serde_json::Map<String, serde_json::Value>, key: &str)
525
482
  .map(str::to_string)
526
483
  }
527
484
 
528
- fn run_tmux(args: &[&str]) -> Result<TmuxOutput, LifecycleError> {
529
- let output = Command::new("tmux")
530
- .args(args)
531
- .output()
532
- .map_err(|e| LifecycleError::StatePersist(format!("tmux {}: {e}", args.join(" "))))?;
533
- let result = TmuxOutput {
534
- ok: output.status.success(),
535
- stdout: String::from_utf8_lossy(&output.stdout).to_string(),
536
- stderr: String::from_utf8_lossy(&output.stderr).to_string(),
537
- };
538
- if result.ok {
539
- Ok(result)
540
- } else {
541
- Err(LifecycleError::StatePersist(format!(
542
- "tmux {}: {}",
543
- args.join(" "),
544
- result.stderr.trim()
545
- )))
546
- }
547
- }
@@ -228,25 +228,35 @@ pub(super) fn spawn_agent_window(
228
228
  );
229
229
  }
230
230
 
231
+ // 0.5.39 Slice 2 (tmux-server-death-locate §7 Slice 2): route the
232
+ // primary worker spawn through the worker shell wrapper so provider
233
+ // exit leaves the pane at an interactive shell with an explicit exit
234
+ // marker instead of collapsing the pane into `[exited]`. This mirrors
235
+ // the leader wrapper's manual-tmux equivalence. The split path stays
236
+ // on plain spawn_split — split panes are display overlays, not the
237
+ // primary worker process.
238
+ let provider_label = crate::provider::wire::command_name(provider);
231
239
  let result = if let Some(placement) = layout_placement {
232
240
  if placement.starts_window {
233
241
  if into_existing_session {
234
- transport.spawn_into_with_env_unset(
242
+ transport.spawn_into_with_worker_shell_wrapper(
235
243
  session_name,
236
244
  &window,
237
245
  &plan.argv,
238
246
  spawn_cwd,
239
247
  &env,
240
248
  &env_unset,
249
+ provider_label,
241
250
  )
242
251
  } else {
243
- transport.spawn_first_with_env_unset(
252
+ transport.spawn_first_with_worker_shell_wrapper(
244
253
  session_name,
245
254
  &window,
246
255
  &plan.argv,
247
256
  spawn_cwd,
248
257
  &env,
249
258
  &env_unset,
259
+ provider_label,
250
260
  )
251
261
  }
252
262
  } else if !window_present_in_live(transport, session_name, &window)
@@ -264,13 +274,14 @@ pub(super) fn spawn_agent_window(
264
274
  // hijack the developer worker's pane. Downgrade to spawn_into
265
275
  // (new window named after agent_id) — canonical per-agent
266
276
  // fallback the existing 7 workers use.
267
- transport.spawn_into_with_env_unset(
277
+ transport.spawn_into_with_worker_shell_wrapper(
268
278
  session_name,
269
279
  &WindowName::new(agent_id.as_str()),
270
280
  &plan.argv,
271
281
  spawn_cwd,
272
282
  &env,
273
283
  &env_unset,
284
+ provider_label,
274
285
  )
275
286
  } else {
276
287
  // 0.3.28 Step 8: spawn_split must only fire from the display
@@ -286,22 +297,24 @@ pub(super) fn spawn_agent_window(
286
297
  )
287
298
  }
288
299
  } else if into_existing_session {
289
- transport.spawn_into_with_env_unset(
300
+ transport.spawn_into_with_worker_shell_wrapper(
290
301
  session_name,
291
302
  &window,
292
303
  &plan.argv,
293
304
  spawn_cwd,
294
305
  &env,
295
306
  &env_unset,
307
+ provider_label,
296
308
  )
297
309
  } else {
298
- transport.spawn_first_with_env_unset(
310
+ transport.spawn_first_with_worker_shell_wrapper(
299
311
  session_name,
300
312
  &window,
301
313
  &plan.argv,
302
314
  spawn_cwd,
303
315
  &env,
304
316
  &env_unset,
317
+ provider_label,
305
318
  )
306
319
  };
307
320
  let spawn = result.map_err(|e| LifecycleError::Transport(e.to_string()))?;
@@ -410,22 +410,27 @@ fn restart_with_selected_team_and_transport(
410
410
  }
411
411
  }
412
412
  let session_name = state_session_name(&state);
413
- if session_live_or_default(transport, &session_name, false) {
414
- // 0.3.28 Step 5 (warn-only): per architecture, restart should REFUSE
415
- // when the worker session is live (Python `restart/orchestration.py:79-85`
416
- // raises `_tmux_session_conflict_error`). Pre-0.3.28 Rust kills the
417
- // worker session here which under the old co-located topology also
418
- // killed the leader pane (the E57-1 cascade contribution from the
419
- // layout layer).
420
- //
421
- // After Step 2 the leader lives in a DIFFERENT session
422
- // (`team-agent-leader-...`), so killing the worker session no longer
423
- // tears down the leader. That makes this kill structurally safe, but
424
- // it still loses provider session state in the worker panes.
425
- //
426
- // Full "refuse" semantics will land once Steps 6+7 expose the
427
- // recovery path so users have a clean alternative. For now we emit
428
- // the warn-only event so operators see the drift in event logs.
413
+ // 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3, first-version
414
+ // build-before-destroy). Pre-0.5.40 code UNCONDITIONALLY killed the
415
+ // live worker session here, then ran the spawn loop against a fresh
416
+ // session which loses provider session state (Case B) and, under
417
+ // upstream tmux 3.6a private-server bugs, can cascade into whole-
418
+ // server death (Case A). Locate §7 Slice 3 requires: "restart must
419
+ // build/prove the replacement before retiring a currently live
420
+ // worker session." First-version narrow shape: defer the kill until
421
+ // AFTER the spawn loop proves the replacement is minimally viable,
422
+ // and refuse the kill on failure so the original session/state stay
423
+ // authoritative. Discriminator = `collect_live_agents_from_state`
424
+ // (state marks agents running with real pane_ids). When state has
425
+ // no authoritative live agents, the previous behavior (pre-spawn
426
+ // teardown of a stale empty session) is preserved so tests that
427
+ // seed a fake-live session without live agent rows still work.
428
+ let live_worker_session_deferred_teardown =
429
+ session_live_or_default(transport, &session_name, false)
430
+ && !collect_live_agents_from_state(&state).is_empty();
431
+ if session_live_or_default(transport, &session_name, false)
432
+ && !live_worker_session_deferred_teardown
433
+ {
429
434
  eprintln!(
430
435
  "team_agent::layout restart_precondition_warning worker_session=`{}` action=killing \
431
436
  (post-Step-7 will refuse and direct user to recover; safe today because Step 2 \
@@ -451,6 +456,20 @@ fn restart_with_selected_team_and_transport(
451
456
  }
452
457
  phase_timer.emit(&selected.run_workspace, "restart.phase", "teardown");
453
458
  phase_timer.emit(&selected.run_workspace, "restart.phase", "spawn_all");
459
+ // 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3): when the
460
+ // pre-spawn teardown was DEFERRED (live worker session with
461
+ // authoritative running agents), snapshot the pre-spawn agent rows
462
+ // so we can restore them if the replacement build fails. This
463
+ // enforces the "old session/state stay authoritative on failure"
464
+ // invariant without threading a buffer through every state
465
+ // mutation. On success (all spawns viable) the mutations already
466
+ // written win; on failure the snapshot restores the original rows.
467
+ let build_before_destroy_agents_snapshot: Option<serde_json::Value> =
468
+ if live_worker_session_deferred_teardown {
469
+ state.get("agents").cloned()
470
+ } else {
471
+ None
472
+ };
454
473
  let mut successful_agents: Vec<RestartedAgent> = Vec::new();
455
474
  let mut failed_agents: Vec<RestartFailedAgent> = Vec::new();
456
475
  let mut fatal_resume_failure = false;
@@ -599,6 +618,37 @@ fn restart_with_selected_team_and_transport(
599
618
  };
600
619
  let mut session_live = session_live_or_default(transport, &session_name, false);
601
620
  if !session_live {
621
+ // 0.5.40 Slice 3: when we deferred the pre-spawn teardown
622
+ // because the original session was live with authoritative
623
+ // running agents, a mid-flight session disappearance
624
+ // classifies as tmux_server_crashed (0539 §11.1 B) — the
625
+ // whole server died out from under us. Do NOT cascade-pop
626
+ // previously-successful agents into `session_disappeared_after_spawn`:
627
+ // that path (a) rewrites state.agents.<popped>.status to
628
+ // restart_failed (losing the authoritative old row R3
629
+ // requires stays untouched), and (b) emits the exact
630
+ // cascade error string R3 forbids. Instead, refuse the
631
+ // build-before-destroy cohort: leave `successful_agents`
632
+ // alone (their live spawn already happened but the server
633
+ // died before we could prove them viable), stop the loop,
634
+ // and let the terminal Failed report attribute the failure
635
+ // to tmux_server_crashed via `restart_failure_phase`. The
636
+ // legacy path still runs when the pre-spawn teardown was
637
+ // taken (empty session case), because
638
+ // `live_worker_session_deferred_teardown` is false there.
639
+ if live_worker_session_deferred_teardown {
640
+ fatal_resume_failure = true;
641
+ let error = format!(
642
+ "tmux_server_crashed: session {} disappeared during replacement build; refusing to touch original agent state",
643
+ session_name.as_str()
644
+ );
645
+ failed_agents.push(restart_failed_agent(
646
+ decision,
647
+ "tmux_server_crashed",
648
+ error,
649
+ ));
650
+ continue;
651
+ }
602
652
  if let Some(previous) = successful_agents.pop() {
603
653
  let error = format!(
604
654
  "session_disappeared_after_spawn: provider_resume_exited for {}; session {} disappeared before spawning {}",
@@ -750,6 +800,38 @@ fn restart_with_selected_team_and_transport(
750
800
  })
751
801
  .map(|agent| agent.agent_id.as_str().to_string())
752
802
  .collect::<Vec<_>>();
803
+ // 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3): restore the
804
+ // pre-spawn agents snapshot when we deferred the teardown AND the
805
+ // replacement build failed. This is the "old session/state stay
806
+ // authoritative on failure" invariant made concrete — the mid-loop
807
+ // mark_agent_respawned/mark_agent_restart_failed calls already
808
+ // mutated `state.agents` in memory; restoring the snapshot rewinds
809
+ // those mutations so `save_restart_state_...` below persists rows
810
+ // byte-identical to what was loaded. Only rewind when
811
+ // `fatal_resume_failure` (whole cohort refused) OR when NO agent
812
+ // survived (`successful_agents.is_empty()` with failures) — on
813
+ // partial success the surviving replacements are legitimately new
814
+ // and the pre-restart rows are stale.
815
+ if let Some(snapshot) = build_before_destroy_agents_snapshot.as_ref() {
816
+ let replacement_failed =
817
+ fatal_resume_failure || (!failed_agents.is_empty() && successful_agents.is_empty());
818
+ if replacement_failed {
819
+ if let Some(state_obj) = state.as_object_mut() {
820
+ state_obj.insert("agents".to_string(), snapshot.clone());
821
+ if let Some(teams) = state_obj
822
+ .get_mut("teams")
823
+ .and_then(serde_json::Value::as_object_mut)
824
+ {
825
+ if let Some(team_entry) = teams
826
+ .get_mut(selected.team_key.as_str())
827
+ .and_then(serde_json::Value::as_object_mut)
828
+ {
829
+ team_entry.insert("agents".to_string(), snapshot.clone());
830
+ }
831
+ }
832
+ }
833
+ }
834
+ }
753
835
  save_restart_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
754
836
  &selected.run_workspace,
755
837
  &mut state,
@@ -2049,6 +2131,16 @@ fn restart_failure_phase(
2049
2131
  phase: &'static str,
2050
2132
  error: &str,
2051
2133
  ) -> &'static str {
2134
+ // 0.5.39 Slice 1 (tmux-server-death-locate §11.1 B): promote
2135
+ // spawn/readiness errors whose stderr contains "server exited
2136
+ // unexpectedly" to `tmux_server_crashed`, so downstream event
2137
+ // classification and diagnose next_actions ("team-agent diagnose")
2138
+ // don't misattribute a whole-server death to a per-agent provider
2139
+ // failure. Restart itself does not recover from server crashes in
2140
+ // 0.5.39 (§11.1 C is Slice 3, later car); it just classifies clearly.
2141
+ if is_tmux_server_crashed(error) {
2142
+ return "tmux_server_crashed";
2143
+ }
2052
2144
  if is_resume_integrity_failure(decision, phase, error) {
2053
2145
  "resume"
2054
2146
  } else {
@@ -2056,6 +2148,10 @@ fn restart_failure_phase(
2056
2148
  }
2057
2149
  }
2058
2150
 
2151
+ fn is_tmux_server_crashed(error: &str) -> bool {
2152
+ error.contains("server exited unexpectedly")
2153
+ }
2154
+
2059
2155
  fn is_resume_integrity_failure(decision: &RestartedAgent, phase: &str, error: &str) -> bool {
2060
2156
  if !matches!(decision.restart_mode, StartMode::Resumed) {
2061
2157
  return false;
@@ -2349,7 +2445,7 @@ fn run_bounded_parallel_worker_spawns(
2349
2445
  }
2350
2446
  // Advance the gate so the next plan-order thread can
2351
2447
  // start its own `spawn_agent_window` in parallel; a
2352
- // deterministic 2ms stagger between plan slots keeps
2448
+ // deterministic 10ms stagger between plan slots keeps
2353
2449
  // pane_id assignment (which happens inside the
2354
2450
  // transport at the tail of its call) ordered by plan
2355
2451
  // even when each individual call sleeps for tens of
@@ -2362,11 +2458,14 @@ fn run_bounded_parallel_worker_spawns(
2362
2458
  // per-slot stagger so pane_id assignment inside the
2363
2459
  // transport happens in plan order even though the
2364
2460
  // subsequent per-call sleeps overlap across threads.
2365
- // 10ms per slot is enough headroom over OS scheduler
2366
- // jitter (previous 3/5ms values flaked on shared macOS
2367
- // gate machines) while remaining well under real tmux
2461
+ // 10ms per slot is enough headroom over OS scheduler jitter
2462
+ // (previous 3/5ms values flaked on shared macOS gate
2463
+ // machines) while remaining well under real tmux
2368
2464
  // new-window latency (~50-120ms) so the overlap
2369
- // property required by R1 still holds.
2465
+ // property required by R1 still holds. Reverse guard for
2466
+ // this stagger lives in
2467
+ // `tests/parallel_spawn_stagger_reverse_guard.rs`
2468
+ // (0.5.38 cr observation D-k).
2370
2469
  std::thread::sleep(std::time::Duration::from_millis(slot as u64 * 10));
2371
2470
  let spawn_start = std::time::Instant::now();
2372
2471
  let outcome = match spawn_agent_window(
@@ -3056,6 +3155,32 @@ fn load_endpoint_convergence_runtime_spec(
3056
3155
  Ok(Some(spec))
3057
3156
  }
3058
3157
 
3158
+ /// 0.5.40 Slice 3: collect the set of agent ids that state marks as
3159
+ /// running with a real pane_id. "Running with pane_id" is the
3160
+ /// discriminator between "state has an authoritative live team the
3161
+ /// user would lose on teardown" and "state is fresh / all stopped and
3162
+ /// the live session is just leftover empty topology". Sorted for
3163
+ /// deterministic report output.
3164
+ fn collect_live_agents_from_state(state: &serde_json::Value) -> Vec<String> {
3165
+ let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
3166
+ return Vec::new();
3167
+ };
3168
+ let mut live: Vec<String> = agents
3169
+ .iter()
3170
+ .filter_map(|(agent_id, agent)| {
3171
+ let status = agent.get("status").and_then(serde_json::Value::as_str)?;
3172
+ let pane_id = agent
3173
+ .get("pane_id")
3174
+ .and_then(serde_json::Value::as_str)
3175
+ .filter(|s| !s.is_empty())?;
3176
+ let _ = pane_id;
3177
+ (status == "running").then(|| agent_id.clone())
3178
+ })
3179
+ .collect();
3180
+ live.sort();
3181
+ live
3182
+ }
3183
+
3059
3184
  fn has_endpoint_convergence_marker(state: &serde_json::Value) -> bool {
3060
3185
  state
3061
3186
  .get("topology_convergence")
@@ -336,7 +336,17 @@ if [ "$track" != 1 ]; then
336
336
  fi
337
337
  exit 127
338
338
  fi
339
- printf '%s\n' "$*" >> "$log"
339
+ # 0.5.39 Slice 2: worker spawn now goes through the worker shell
340
+ # wrapper (tmux-server-death-locate §7 Slice 2), which embeds a
341
+ # printf format literal containing "\n" bytes so the pane returns to
342
+ # an interactive shell with an explicit exit marker instead of
343
+ # collapsing to `[exited]`. Those embedded newlines land inside the
344
+ # tmux argv payload, so a naive `printf '%s\n' "$*"` writes multiple
345
+ # lines per tmux invocation and downstream `raw.lines()` scans see
346
+ # fake "argv" lines that carry only the marker text. Escape newlines
347
+ # to a literal `\n` marker so one shim call = one log line = one
348
+ # assertion target.
349
+ printf '%s\n' "$*" | awk 'BEGIN{ORS=""} {print sep $0; sep="\\n"} END{print "\n"}' >> "$log"
340
350
  case "$*" in
341
351
  *"-S $expected"*) ;;
342
352
  *)
@@ -745,6 +745,27 @@ pub enum RestartReport {
745
745
  allow_fresh: bool,
746
746
  error: String,
747
747
  },
748
+ /// 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3, first-version
749
+ /// all-or-refuse): the previous worker session is still live AND state
750
+ /// carries running agents with real pane_ids. Restart today can only
751
+ /// proceed by killing the live session first — which loses provider
752
+ /// session state in the worker panes and, worse, can cascade into
753
+ /// server death under Case B. Until the build-before-destroy path
754
+ /// (later car: temporary session + minimal-viability proof + swap)
755
+ /// lands, restart refuses this shape *before* touching tmux or state.
756
+ /// **nothing created or killed; state.agents rows are byte-identical
757
+ /// to what was seeded before the call.**
758
+ RefusedBuildBeforeDestroyRequired {
759
+ /// Live worker session name from state that would need destructive
760
+ /// rebuild.
761
+ session_name: String,
762
+ /// Snapshot of agent ids currently running with real pane_ids —
763
+ /// this is the "authoritative old state" the refusal preserves.
764
+ live_agents: Vec<String>,
765
+ /// Human-readable error pointing the user at the canonical recovery
766
+ /// path (bare shutdown then restart).
767
+ error: String,
768
+ },
748
769
  /// unit-3 (Stage 1): session-identity preflight refused — `state.session_name`
749
770
  /// is a leader launcher session (`team-agent-leader-*`). Proceeding would
750
771
  /// tear down the leader pane (E49 / 0.3.39). **nothing created or killed**.
@@ -1509,6 +1509,149 @@ pub fn leader_provider_exit_marker(provider_label: &str) -> String {
1509
1509
  )
1510
1510
  }
1511
1511
 
1512
+ /// 0.5.39 Slice 1 (tmux-server-death-locate §7 Slice 1): ambient-tmux
1513
+ /// leader-pane probe. Kept inside `tmux_backend` because it is
1514
+ /// definitionally ambient — its job is to discover *which* session/pane
1515
+ /// the leader process is currently inside via $TMUX/$TMUX_PANE +
1516
+ /// `tmux display-message`. Everywhere else in the codebase, tmux ops
1517
+ /// must go through a socket-scoped `TmuxBackend` (that constraint is
1518
+ /// enforced by `n16_tmux_socket_invariant_red.rs` +
1519
+ /// `tmux_server_death_0539_contract.rs::display_cleanup_...`); this
1520
+ /// helper is the single controlled exception.
1521
+ ///
1522
+ /// Returns `(session_name, Some(pane_id))` when the ambient tmux
1523
+ /// responds, or `None` if `$TMUX` is unset / `display-message` fails.
1524
+ pub fn probe_ambient_leader_pane_info() -> Option<(String, Option<String>)> {
1525
+ let pane = std::env::var("TMUX_PANE")
1526
+ .ok()
1527
+ .filter(|value| !value.is_empty());
1528
+ let mut commands: Vec<Vec<String>> = Vec::new();
1529
+ if let Some(pane) = pane.as_deref() {
1530
+ commands.push(vec![
1531
+ "display-message".to_string(),
1532
+ "-p".to_string(),
1533
+ "-t".to_string(),
1534
+ pane.to_string(),
1535
+ "-F".to_string(),
1536
+ "#{session_name}\t#{pane_id}".to_string(),
1537
+ ]);
1538
+ commands.push(vec![
1539
+ "display-message".to_string(),
1540
+ "-p".to_string(),
1541
+ "-t".to_string(),
1542
+ pane.to_string(),
1543
+ "-F".to_string(),
1544
+ "#{session_name}".to_string(),
1545
+ ]);
1546
+ }
1547
+ if std::env::var("TMUX").is_ok_and(|value| !value.is_empty()) {
1548
+ commands.push(vec![
1549
+ "display-message".to_string(),
1550
+ "-p".to_string(),
1551
+ "-F".to_string(),
1552
+ "#{session_name}\t#{pane_id}".to_string(),
1553
+ ]);
1554
+ commands.push(vec![
1555
+ "display-message".to_string(),
1556
+ "-p".to_string(),
1557
+ "-F".to_string(),
1558
+ "#{session_name}".to_string(),
1559
+ ]);
1560
+ }
1561
+ for command in commands {
1562
+ let output = match std::process::Command::new("tmux").args(&command).output() {
1563
+ Ok(output) if output.status.success() => output,
1564
+ _ => continue,
1565
+ };
1566
+ let stdout = String::from_utf8_lossy(&output.stdout);
1567
+ let Some(line) = stdout.lines().find(|line| !line.trim().is_empty()) else {
1568
+ continue;
1569
+ };
1570
+ let line = line.trim();
1571
+ let parts: Vec<&str> = line.split('\t').collect();
1572
+ let parsed = match parts.as_slice() {
1573
+ [session, pane_str, ..] if !session.is_empty() && !session.starts_with('%') => Some((
1574
+ (*session).to_string(),
1575
+ (!pane_str.is_empty()).then(|| (*pane_str).to_string()),
1576
+ )),
1577
+ [pane_str, session, ..] if pane_str.starts_with('%') && !session.is_empty() => {
1578
+ Some(((*session).to_string(), Some((*pane_str).to_string())))
1579
+ }
1580
+ [session] if !session.is_empty() && !session.starts_with('%') => {
1581
+ Some(((*session).to_string(), None))
1582
+ }
1583
+ _ => None,
1584
+ };
1585
+ if parsed.is_some() {
1586
+ return parsed;
1587
+ }
1588
+ }
1589
+ None
1590
+ }
1591
+
1592
+ /// 0.5.39 Slice 2 (tmux-server-death-locate §11.2): single-source worker
1593
+ /// exit marker prefix. Same envelope shape as `LEADER_PROVIDER_EXIT_MARKER_*`
1594
+ /// but distinct so status/classifier code can tell "leader pane fell back
1595
+ /// to shell" from "worker pane fell back to shell". Format:
1596
+ /// `"[team-agent worker] {provider_label} exited with {rc}"`.
1597
+ pub const WORKER_PROVIDER_EXIT_MARKER_PREFIX: &str = "[team-agent worker]";
1598
+ pub const WORKER_PROVIDER_EXIT_MARKER_SUFFIX: &str = "exited with";
1599
+
1600
+ /// 0.5.39 Slice 2: build the worker exit marker text for `provider_label`.
1601
+ /// Used by both the worker shell wrapper (printf source) and future
1602
+ /// status/classifier code (capture substring) so they cannot drift.
1603
+ pub fn worker_provider_exit_marker(provider_label: &str) -> String {
1604
+ format!(
1605
+ "{WORKER_PROVIDER_EXIT_MARKER_PREFIX} {provider_label} {WORKER_PROVIDER_EXIT_MARKER_SUFFIX}"
1606
+ )
1607
+ }
1608
+
1609
+ /// 0.5.39 Slice 2 (tmux-server-death-locate §7 Slice 2): worker shell
1610
+ /// wrapper. Same shape as `leader_shell_wrapper_command` — provider runs
1611
+ /// as a CHILD of a long-lived shell so provider exit does NOT collapse the
1612
+ /// worker pane (which under upstream tmux 3.6a private-server bugs can
1613
+ /// cascade into whole-server death). When the provider exits, the worker
1614
+ /// pane returns to an interactive shell with an explicit worker exit
1615
+ /// marker, matching manual `tmux new-window` then `<provider>` behaviour.
1616
+ pub fn worker_shell_wrapper_command(
1617
+ argv: &[String],
1618
+ cwd: &Path,
1619
+ env: &BTreeMap<String, String>,
1620
+ env_unset: &[String],
1621
+ provider_label: &str,
1622
+ ) -> String {
1623
+ let unset_set: std::collections::BTreeSet<&str> =
1624
+ env_unset.iter().map(String::as_str).collect();
1625
+ let mut parts = Vec::new();
1626
+ parts.push("cd".to_string());
1627
+ parts.push(shell_quote(&cwd.to_string_lossy()));
1628
+ parts.push("&&".to_string());
1629
+ for key in env_unset {
1630
+ parts.push("unset".to_string());
1631
+ parts.push(key.clone());
1632
+ parts.push("&&".to_string());
1633
+ }
1634
+ for (key, value) in env {
1635
+ if unset_set.contains(key.as_str()) {
1636
+ continue;
1637
+ }
1638
+ parts.push(format!("{key}={}", shell_quote(value)));
1639
+ }
1640
+ parts.extend(argv.iter().map(|arg| shell_quote(arg)));
1641
+ parts.push(";".to_string());
1642
+ parts.push("rc=$?;".to_string());
1643
+ parts.push("printf".to_string());
1644
+ parts.push(shell_quote(&format!(
1645
+ "\n{} %s\n",
1646
+ worker_provider_exit_marker(provider_label)
1647
+ )));
1648
+ parts.push("\"$rc\";".to_string());
1649
+ parts.push("exec".to_string());
1650
+ parts.push("\"${SHELL:-/bin/zsh}\"".to_string());
1651
+ parts.push("-l".to_string());
1652
+ parts.join(" ")
1653
+ }
1654
+
1512
1655
  /// 0.4.x (CR C-2): leader shell wrapper — provider runs as a CHILD of a
1513
1656
  /// long-lived shell, not as the pane's primary process. When the provider
1514
1657
  /// exits, the pane returns to an interactive shell with an explicit exit
@@ -1670,6 +1813,38 @@ impl Transport for TmuxBackend {
1670
1813
  self.spawn_split(session, window, argv, cwd, env, env_unset)
1671
1814
  }
1672
1815
 
1816
+ /// 0.5.39 Slice 2: TmuxBackend override of the worker-shell-wrapper
1817
+ /// variant. Same mechanism as the leader wrapper (child provider under
1818
+ /// long-lived shell), but the marker text is distinct so downstream
1819
+ /// classifiers can tell leader vs worker provider exit apart.
1820
+ fn spawn_first_with_worker_shell_wrapper(
1821
+ &self,
1822
+ session: &SessionName,
1823
+ window: &WindowName,
1824
+ argv: &[String],
1825
+ cwd: &Path,
1826
+ env: &BTreeMap<String, String>,
1827
+ env_unset: &[String],
1828
+ provider_label: &str,
1829
+ ) -> Result<SpawnResult, TransportError> {
1830
+ let command = worker_shell_wrapper_command(argv, cwd, env, env_unset, provider_label);
1831
+ self.spawn_with_command(session, window, &command, true)
1832
+ }
1833
+
1834
+ fn spawn_into_with_worker_shell_wrapper(
1835
+ &self,
1836
+ session: &SessionName,
1837
+ window: &WindowName,
1838
+ argv: &[String],
1839
+ cwd: &Path,
1840
+ env: &BTreeMap<String, String>,
1841
+ env_unset: &[String],
1842
+ provider_label: &str,
1843
+ ) -> Result<SpawnResult, TransportError> {
1844
+ let command = worker_shell_wrapper_command(argv, cwd, env, env_unset, provider_label);
1845
+ self.spawn_with_command(session, window, &command, false)
1846
+ }
1847
+
1673
1848
  /// 0.4.x (CR C-2): TmuxBackend override of the leader-shell-wrapper
1674
1849
  /// variant. Builds the wrapper shell line via
1675
1850
  /// `leader_shell_wrapper_command` and runs it through
@@ -589,6 +589,44 @@ pub trait Transport: Send + Sync {
589
589
  self.spawn_into(session, window, argv, cwd, env)
590
590
  }
591
591
 
592
+ /// 0.5.39 Slice 2 (tmux-server-death-locate §7 Slice 2): worker spawn
593
+ /// variant that reuses the leader-wrapper mechanism so worker provider
594
+ /// exit does not collapse the pane into `[exited]` (which under
595
+ /// upstream tmux 3.6a private-server bugs can cascade into server
596
+ /// death). When the provider exits, the worker pane returns to an
597
+ /// interactive shell with an explicit worker exit marker — matching
598
+ /// manual `tmux new-window` then `<provider>` behaviour. Default falls
599
+ /// back to plain `spawn_first_with_env_unset` for backends that have
600
+ /// no shell layer (test-only `OfflineTransport`).
601
+ fn spawn_first_with_worker_shell_wrapper(
602
+ &self,
603
+ session: &SessionName,
604
+ window: &WindowName,
605
+ argv: &[String],
606
+ cwd: &Path,
607
+ env: &BTreeMap<String, String>,
608
+ env_unset: &[String],
609
+ provider_label: &str,
610
+ ) -> Result<SpawnResult, TransportError> {
611
+ let _ = provider_label;
612
+ self.spawn_first_with_env_unset(session, window, argv, cwd, env, env_unset)
613
+ }
614
+
615
+ /// 同 [`Transport::spawn_first_with_worker_shell_wrapper`],对应 `spawn_into`。
616
+ fn spawn_into_with_worker_shell_wrapper(
617
+ &self,
618
+ session: &SessionName,
619
+ window: &WindowName,
620
+ argv: &[String],
621
+ cwd: &Path,
622
+ env: &BTreeMap<String, String>,
623
+ env_unset: &[String],
624
+ provider_label: &str,
625
+ ) -> Result<SpawnResult, TransportError> {
626
+ let _ = provider_label;
627
+ self.spawn_into_with_env_unset(session, window, argv, cwd, env, env_unset)
628
+ }
629
+
592
630
  /// 0.4.x (CR C-2): leader-specific spawn variant. Instead of `exec <cmd>`
593
631
  /// (which makes the provider the pane's primary process and turns the
594
632
  /// pane into `[exited]` when the provider exits), build a shell line
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.38",
3
+ "version": "0.5.40",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.38",
24
- "@team-agent/cli-darwin-x64": "0.5.38",
25
- "@team-agent/cli-linux-x64": "0.5.38"
23
+ "@team-agent/cli-darwin-arm64": "0.5.40",
24
+ "@team-agent/cli-darwin-x64": "0.5.40",
25
+ "@team-agent/cli-linux-x64": "0.5.40"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",