@team-agent/installer 0.3.23 → 0.3.25

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.
@@ -213,8 +213,7 @@ fn spawn_agents(
213
213
  safety,
214
214
  )?;
215
215
  let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
216
- let mcp_team_id =
217
- runtime_active_team_key_for_spawn(workspace, spec_path, spec, session_name);
216
+ let mcp_team_id = runtime_team_key_for_spec(spec_path, spec, session_name);
218
217
  let mcp_config = adapter
219
218
  .mcp_config(auth_mode)
220
219
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
@@ -556,12 +555,26 @@ pub(crate) fn adaptive_placement_for_agent(
556
555
  if !state_uses_adaptive_layout(state) {
557
556
  return None;
558
557
  }
559
- let live_panes: BTreeSet<String> = transport
560
- .list_targets()
561
- .unwrap_or_default()
562
- .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()
563
567
  .map(|pane| pane.pane_id.as_str().to_string())
564
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();
565
578
  let live_windows: BTreeSet<String> = transport
566
579
  .list_windows(session_name)
567
580
  .unwrap_or_default()
@@ -582,10 +595,32 @@ pub(crate) fn adaptive_placement_for_agent(
582
595
  else {
583
596
  continue;
584
597
  };
585
- 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
586
611
  .get("pane_id")
587
- .and_then(serde_json::Value::as_str)
588
- .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
+ }
589
624
  if !pane_live && (!live_panes.is_empty() || !live_windows.contains(window)) {
590
625
  continue;
591
626
  }
@@ -612,6 +647,17 @@ pub(crate) fn adaptive_placement_for_agent(
612
647
  });
613
648
  }
614
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
+ }
615
661
  let next_index = windows.keys().next_back().map(|idx| idx + 1).unwrap_or(0);
616
662
  let base = format!("team-w{}", next_index + 1);
617
663
  Some(LayoutPlacement {
@@ -638,6 +684,14 @@ pub(crate) fn adaptive_existing_placement_for_agent(
638
684
  .or_else(|| agent.get("window"))
639
685
  .and_then(serde_json::Value::as_str)
640
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
+ }
641
695
  let layout_index = agent
642
696
  .get("layout_index")
643
697
  .and_then(serde_json::Value::as_u64)
@@ -656,12 +710,18 @@ pub(crate) fn adaptive_existing_placement_for_agent(
656
710
  .map(|window| window.as_str().to_string())
657
711
  .collect();
658
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.
659
719
  return Some(LayoutPlacement {
660
720
  agent_id: agent_id.clone(),
661
- layout_window: WindowName::new(window),
721
+ layout_window: WindowName::new(agent_id.as_str()),
662
722
  layout_index,
663
- pane_index: desired_pane_index,
664
- starts_window: desired_pane_index == 0,
723
+ pane_index: 0,
724
+ starts_window: true,
665
725
  });
666
726
  }
667
727
  let existing_panes = transport
@@ -693,6 +753,28 @@ fn parse_team_layout_index(window: &str) -> Option<usize> {
693
753
  .and_then(|idx| idx.checked_sub(1))
694
754
  }
695
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
+
696
778
  fn unique_layout_window_name(base: &str, live_windows: &BTreeSet<String>) -> WindowName {
697
779
  if !live_windows.contains(base) {
698
780
  return WindowName::new(base);
@@ -724,8 +806,7 @@ fn persist_spawn_agent_state(
724
806
  } else {
725
807
  serde_json::json!({"agents": {}})
726
808
  };
727
- let team_id = explicit_active_team_key(&state)
728
- .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name));
809
+ let team_id = runtime_team_key_for_spec(spec_path, spec, session_name);
729
810
  let worker_tmux_socket = launched_worker_tmux_socket(transport, workspace);
730
811
  drop_worker_pane_seeded_owner(&mut state, &team_id, started, worker_tmux_socket.as_deref());
731
812
  // Only persist running state for agents whose spawn still has a live target.
@@ -2330,18 +2411,6 @@ fn spec_team_id(spec: &Value) -> Option<String> {
2330
2411
  .or_else(|| spec.get("name").and_then(Value::as_str).map(str::to_string))
2331
2412
  }
2332
2413
 
2333
- fn runtime_active_team_key_for_spawn(
2334
- workspace: &Path,
2335
- spec_path: &Path,
2336
- spec: &Value,
2337
- session_name: &SessionName,
2338
- ) -> String {
2339
- load_runtime_state(workspace)
2340
- .ok()
2341
- .and_then(|state| explicit_active_team_key(&state))
2342
- .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name))
2343
- }
2344
-
2345
2414
  fn explicit_active_team_key(state: &serde_json::Value) -> Option<String> {
2346
2415
  state
2347
2416
  .get("active_team_key")
@@ -2931,7 +3000,13 @@ pub fn quick_start_with_transport_in_workspace_with_display(
2931
3000
  })
2932
3001
  }
2933
3002
 
2934
- 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) {
2935
3010
  let Some(endpoint) = transport.tmux_endpoint() else {
2936
3011
  return;
2937
3012
  };
@@ -3374,19 +3449,41 @@ pub fn add_agent(
3374
3449
  ) {
3375
3450
  Ok(selected) => selected,
3376
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));
3377
3463
  return add_agent_with_transport(
3378
3464
  workspace,
3379
3465
  agent_id,
3380
3466
  role_file_path,
3381
3467
  open_display,
3382
3468
  team,
3383
- &crate::tmux_backend::TmuxBackend::for_workspace(&team_workspace(workspace)),
3469
+ &transport,
3384
3470
  );
3385
3471
  }
3386
3472
  Err(error) => return Err(LifecycleError::TeamSelect(error.to_string())),
3387
3473
  };
3388
3474
  // E5 §3:compile_team 要角色定义目录(team_dir),不是 spec 落点(spec_workspace=runtime)。
3389
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));
3390
3487
  add_agent_with_transport_at_paths(
3391
3488
  &selected.run_workspace,
3392
3489
  &team_dir,
@@ -3394,7 +3491,7 @@ pub fn add_agent(
3394
3491
  role_file_path,
3395
3492
  open_display,
3396
3493
  Some(selected.team_key.as_str()),
3397
- &crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace),
3494
+ &transport,
3398
3495
  )
3399
3496
  }
3400
3497
 
@@ -3477,18 +3574,40 @@ fn add_agent_with_transport_at_paths(
3477
3574
  let safety = effective_runtime_config(&spec)?;
3478
3575
  // E5 spec 迁移:重编译的 spec 原子写到 .team/runtime/<team_key>/(不落用户目录 team_dir)。
3479
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();
3480
3587
  write_spec_atomic(&spec_path, &spec)?;
3481
3588
  let (meta, _) = crate::compiler::read_front_matter(role_file_path)
3482
3589
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
3483
- 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(
3484
3594
  run_workspace,
3485
3595
  &canonical_team_key,
3486
3596
  agent_id,
3487
3597
  &meta,
3488
3598
  role_file_path,
3489
3599
  &safety,
3490
- )?;
3491
- 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(
3492
3611
  run_workspace,
3493
3612
  team_dir,
3494
3613
  agent_id,
@@ -3497,13 +3616,32 @@ fn add_agent_with_transport_at_paths(
3497
3616
  true,
3498
3617
  Some(&canonical_team_key),
3499
3618
  transport,
3500
- )?;
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
+ };
3501
3632
  let (env, start_mode) = match started {
3502
3633
  StartAgentOutcome::Running {
3503
3634
  env, start_mode, ..
3504
3635
  } => (env, start_mode),
3505
3636
  StartAgentOutcome::Noop { env, .. } => (env, StartMode::Noop),
3506
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
+ );
3507
3645
  return Err(LifecycleError::RequirementUnmet(format!(
3508
3646
  "added agent {agent_id} is paused"
3509
3647
  )));
@@ -3516,6 +3654,40 @@ fn add_agent_with_transport_at_paths(
3516
3654
  })
3517
3655
  }
3518
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
+
3519
3691
  fn upsert_agent_state_from_role(
3520
3692
  workspace: &Path,
3521
3693
  canonical_team_key: &str,
@@ -3558,11 +3730,15 @@ fn upsert_agent_state_from_role(
3558
3730
  .get("role")
3559
3731
  .and_then(Value::as_str)
3560
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.
3561
3737
  let mut entry = serde_json::json!({
3562
3738
  "provider": provider,
3563
3739
  "auth_mode": auth_mode,
3564
3740
  "role": role,
3565
- "status": "running",
3741
+ "status": "starting",
3566
3742
  "dynamic_role_file": dynamic_role_file.to_string_lossy().to_string(),
3567
3743
  });
3568
3744
  if let Some(model) = meta.get("model").and_then(Value::as_str) {
@@ -3689,6 +3865,14 @@ pub fn fork_agent(
3689
3865
  crate::state::selector::SelectorMode::RequireSpec,
3690
3866
  )
3691
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));
3692
3876
  fork_agent_with_transport(
3693
3877
  workspace,
3694
3878
  source_agent_id,
@@ -3696,7 +3880,7 @@ pub fn fork_agent(
3696
3880
  label,
3697
3881
  open_display,
3698
3882
  team,
3699
- &crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace),
3883
+ &transport,
3700
3884
  )
3701
3885
  }
3702
3886
 
@@ -185,8 +185,33 @@ pub(crate) fn start_agent_at_paths(
185
185
  None,
186
186
  )?;
187
187
  mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety)?;
188
+ // **0.3.24 add-agent socket drift fix**: keep `state.tmux_endpoint` /
189
+ // `state.tmux_socket` synchronized with the transport actually used for the
190
+ // spawn. Without this, add-agent / fork-agent could spawn to a socket that
191
+ // never gets persisted, and the next coordinator tick would re-resolve to
192
+ // the workspace-hash socket and lose the new pane. Annotation runs inside
193
+ // the same `save_restart_projected_state` window — no parallel "annotate
194
+ // after spawn" race with coordinator and no double source of truth.
195
+ crate::lifecycle::launch::annotate_runtime_tmux_endpoint(&mut state, transport, workspace);
188
196
  let team_key = restart_projection_team_key(&state, team);
189
197
  save_restart_projected_state(workspace, &mut state, &team_key)?;
198
+ // **0.3.24 reachability gate (调整 4)** — strict, non-capture verification
199
+ // that the spawn actually produced an addressable window/pane on the
200
+ // transport's socket. Catches the macmini假绿 (`ok=true` returned by
201
+ // `add_agent` while the spawned `claude` process orphaned on a different
202
+ // socket and the leader never registered it). We use `has_pane` /
203
+ // `liveness` / `list_targets` (structural addressing only) — never
204
+ // `capture` — to avoid contention with E31's pane-readback gate timing.
205
+ if !spawned_pane_is_reachable(transport, &spawn.spawn.pane_id) {
206
+ return Err(LifecycleError::RequirementUnmet(format!(
207
+ "add-agent spawn unreachable: pane {} not addressable on transport \
208
+ socket {:?} (likely socket drift — see 0.3.24 fix); the agent \
209
+ process may have orphaned on a different tmux socket. Re-run after \
210
+ confirming the team's tmux_endpoint persisted via `team-agent status`.",
211
+ spawn.spawn.pane_id.as_str(),
212
+ transport.tmux_endpoint().unwrap_or_default()
213
+ )));
214
+ }
190
215
  write_start_agent_start_event(
191
216
  workspace,
192
217
  agent_id,
@@ -213,6 +238,42 @@ pub(crate) fn start_agent_at_paths(
213
238
  })
214
239
  }
215
240
 
241
+ /// **0.3.24 strict-non-capture reachability gate**. Returns `true` IFF the spawn's
242
+ /// pane is currently addressable on the transport's tmux socket. Uses structural
243
+ /// addressing only (`has_pane` → `liveness` → `list_targets`) — **never**
244
+ /// `capture` — so it does not race with E31's pane-readback gate timing
245
+ /// (E31 reads pane contents after submit; running our own capture here would
246
+ /// either consume the first-paste tokens E31 looks for, or vice versa).
247
+ ///
248
+ /// The gate fires after `mark_agent_started` + `save_restart_projected_state`,
249
+ /// so an unreachable pane returns a structured `LifecycleError` that the CLI
250
+ /// surface translates to `ok=false`, replacing the macmini假绿
251
+ /// (`ok=true` with orphaned-on-wrong-socket spawn).
252
+ fn spawned_pane_is_reachable(
253
+ transport: &dyn crate::transport::Transport,
254
+ pane: &crate::transport::PaneId,
255
+ ) -> bool {
256
+ // (a) authoritative: has_pane Some(true) → addressable.
257
+ if matches!(transport.has_pane(pane), Ok(Some(true))) {
258
+ return true;
259
+ }
260
+ // (b) liveness probe: not-Dead → addressable. Dead → unreachable.
261
+ if matches!(
262
+ transport.liveness(pane),
263
+ Ok(crate::model::enums::PaneLiveness::Live)
264
+ ) {
265
+ return true;
266
+ }
267
+ // (c) last resort: enumeration. If the pane appears in `list_targets()`
268
+ // (cross-session enumeration on the socket), it is addressable.
269
+ if let Ok(targets) = transport.list_targets() {
270
+ if targets.iter().any(|t| t.pane_id.as_str() == pane.as_str()) {
271
+ return true;
272
+ }
273
+ }
274
+ false
275
+ }
276
+
216
277
  fn agent_pane_live(
217
278
  transport: &dyn crate::transport::Transport,
218
279
  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,