@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.
@@ -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
+ }