@team-agent/installer 0.5.39 → 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.39"
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.39"
12
+ version = "0.5.40"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -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,
@@ -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,
@@ -3073,6 +3155,32 @@ fn load_endpoint_convergence_runtime_spec(
3073
3155
  Ok(Some(spec))
3074
3156
  }
3075
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
+
3076
3184
  fn has_endpoint_convergence_marker(state: &serde_json::Value) -> bool {
3077
3185
  state
3078
3186
  .get("topology_convergence")
@@ -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**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.39",
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.39",
24
- "@team-agent/cli-darwin-x64": "0.5.39",
25
- "@team-agent/cli-linux-x64": "0.5.39"
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",