@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/coordinator/tick.rs +55 -7
- package/crates/team-agent/src/leader/lease.rs +34 -13
- package/crates/team-agent/src/leader/tests/lease_api.rs +79 -0
- package/crates/team-agent/src/lifecycle/helpers.rs +11 -3
- package/crates/team-agent/src/lifecycle/launch.rs +219 -35
- package/crates/team-agent/src/lifecycle/restart/agent.rs +61 -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 +86 -27
- package/crates/team-agent/src/messaging/helpers.rs +64 -24
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +157 -4
- package/crates/team-agent/src/messaging/tests/wave2.rs +689 -0
- package/crates/team-agent/src/state/projection.rs +5 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +297 -0
- package/crates/team-agent/src/tmux_backend.rs +145 -5
- package/crates/team-agent/src/transport/test_support.rs +79 -6
- package/crates/team-agent/src/transport.rs +26 -1
- package/npm/install.mjs +90 -25
- package/package.json +4 -4
|
@@ -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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
721
|
+
layout_window: WindowName::new(agent_id.as_str()),
|
|
662
722
|
layout_index,
|
|
663
|
-
pane_index:
|
|
664
|
-
starts_window:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
&
|
|
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
|
-
&
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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
|
-
&
|
|
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
|
-
|
|
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,
|