@team-agent/installer 0.3.24 → 0.3.26

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 (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +1 -0
  4. package/crates/team-agent/src/cli/mod.rs +193 -7
  5. package/crates/team-agent/src/cli/send.rs +106 -0
  6. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  7. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +270 -13
  8. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +7 -0
  10. package/crates/team-agent/src/coordinator/tests/energy.rs +1 -0
  11. package/crates/team-agent/src/coordinator/tests/health_sync.rs +1 -0
  12. package/crates/team-agent/src/coordinator/tests/spine.rs +1 -0
  13. package/crates/team-agent/src/coordinator/tests/takeover.rs +1 -0
  14. package/crates/team-agent/src/coordinator/tick.rs +55 -7
  15. package/crates/team-agent/src/leader/lease.rs +57 -0
  16. package/crates/team-agent/src/leader/start.rs +1 -0
  17. package/crates/team-agent/src/lifecycle/launch.rs +217 -19
  18. package/crates/team-agent/src/lifecycle/restart/agent.rs +116 -0
  19. package/crates/team-agent/src/lifecycle/restart/common.rs +66 -1
  20. package/crates/team-agent/src/lifecycle/restart.rs +19 -2
  21. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +994 -0
  22. package/crates/team-agent/src/messaging/delivery.rs +91 -1
  23. package/crates/team-agent/src/messaging/helpers.rs +64 -24
  24. package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
  25. package/crates/team-agent/src/messaging/tests/spine.rs +158 -4
  26. package/crates/team-agent/src/tmux_backend/tests.rs +414 -2
  27. package/crates/team-agent/src/tmux_backend.rs +366 -2
  28. package/crates/team-agent/src/transport/test_support.rs +1 -0
  29. package/crates/team-agent/src/transport/tests/mod.rs +1 -0
  30. package/crates/team-agent/src/transport/tests/wire.rs +2 -1
  31. package/crates/team-agent/src/transport.rs +103 -1
  32. package/npm/install.mjs +312 -39
  33. package/package.json +4 -4
@@ -555,12 +555,26 @@ pub(crate) fn adaptive_placement_for_agent(
555
555
  if !state_uses_adaptive_layout(state) {
556
556
  return None;
557
557
  }
558
- let live_panes: BTreeSet<String> = transport
559
- .list_targets()
560
- .unwrap_or_default()
561
- .into_iter()
558
+ // E43 Fix A (0.3.24 bug#3, demo-director blocker): cross-check live panes
559
+ // by both pane_id AND window_name. Real-machine state can carry a stale
560
+ // `layout_window` residue (e.g. "team-w2") while the live pane for that
561
+ // agent actually lives under a DIFFERENT window_name (e.g. "architect").
562
+ // The prior pane_id-only check let the stale claim slip through, and the
563
+ // resulting placement asked spawn_agent_window to split a phantom window.
564
+ let live_targets = transport.list_targets().unwrap_or_default();
565
+ let live_panes: BTreeSet<String> = live_targets
566
+ .iter()
562
567
  .map(|pane| pane.pane_id.as_str().to_string())
563
568
  .collect();
569
+ // Map pane_id → window_name for the window-name cross-check.
570
+ let live_pane_window: BTreeMap<String, String> = live_targets
571
+ .iter()
572
+ .filter_map(|pane| {
573
+ pane.window_name
574
+ .as_ref()
575
+ .map(|name| (pane.pane_id.as_str().to_string(), name.as_str().to_string()))
576
+ })
577
+ .collect();
564
578
  let live_windows: BTreeSet<String> = transport
565
579
  .list_windows(session_name)
566
580
  .unwrap_or_default()
@@ -581,10 +595,32 @@ pub(crate) fn adaptive_placement_for_agent(
581
595
  else {
582
596
  continue;
583
597
  };
584
- let pane_live = agent
598
+ // E45 (0.3.24 bug#4): only canonical `team-w<N>[-suffix]` windows
599
+ // count as adaptive layout windows. Per-agent windows (named
600
+ // after agent_id like `developer`) are NOT layout windows even if
601
+ // the state's `layout_window`/`layout_index` carries them — those
602
+ // are residue from a prior launch or explicit per-agent topology.
603
+ // Treating them as adaptive caused add-agent to split into the
604
+ // developer worker's pane on macmini (split-window -t :developer
605
+ // succeeded → 2 panes in developer window, no new window for
606
+ // demo-director).
607
+ if !is_adaptive_layout_window(window) {
608
+ continue;
609
+ }
610
+ let pane_id = agent
585
611
  .get("pane_id")
586
- .and_then(serde_json::Value::as_str)
587
- .is_some_and(|pane| live_panes.contains(pane));
612
+ .and_then(serde_json::Value::as_str);
613
+ let pane_live = pane_id.is_some_and(|pane| live_panes.contains(pane));
614
+ // E43 Fix A: window_name match — when the agent's pane is live,
615
+ // its live window_name MUST equal the claimed `window`; otherwise
616
+ // the claim is stale residue from a respawn/rename and must not
617
+ // count toward the layout map.
618
+ let pane_window_matches = pane_id
619
+ .and_then(|pane| live_pane_window.get(pane))
620
+ .is_some_and(|name| name == window);
621
+ if pane_live && !pane_window_matches {
622
+ continue;
623
+ }
588
624
  if !pane_live && (!live_panes.is_empty() || !live_windows.contains(window)) {
589
625
  continue;
590
626
  }
@@ -611,6 +647,17 @@ pub(crate) fn adaptive_placement_for_agent(
611
647
  });
612
648
  }
613
649
  }
650
+ // E45 (0.3.24 bug#4): when the live session has NO real adaptive layout
651
+ // window (the topology is effectively per-agent, even though state says
652
+ // display_backend=adaptive), DO NOT synthesise a fresh `team-w<N>`
653
+ // window — that would force the new agent into an adaptive-layout pane
654
+ // shape the rest of the session does not actually use. Return None so
655
+ // the caller (`start_agent_at_paths` → `spawn_agent_window`) falls back
656
+ // to its non-placement path, which opens a new window named after the
657
+ // agent_id (canonical per-agent pattern, matches the existing 7 workers).
658
+ if windows.is_empty() {
659
+ return None;
660
+ }
614
661
  let next_index = windows.keys().next_back().map(|idx| idx + 1).unwrap_or(0);
615
662
  let base = format!("team-w{}", next_index + 1);
616
663
  Some(LayoutPlacement {
@@ -637,6 +684,14 @@ pub(crate) fn adaptive_existing_placement_for_agent(
637
684
  .or_else(|| agent.get("window"))
638
685
  .and_then(serde_json::Value::as_str)
639
686
  .filter(|window| !window.is_empty())?;
687
+ // E45 (0.3.24 bug#4): existing-placement is meaningless for a per-agent
688
+ // window name (e.g. `developer`). Return None and let the caller fall
689
+ // back to its non-placement spawn path, which opens / reuses a window
690
+ // named after the agent_id. Only canonical `team-w<N>` windows are
691
+ // honored as existing adaptive placements.
692
+ if !is_adaptive_layout_window(window) {
693
+ return None;
694
+ }
640
695
  let layout_index = agent
641
696
  .get("layout_index")
642
697
  .and_then(serde_json::Value::as_u64)
@@ -655,12 +710,18 @@ pub(crate) fn adaptive_existing_placement_for_agent(
655
710
  .map(|window| window.as_str().to_string())
656
711
  .collect();
657
712
  if !live_windows.contains(window) {
713
+ // E43 Fix B (0.3.24 bug#3): the claimed `layout_window` is NOT in
714
+ // live_windows — it's stale residue. The session is effectively
715
+ // per-agent (live windows are named after agent_ids), so fall back
716
+ // to a new window named after the agent_id itself instead of
717
+ // synthesising a fresh phantom-named window the next spawn would
718
+ // try (and fail) to split.
658
719
  return Some(LayoutPlacement {
659
720
  agent_id: agent_id.clone(),
660
- layout_window: WindowName::new(window),
721
+ layout_window: WindowName::new(agent_id.as_str()),
661
722
  layout_index,
662
- pane_index: desired_pane_index,
663
- starts_window: desired_pane_index == 0,
723
+ pane_index: 0,
724
+ starts_window: true,
664
725
  });
665
726
  }
666
727
  let existing_panes = transport
@@ -692,6 +753,28 @@ fn parse_team_layout_index(window: &str) -> Option<usize> {
692
753
  .and_then(|idx| idx.checked_sub(1))
693
754
  }
694
755
 
756
+ /// E45 (0.3.24 bug#4, demo-director second-layer drift): a window name is a
757
+ /// REAL adaptive layout window only when it matches the canonical
758
+ /// `team-w<N>[-suffix]` shape (i.e. `parse_team_layout_index` returns Some).
759
+ /// Per-agent window names like `developer` / `architect` / `demo-director`
760
+ /// are NOT adaptive layout windows even if state happens to carry them in
761
+ /// the agent's `layout_window` or `layout_index` field — they are residue
762
+ /// from a prior launch / explicit per-agent topology. Treating them as
763
+ /// adaptive caused the macmini repro: add-agent demo-director split into
764
+ /// the developer worker's window @453 instead of opening its own.
765
+ fn is_adaptive_layout_window(window: &str) -> bool {
766
+ parse_team_layout_index(window).is_some()
767
+ }
768
+
769
+ /// Crate-public wrapper for the defensive guard at
770
+ /// `restart/common.rs::spawn_agent_window`. Same semantics as the private
771
+ /// helper above; promoted to `pub(crate)` so the spawn-time defence-in-depth
772
+ /// layer can refuse to split into a per-agent window even if a stale
773
+ /// placement asks for it.
774
+ pub(crate) fn is_adaptive_layout_window_pub(window: &str) -> bool {
775
+ is_adaptive_layout_window(window)
776
+ }
777
+
695
778
  fn unique_layout_window_name(base: &str, live_windows: &BTreeSet<String>) -> WindowName {
696
779
  if !live_windows.contains(base) {
697
780
  return WindowName::new(base);
@@ -2917,7 +3000,13 @@ pub fn quick_start_with_transport_in_workspace_with_display(
2917
3000
  })
2918
3001
  }
2919
3002
 
2920
- fn annotate_runtime_tmux_endpoint(state: &mut serde_json::Value, transport: &dyn Transport, workspace: &Path) {
3003
+ /// Annotate `state.tmux_endpoint` / `state.tmux_socket` (and `tmux_socket_source`)
3004
+ /// from the active transport. Originally only called at `launch_with_transport`
3005
+ /// init time; **`0.3.24` opened this to `pub(crate)` so restart/add/fork can keep
3006
+ /// the persisted endpoint synchronized with the transport they actually used,
3007
+ /// closing the silent socket-drift gap** (single state-save path; no parallel
3008
+ /// "annotate after spawn" race with coordinator).
3009
+ pub(crate) fn annotate_runtime_tmux_endpoint(state: &mut serde_json::Value, transport: &dyn Transport, workspace: &Path) {
2921
3010
  let Some(endpoint) = transport.tmux_endpoint() else {
2922
3011
  return;
2923
3012
  };
@@ -3360,19 +3449,41 @@ pub fn add_agent(
3360
3449
  ) {
3361
3450
  Ok(selected) => selected,
3362
3451
  Err(_) if workspace.join("TEAM.md").exists() => {
3452
+ // **0.3.24 add-agent socket drift fix**: even on the TEAM.md fallback
3453
+ // path (no spec yet), prefer the state-aware resolver. It reads the
3454
+ // team's persisted `tmux_endpoint` (set at `team-agent launch` time)
3455
+ // and routes the new agent's spawn to the SAME tmux socket the live
3456
+ // team uses. Cold workspaces / first-agent paths safely fall back to
3457
+ // `TmuxBackend::for_workspace(team_workspace)` inside the resolver.
3458
+ let team_ws = team_workspace(workspace);
3459
+ let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
3460
+ &team_ws, team,
3461
+ )
3462
+ .unwrap_or_else(|_| crate::tmux_backend::TmuxBackend::for_workspace(&team_ws));
3363
3463
  return add_agent_with_transport(
3364
3464
  workspace,
3365
3465
  agent_id,
3366
3466
  role_file_path,
3367
3467
  open_display,
3368
3468
  team,
3369
- &crate::tmux_backend::TmuxBackend::for_workspace(&team_workspace(workspace)),
3469
+ &transport,
3370
3470
  );
3371
3471
  }
3372
3472
  Err(error) => return Err(LifecycleError::TeamSelect(error.to_string())),
3373
3473
  };
3374
3474
  // E5 §3:compile_team 要角色定义目录(team_dir),不是 spec 落点(spec_workspace=runtime)。
3375
3475
  let team_dir = selected.team_dir;
3476
+ // **0.3.24 add-agent socket drift fix**: route to the live team's persisted
3477
+ // tmux endpoint (NOT the workspace-hash for_workspace socket). Without this,
3478
+ // `add-agent` spawns into an orphan socket (e.g. `ta-<hash>/termclaud`) while
3479
+ // the live team runs on its persisted default socket — the leader can't see
3480
+ // the new window, state never registers, and the orphaned `claude` process
3481
+ // floats forever (macmini repro: `demo-director` startup blocker).
3482
+ let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
3483
+ &selected.run_workspace,
3484
+ Some(selected.team_key.as_str()),
3485
+ )
3486
+ .unwrap_or_else(|_| crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace));
3376
3487
  add_agent_with_transport_at_paths(
3377
3488
  &selected.run_workspace,
3378
3489
  &team_dir,
@@ -3380,7 +3491,7 @@ pub fn add_agent(
3380
3491
  role_file_path,
3381
3492
  open_display,
3382
3493
  Some(selected.team_key.as_str()),
3383
- &crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace),
3494
+ &transport,
3384
3495
  )
3385
3496
  }
3386
3497
 
@@ -3463,18 +3574,40 @@ fn add_agent_with_transport_at_paths(
3463
3574
  let safety = effective_runtime_config(&spec)?;
3464
3575
  // E5 spec 迁移:重编译的 spec 原子写到 .team/runtime/<team_key>/(不落用户目录 team_dir)。
3465
3576
  let spec_path = crate::model::paths::runtime_spec_path(run_workspace, &canonical_team_key);
3577
+ // E42 (0.3.24 P0): capture pre-write bytes for atomic rollback. If anything
3578
+ // downstream of write_spec_atomic + upsert_agent_state_from_role + spawn
3579
+ // fails, restore the prior bytes so the canonical spec / runtime state never
3580
+ // get a half-written row that disagrees with what remove-agent can see.
3581
+ let pre_spec_text = match std::fs::read_to_string(&spec_path) {
3582
+ Ok(text) => Some(text),
3583
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
3584
+ Err(e) => return Err(LifecycleError::StatePersist(format!("read spec: {e}"))),
3585
+ };
3586
+ let pre_runtime_state = crate::state::persist::load_runtime_state(run_workspace).ok();
3466
3587
  write_spec_atomic(&spec_path, &spec)?;
3467
3588
  let (meta, _) = crate::compiler::read_front_matter(role_file_path)
3468
3589
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
3469
- upsert_agent_state_from_role(
3590
+ // upsert writes status="starting" (E42) — start_agent_at_paths::mark_agent_started
3591
+ // promotes to "running" on Ok. If anything fails between here and the Ok
3592
+ // return below, rollback restores the captured pre-bytes.
3593
+ if let Err(error) = upsert_agent_state_from_role(
3470
3594
  run_workspace,
3471
3595
  &canonical_team_key,
3472
3596
  agent_id,
3473
3597
  &meta,
3474
3598
  role_file_path,
3475
3599
  &safety,
3476
- )?;
3477
- let started = crate::lifecycle::restart::start_agent_at_paths(
3600
+ ) {
3601
+ rollback_add_agent_atomic(
3602
+ run_workspace,
3603
+ &spec_path,
3604
+ pre_spec_text.as_deref(),
3605
+ pre_runtime_state.as_ref(),
3606
+ agent_id,
3607
+ );
3608
+ return Err(error);
3609
+ }
3610
+ let started = match crate::lifecycle::restart::start_agent_at_paths(
3478
3611
  run_workspace,
3479
3612
  team_dir,
3480
3613
  agent_id,
@@ -3483,13 +3616,32 @@ fn add_agent_with_transport_at_paths(
3483
3616
  true,
3484
3617
  Some(&canonical_team_key),
3485
3618
  transport,
3486
- )?;
3619
+ ) {
3620
+ Ok(started) => started,
3621
+ Err(error) => {
3622
+ rollback_add_agent_atomic(
3623
+ run_workspace,
3624
+ &spec_path,
3625
+ pre_spec_text.as_deref(),
3626
+ pre_runtime_state.as_ref(),
3627
+ agent_id,
3628
+ );
3629
+ return Err(error);
3630
+ }
3631
+ };
3487
3632
  let (env, start_mode) = match started {
3488
3633
  StartAgentOutcome::Running {
3489
3634
  env, start_mode, ..
3490
3635
  } => (env, start_mode),
3491
3636
  StartAgentOutcome::Noop { env, .. } => (env, StartMode::Noop),
3492
3637
  StartAgentOutcome::Paused { .. } => {
3638
+ rollback_add_agent_atomic(
3639
+ run_workspace,
3640
+ &spec_path,
3641
+ pre_spec_text.as_deref(),
3642
+ pre_runtime_state.as_ref(),
3643
+ agent_id,
3644
+ );
3493
3645
  return Err(LifecycleError::RequirementUnmet(format!(
3494
3646
  "added agent {agent_id} is paused"
3495
3647
  )));
@@ -3502,6 +3654,40 @@ fn add_agent_with_transport_at_paths(
3502
3654
  })
3503
3655
  }
3504
3656
 
3657
+ /// E42 (0.3.24 P0, double-spec deadlock): best-effort atomic rollback for a
3658
+ /// failed add-agent. Restores the canonical spec to its pre-write bytes (or
3659
+ /// removes the file if it didn't exist), and restores runtime state to its
3660
+ /// pre-write JSON (so the half-written `status:starting` row is gone). The
3661
+ /// caller propagates the ORIGINAL operation error after rollback; rollback
3662
+ /// errors are swallowed (best-effort, no panic).
3663
+ fn rollback_add_agent_atomic(
3664
+ run_workspace: &Path,
3665
+ spec_path: &Path,
3666
+ pre_spec_text: Option<&str>,
3667
+ pre_runtime_state: Option<&serde_json::Value>,
3668
+ agent_id: &AgentId,
3669
+ ) {
3670
+ if let Some(text) = pre_spec_text {
3671
+ let _ = std::fs::write(spec_path, text);
3672
+ } else {
3673
+ let _ = std::fs::remove_file(spec_path);
3674
+ }
3675
+ if let Some(state) = pre_runtime_state {
3676
+ let _ = crate::state::persist::save_runtime_state(run_workspace, state);
3677
+ } else {
3678
+ // No prior runtime state — drop just the agent we added (load → strip → save).
3679
+ if let Ok(mut state) = crate::state::persist::load_runtime_state(run_workspace) {
3680
+ if let Some(agents) = state
3681
+ .get_mut("agents")
3682
+ .and_then(serde_json::Value::as_object_mut)
3683
+ {
3684
+ agents.remove(agent_id.as_str());
3685
+ }
3686
+ let _ = crate::state::persist::save_runtime_state(run_workspace, &state);
3687
+ }
3688
+ }
3689
+ }
3690
+
3505
3691
  fn upsert_agent_state_from_role(
3506
3692
  workspace: &Path,
3507
3693
  canonical_team_key: &str,
@@ -3544,11 +3730,15 @@ fn upsert_agent_state_from_role(
3544
3730
  .get("role")
3545
3731
  .and_then(Value::as_str)
3546
3732
  .unwrap_or_else(|| agent_id.as_str());
3733
+ // E42 (0.3.24 P0, double-spec deadlock): persist the initial state row as
3734
+ // "starting" (not "running"). The caller (add_agent_with_transport_at_paths)
3735
+ // promotes to "running" only after start_agent_at_paths returns Running.
3736
+ // If the spawn fails, the rollback below removes the entry entirely.
3547
3737
  let mut entry = serde_json::json!({
3548
3738
  "provider": provider,
3549
3739
  "auth_mode": auth_mode,
3550
3740
  "role": role,
3551
- "status": "running",
3741
+ "status": "starting",
3552
3742
  "dynamic_role_file": dynamic_role_file.to_string_lossy().to_string(),
3553
3743
  });
3554
3744
  if let Some(model) = meta.get("model").and_then(Value::as_str) {
@@ -3675,6 +3865,14 @@ pub fn fork_agent(
3675
3865
  crate::state::selector::SelectorMode::RequireSpec,
3676
3866
  )
3677
3867
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
3868
+ // **0.3.24 add-agent socket drift fix** (same root cause): fork-agent must
3869
+ // also route to the live team's persisted tmux endpoint, not the workspace-
3870
+ // hash for_workspace socket. Same orphan-on-wrong-socket pathology.
3871
+ let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
3872
+ &selected.run_workspace,
3873
+ Some(selected.team_key.as_str()),
3874
+ )
3875
+ .unwrap_or_else(|_| crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace));
3678
3876
  fork_agent_with_transport(
3679
3877
  workspace,
3680
3878
  source_agent_id,
@@ -3682,7 +3880,7 @@ pub fn fork_agent(
3682
3880
  label,
3683
3881
  open_display,
3684
3882
  team,
3685
- &crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace),
3883
+ &transport,
3686
3884
  )
3687
3885
  }
3688
3886
 
@@ -95,6 +95,16 @@ pub(crate) fn start_agent_at_paths(
95
95
  } else {
96
96
  window_exists(transport, &session_name, &window)
97
97
  };
98
+ // E51 (0.3.26 P0, restart self-heal): even if the pane is structurally
99
+ // alive, it must not be considered "this agent's pane" if it's actually
100
+ // the leader anchor pane or another agent's pane. When lease corruption
101
+ // (E51 root cause) left `agent.pane_id == leader_receiver.pane_id`,
102
+ // start_agent would short-circuit to Noop (the pane IS live — it's just
103
+ // the wrong pane). Force a fresh spawn by treating the pane as not-live
104
+ // when it conflicts with the leader or a different agent.
105
+ let agent_live = agent_live && !pane_conflicts_with_leader_or_other(
106
+ &state, agent_id, &agent,
107
+ );
98
108
  if !force && agent_live {
99
109
  mark_agent_running_noop(&mut state, agent_id, &session_name, &window)?;
100
110
  let team_key = restart_projection_team_key(&state, team);
@@ -185,8 +195,33 @@ pub(crate) fn start_agent_at_paths(
185
195
  None,
186
196
  )?;
187
197
  mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety)?;
198
+ // **0.3.24 add-agent socket drift fix**: keep `state.tmux_endpoint` /
199
+ // `state.tmux_socket` synchronized with the transport actually used for the
200
+ // spawn. Without this, add-agent / fork-agent could spawn to a socket that
201
+ // never gets persisted, and the next coordinator tick would re-resolve to
202
+ // the workspace-hash socket and lose the new pane. Annotation runs inside
203
+ // the same `save_restart_projected_state` window — no parallel "annotate
204
+ // after spawn" race with coordinator and no double source of truth.
205
+ crate::lifecycle::launch::annotate_runtime_tmux_endpoint(&mut state, transport, workspace);
188
206
  let team_key = restart_projection_team_key(&state, team);
189
207
  save_restart_projected_state(workspace, &mut state, &team_key)?;
208
+ // **0.3.24 reachability gate (调整 4)** — strict, non-capture verification
209
+ // that the spawn actually produced an addressable window/pane on the
210
+ // transport's socket. Catches the macmini假绿 (`ok=true` returned by
211
+ // `add_agent` while the spawned `claude` process orphaned on a different
212
+ // socket and the leader never registered it). We use `has_pane` /
213
+ // `liveness` / `list_targets` (structural addressing only) — never
214
+ // `capture` — to avoid contention with E31's pane-readback gate timing.
215
+ if !spawned_pane_is_reachable(transport, &spawn.spawn.pane_id) {
216
+ return Err(LifecycleError::RequirementUnmet(format!(
217
+ "add-agent spawn unreachable: pane {} not addressable on transport \
218
+ socket {:?} (likely socket drift — see 0.3.24 fix); the agent \
219
+ process may have orphaned on a different tmux socket. Re-run after \
220
+ confirming the team's tmux_endpoint persisted via `team-agent status`.",
221
+ spawn.spawn.pane_id.as_str(),
222
+ transport.tmux_endpoint().unwrap_or_default()
223
+ )));
224
+ }
190
225
  write_start_agent_start_event(
191
226
  workspace,
192
227
  agent_id,
@@ -213,6 +248,87 @@ pub(crate) fn start_agent_at_paths(
213
248
  })
214
249
  }
215
250
 
251
+ /// **0.3.24 strict-non-capture reachability gate**. Returns `true` IFF the spawn's
252
+ /// pane is currently addressable on the transport's tmux socket. Uses structural
253
+ /// addressing only (`has_pane` → `liveness` → `list_targets`) — **never**
254
+ /// `capture` — so it does not race with E31's pane-readback gate timing
255
+ /// (E31 reads pane contents after submit; running our own capture here would
256
+ /// either consume the first-paste tokens E31 looks for, or vice versa).
257
+ ///
258
+ /// The gate fires after `mark_agent_started` + `save_restart_projected_state`,
259
+ /// so an unreachable pane returns a structured `LifecycleError` that the CLI
260
+ /// surface translates to `ok=false`, replacing the macmini假绿
261
+ /// (`ok=true` with orphaned-on-wrong-socket spawn).
262
+ fn spawned_pane_is_reachable(
263
+ transport: &dyn crate::transport::Transport,
264
+ pane: &crate::transport::PaneId,
265
+ ) -> bool {
266
+ // (a) authoritative: has_pane Some(true) → addressable.
267
+ if matches!(transport.has_pane(pane), Ok(Some(true))) {
268
+ return true;
269
+ }
270
+ // (b) liveness probe: not-Dead → addressable. Dead → unreachable.
271
+ if matches!(
272
+ transport.liveness(pane),
273
+ Ok(crate::model::enums::PaneLiveness::Live)
274
+ ) {
275
+ return true;
276
+ }
277
+ // (c) last resort: enumeration. If the pane appears in `list_targets()`
278
+ // (cross-session enumeration on the socket), it is addressable.
279
+ if let Ok(targets) = transport.list_targets() {
280
+ if targets.iter().any(|t| t.pane_id.as_str() == pane.as_str()) {
281
+ return true;
282
+ }
283
+ }
284
+ false
285
+ }
286
+
287
+ /// E51 (0.3.26 P0, restart self-heal): returns `true` when the agent's pane_id
288
+ /// is the same as the leader_receiver/team_owner pane_id OR is owned by a
289
+ /// different agent in the state. In both cases `start_agent` must NOT treat the
290
+ /// pane as "this agent's live pane" (it should spawn fresh).
291
+ fn pane_conflicts_with_leader_or_other(
292
+ state: &serde_json::Value,
293
+ agent_id: &crate::model::ids::AgentId,
294
+ agent: &serde_json::Value,
295
+ ) -> bool {
296
+ let Some(pane_id) = agent
297
+ .get("pane_id")
298
+ .and_then(serde_json::Value::as_str)
299
+ .filter(|s| !s.is_empty())
300
+ else {
301
+ return false;
302
+ };
303
+ // Check leader anchor.
304
+ for key in ["leader_receiver", "team_owner"] {
305
+ if state
306
+ .get(key)
307
+ .and_then(|v| v.get("pane_id"))
308
+ .and_then(serde_json::Value::as_str)
309
+ .is_some_and(|lp| lp == pane_id)
310
+ {
311
+ return true;
312
+ }
313
+ }
314
+ // Check other agents.
315
+ if let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) {
316
+ for (id, other) in agents {
317
+ if id == agent_id.as_str() {
318
+ continue;
319
+ }
320
+ if other
321
+ .get("pane_id")
322
+ .and_then(serde_json::Value::as_str)
323
+ .is_some_and(|op| op == pane_id)
324
+ {
325
+ return true;
326
+ }
327
+ }
328
+ }
329
+ false
330
+ }
331
+
216
332
  fn agent_pane_live(
217
333
  transport: &dyn crate::transport::Transport,
218
334
  agent: &serde_json::Value,
@@ -157,6 +157,29 @@ pub(super) fn spawn_agent_window(
157
157
  &env_unset,
158
158
  )
159
159
  }
160
+ } else if !window_present_in_live(transport, session_name, &window)
161
+ || !crate::lifecycle::launch::is_adaptive_layout_window_pub(window.as_str())
162
+ {
163
+ // E43 Fix C + E45 (0.3.24 bug#3 → bug#4): never split into a
164
+ // window that either does not exist on live tmux OR is a
165
+ // per-agent window (`developer`, `architect`, ...) that the
166
+ // upstream placement guards should have refused. This is the
167
+ // defence-in-depth layer; the primary fix is in
168
+ // `adaptive_placement_for_agent` / `adaptive_existing_placement_for_agent`,
169
+ // but a placement built from stale `pane_index>0` state can
170
+ // still ask to split a per-agent window — and the macmini repro
171
+ // showed split-window -t :developer would otherwise succeed and
172
+ // hijack the developer worker's pane. Downgrade to spawn_into
173
+ // (new window named after agent_id) — canonical per-agent
174
+ // fallback the existing 7 workers use.
175
+ transport.spawn_into_with_env_unset(
176
+ session_name,
177
+ &WindowName::new(agent_id.as_str()),
178
+ &plan.argv,
179
+ spawn_cwd,
180
+ &env,
181
+ &env_unset,
182
+ )
160
183
  } else {
161
184
  transport.spawn_split_with_env_unset(
162
185
  session_name,
@@ -211,6 +234,33 @@ pub(super) fn spawn_agent_window(
211
234
  })
212
235
  }
213
236
 
237
+ /// E43 Fix C helper (0.3.24 bug#3): probe live tmux for a window's existence
238
+ /// before issuing `split-window -t :<window>`. Uses `list_windows` first
239
+ /// (cheaper, authoritative when present); falls back to `list_targets` so
240
+ /// transports that don't seed `windows` directly still surface real entries.
241
+ fn window_present_in_live(
242
+ transport: &dyn crate::transport::Transport,
243
+ session: &SessionName,
244
+ window: &WindowName,
245
+ ) -> bool {
246
+ if let Ok(windows) = transport.list_windows(session) {
247
+ if windows.iter().any(|w| w.as_str() == window.as_str()) {
248
+ return true;
249
+ }
250
+ }
251
+ if let Ok(targets) = transport.list_targets() {
252
+ if targets.iter().any(|t| {
253
+ t.session.as_str() == session.as_str()
254
+ && t.window_name
255
+ .as_ref()
256
+ .is_some_and(|n| n.as_str() == window.as_str())
257
+ }) {
258
+ return true;
259
+ }
260
+ }
261
+ false
262
+ }
263
+
214
264
  pub(super) fn start_coordinator_for_workspace(workspace: &Path) -> Result<bool, LifecycleError> {
215
265
  let workspace = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
216
266
  crate::coordinator::start_coordinator(&workspace)
@@ -218,7 +268,22 @@ pub(super) fn start_coordinator_for_workspace(workspace: &Path) -> Result<bool,
218
268
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
219
269
  }
220
270
 
221
- pub(super) fn lifecycle_worker_tmux_backend_for_selected_state(
271
+ /// State-aware tmux backend resolver. Reads the team's persisted
272
+ /// `tmux_endpoint` (set at `team-agent launch` time and shared across
273
+ /// restart/add-agent/fork-agent) and constructs a TmuxBackend on THAT socket,
274
+ /// so add-agent / fork-agent / restart all spawn into the SAME tmux socket
275
+ /// the live team already runs on.
276
+ ///
277
+ /// First-agent / cold workspace (no persisted endpoint) safely falls back to
278
+ /// `TmuxBackend::for_workspace(run_workspace)` — the canonical workspace-hash
279
+ /// socket. No panic, no None.
280
+ ///
281
+ /// **Exposed `pub(crate)` for `lifecycle::launch::add_agent` / `fork_agent`
282
+ /// (`0.3.24 add-agent socket drift fix`). Previously `pub(super)` and shared
283
+ /// only within `lifecycle::restart`. Sharing the resolver across the lifecycle
284
+ /// module is the correct ownership: restart/add/fork all need the SAME socket
285
+ /// the live team uses, and duplicating the lookup invited drift.**
286
+ pub(crate) fn lifecycle_worker_tmux_backend_for_selected_state(
222
287
  run_workspace: &Path,
223
288
  team: Option<&str>,
224
289
  ) -> Result<crate::tmux_backend::TmuxBackend, LifecycleError> {
@@ -34,6 +34,10 @@ mod team_state;
34
34
  pub use agent::{reset_agent, reset_agent_with_transport, start_agent, start_agent_with_transport, stop_agent, stop_agent_with_transport};
35
35
  pub(crate) use agent::start_agent_at_paths;
36
36
  pub(crate) use common::refresh_missing_provider_sessions;
37
+ // 0.3.24 add-agent socket drift fix: state-aware tmux resolver shared with
38
+ // `lifecycle::launch::add_agent` / `fork_agent` so all three (restart / add / fork)
39
+ // route to the SAME tmux socket the live team uses.
40
+ pub(crate) use common::lifecycle_worker_tmux_backend_for_selected_state;
37
41
  pub use orchestrator::{halt_plan, plan_status};
38
42
  pub use rebuild::{
39
43
  restart, restart_candidates, restart_with_session_convergence_deadline, restart_with_transport,
@@ -66,8 +70,21 @@ fn lifecycle_paths(workspace: &Path, team: Option<&str>) -> Result<LifecyclePath
66
70
  crate::state::selector::SelectorMode::RequireSpec,
67
71
  )
68
72
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
69
- let spec_workspace = selected_state_spec_workspace(&selected.state)
70
- .or(selected.spec_workspace)
73
+ // E42 (0.3.24 P0, double-spec deadlock): canonical-first. The selector at
74
+ // state/selector.rs:71-74 already sets `selected.spec_workspace` to the
75
+ // canonical runtime spec parent (.team/runtime/<key>/) whenever the
76
+ // runtime spec exists; only when the runtime spec is missing does it fall
77
+ // back to the legacy user-dir spec_workspace. Honoring that first stops
78
+ // remove-agent / stop-agent / reset-agent / fork-agent from reading a
79
+ // stale `state.spec_path` (legacy `.team/<key>/team.spec.yaml`) that
80
+ // disagrees with what add-agent writes to canonical
81
+ // (lifecycle/launch.rs:3576-3577). Pre-fix order let the legacy stale
82
+ // spec win and remove-agent couldn't see agents add-agent had just
83
+ // added — the macmini e46-probe deadlock truth source.
84
+ let spec_workspace = selected
85
+ .spec_workspace
86
+ .clone()
87
+ .or_else(|| selected_state_spec_workspace(&selected.state))
71
88
  .ok_or_else(|| LifecycleError::TeamSelect("active team spec workspace not found".to_string()))?;
72
89
  Ok(LifecyclePaths {
73
90
  run_workspace: selected.run_workspace,