@team-agent/installer 0.3.24 → 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/lifecycle/launch.rs +217 -19
- 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 +12 -0
- package/crates/team-agent/src/messaging/helpers.rs +64 -24
- package/crates/team-agent/src/messaging/tests/spine.rs +157 -4
- package/crates/team-agent/src/tmux_backend/tests.rs +251 -0
- package/crates/team-agent/src/tmux_backend.rs +128 -0
- package/crates/team-agent/src/transport.rs +26 -1
- package/npm/install.mjs +90 -25
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -544,13 +544,27 @@ impl Coordinator {
|
|
|
544
544
|
.get("last_output_at")
|
|
545
545
|
.and_then(Value::as_str)
|
|
546
546
|
.map(str::to_string);
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
)
|
|
547
|
+
// E47 (0.3.24 P0, idle/busy 假阳): consult the AUTHORITATIVE
|
|
548
|
+
// provider JSONL classifier BEFORE the TUI keyword scan. The
|
|
549
|
+
// scrollback Tail(40) keeps historical `• Working (...· esc to
|
|
550
|
+
// interrupt)` + spinners within the 40-line window long after a
|
|
551
|
+
// turn completed, so `classify_agent_activity`'s rfind-recency
|
|
552
|
+
// grep flips a truly-idle worker to Working/Stuck. The provider
|
|
553
|
+
// JSONL (codex task_complete / claude end_turn) is the single
|
|
554
|
+
// source of truth for "turn closed". Only fall back to TUI
|
|
555
|
+
// scanning when the JSONL is unreadable or no lifecycle fact has
|
|
556
|
+
// been written yet (TurnState::Unknown) — never silently fall
|
|
557
|
+
// through into the leaky grep on a CONFIRMED IDLE.
|
|
558
|
+
let activity_from_jsonl = jsonl_activity_for_agent(agent);
|
|
559
|
+
let activity = activity_from_jsonl.unwrap_or_else(|| {
|
|
560
|
+
crate::messaging::classify_agent_activity(
|
|
561
|
+
&snapshot,
|
|
562
|
+
&captured.text,
|
|
563
|
+
pane_in_mode,
|
|
564
|
+
current_command.as_deref(),
|
|
565
|
+
last_output_at_now.as_deref(),
|
|
566
|
+
)
|
|
567
|
+
});
|
|
554
568
|
remember_idle_capture_schedule(agent, &activity);
|
|
555
569
|
write_activity(agent, &activity, false);
|
|
556
570
|
let last_output_at = last_output_at_now;
|
|
@@ -2519,6 +2533,40 @@ fn agent_rollout_path(agent: &Value) -> Option<PathBuf> {
|
|
|
2519
2533
|
.map(PathBuf::from)
|
|
2520
2534
|
}
|
|
2521
2535
|
|
|
2536
|
+
/// E47 (0.3.24 P0, idle/busy 假阳): consult the authoritative provider JSONL
|
|
2537
|
+
/// classifier and map to neutral `AgentActivity`. Returns `None` when the
|
|
2538
|
+
/// classifier reports `TurnState::Unknown` (unreadable JSONL / no lifecycle
|
|
2539
|
+
/// fact yet) so the caller falls back to the TUI scan — this honours the
|
|
2540
|
+
/// IRON LAW (activity.rs:3 / bug-071/077/085): no-signal = Uncertain (not
|
|
2541
|
+
/// silently coerced to Idle); but here Unknown means "JSONL gave no signal",
|
|
2542
|
+
/// so we hand off to the TUI scanner which has its OWN no-signal → Uncertain
|
|
2543
|
+
/// path. Copilot/Gemini/Fake providers (which don't have JSONL — classify.rs
|
|
2544
|
+
/// returns Unknown for them) thus keep using TUI scanning unchanged.
|
|
2545
|
+
fn jsonl_activity_for_agent(agent: &Value) -> Option<crate::messaging::AgentActivity> {
|
|
2546
|
+
let rollout_path = agent_rollout_path(agent)?;
|
|
2547
|
+
let provider = agent
|
|
2548
|
+
.get("provider")
|
|
2549
|
+
.and_then(Value::as_str)
|
|
2550
|
+
.and_then(parse_provider)?;
|
|
2551
|
+
let log_text = std::fs::read_to_string(&rollout_path).ok()?;
|
|
2552
|
+
let process = explicit_process_liveness(agent).unwrap_or(ProcessLiveness::Unverifiable);
|
|
2553
|
+
let result = crate::provider::classify(provider, &log_text, process, 0.0).ok()?;
|
|
2554
|
+
use crate::messaging::{ActivityStatus, AgentActivity};
|
|
2555
|
+
use crate::provider::types::TurnState;
|
|
2556
|
+
let status = match result.state {
|
|
2557
|
+
TurnState::Idle => ActivityStatus::Idle,
|
|
2558
|
+
TurnState::IdleInterrupted => ActivityStatus::Idle,
|
|
2559
|
+
TurnState::Working => ActivityStatus::Working,
|
|
2560
|
+
TurnState::BlockedOnHuman | TurnState::Abnormal => ActivityStatus::Uncertain,
|
|
2561
|
+
TurnState::Unknown => return None,
|
|
2562
|
+
};
|
|
2563
|
+
Some(AgentActivity {
|
|
2564
|
+
status,
|
|
2565
|
+
confidence: 0.95,
|
|
2566
|
+
rationale: format!("provider_jsonl:{}", result.reason),
|
|
2567
|
+
})
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2522
2570
|
fn runtime_approval_target(
|
|
2523
2571
|
agent: &Value,
|
|
2524
2572
|
session_name: Option<&str>,
|
|
@@ -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
|
|
|
@@ -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> {
|