@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
|
@@ -2526,3 +2526,997 @@ fn quick_start_paused_agent_state_is_paused_provider_only_and_not_spawned() {
|
|
|
2526
2526
|
transport.spawn_records()
|
|
2527
2527
|
);
|
|
2528
2528
|
}
|
|
2529
|
+
|
|
2530
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2531
|
+
// 0.3.24 add-agent socket drift fix (macmini demo-director startup blocker).
|
|
2532
|
+
//
|
|
2533
|
+
// Root cause: `launch.rs::add_agent` / `fork_agent` hardcoded
|
|
2534
|
+
// `TmuxBackend::for_workspace(...)` (workspace-hash socket, e.g.
|
|
2535
|
+
// `/private/tmp/tmux-501/ta-<hash>/termclaud`) instead of consulting the
|
|
2536
|
+
// team's persisted `tmux_endpoint` (set at `team-agent launch` time, e.g.
|
|
2537
|
+
// `/private/tmp/tmux-501/default`). The orphan `claude` process then spawned
|
|
2538
|
+
// onto the wrong socket; the leader (running on the persisted socket) never
|
|
2539
|
+
// saw the window; state never registered; `add_agent` returned `ok=true`
|
|
2540
|
+
// (假绿) because the lifecycle layer didn't verify reachability post-spawn.
|
|
2541
|
+
//
|
|
2542
|
+
// Architect-approved fix has 4 调整:
|
|
2543
|
+
// 1. Reuse state-aware resolver (`lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state`).
|
|
2544
|
+
// 2. fork-agent same path.
|
|
2545
|
+
// 3. Endpoint annotate folded into start_agent state-save (no parallel
|
|
2546
|
+
// `annotate_runtime_tmux_endpoint` race with coordinator).
|
|
2547
|
+
// 4. Strict-non-capture reachability gate post-spawn (uses `has_pane` /
|
|
2548
|
+
// `liveness` / `list_targets` only — never `capture` to avoid E31 timing).
|
|
2549
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2550
|
+
|
|
2551
|
+
/// **RED**: `add_agent` must read the team's persisted `tmux_endpoint` (set at
|
|
2552
|
+
/// `team-agent launch` time and stored in the runtime state) and resolve the
|
|
2553
|
+
/// transport's socket to that endpoint — NOT the workspace-hash for_workspace
|
|
2554
|
+
/// socket. We exercise the resolver directly (the public surface
|
|
2555
|
+
/// `lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state`) so
|
|
2556
|
+
/// the contract is independent of CLI plumbing.
|
|
2557
|
+
#[test]
|
|
2558
|
+
fn add_agent_resolves_to_persisted_endpoint_socket_not_workspace_hash() {
|
|
2559
|
+
let team = temp_ws().join("addteam-persisted-endpoint");
|
|
2560
|
+
std::fs::create_dir_all(&team).unwrap();
|
|
2561
|
+
let live_endpoint = "/private/tmp/tmux-501/default".to_string();
|
|
2562
|
+
crate::state::persist::save_runtime_state(
|
|
2563
|
+
&team,
|
|
2564
|
+
&json!({
|
|
2565
|
+
"session_name": "team-persisted",
|
|
2566
|
+
"tmux_endpoint": live_endpoint.clone(),
|
|
2567
|
+
"tmux_socket": live_endpoint.clone(),
|
|
2568
|
+
"agents": {}
|
|
2569
|
+
}),
|
|
2570
|
+
)
|
|
2571
|
+
.unwrap();
|
|
2572
|
+
|
|
2573
|
+
let resolved = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
|
|
2574
|
+
&team, None,
|
|
2575
|
+
)
|
|
2576
|
+
.expect("resolver must succeed when state is present");
|
|
2577
|
+
|
|
2578
|
+
let endpoint = <crate::tmux_backend::TmuxBackend as crate::transport::Transport>::tmux_endpoint(&resolved);
|
|
2579
|
+
assert_eq!(
|
|
2580
|
+
endpoint.as_deref(),
|
|
2581
|
+
Some(live_endpoint.as_str()),
|
|
2582
|
+
"0.3.24 add-agent socket drift fix: the state-aware resolver must \
|
|
2583
|
+
route to the PERSISTED tmux_endpoint ({live_endpoint}), not the \
|
|
2584
|
+
workspace-hash for_workspace socket. Got: {endpoint:?}. macmini \
|
|
2585
|
+
repro: add-agent demo-director spawned `claude` on the wrong socket \
|
|
2586
|
+
while the live team ran on the persisted default socket — orphan \
|
|
2587
|
+
process + ok=true假绿."
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
/// **Regression guard**: cold workspace (no persisted endpoint) must safely
|
|
2592
|
+
/// fall back to `TmuxBackend::for_workspace(workspace)`. Encodes the
|
|
2593
|
+
/// first-agent / fresh-launch path so a future refactor doesn't accidentally
|
|
2594
|
+
/// panic/None there.
|
|
2595
|
+
#[test]
|
|
2596
|
+
fn add_agent_resolver_falls_back_to_workspace_socket_when_no_persisted_endpoint() {
|
|
2597
|
+
let team = temp_ws().join("addteam-cold-fallback");
|
|
2598
|
+
std::fs::create_dir_all(&team).unwrap();
|
|
2599
|
+
// NOTE: no save_runtime_state — cold workspace, no persisted endpoint.
|
|
2600
|
+
|
|
2601
|
+
let resolved = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
|
|
2602
|
+
&team, None,
|
|
2603
|
+
)
|
|
2604
|
+
.expect("resolver must succeed (fall back to for_workspace) on cold workspace");
|
|
2605
|
+
|
|
2606
|
+
let endpoint = <crate::tmux_backend::TmuxBackend as crate::transport::Transport>::tmux_endpoint(&resolved);
|
|
2607
|
+
let fallback = <crate::tmux_backend::TmuxBackend as crate::transport::Transport>::tmux_endpoint(
|
|
2608
|
+
&crate::tmux_backend::TmuxBackend::for_workspace(&team),
|
|
2609
|
+
);
|
|
2610
|
+
assert_eq!(
|
|
2611
|
+
endpoint, fallback,
|
|
2612
|
+
"0.3.24 regression guard: when state has no persisted tmux_endpoint, \
|
|
2613
|
+
resolver must fall back to TmuxBackend::for_workspace (workspace-hash \
|
|
2614
|
+
socket). Got endpoint={endpoint:?} expected fallback={fallback:?}."
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
/// **RED**: `add_agent` must verify reachability AFTER spawn (strict,
|
|
2619
|
+
/// non-capture). A spawn that lands on the wrong tmux socket (or on the right
|
|
2620
|
+
/// socket but produces no addressable pane) must surface as a structured
|
|
2621
|
+
/// `Err`, NOT `ok=true`. Models the macmini假绿 root cause: the spawn
|
|
2622
|
+
/// recorded into the OfflineTransport but `has_pane` / `liveness` /
|
|
2623
|
+
/// `list_targets` all say "not addressable" (`spawned_panes_addressable=false`).
|
|
2624
|
+
#[test]
|
|
2625
|
+
fn add_agent_reachability_gate_returns_error_when_spawn_pane_not_addressable() {
|
|
2626
|
+
let team = temp_ws().join("addteam-reachability-gate");
|
|
2627
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
2628
|
+
std::fs::write(
|
|
2629
|
+
team.join("TEAM.md"),
|
|
2630
|
+
"---\nname: reachgate\nobjective: Reachability probe.\nprovider: codex\n---\n\nteam.\n",
|
|
2631
|
+
)
|
|
2632
|
+
.unwrap();
|
|
2633
|
+
std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap();
|
|
2634
|
+
let role_file = team.join("worker2-role.md");
|
|
2635
|
+
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
2636
|
+
seed_healthy_coordinator(&team);
|
|
2637
|
+
|
|
2638
|
+
// Models "spawn to wrong socket" / "spawn succeeded but pane orphan": the
|
|
2639
|
+
// OfflineTransport records the spawn but reports the new pane is NOT
|
|
2640
|
+
// addressable (has_pane→Some(false), liveness defaults to Unknown=fall
|
|
2641
|
+
// through, list_targets doesn't include it).
|
|
2642
|
+
let transport = OfflineTransport::new()
|
|
2643
|
+
.with_spawned_panes_addressable(false)
|
|
2644
|
+
.with_default_liveness(crate::transport::PaneLiveness::Dead);
|
|
2645
|
+
|
|
2646
|
+
let result = crate::lifecycle::add_agent_with_transport(
|
|
2647
|
+
&team,
|
|
2648
|
+
&AgentId::new("worker2"),
|
|
2649
|
+
&role_file,
|
|
2650
|
+
false,
|
|
2651
|
+
None,
|
|
2652
|
+
&transport,
|
|
2653
|
+
);
|
|
2654
|
+
|
|
2655
|
+
// The spawn DID record (proving we reached the spawn step), but the gate
|
|
2656
|
+
// must NOT let the function return Ok — pane is not addressable so the
|
|
2657
|
+
// caller must see the structured failure and roll back / retry.
|
|
2658
|
+
assert!(
|
|
2659
|
+
!transport.spawn_records().is_empty(),
|
|
2660
|
+
"add-agent must still attempt the spawn (gate fires post-spawn); \
|
|
2661
|
+
got no spawn records — indicates a regression where the gate fires \
|
|
2662
|
+
too early."
|
|
2663
|
+
);
|
|
2664
|
+
assert!(
|
|
2665
|
+
result.is_err(),
|
|
2666
|
+
"0.3.24 reachability gate: add-agent must return Err when the spawned \
|
|
2667
|
+
pane is not addressable on the transport's socket (macmini假绿 root \
|
|
2668
|
+
cause). Got Ok(...) — gate did not fire."
|
|
2669
|
+
);
|
|
2670
|
+
let err_msg = result.unwrap_err().to_string();
|
|
2671
|
+
assert!(
|
|
2672
|
+
err_msg.contains("unreachable") || err_msg.contains("not addressable") || err_msg.contains("socket drift"),
|
|
2673
|
+
"0.3.24 reachability gate: error message must name the unreachable / \
|
|
2674
|
+
socket-drift symptom so operators can diagnose. Got: {err_msg}"
|
|
2675
|
+
);
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
/// **Regression guard**: when the spawned pane IS addressable (default
|
|
2679
|
+
/// `OfflineTransport` state), reachability gate passes and add-agent returns
|
|
2680
|
+
/// Ok as before. Ensures the gate is precision, not blanket.
|
|
2681
|
+
#[test]
|
|
2682
|
+
fn add_agent_reachability_gate_passes_when_spawn_pane_addressable() {
|
|
2683
|
+
let team = temp_ws().join("addteam-gate-pass");
|
|
2684
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
2685
|
+
std::fs::write(
|
|
2686
|
+
team.join("TEAM.md"),
|
|
2687
|
+
"---\nname: gatepass\nobjective: Gate-pass probe.\nprovider: codex\n---\n\nteam.\n",
|
|
2688
|
+
)
|
|
2689
|
+
.unwrap();
|
|
2690
|
+
std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap();
|
|
2691
|
+
let role_file = team.join("worker2-role.md");
|
|
2692
|
+
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
2693
|
+
seed_healthy_coordinator(&team);
|
|
2694
|
+
|
|
2695
|
+
// Default OfflineTransport: spawned_panes_addressable=true, liveness=Unknown
|
|
2696
|
+
// (treated as not-Dead by the gate's fallthrough).
|
|
2697
|
+
let transport = OfflineTransport::new();
|
|
2698
|
+
|
|
2699
|
+
let result = crate::lifecycle::add_agent_with_transport(
|
|
2700
|
+
&team,
|
|
2701
|
+
&AgentId::new("worker2"),
|
|
2702
|
+
&role_file,
|
|
2703
|
+
false,
|
|
2704
|
+
None,
|
|
2705
|
+
&transport,
|
|
2706
|
+
);
|
|
2707
|
+
|
|
2708
|
+
assert!(
|
|
2709
|
+
result.is_ok(),
|
|
2710
|
+
"0.3.24 reachability gate regression guard: when spawn pane is \
|
|
2711
|
+
addressable, add-agent must still return Ok. Got: {result:?}"
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2716
|
+
// E43 add-agent window layout drift (0.3.24 bug#3, demo-director startup blocker).
|
|
2717
|
+
//
|
|
2718
|
+
// Root cause (architect res_f27ff9b29957): with `display_backend=adaptive` in
|
|
2719
|
+
// state AND stale `agents.<id>.layout_window="team-w2"` residue, the existing
|
|
2720
|
+
// `adaptive_placement_for_agent` validates a layout_window claim by checking
|
|
2721
|
+
// whether the existing agent's `pane_id` is in `live_panes` — but it does NOT
|
|
2722
|
+
// verify that the live pane's `window_name` actually equals the claimed
|
|
2723
|
+
// `layout_window`. So a live `%X` whose REAL window_name is "architect" still
|
|
2724
|
+
// "validates" the stale "team-w2" claim, the placement returns
|
|
2725
|
+
// `layout_window=team-w2, starts_window=false`, and `spawn_agent_window`
|
|
2726
|
+
// issues `tmux split-window -t :team-w2` which fails with
|
|
2727
|
+
// "can't find window: team-w2". macmini repro: demo-director add-agent blocks.
|
|
2728
|
+
//
|
|
2729
|
+
// Architect-approved minimal fix (3 调整, NO restructure of display/topology):
|
|
2730
|
+
// Fix A — `adaptive_placement_for_agent`: validate the live pane's
|
|
2731
|
+
// window_name MATCHES the agent's claimed layout_window. If pane_id is
|
|
2732
|
+
// live but lives under a different window_name, treat the agent's
|
|
2733
|
+
// layout_window claim as stale and skip it (don't count toward the
|
|
2734
|
+
// "windows with room" map).
|
|
2735
|
+
// Fix B — `adaptive_existing_placement_for_agent`: when the agent's
|
|
2736
|
+
// claimed layout_window is NOT present in live_windows, fall back to a
|
|
2737
|
+
// `starts_window=true` placement with `layout_window=agent_id` instead
|
|
2738
|
+
// of synthesising a fresh stale-named window.
|
|
2739
|
+
// Fix C — `spawn_agent_window`: pre-split guard. When `!starts_window` and
|
|
2740
|
+
// the target window is NOT in live_windows, downgrade to spawn_into
|
|
2741
|
+
// (a new window named `agent_id`) instead of splitting a phantom.
|
|
2742
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2743
|
+
|
|
2744
|
+
/// **RED — Fix A**: `adaptive_placement_for_agent` must skip an existing
|
|
2745
|
+
/// agent whose live pane resides under a window_name that differs from the
|
|
2746
|
+
/// agent's claimed `layout_window`. Real-machine state: agent "architect"
|
|
2747
|
+
/// carries `layout_window="team-w2"` (stale residue) but its live pane lives
|
|
2748
|
+
/// under `window_name="architect"`. The new placement for demo-director must
|
|
2749
|
+
/// NOT inherit the stale "team-w2" claim.
|
|
2750
|
+
#[test]
|
|
2751
|
+
fn e43_adaptive_placement_skips_existing_agent_whose_live_pane_window_name_differs_from_claim() {
|
|
2752
|
+
use crate::lifecycle::launch::{adaptive_placement_for_agent, LayoutPlacement};
|
|
2753
|
+
use crate::model::ids::AgentId;
|
|
2754
|
+
use crate::transport::{SessionName, WindowName};
|
|
2755
|
+
|
|
2756
|
+
let state = json!({
|
|
2757
|
+
"display_backend": "adaptive",
|
|
2758
|
+
"session_name": "team-iso",
|
|
2759
|
+
"agents": {
|
|
2760
|
+
"architect": {
|
|
2761
|
+
"status": "running",
|
|
2762
|
+
"provider": "codex",
|
|
2763
|
+
"window": "architect",
|
|
2764
|
+
// Stale residue — placement must NOT inherit this name.
|
|
2765
|
+
"layout_window": "team-w2",
|
|
2766
|
+
"pane_id": "%1",
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
let transport = OfflineTransport::new()
|
|
2772
|
+
.with_targets(vec![layout_pane("team-iso", "architect", "%1", 0)])
|
|
2773
|
+
.with_windows(vec![WindowName::new("architect")]);
|
|
2774
|
+
|
|
2775
|
+
let placement = adaptive_placement_for_agent(
|
|
2776
|
+
&state,
|
|
2777
|
+
&transport,
|
|
2778
|
+
&SessionName::new("team-iso"),
|
|
2779
|
+
&AgentId::new("demo-director"),
|
|
2780
|
+
);
|
|
2781
|
+
|
|
2782
|
+
// **E45 amendment (0.3.24 bug#4)**: when the live session has NO real
|
|
2783
|
+
// adaptive layout window (the topology is effectively per-agent — live
|
|
2784
|
+
// windows are `architect` not `team-wN`), placement returns None. The
|
|
2785
|
+
// caller (`start_agent_at_paths` → `spawn_agent_window`) then falls back
|
|
2786
|
+
// to opening a new window named after `agent_id`. This is the macmini
|
|
2787
|
+
// demo-director repro fix: per-agent topology must NOT be tricked into
|
|
2788
|
+
// adaptive mode by stale `layout_window=team-w2` residue.
|
|
2789
|
+
//
|
|
2790
|
+
// Pre-E45 expectation was Some(starts_window=true) with a fresh
|
|
2791
|
+
// team-w<next> name, but that forced an adaptive shape onto a session
|
|
2792
|
+
// that was already per-agent — which led to E45 bug#4 (split-window
|
|
2793
|
+
// into the developer worker after stale state pointed there).
|
|
2794
|
+
if let Some(p) = placement {
|
|
2795
|
+
// If we ever do return Some here, the STALE name must still not
|
|
2796
|
+
// survive — this branch shouldn't fire post-E45 but guards against
|
|
2797
|
+
// future regressions where placement re-enables adaptive on
|
|
2798
|
+
// per-agent live topology.
|
|
2799
|
+
assert_ne!(
|
|
2800
|
+
p.layout_window.as_str(),
|
|
2801
|
+
"team-w2",
|
|
2802
|
+
"E43 Fix A still applies: stale layout_window 'team-w2' must \
|
|
2803
|
+
NEVER survive. Got {p:?}"
|
|
2804
|
+
);
|
|
2805
|
+
let _: LayoutPlacement = p; // type witness
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
/// **Regression guard — Fix A**: when an existing agent's live pane DOES
|
|
2810
|
+
/// reside under the claimed layout_window, placement keeps the prior
|
|
2811
|
+
/// behaviour (groups the new agent into that window). This is the canonical
|
|
2812
|
+
/// adaptive 1-window-multiple-panes use case.
|
|
2813
|
+
#[test]
|
|
2814
|
+
fn e43_adaptive_placement_groups_into_layout_window_when_live_pane_matches_claim() {
|
|
2815
|
+
use crate::lifecycle::launch::adaptive_placement_for_agent;
|
|
2816
|
+
use crate::model::ids::AgentId;
|
|
2817
|
+
use crate::transport::{SessionName, WindowName};
|
|
2818
|
+
|
|
2819
|
+
let state = json!({
|
|
2820
|
+
"display_backend": "adaptive",
|
|
2821
|
+
"session_name": "team-iso",
|
|
2822
|
+
"agents": {
|
|
2823
|
+
"alpha": {
|
|
2824
|
+
"status": "running",
|
|
2825
|
+
"provider": "codex",
|
|
2826
|
+
"layout_window": "team-w1",
|
|
2827
|
+
"pane_id": "%1",
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
});
|
|
2831
|
+
|
|
2832
|
+
let transport = OfflineTransport::new()
|
|
2833
|
+
.with_targets(vec![layout_pane("team-iso", "team-w1", "%1", 0)])
|
|
2834
|
+
.with_windows(vec![WindowName::new("team-w1")]);
|
|
2835
|
+
|
|
2836
|
+
let placement = adaptive_placement_for_agent(
|
|
2837
|
+
&state,
|
|
2838
|
+
&transport,
|
|
2839
|
+
&SessionName::new("team-iso"),
|
|
2840
|
+
&AgentId::new("beta"),
|
|
2841
|
+
)
|
|
2842
|
+
.expect("placement should succeed");
|
|
2843
|
+
|
|
2844
|
+
assert_eq!(
|
|
2845
|
+
placement.layout_window.as_str(),
|
|
2846
|
+
"team-w1",
|
|
2847
|
+
"E43 Fix A regression guard: when alpha's live pane DOES live under \
|
|
2848
|
+
layout_window 'team-w1', the new agent beta should be grouped into \
|
|
2849
|
+
the same window (canonical adaptive behaviour). Got {placement:?}"
|
|
2850
|
+
);
|
|
2851
|
+
assert!(
|
|
2852
|
+
!placement.starts_window,
|
|
2853
|
+
"E43 Fix A regression guard: grouping into an existing window means \
|
|
2854
|
+
starts_window=false (split into the window). Got {placement:?}"
|
|
2855
|
+
);
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
/// **RED — Fix B**: `adaptive_existing_placement_for_agent` must NOT return a
|
|
2859
|
+
/// `starts_window=true` placement that names a window NOT present in
|
|
2860
|
+
/// `live_windows`. When the agent's claimed `layout_window` is stale and the
|
|
2861
|
+
/// session is effectively per-agent (live windows are named after agent_ids),
|
|
2862
|
+
/// the placement must fall back to a per-agent window named after the
|
|
2863
|
+
/// agent_id itself — so the subsequent spawn opens a new window, not a split
|
|
2864
|
+
/// of a phantom.
|
|
2865
|
+
#[test]
|
|
2866
|
+
fn e43_adaptive_existing_placement_falls_back_to_agent_id_window_when_claim_missing_from_live() {
|
|
2867
|
+
use crate::lifecycle::launch::adaptive_existing_placement_for_agent;
|
|
2868
|
+
use crate::model::ids::AgentId;
|
|
2869
|
+
use crate::transport::{SessionName, WindowName};
|
|
2870
|
+
|
|
2871
|
+
let state = json!({
|
|
2872
|
+
"display_backend": "adaptive",
|
|
2873
|
+
"session_name": "team-iso",
|
|
2874
|
+
"agents": {
|
|
2875
|
+
"demo-director": {
|
|
2876
|
+
"status": "running",
|
|
2877
|
+
"provider": "codex",
|
|
2878
|
+
// Stale claim — live session has no team-w2 window.
|
|
2879
|
+
"layout_window": "team-w2",
|
|
2880
|
+
"pane_id": "%99",
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
let transport = OfflineTransport::new()
|
|
2886
|
+
.with_targets(vec![
|
|
2887
|
+
layout_pane("team-iso", "architect", "%1", 0),
|
|
2888
|
+
layout_pane("team-iso", "test-engineer", "%2", 0),
|
|
2889
|
+
])
|
|
2890
|
+
.with_windows(vec![
|
|
2891
|
+
WindowName::new("architect"),
|
|
2892
|
+
WindowName::new("test-engineer"),
|
|
2893
|
+
]);
|
|
2894
|
+
|
|
2895
|
+
let placement = adaptive_existing_placement_for_agent(
|
|
2896
|
+
&state,
|
|
2897
|
+
&transport,
|
|
2898
|
+
&SessionName::new("team-iso"),
|
|
2899
|
+
&AgentId::new("demo-director"),
|
|
2900
|
+
)
|
|
2901
|
+
.expect("placement should resolve");
|
|
2902
|
+
|
|
2903
|
+
assert_ne!(
|
|
2904
|
+
placement.layout_window.as_str(),
|
|
2905
|
+
"team-w2",
|
|
2906
|
+
"E43 Fix B: stale claim 'team-w2' is not in live_windows; placement \
|
|
2907
|
+
must NOT reuse that name (otherwise spawn issues split -t :team-w2 \
|
|
2908
|
+
and tmux says 'can't find window: team-w2'). Got {placement:?}"
|
|
2909
|
+
);
|
|
2910
|
+
assert_eq!(
|
|
2911
|
+
placement.layout_window.as_str(),
|
|
2912
|
+
"demo-director",
|
|
2913
|
+
"E43 Fix B: when the claim is stale and the live layout is \
|
|
2914
|
+
per-agent-named, fall back to a window named after the agent_id \
|
|
2915
|
+
(canonical per-agent pattern). Got {placement:?}"
|
|
2916
|
+
);
|
|
2917
|
+
assert!(
|
|
2918
|
+
placement.starts_window,
|
|
2919
|
+
"E43 Fix B: the fallback opens a NEW window (starts_window=true); \
|
|
2920
|
+
do not try to split an existing window with the wrong name. Got \
|
|
2921
|
+
{placement:?}"
|
|
2922
|
+
);
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
/// **Regression guard — Fix B**: when the agent's claimed layout_window IS
|
|
2926
|
+
/// in live_windows, the existing-placement path keeps its current behaviour
|
|
2927
|
+
/// (return a starts_window=false placement that splits into the existing
|
|
2928
|
+
/// window).
|
|
2929
|
+
#[test]
|
|
2930
|
+
fn e43_adaptive_existing_placement_keeps_starts_window_false_when_claim_in_live_windows() {
|
|
2931
|
+
use crate::lifecycle::launch::adaptive_existing_placement_for_agent;
|
|
2932
|
+
use crate::model::ids::AgentId;
|
|
2933
|
+
use crate::transport::{SessionName, WindowName};
|
|
2934
|
+
|
|
2935
|
+
let state = json!({
|
|
2936
|
+
"display_backend": "adaptive",
|
|
2937
|
+
"session_name": "team-iso",
|
|
2938
|
+
"agents": {
|
|
2939
|
+
"alpha": {
|
|
2940
|
+
"status": "running",
|
|
2941
|
+
"provider": "codex",
|
|
2942
|
+
"layout_window": "team-w1",
|
|
2943
|
+
"pane_id": "%1",
|
|
2944
|
+
"pane_index": 1u64,
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
});
|
|
2948
|
+
|
|
2949
|
+
let transport = OfflineTransport::new()
|
|
2950
|
+
.with_targets(vec![layout_pane("team-iso", "team-w1", "%1", 0)])
|
|
2951
|
+
.with_windows(vec![WindowName::new("team-w1")]);
|
|
2952
|
+
|
|
2953
|
+
let placement = adaptive_existing_placement_for_agent(
|
|
2954
|
+
&state,
|
|
2955
|
+
&transport,
|
|
2956
|
+
&SessionName::new("team-iso"),
|
|
2957
|
+
&AgentId::new("alpha"),
|
|
2958
|
+
)
|
|
2959
|
+
.expect("placement should resolve");
|
|
2960
|
+
|
|
2961
|
+
assert_eq!(
|
|
2962
|
+
placement.layout_window.as_str(),
|
|
2963
|
+
"team-w1",
|
|
2964
|
+
"E43 Fix B regression guard: when claim 'team-w1' IS in live_windows, \
|
|
2965
|
+
existing-placement must keep it. Got {placement:?}"
|
|
2966
|
+
);
|
|
2967
|
+
// pane_index=1 → starts_window=false in the existing behaviour.
|
|
2968
|
+
assert!(
|
|
2969
|
+
!placement.starts_window,
|
|
2970
|
+
"E43 Fix B regression guard: existing window + pane_index>0 → \
|
|
2971
|
+
starts_window=false. Got {placement:?}"
|
|
2972
|
+
);
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
/// **RED — Fix C end-to-end**: `add_agent` end-to-end must NOT emit a
|
|
2976
|
+
/// spawn_split with the stale window name. With state carrying
|
|
2977
|
+
/// `display_backend=adaptive` + stale `layout_window=team-w2` residue and
|
|
2978
|
+
/// live tmux windows named per-agent, the spawn must be a `spawn_into`
|
|
2979
|
+
/// (new window) — NOT `spawn_split` against the missing 'team-w2'.
|
|
2980
|
+
#[test]
|
|
2981
|
+
fn e43_add_agent_does_not_split_against_phantom_layout_window() {
|
|
2982
|
+
use crate::transport::WindowName;
|
|
2983
|
+
|
|
2984
|
+
let team = temp_ws().join("e43-add-agent-phantom-window");
|
|
2985
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
2986
|
+
std::fs::write(
|
|
2987
|
+
team.join("TEAM.md"),
|
|
2988
|
+
"---\nname: e43probe\nobjective: E43 probe.\nprovider: codex\n---\n\nteam.\n",
|
|
2989
|
+
)
|
|
2990
|
+
.unwrap();
|
|
2991
|
+
std::fs::write(team.join("agents").join("architect.md"), QS_VALID_ROLE).unwrap();
|
|
2992
|
+
let role_file = team.join("worker2-role.md");
|
|
2993
|
+
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
2994
|
+
// Real-machine repro: existing 'architect' carries stale layout_window=team-w2
|
|
2995
|
+
// residue; live tmux shows it under window_name='architect' (per-agent
|
|
2996
|
+
// layout). adaptive_layout flag is on.
|
|
2997
|
+
crate::state::persist::save_runtime_state(
|
|
2998
|
+
&team,
|
|
2999
|
+
&json!({
|
|
3000
|
+
"session_name": "team-iso",
|
|
3001
|
+
"display_backend": "adaptive",
|
|
3002
|
+
"agents": {
|
|
3003
|
+
"architect": {
|
|
3004
|
+
"status": "running",
|
|
3005
|
+
"provider": "codex",
|
|
3006
|
+
"window": "architect",
|
|
3007
|
+
"layout_window": "team-w2",
|
|
3008
|
+
"pane_id": "%1",
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
}),
|
|
3012
|
+
)
|
|
3013
|
+
.unwrap();
|
|
3014
|
+
seed_healthy_coordinator(&team);
|
|
3015
|
+
|
|
3016
|
+
let transport = OfflineTransport::new()
|
|
3017
|
+
.with_targets(vec![layout_pane("team-iso", "architect", "%1", 0)])
|
|
3018
|
+
.with_windows(vec![WindowName::new("architect")])
|
|
3019
|
+
.with_session_present(true);
|
|
3020
|
+
|
|
3021
|
+
let _result = crate::lifecycle::add_agent_with_transport(
|
|
3022
|
+
&team,
|
|
3023
|
+
&AgentId::new("worker2"),
|
|
3024
|
+
&role_file,
|
|
3025
|
+
false,
|
|
3026
|
+
None,
|
|
3027
|
+
&transport,
|
|
3028
|
+
);
|
|
3029
|
+
|
|
3030
|
+
let spawn_window_records = transport.spawn_window_records();
|
|
3031
|
+
// The macmini failure mode: split-window -t :team-w2. We must not split,
|
|
3032
|
+
// and we definitely must not target "team-w2".
|
|
3033
|
+
let stale_split = spawn_window_records
|
|
3034
|
+
.iter()
|
|
3035
|
+
.any(|(kind, window)| kind == "spawn_split" && window == "team-w2");
|
|
3036
|
+
assert!(
|
|
3037
|
+
!stale_split,
|
|
3038
|
+
"E43 Fix C end-to-end: add-agent must NOT issue spawn_split with \
|
|
3039
|
+
stale window name 'team-w2' (real-machine macmini error: \
|
|
3040
|
+
'tmux split-window -t :team-w2 → can't find window: team-w2'). \
|
|
3041
|
+
spawn_window_records={spawn_window_records:?}"
|
|
3042
|
+
);
|
|
3043
|
+
// The agent must actually be spawned somewhere (new window or split into
|
|
3044
|
+
// a valid live window). We at least expect one spawn recorded.
|
|
3045
|
+
assert!(
|
|
3046
|
+
!spawn_window_records.is_empty(),
|
|
3047
|
+
"E43 Fix C: add-agent must still attempt the spawn; got no records."
|
|
3048
|
+
);
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
3052
|
+
// E45 per-agent window drift (0.3.24 task#326 P0).
|
|
3053
|
+
//
|
|
3054
|
+
// Surfaced AFTER E43 (c5ed576) fixed the team-w2 phantom split. New repro:
|
|
3055
|
+
// state has display_backend=adaptive + an existing `developer` worker whose
|
|
3056
|
+
// state row carries `layout_window=developer, layout_index=0, pane_id=%519`,
|
|
3057
|
+
// live tmux shows `developer` window with that pane. add-agent demo-director
|
|
3058
|
+
// resolves placement via `adaptive_placement_for_agent`, which finds the
|
|
3059
|
+
// `developer` window valid (pane_id live + window_name matches), groups
|
|
3060
|
+
// demo-director into it (count<3) → returns
|
|
3061
|
+
// `layout_window=developer, starts_window=false` → `spawn_agent_window`
|
|
3062
|
+
// issues `split-window -t :developer` which SUCCEEDS — splitting into the
|
|
3063
|
+
// developer worker's pane. macmini repro: add-agent demo-director hijacked
|
|
3064
|
+
// the developer worker's window @453 (2 panes), no new window.
|
|
3065
|
+
//
|
|
3066
|
+
// Root cause (architect): adaptive_placement_for_agent accepts ANY state
|
|
3067
|
+
// layout_window/window as adaptive (including per-agent names like
|
|
3068
|
+
// `developer`). E45 fix: only canonical `team-w<N>[-suffix]` windows count
|
|
3069
|
+
// as adaptive layout windows.
|
|
3070
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
3071
|
+
|
|
3072
|
+
/// **RED — Fix #2**: `adaptive_placement_for_agent` must NOT count a
|
|
3073
|
+
/// per-agent window (e.g. `developer`) as adaptive layout space, even when
|
|
3074
|
+
/// it has matching live pane + window_name. With no real adaptive
|
|
3075
|
+
/// `team-w<N>` window in the live session, placement returns None so the
|
|
3076
|
+
/// caller opens a new per-agent window.
|
|
3077
|
+
#[test]
|
|
3078
|
+
fn e45_adaptive_placement_returns_none_when_no_real_team_w_window_present_in_state() {
|
|
3079
|
+
use crate::lifecycle::launch::adaptive_placement_for_agent;
|
|
3080
|
+
use crate::model::ids::AgentId;
|
|
3081
|
+
use crate::transport::{SessionName, WindowName};
|
|
3082
|
+
|
|
3083
|
+
// Real-machine state: adaptive on, developer's layout_window is
|
|
3084
|
+
// literally `developer` (per-agent name, NOT team-wN), pane_id %519
|
|
3085
|
+
// lives under the `developer` window.
|
|
3086
|
+
let state = json!({
|
|
3087
|
+
"display_backend": "adaptive",
|
|
3088
|
+
"session_name": "team-iso",
|
|
3089
|
+
"agents": {
|
|
3090
|
+
"developer": {
|
|
3091
|
+
"status": "running",
|
|
3092
|
+
"provider": "codex",
|
|
3093
|
+
"window": "developer",
|
|
3094
|
+
"layout_window": "developer",
|
|
3095
|
+
"layout_index": 0u64,
|
|
3096
|
+
"pane_id": "%519",
|
|
3097
|
+
},
|
|
3098
|
+
"test-engineer": {
|
|
3099
|
+
"status": "running",
|
|
3100
|
+
"provider": "codex",
|
|
3101
|
+
"window": "test-engineer",
|
|
3102
|
+
"layout_window": "test-engineer",
|
|
3103
|
+
"pane_id": "%521",
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
});
|
|
3107
|
+
|
|
3108
|
+
let transport = OfflineTransport::new()
|
|
3109
|
+
.with_targets(vec![
|
|
3110
|
+
layout_pane("team-iso", "developer", "%519", 0),
|
|
3111
|
+
layout_pane("team-iso", "test-engineer", "%521", 0),
|
|
3112
|
+
])
|
|
3113
|
+
.with_windows(vec![
|
|
3114
|
+
WindowName::new("developer"),
|
|
3115
|
+
WindowName::new("test-engineer"),
|
|
3116
|
+
]);
|
|
3117
|
+
|
|
3118
|
+
let placement = adaptive_placement_for_agent(
|
|
3119
|
+
&state,
|
|
3120
|
+
&transport,
|
|
3121
|
+
&SessionName::new("team-iso"),
|
|
3122
|
+
&AgentId::new("demo-director"),
|
|
3123
|
+
);
|
|
3124
|
+
|
|
3125
|
+
assert!(
|
|
3126
|
+
placement.is_none(),
|
|
3127
|
+
"E45 (0.3.24 bug#4): placement must return None when the live \
|
|
3128
|
+
session has NO real `team-w<N>` adaptive window. Per-agent windows \
|
|
3129
|
+
like `developer` are NOT adaptive layout windows — they are \
|
|
3130
|
+
per-agent topology, even if display_backend=adaptive lingers in \
|
|
3131
|
+
state. With no real adaptive layout, the caller falls back to \
|
|
3132
|
+
opening a new per-agent window named after agent_id. Got \
|
|
3133
|
+
{placement:?} — must be None to prevent split-window -t :developer \
|
|
3134
|
+
hijacking the developer worker's pane (macmini repro)."
|
|
3135
|
+
);
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
/// **RED — Fix #3**: `adaptive_existing_placement_for_agent` must return
|
|
3139
|
+
/// None when the agent's claimed layout_window is per-agent (not team-wN).
|
|
3140
|
+
/// The caller then falls back to non-placement spawn path which opens /
|
|
3141
|
+
/// reuses a window named after agent_id.
|
|
3142
|
+
#[test]
|
|
3143
|
+
fn e45_adaptive_existing_placement_returns_none_when_claim_is_per_agent_window() {
|
|
3144
|
+
use crate::lifecycle::launch::adaptive_existing_placement_for_agent;
|
|
3145
|
+
use crate::model::ids::AgentId;
|
|
3146
|
+
use crate::transport::{SessionName, WindowName};
|
|
3147
|
+
|
|
3148
|
+
let state = json!({
|
|
3149
|
+
"display_backend": "adaptive",
|
|
3150
|
+
"session_name": "team-iso",
|
|
3151
|
+
"agents": {
|
|
3152
|
+
"developer": {
|
|
3153
|
+
"status": "running",
|
|
3154
|
+
"provider": "codex",
|
|
3155
|
+
"window": "developer",
|
|
3156
|
+
"layout_window": "developer",
|
|
3157
|
+
"layout_index": 0u64,
|
|
3158
|
+
"pane_id": "%519",
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
});
|
|
3162
|
+
|
|
3163
|
+
let transport = OfflineTransport::new()
|
|
3164
|
+
.with_targets(vec![layout_pane("team-iso", "developer", "%519", 0)])
|
|
3165
|
+
.with_windows(vec![WindowName::new("developer")]);
|
|
3166
|
+
|
|
3167
|
+
let placement = adaptive_existing_placement_for_agent(
|
|
3168
|
+
&state,
|
|
3169
|
+
&transport,
|
|
3170
|
+
&SessionName::new("team-iso"),
|
|
3171
|
+
&AgentId::new("developer"),
|
|
3172
|
+
);
|
|
3173
|
+
|
|
3174
|
+
assert!(
|
|
3175
|
+
placement.is_none(),
|
|
3176
|
+
"E45 (0.3.24 bug#4): existing-placement must return None when \
|
|
3177
|
+
claimed layout_window is per-agent (e.g. `developer`). Only real \
|
|
3178
|
+
`team-w<N>[-suffix]` windows are honored as existing adaptive \
|
|
3179
|
+
placements. Got {placement:?}."
|
|
3180
|
+
);
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
/// **RED — end-to-end Fix #2 + Fix #3 + defence**: add-agent against a
|
|
3184
|
+
/// per-agent live topology + adaptive backend in state must issue
|
|
3185
|
+
/// spawn_into (new window named after agent_id) and MUST NOT spawn_split
|
|
3186
|
+
/// into any existing per-agent window. macmini repro: split-window
|
|
3187
|
+
/// -t :developer would hijack the developer worker's pane.
|
|
3188
|
+
#[test]
|
|
3189
|
+
fn e45_add_agent_opens_new_window_does_not_split_into_per_agent_window() {
|
|
3190
|
+
use crate::transport::WindowName;
|
|
3191
|
+
|
|
3192
|
+
let team = temp_ws().join("e45-per-agent-no-split");
|
|
3193
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
3194
|
+
std::fs::write(
|
|
3195
|
+
team.join("TEAM.md"),
|
|
3196
|
+
"---\nname: e45probe\nobjective: E45 probe.\nprovider: codex\n---\n\nteam.\n",
|
|
3197
|
+
)
|
|
3198
|
+
.unwrap();
|
|
3199
|
+
std::fs::write(team.join("agents").join("developer.md"), QS_VALID_ROLE).unwrap();
|
|
3200
|
+
let role_file = team.join("worker2-role.md");
|
|
3201
|
+
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
3202
|
+
|
|
3203
|
+
// Real-machine state: display_backend=adaptive, but the existing
|
|
3204
|
+
// `developer` worker carries per-agent layout_window=developer with
|
|
3205
|
+
// layout_index=0. macmini-equivalent state shape.
|
|
3206
|
+
crate::state::persist::save_runtime_state(
|
|
3207
|
+
&team,
|
|
3208
|
+
&json!({
|
|
3209
|
+
"session_name": "team-iso",
|
|
3210
|
+
"display_backend": "adaptive",
|
|
3211
|
+
"agents": {
|
|
3212
|
+
"developer": {
|
|
3213
|
+
"status": "running",
|
|
3214
|
+
"provider": "codex",
|
|
3215
|
+
"window": "developer",
|
|
3216
|
+
"layout_window": "developer",
|
|
3217
|
+
"layout_index": 0u64,
|
|
3218
|
+
"pane_id": "%519",
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
}),
|
|
3222
|
+
)
|
|
3223
|
+
.unwrap();
|
|
3224
|
+
seed_healthy_coordinator(&team);
|
|
3225
|
+
|
|
3226
|
+
let transport = OfflineTransport::new()
|
|
3227
|
+
.with_targets(vec![layout_pane("team-iso", "developer", "%519", 0)])
|
|
3228
|
+
.with_windows(vec![WindowName::new("developer")])
|
|
3229
|
+
.with_session_present(true);
|
|
3230
|
+
|
|
3231
|
+
let _result = crate::lifecycle::add_agent_with_transport(
|
|
3232
|
+
&team,
|
|
3233
|
+
&AgentId::new("worker2"),
|
|
3234
|
+
&role_file,
|
|
3235
|
+
false,
|
|
3236
|
+
None,
|
|
3237
|
+
&transport,
|
|
3238
|
+
);
|
|
3239
|
+
|
|
3240
|
+
let spawn_records = transport.spawn_window_records();
|
|
3241
|
+
// PRIMARY assertion: no spawn_split anywhere — adaptive placement
|
|
3242
|
+
// refused to group into the per-agent `developer` window.
|
|
3243
|
+
let any_split = spawn_records.iter().any(|(kind, _)| kind == "spawn_split");
|
|
3244
|
+
assert!(
|
|
3245
|
+
!any_split,
|
|
3246
|
+
"E45 end-to-end: add-agent against a per-agent live topology must \
|
|
3247
|
+
NOT issue spawn_split into any window (macmini repro: \
|
|
3248
|
+
split-window -t :developer would hijack the developer worker's \
|
|
3249
|
+
pane). Got spawn_records={spawn_records:?}"
|
|
3250
|
+
);
|
|
3251
|
+
// Defence: the spawn target name must not be `developer` (the existing
|
|
3252
|
+
// per-agent window) — if it were, the spawn would hijack.
|
|
3253
|
+
let spawned_into_developer = spawn_records
|
|
3254
|
+
.iter()
|
|
3255
|
+
.any(|(_, window)| window == "developer");
|
|
3256
|
+
assert!(
|
|
3257
|
+
!spawned_into_developer,
|
|
3258
|
+
"E45 end-to-end: spawn target must NOT be `developer` (the existing \
|
|
3259
|
+
worker's window). Got spawn_records={spawn_records:?}"
|
|
3260
|
+
);
|
|
3261
|
+
// The new worker must have been spawned somewhere (proves we reached
|
|
3262
|
+
// the spawn step and didn't refuse silently).
|
|
3263
|
+
assert!(
|
|
3264
|
+
!spawn_records.is_empty(),
|
|
3265
|
+
"E45 end-to-end: add-agent must still attempt the spawn; got no \
|
|
3266
|
+
records."
|
|
3267
|
+
);
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
/// **Regression guard**: when the live session DOES have a real `team-w1`
|
|
3271
|
+
/// adaptive window with a live pane that matches state's claim, the
|
|
3272
|
+
/// existing E43+adaptive grouping behaviour is preserved (split into team-w1
|
|
3273
|
+
/// as before). E45 only changes behaviour for per-agent windows.
|
|
3274
|
+
#[test]
|
|
3275
|
+
fn e45_real_team_w_adaptive_layout_still_groups_into_layout_window() {
|
|
3276
|
+
use crate::lifecycle::launch::adaptive_placement_for_agent;
|
|
3277
|
+
use crate::model::ids::AgentId;
|
|
3278
|
+
use crate::transport::{SessionName, WindowName};
|
|
3279
|
+
|
|
3280
|
+
let state = json!({
|
|
3281
|
+
"display_backend": "adaptive",
|
|
3282
|
+
"session_name": "team-iso",
|
|
3283
|
+
"agents": {
|
|
3284
|
+
"alpha": {
|
|
3285
|
+
"status": "running",
|
|
3286
|
+
"provider": "codex",
|
|
3287
|
+
"layout_window": "team-w1",
|
|
3288
|
+
"layout_index": 0u64,
|
|
3289
|
+
"pane_id": "%1",
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
});
|
|
3293
|
+
|
|
3294
|
+
let transport = OfflineTransport::new()
|
|
3295
|
+
.with_targets(vec![layout_pane("team-iso", "team-w1", "%1", 0)])
|
|
3296
|
+
.with_windows(vec![WindowName::new("team-w1")]);
|
|
3297
|
+
|
|
3298
|
+
let placement = adaptive_placement_for_agent(
|
|
3299
|
+
&state,
|
|
3300
|
+
&transport,
|
|
3301
|
+
&SessionName::new("team-iso"),
|
|
3302
|
+
&AgentId::new("beta"),
|
|
3303
|
+
)
|
|
3304
|
+
.expect("E45 regression guard: real team-w1 adaptive layout must still group new agents in");
|
|
3305
|
+
|
|
3306
|
+
assert_eq!(
|
|
3307
|
+
placement.layout_window.as_str(),
|
|
3308
|
+
"team-w1",
|
|
3309
|
+
"E45 regression guard: real `team-w1` adaptive window must still \
|
|
3310
|
+
host new agents (canonical adaptive behaviour). Got {placement:?}"
|
|
3311
|
+
);
|
|
3312
|
+
assert!(
|
|
3313
|
+
!placement.starts_window,
|
|
3314
|
+
"E45 regression guard: grouping into existing team-w1 → \
|
|
3315
|
+
starts_window=false. Got {placement:?}"
|
|
3316
|
+
);
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
/// **Regression guard**: helper `is_adaptive_layout_window` golden cases.
|
|
3320
|
+
/// `team-w1`, `team-w42`, `team-w7-foo` are adaptive; agent_id names and
|
|
3321
|
+
/// other shapes are not.
|
|
3322
|
+
#[test]
|
|
3323
|
+
fn e45_is_adaptive_layout_window_recognises_team_w_pattern_only() {
|
|
3324
|
+
use crate::lifecycle::launch::is_adaptive_layout_window_pub as is_adapt;
|
|
3325
|
+
|
|
3326
|
+
assert!(is_adapt("team-w1"), "team-w1 must be adaptive");
|
|
3327
|
+
assert!(is_adapt("team-w2"), "team-w2 must be adaptive");
|
|
3328
|
+
assert!(is_adapt("team-w42"), "team-w42 must be adaptive");
|
|
3329
|
+
assert!(is_adapt("team-w7-suffix"), "team-w7-suffix must be adaptive");
|
|
3330
|
+
|
|
3331
|
+
assert!(!is_adapt("developer"), "developer is per-agent, not adaptive");
|
|
3332
|
+
assert!(!is_adapt("architect"), "architect is per-agent, not adaptive");
|
|
3333
|
+
assert!(!is_adapt("demo-director"), "demo-director is per-agent");
|
|
3334
|
+
assert!(!is_adapt("leader"), "leader is not adaptive");
|
|
3335
|
+
assert!(!is_adapt(""), "empty is not adaptive");
|
|
3336
|
+
assert!(!is_adapt("team-w"), "team-w (no index) is not adaptive");
|
|
3337
|
+
assert!(!is_adapt("team-w0"), "team-w0 (index 0 invalid post-subtract) is not adaptive");
|
|
3338
|
+
assert!(!is_adapt("team-wfoo"), "team-wfoo (non-numeric) is not adaptive");
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
3342
|
+
// E42 (0.3.24 P0, double-spec deadlock) — add-agent / remove-agent must read
|
|
3343
|
+
// the SAME canonical spec, and add-agent's writes must be atomic (rollback on
|
|
3344
|
+
// any downstream failure).
|
|
3345
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
3346
|
+
|
|
3347
|
+
/// **E42 RED (a) round-trip**: stale `state.spec_path` points at a legacy
|
|
3348
|
+
/// user-dir spec while the canonical runtime spec is authoritative.
|
|
3349
|
+
/// add-agent writes to canonical; remove-agent (from_spec=true, force=true)
|
|
3350
|
+
/// must succeed because the fix routes lifecycle_paths::spec_workspace to
|
|
3351
|
+
/// canonical, not the stale legacy path.
|
|
3352
|
+
#[test]
|
|
3353
|
+
fn e42_add_then_remove_round_trip_resolves_canonical_spec_not_legacy() {
|
|
3354
|
+
let team = temp_ws().join("e42-roundtrip");
|
|
3355
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
3356
|
+
std::fs::write(
|
|
3357
|
+
team.join("TEAM.md"),
|
|
3358
|
+
"---\nname: e42rt\nobjective: E42 round-trip probe.\nprovider: codex\n---\n\nteam.\n",
|
|
3359
|
+
)
|
|
3360
|
+
.unwrap();
|
|
3361
|
+
std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap();
|
|
3362
|
+
let role_file = team.join("newagent.md");
|
|
3363
|
+
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
3364
|
+
seed_healthy_coordinator(&team);
|
|
3365
|
+
// Pre-populate the state with a STALE spec_path pointing at a legacy
|
|
3366
|
+
// user-dir spec that does NOT carry the new agent we are about to add.
|
|
3367
|
+
// canonical runtime spec is what add-agent writes to.
|
|
3368
|
+
let team_ws = crate::model::paths::team_workspace(&team).unwrap();
|
|
3369
|
+
let legacy_spec_dir = team_ws.join(".team").join("e42rt");
|
|
3370
|
+
std::fs::create_dir_all(&legacy_spec_dir).unwrap();
|
|
3371
|
+
let legacy_spec_path = legacy_spec_dir.join("team.spec.yaml");
|
|
3372
|
+
std::fs::write(
|
|
3373
|
+
&legacy_spec_path,
|
|
3374
|
+
"name: e42rt\nagents: []\nrouting:\n rules: []\n",
|
|
3375
|
+
)
|
|
3376
|
+
.unwrap();
|
|
3377
|
+
crate::state::persist::save_runtime_state(
|
|
3378
|
+
&team,
|
|
3379
|
+
&json!({
|
|
3380
|
+
"session_name": "team-iso",
|
|
3381
|
+
"spec_path": legacy_spec_path.to_string_lossy(),
|
|
3382
|
+
"agents": {
|
|
3383
|
+
"implementer": {"status": "running", "provider": "codex", "window": "implementer"}
|
|
3384
|
+
}
|
|
3385
|
+
}),
|
|
3386
|
+
)
|
|
3387
|
+
.unwrap();
|
|
3388
|
+
let transport = OfflineTransport::new();
|
|
3389
|
+
|
|
3390
|
+
// (1) add-agent succeeds (writes canonical spec + state)
|
|
3391
|
+
let add_result = crate::lifecycle::add_agent_with_transport(
|
|
3392
|
+
&team,
|
|
3393
|
+
&AgentId::new("worker2"),
|
|
3394
|
+
&role_file,
|
|
3395
|
+
false,
|
|
3396
|
+
None,
|
|
3397
|
+
&transport,
|
|
3398
|
+
);
|
|
3399
|
+
assert!(
|
|
3400
|
+
add_result.is_ok(),
|
|
3401
|
+
"E42 (a) round-trip: add-agent must succeed even with stale state.spec_path; \
|
|
3402
|
+
got {add_result:?}"
|
|
3403
|
+
);
|
|
3404
|
+
// (2) remove-agent must find the agent (lifecycle_paths resolves canonical,
|
|
3405
|
+
// not the stale legacy spec)
|
|
3406
|
+
let remove_result = crate::lifecycle::remove_agent_with_transport(
|
|
3407
|
+
&team,
|
|
3408
|
+
&AgentId::new("worker2"),
|
|
3409
|
+
true,
|
|
3410
|
+
true,
|
|
3411
|
+
None,
|
|
3412
|
+
&transport,
|
|
3413
|
+
);
|
|
3414
|
+
assert!(
|
|
3415
|
+
remove_result.is_ok(),
|
|
3416
|
+
"E42 (a) round-trip: remove-agent must find the agent add-agent just wrote \
|
|
3417
|
+
(canonical-first lifecycle_paths). Pre-fix: stale state.spec_path wins → \
|
|
3418
|
+
remove sees legacy spec without the new agent → 'unknown'. Got {remove_result:?}"
|
|
3419
|
+
);
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
/// **E42 RED (b) re-add symmetry**: after add(x), a second add(x) must say
|
|
3423
|
+
/// "already exists" AND remove(x) must recognise x — never give relative
|
|
3424
|
+
/// existence judgments. Same canonical truth source on both sides.
|
|
3425
|
+
#[test]
|
|
3426
|
+
fn e42_re_add_and_remove_agree_on_existence_after_add() {
|
|
3427
|
+
let team = temp_ws().join("e42-readdsym");
|
|
3428
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
3429
|
+
std::fs::write(
|
|
3430
|
+
team.join("TEAM.md"),
|
|
3431
|
+
"---\nname: e42sym\nobjective: E42 symmetry probe.\nprovider: codex\n---\n\nteam.\n",
|
|
3432
|
+
)
|
|
3433
|
+
.unwrap();
|
|
3434
|
+
std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap();
|
|
3435
|
+
let role_file = team.join("newagent.md");
|
|
3436
|
+
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
3437
|
+
seed_healthy_coordinator(&team);
|
|
3438
|
+
let transport = OfflineTransport::new();
|
|
3439
|
+
// (1) first add
|
|
3440
|
+
let r1 = crate::lifecycle::add_agent_with_transport(
|
|
3441
|
+
&team,
|
|
3442
|
+
&AgentId::new("worker2"),
|
|
3443
|
+
&role_file,
|
|
3444
|
+
false,
|
|
3445
|
+
None,
|
|
3446
|
+
&transport,
|
|
3447
|
+
);
|
|
3448
|
+
assert!(r1.is_ok(), "first add must succeed; got {r1:?}");
|
|
3449
|
+
// (2) second add must report already-exists Err (not Ok)
|
|
3450
|
+
let r2 = crate::lifecycle::add_agent_with_transport(
|
|
3451
|
+
&team,
|
|
3452
|
+
&AgentId::new("worker2"),
|
|
3453
|
+
&role_file,
|
|
3454
|
+
false,
|
|
3455
|
+
None,
|
|
3456
|
+
&transport,
|
|
3457
|
+
);
|
|
3458
|
+
assert!(
|
|
3459
|
+
r2.is_err(),
|
|
3460
|
+
"E42 (b) symmetry: second add must Err 'already exists'; got {r2:?}"
|
|
3461
|
+
);
|
|
3462
|
+
// (3) remove must also see the agent (no relative judgments)
|
|
3463
|
+
let r3 = crate::lifecycle::remove_agent_with_transport(
|
|
3464
|
+
&team,
|
|
3465
|
+
&AgentId::new("worker2"),
|
|
3466
|
+
true,
|
|
3467
|
+
true,
|
|
3468
|
+
None,
|
|
3469
|
+
&transport,
|
|
3470
|
+
);
|
|
3471
|
+
assert!(
|
|
3472
|
+
r3.is_ok(),
|
|
3473
|
+
"E42 (b) symmetry: remove must see what add wrote — both reading canonical. \
|
|
3474
|
+
Got {r3:?}"
|
|
3475
|
+
);
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
/// **E42 RED (d) projection consistency**: after add(x), canonical runtime
|
|
3479
|
+
/// spec AND runtime state agents map BOTH contain x; lifecycle_paths
|
|
3480
|
+
/// resolves spec_workspace to canonical (not legacy).
|
|
3481
|
+
#[test]
|
|
3482
|
+
fn e42_add_writes_to_canonical_runtime_spec_and_state_consistently() {
|
|
3483
|
+
let team = temp_ws().join("e42-projection");
|
|
3484
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
3485
|
+
std::fs::write(
|
|
3486
|
+
team.join("TEAM.md"),
|
|
3487
|
+
"---\nname: e42proj\nobjective: E42 projection probe.\nprovider: codex\n---\n\nteam.\n",
|
|
3488
|
+
)
|
|
3489
|
+
.unwrap();
|
|
3490
|
+
std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap();
|
|
3491
|
+
let role_file = team.join("newagent.md");
|
|
3492
|
+
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
3493
|
+
seed_healthy_coordinator(&team);
|
|
3494
|
+
let transport = OfflineTransport::new();
|
|
3495
|
+
let _ = crate::lifecycle::add_agent_with_transport(
|
|
3496
|
+
&team,
|
|
3497
|
+
&AgentId::new("worker2"),
|
|
3498
|
+
&role_file,
|
|
3499
|
+
false,
|
|
3500
|
+
None,
|
|
3501
|
+
&transport,
|
|
3502
|
+
)
|
|
3503
|
+
.expect("add must succeed");
|
|
3504
|
+
|
|
3505
|
+
// (a) runtime state has worker2 — read same way add_agent does (team_workspace
|
|
3506
|
+
// resolves the run_workspace, and select_runtime_state reads canonical-scoped).
|
|
3507
|
+
let state = crate::state::persist::load_runtime_state(&team).unwrap();
|
|
3508
|
+
assert!(
|
|
3509
|
+
state.pointer("/agents/worker2").is_some(),
|
|
3510
|
+
"E42 (d): runtime state must contain worker2 after add; state={state}"
|
|
3511
|
+
);
|
|
3512
|
+
|
|
3513
|
+
// (b) canonical runtime spec has worker2
|
|
3514
|
+
let runtime_spec = find_runtime_spec(&team, "e42proj");
|
|
3515
|
+
let canonical_spec_path = runtime_spec.expect("canonical runtime spec must exist");
|
|
3516
|
+
let canonical_spec_text = std::fs::read_to_string(&canonical_spec_path).unwrap();
|
|
3517
|
+
assert!(
|
|
3518
|
+
canonical_spec_text.contains("worker2"),
|
|
3519
|
+
"E42 (d): canonical runtime spec must contain worker2; \
|
|
3520
|
+
canonical_spec={canonical_spec_text}"
|
|
3521
|
+
);
|
|
3522
|
+
}
|