@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +1 -0
- package/crates/team-agent/src/cli/mod.rs +193 -7
- package/crates/team-agent/src/cli/send.rs +106 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +270 -13
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +7 -0
- package/crates/team-agent/src/coordinator/tests/energy.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/spine.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/takeover.rs +1 -0
- package/crates/team-agent/src/coordinator/tick.rs +55 -7
- package/crates/team-agent/src/leader/lease.rs +57 -0
- package/crates/team-agent/src/leader/start.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch.rs +217 -19
- package/crates/team-agent/src/lifecycle/restart/agent.rs +116 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +66 -1
- package/crates/team-agent/src/lifecycle/restart.rs +19 -2
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +994 -0
- package/crates/team-agent/src/messaging/delivery.rs +91 -1
- package/crates/team-agent/src/messaging/helpers.rs +64 -24
- package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +158 -4
- package/crates/team-agent/src/tmux_backend/tests.rs +414 -2
- package/crates/team-agent/src/tmux_backend.rs +366 -2
- package/crates/team-agent/src/transport/test_support.rs +1 -0
- package/crates/team-agent/src/transport/tests/mod.rs +1 -0
- package/crates/team-agent/src/transport/tests/wire.rs +2 -1
- package/crates/team-agent/src/transport.rs +103 -1
- package/npm/install.mjs +312 -39
- 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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
721
|
+
layout_window: WindowName::new(agent_id.as_str()),
|
|
661
722
|
layout_index,
|
|
662
|
-
pane_index:
|
|
663
|
-
starts_window:
|
|
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
|
-
|
|
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
|
-
&
|
|
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
|
-
&
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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
|
-
&
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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,
|