@team-agent/installer 0.3.26 → 0.3.28

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 CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.26"
569
+ version = "0.3.28"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.26"
12
+ version = "0.3.28"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -443,6 +443,20 @@ pub mod lifecycle_port {
443
443
  );
444
444
  let mut error = None;
445
445
  for session in &to_kill {
446
+ // 0.3.28 Step 6 (warn-only invariant): the kill list MUST NOT
447
+ // contain a leader session. `sessions_to_kill` already excludes
448
+ // leader-prefixed sessions, but the managed-leader cleanup path
449
+ // (this function) computes its own `to_kill` from `target`. Any
450
+ // leader-prefixed entry here is a topology violation introduced
451
+ // somewhere upstream. Log loudly and skip the kill.
452
+ if session.as_str().starts_with(crate::leader::LEADER_SESSION_PREFIX) {
453
+ eprintln!(
454
+ "team_agent::layout shutdown_invariant_violation kind=KillListContainsLeaderSession \
455
+ session=`{}` action=skipping_kill (post-Step-9 will hard-fail)",
456
+ session.as_str()
457
+ );
458
+ continue;
459
+ }
446
460
  if let Err(err) = transport.kill_session(session) {
447
461
  if !tmux_absent_error(&err.to_string()) {
448
462
  error.get_or_insert_with(|| err.to_string());
@@ -0,0 +1,241 @@
1
+ //! 0.3.28 layout step 2 — leader placement.
2
+ //!
3
+ //! Public API for managed-mode leader placement. The runtime calls
4
+ //! `leader_placement` to compute where the leader pane lives and
5
+ //! `ensure_leader_pane` to spawn/reuse it idempotently.
6
+ //!
7
+ //! Python parity (recap): leader **always** uses its dedicated session
8
+ //! `team-agent-leader-<provider>-<folder>-<sha1[:8]>`. The window inside
9
+ //! that session is named after the provider wire (`codex` / `claude` /
10
+ //! `copilot`), never the literal `leader`. The pre-0.3.28 Rust managed
11
+ //! mode co-located leader into the worker session `team-<team>` with a
12
+ //! window named `leader` — this is the root cause of E49 / E51 / E53 /
13
+ //! E57-3 / E60.
14
+
15
+ use std::path::Path;
16
+
17
+ use crate::leader::start::leader_session_name;
18
+ use crate::leader::types::{LeaderIdentity, LeaderStartMode};
19
+ use crate::model::enums::Provider;
20
+ use crate::transport::{SessionName, WindowName};
21
+
22
+ /// 0.3.28: managed-mode leader runs in its own session, window named after
23
+ /// the provider. Mirrors `team-agent-leader-<provider>-<folder>-<sha1[:8]>`
24
+ /// from Python.
25
+ #[derive(Debug, Clone, PartialEq, Eq)]
26
+ pub enum LeaderPlacement {
27
+ /// Leader executes in the current pane (caller has `$TMUX` set).
28
+ /// No new session/window is created.
29
+ ExecCurrentPane,
30
+ /// Leader runs in its dedicated session.
31
+ DedicatedSession {
32
+ session: SessionName,
33
+ window: WindowName,
34
+ },
35
+ }
36
+
37
+ /// Compute the placement for a leader given its identity, the desired
38
+ /// `LeaderStartMode`, and the environment (used to detect `$TMUX`).
39
+ ///
40
+ /// Returns `ExecCurrentPane` iff `mode == ExecProvider`; otherwise
41
+ /// `DedicatedSession{ leader_session_name(provider, workspace),
42
+ /// WindowName(provider_wire(provider)) }`.
43
+ pub fn leader_placement(
44
+ mode: LeaderStartMode,
45
+ provider: Provider,
46
+ workspace: &Path,
47
+ ) -> LeaderPlacement {
48
+ match mode {
49
+ LeaderStartMode::ExecProvider => LeaderPlacement::ExecCurrentPane,
50
+ // Managed + External + AttachExisting + NewTmuxSession all converge
51
+ // on the SAME `leader_session_name(provider, workspace)` per Python
52
+ // parity. The pre-0.3.28 managed branch used `team-<team_id>` (the
53
+ // worker session) — that divergence is what this step deletes.
54
+ _ => LeaderPlacement::DedicatedSession {
55
+ session: leader_session_name(provider, workspace),
56
+ window: WindowName::new(super::worker_window_helpers::provider_window_name(provider)),
57
+ },
58
+ }
59
+ }
60
+
61
+ /// The leader session name for a given provider + workspace. `LeaderIdentity`
62
+ /// in this codebase does not carry the provider field; the caller supplies it
63
+ /// explicitly (matches how `leader_session_name` itself is shaped).
64
+ pub fn leader_session_for_provider(provider: Provider, workspace: &Path) -> SessionName {
65
+ leader_session_name(provider, workspace)
66
+ }
67
+
68
+ #[allow(dead_code)]
69
+ fn _unused_identity_marker(_id: &LeaderIdentity) {}
70
+
71
+ // ─── Worker placement (Step 4) ──────────────────────────────────────────
72
+
73
+ use crate::layout::placement::{WorkerSpawnAction, WorkerSpawnTarget};
74
+ use crate::model::yaml::Value as YamlValue;
75
+ use crate::transport::Transport;
76
+
77
+ /// Compute the spawn target for a worker. Python parity: one window per
78
+ /// agent in the worker session, named after `agent_id`. First worker
79
+ /// (= session does not yet exist) → NewSession; otherwise NewWindow.
80
+ ///
81
+ /// `force` flag: when the window already exists, controls whether to
82
+ /// emit `ForceReplace` (caller will `kill-window` then recreate) or
83
+ /// `Noop` (caller does nothing).
84
+ pub fn next_worker_window(
85
+ spec: &YamlValue,
86
+ agent_id: &str,
87
+ transport: &dyn Transport,
88
+ force: bool,
89
+ ) -> WorkerSpawnTarget {
90
+ let session = super::sessions::worker_session_name(spec);
91
+ let window = WindowName::new(agent_id);
92
+ let session_exists = transport.has_session(&session).unwrap_or(false);
93
+ if !session_exists {
94
+ return WorkerSpawnTarget::new(session, window, WorkerSpawnAction::NewSession);
95
+ }
96
+ let window_exists = transport
97
+ .list_windows(&session)
98
+ .unwrap_or_default()
99
+ .iter()
100
+ .any(|w| w.as_str() == window.as_str());
101
+ let action = if window_exists {
102
+ if force { WorkerSpawnAction::ForceReplace } else { WorkerSpawnAction::Noop }
103
+ } else {
104
+ WorkerSpawnAction::NewWindow
105
+ };
106
+ WorkerSpawnTarget::new(session, window, action)
107
+ }
108
+
109
+ #[cfg(test)]
110
+ mod worker_placement_tests {
111
+ use super::*;
112
+
113
+ fn spec_for(team: &str) -> YamlValue {
114
+ YamlValue::Map(vec![(
115
+ "team".to_string(),
116
+ YamlValue::Map(vec![("name".to_string(), YamlValue::Str(team.to_string()))]),
117
+ )])
118
+ }
119
+
120
+ /// Pure-logic helper: same decision tree as `next_worker_window` but
121
+ /// without going through the `Transport` trait. Lets unit tests pin the
122
+ /// invariants without mocking the whole transport surface (which would
123
+ /// require ~20 unrelated method stubs).
124
+ fn next_worker_window_pure(
125
+ spec: &YamlValue,
126
+ agent_id: &str,
127
+ session_exists: bool,
128
+ window_exists: bool,
129
+ force: bool,
130
+ ) -> WorkerSpawnTarget {
131
+ let session = super::super::sessions::worker_session_name(spec);
132
+ let window = WindowName::new(agent_id);
133
+ if !session_exists {
134
+ return WorkerSpawnTarget::new(session, window, WorkerSpawnAction::NewSession);
135
+ }
136
+ let action = if window_exists {
137
+ if force { WorkerSpawnAction::ForceReplace } else { WorkerSpawnAction::Noop }
138
+ } else {
139
+ WorkerSpawnAction::NewWindow
140
+ };
141
+ WorkerSpawnTarget::new(session, window, action)
142
+ }
143
+
144
+ #[test]
145
+ fn first_worker_on_absent_session_returns_new_session() {
146
+ let t = next_worker_window_pure(&spec_for("alpha"), "developer", false, false, false);
147
+ assert_eq!(t.action, WorkerSpawnAction::NewSession);
148
+ assert_eq!(t.window.as_str(), "developer");
149
+ assert_eq!(t.session.as_str(), "team-alpha");
150
+ }
151
+
152
+ #[test]
153
+ fn second_worker_on_present_session_returns_new_window() {
154
+ let t = next_worker_window_pure(&spec_for("alpha"), "tester", true, false, false);
155
+ assert_eq!(t.action, WorkerSpawnAction::NewWindow);
156
+ assert_eq!(t.window.as_str(), "tester");
157
+ }
158
+
159
+ #[test]
160
+ fn existing_window_without_force_returns_noop() {
161
+ let t = next_worker_window_pure(&spec_for("alpha"), "developer", true, true, false);
162
+ assert_eq!(t.action, WorkerSpawnAction::Noop);
163
+ }
164
+
165
+ #[test]
166
+ fn existing_window_with_force_returns_force_replace() {
167
+ let t = next_worker_window_pure(&spec_for("alpha"), "developer", true, true, true);
168
+ assert_eq!(t.action, WorkerSpawnAction::ForceReplace);
169
+ }
170
+
171
+ #[test]
172
+ fn target_window_name_always_equals_agent_id_no_team_wN() {
173
+ // The pre-0.3.28 adaptive layout used team-w1/team-w2 windows with 3
174
+ // workers tiled per window. Step 4 design forbids that.
175
+ for agent_id in &["alice", "bob", "carol", "dave"] {
176
+ let t = next_worker_window_pure(&spec_for("alpha"), agent_id, false, false, false);
177
+ assert_eq!(t.window.as_str(), *agent_id);
178
+ assert!(
179
+ !t.window.as_str().starts_with("team-w"),
180
+ "no team-wN window allowed; got `{}`",
181
+ t.window.as_str()
182
+ );
183
+ }
184
+ }
185
+ }
186
+
187
+ #[cfg(test)]
188
+ mod tests {
189
+ use super::*;
190
+ use crate::layout::sessions::is_leader_session;
191
+
192
+ #[test]
193
+ fn exec_provider_mode_returns_exec_current_pane() {
194
+ let placement =
195
+ leader_placement(LeaderStartMode::ExecProvider, Provider::Claude, Path::new("/tmp/x"));
196
+ assert_eq!(placement, LeaderPlacement::ExecCurrentPane);
197
+ }
198
+
199
+ #[test]
200
+ fn managed_mode_returns_dedicated_leader_session() {
201
+ let placement = leader_placement(
202
+ LeaderStartMode::ManagedTmuxClient,
203
+ Provider::Claude,
204
+ Path::new("/tmp/x"),
205
+ );
206
+ match placement {
207
+ LeaderPlacement::DedicatedSession { session, window } => {
208
+ assert!(
209
+ is_leader_session(&session),
210
+ "managed mode must use the dedicated leader session prefix; got `{}`",
211
+ session.as_str()
212
+ );
213
+ assert_eq!(
214
+ window.as_str(),
215
+ "claude",
216
+ "leader window must be named after provider wire, not `leader`; got `{}`",
217
+ window.as_str()
218
+ );
219
+ }
220
+ other => panic!("expected DedicatedSession, got {other:?}"),
221
+ }
222
+ }
223
+
224
+ #[test]
225
+ fn new_tmux_session_mode_returns_same_leader_session_as_managed() {
226
+ let managed = leader_placement(
227
+ LeaderStartMode::ManagedTmuxClient,
228
+ Provider::Codex,
229
+ Path::new("/tmp/x"),
230
+ );
231
+ let new_session = leader_placement(
232
+ LeaderStartMode::NewTmuxSession,
233
+ Provider::Codex,
234
+ Path::new("/tmp/x"),
235
+ );
236
+ assert_eq!(
237
+ managed, new_session,
238
+ "managed and new-tmux-session both target the dedicated leader session"
239
+ );
240
+ }
241
+ }
@@ -0,0 +1,29 @@
1
+ //! 0.3.28 — Unified adaptive layout manager.
2
+ //!
3
+ //! Single source of truth for tmux topology decisions in the runtime:
4
+ //! * leader session vs worker session (DISJOINT by naming)
5
+ //! * worker placement (1 window per agent, named `agent_id`)
6
+ //! * shutdown vs recovery semantics
7
+ //! * display-overlay 3-pane tiling (separate from worker execution layer)
8
+ //!
9
+ //! Mirrors the Python 0.2.11 truth source. Each invariant is documented in
10
+ //! `assert_topology_invariants` so any regression surfaces as a `warn!` log
11
+ //! (during the incremental migration) and eventually as a hard error.
12
+ //!
13
+ //! Module tree per `.team/artifacts/adaptive-layout-full-architecture-locate.md`:
14
+ //! * `sessions` — name constructors + is_leader_session / is_worker_session +
15
+ //! assert_topology_invariants (Step 1, this file)
16
+ //! * `manager` — public API for leader_placement / next_worker_window /
17
+ //! ensure_leader_pane / recover_leader_pane (Steps 2/4/7)
18
+ //! * `placement` — typed LeaderPlacement / WorkerSpawnTarget enums (Step 2/4)
19
+ //! * `overlay` — 3-pane display overlay (only place split-window appears)
20
+ //! (Step 8)
21
+ //! * `worker_env` — worker_spawn_env whitelist + worker_spawn_cwd (Step 3)
22
+
23
+ pub mod sessions;
24
+ pub mod manager;
25
+ pub mod worker_window_helpers;
26
+ pub mod worker_env;
27
+ pub mod placement;
28
+ pub mod recovery;
29
+ pub mod overlay;
@@ -0,0 +1,122 @@
1
+ //! 0.3.28 layout step 8 — display overlay (3-pane tiling).
2
+ //!
3
+ //! Python truth source: 3-pane tiling exists ONLY in the display overlay
4
+ //! (`display/tiling.py:7` `DISPLAY_PANES_PER_WINDOW = 3`), which is
5
+ //! appended to the LEADER session (never the worker session). Math is
6
+ //! `i // 3` (tab index), `i % 3` (slot index), deterministic by job
7
+ //! index. Tab naming: `team-agent:<session_tag>:overview[-N]`.
8
+ //!
9
+ //! Step 8 ships the overlay computation helpers + the
10
+ //! `assert_overlay_call_site` warning that fires when `spawn_split` is
11
+ //! reached from a non-overlay path (Step 9 promotes to hard error).
12
+
13
+ use crate::transport::{SessionName, WindowName};
14
+
15
+ /// 3 panes per overlay window — Python parity.
16
+ pub const OVERLAY_PANES_PER_WINDOW: usize = 3;
17
+
18
+ /// Overlay window name for a 0-based group index. Index 0 → `overview`;
19
+ /// N ≥ 1 → `overview-{N+1}`. Names are scoped by `session_tag` so multiple
20
+ /// teams can coexist on one leader session.
21
+ pub fn overlay_window_name(session_tag: &str, group_index: usize) -> WindowName {
22
+ if group_index == 0 {
23
+ WindowName::new(format!("team-agent:{session_tag}:overview"))
24
+ } else {
25
+ WindowName::new(format!("team-agent:{session_tag}:overview-{}", group_index + 1))
26
+ }
27
+ }
28
+
29
+ /// Plan output: where each agent_id job lands in the overlay.
30
+ #[derive(Debug, Clone, PartialEq, Eq)]
31
+ pub struct OverlayPlacement {
32
+ pub agent_id: String,
33
+ pub group_index: usize,
34
+ pub slot_index: usize,
35
+ pub window: WindowName,
36
+ }
37
+
38
+ /// Compute the overlay placements for a list of agent_ids. Deterministic by
39
+ /// input order: agent `i` → window `i / 3`, slot `i % 3`.
40
+ pub fn plan_overlay_placements(session_tag: &str, agents: &[&str]) -> Vec<OverlayPlacement> {
41
+ agents
42
+ .iter()
43
+ .enumerate()
44
+ .map(|(i, agent_id)| OverlayPlacement {
45
+ agent_id: agent_id.to_string(),
46
+ group_index: i / OVERLAY_PANES_PER_WINDOW,
47
+ slot_index: i % OVERLAY_PANES_PER_WINDOW,
48
+ window: overlay_window_name(session_tag, i / OVERLAY_PANES_PER_WINDOW),
49
+ })
50
+ .collect()
51
+ }
52
+
53
+ /// True when a window name belongs to a display overlay (matches
54
+ /// `team-agent:<...>:overview[-N]`).
55
+ pub fn is_overlay_window(name: &WindowName) -> bool {
56
+ let n = name.as_str();
57
+ if !n.starts_with("team-agent:") {
58
+ return false;
59
+ }
60
+ n.contains(":overview")
61
+ }
62
+
63
+ /// 0.3.28 Step 8 invariant: `spawn_split` may ONLY be called from the
64
+ /// overlay module. This helper emits a warn-level event when called from
65
+ /// anywhere else (Step 9 promotes to a hard error / panic gate). The
66
+ /// `target_session` and `target_window` are passed so the operator sees
67
+ /// which spawn_split call drifted.
68
+ pub fn assert_overlay_call_site(target_session: &SessionName, target_window: &WindowName) {
69
+ if !is_overlay_window(target_window) {
70
+ eprintln!(
71
+ "team_agent::layout overlay_invariant_violation kind=SplitOutsideOverlay \
72
+ session=`{}` window=`{}` action=permitted_with_warning \
73
+ (post-Step-9 will hard-fail; splits must only occur in display overlay)",
74
+ target_session.as_str(),
75
+ target_window.as_str()
76
+ );
77
+ }
78
+ }
79
+
80
+ #[cfg(test)]
81
+ mod tests {
82
+ use super::*;
83
+
84
+ #[test]
85
+ fn overlay_window_name_first_group_is_overview() {
86
+ assert_eq!(
87
+ overlay_window_name("alpha", 0).as_str(),
88
+ "team-agent:alpha:overview"
89
+ );
90
+ }
91
+
92
+ #[test]
93
+ fn overlay_window_name_second_group_appends_two() {
94
+ assert_eq!(
95
+ overlay_window_name("alpha", 1).as_str(),
96
+ "team-agent:alpha:overview-2"
97
+ );
98
+ }
99
+
100
+ #[test]
101
+ fn plan_overlay_placements_three_per_window_deterministic() {
102
+ let agents = ["a", "b", "c", "d", "e", "f", "g"];
103
+ let plan = plan_overlay_placements("alpha", &agents);
104
+ assert_eq!(plan[0].group_index, 0);
105
+ assert_eq!(plan[0].slot_index, 0);
106
+ assert_eq!(plan[2].group_index, 0);
107
+ assert_eq!(plan[2].slot_index, 2);
108
+ assert_eq!(plan[3].group_index, 1);
109
+ assert_eq!(plan[3].slot_index, 0);
110
+ assert_eq!(plan[6].group_index, 2);
111
+ assert_eq!(plan[6].slot_index, 0);
112
+ }
113
+
114
+ #[test]
115
+ fn is_overlay_window_matches_overview_namespace_only() {
116
+ assert!(is_overlay_window(&WindowName::new("team-agent:alpha:overview")));
117
+ assert!(is_overlay_window(&WindowName::new("team-agent:alpha:overview-2")));
118
+ assert!(!is_overlay_window(&WindowName::new("developer")));
119
+ assert!(!is_overlay_window(&WindowName::new("team-w1")));
120
+ assert!(!is_overlay_window(&WindowName::new("team-alpha:leader")));
121
+ }
122
+ }
@@ -0,0 +1,47 @@
1
+ //! 0.3.28 layout step 4 — typed worker placement target.
2
+ //!
3
+ //! Python parity (`runtime.py:1017-1020`): one window per agent in the
4
+ //! worker session, named `agent_id`. First worker creates the session
5
+ //! (`new-session`); subsequent workers each get a new window
6
+ //! (`new-window`). No splits, no 3-pane tiling at the execution layer.
7
+ //!
8
+ //! This module provides the typed `WorkerSpawnTarget` enum returned by
9
+ //! `next_worker_window` (in `layout::manager`). Step 4 lands the API +
10
+ //! contract tests; Step 4b deletes `adaptive_layout_plan` and migrates
11
+ //! all callsites.
12
+
13
+ use crate::transport::{SessionName, WindowName};
14
+
15
+ /// Action to take when spawning a worker into the layout.
16
+ ///
17
+ /// `NewSession` is used for the first agent in a fresh team (the worker
18
+ /// session does not yet exist). `NewWindow` is used for every subsequent
19
+ /// agent. There is intentionally NO `SplitWindow` variant — splits exist
20
+ /// only in the display overlay (`layout::overlay`), never in the worker
21
+ /// execution layer.
22
+ #[derive(Debug, Clone, PartialEq, Eq)]
23
+ pub enum WorkerSpawnAction {
24
+ /// `tmux new-session -d -s <session> -n <window> ...`
25
+ NewSession,
26
+ /// `tmux new-window -d -t <session> -n <window> ...`
27
+ NewWindow,
28
+ /// Window already exists; reuse (no spawn).
29
+ Noop,
30
+ /// Window already exists; caller asked to force replace —
31
+ /// `tmux kill-window -t <session>:<window>` then recreate.
32
+ ForceReplace,
33
+ }
34
+
35
+ /// A fully-resolved spawn target for a worker.
36
+ #[derive(Debug, Clone, PartialEq, Eq)]
37
+ pub struct WorkerSpawnTarget {
38
+ pub session: SessionName,
39
+ pub window: WindowName,
40
+ pub action: WorkerSpawnAction,
41
+ }
42
+
43
+ impl WorkerSpawnTarget {
44
+ pub fn new(session: SessionName, window: WindowName, action: WorkerSpawnAction) -> Self {
45
+ Self { session, window, action }
46
+ }
47
+ }
@@ -0,0 +1,101 @@
1
+ //! 0.3.28 layout step 7 — leader pane recovery.
2
+ //!
3
+ //! Python truth source (`leader/__init__.py:885-887` +
4
+ //! `restart/orchestration.py:369-383`): leader recovery requires
5
+ //! `TMUX_PANE` env. Without it, runtime emits `leader_receiver.rebind_required`
6
+ //! and continues — NO proactive co-located leader spawn.
7
+ //!
8
+ //! Replaces the pre-0.3.28 `ensure_managed_leader_pane` path that masked
9
+ //! missing leader windows by silently spawning a new one (often into the
10
+ //! worker session — E57-3 root). After Step 2 the leader lives in its
11
+ //! own session, so the proper recovery path is:
12
+ //! 1. If `TMUX_PANE` is set → autobind to that pane.
13
+ //! 2. Else → emit `leader_receiver.rebind_required` and return
14
+ //! `NeedsUserAttach`; the user runs `team-agent attach-leader` or
15
+ //! `team-agent claim-leader` from their leader pane.
16
+
17
+ use crate::transport::PaneId;
18
+
19
+ /// Outcome of a leader recovery attempt.
20
+ #[derive(Debug, Clone, PartialEq, Eq)]
21
+ pub enum RecoveryOutcome {
22
+ /// `TMUX_PANE` env is set; caller should `autobind_leader` against this
23
+ /// pane id.
24
+ AutobindToPane(PaneId),
25
+ /// No `TMUX_PANE` env; runtime emits `leader_receiver.rebind_required`
26
+ /// and continues without a leader pane. The user must run
27
+ /// `team-agent attach-leader` from their leader pane to recover.
28
+ NeedsUserAttach,
29
+ }
30
+
31
+ /// Compute the recovery outcome from the current environment.
32
+ ///
33
+ /// Reads `TMUX_PANE` from the supplied environment iterator (use
34
+ /// `std::env::vars()` in production; tests inject a synthetic map).
35
+ pub fn recover_leader_pane<I>(env: I) -> RecoveryOutcome
36
+ where
37
+ I: IntoIterator<Item = (String, String)>,
38
+ {
39
+ for (k, v) in env {
40
+ if k == "TMUX_PANE" && !v.is_empty() {
41
+ return RecoveryOutcome::AutobindToPane(PaneId::new(v));
42
+ }
43
+ }
44
+ RecoveryOutcome::NeedsUserAttach
45
+ }
46
+
47
+ /// Emit the canonical `leader_receiver.rebind_required` event when the
48
+ /// runtime falls through to `NeedsUserAttach`. Logs to stderr so operators
49
+ /// see the cascade in event logs.
50
+ pub fn log_rebind_required(reason: &str) {
51
+ eprintln!(
52
+ "team_agent::layout leader_receiver.rebind_required reason={reason} \
53
+ action=`run team-agent attach-leader from your leader pane`"
54
+ );
55
+ }
56
+
57
+ #[cfg(test)]
58
+ mod tests {
59
+ use super::*;
60
+
61
+ #[test]
62
+ fn tmux_pane_present_returns_autobind() {
63
+ let env = vec![
64
+ ("TMUX_PANE".to_string(), "%7".to_string()),
65
+ ("PATH".to_string(), "/bin".to_string()),
66
+ ];
67
+ let outcome = recover_leader_pane(env);
68
+ match outcome {
69
+ RecoveryOutcome::AutobindToPane(pane) => assert_eq!(pane.as_str(), "%7"),
70
+ other => panic!("expected AutobindToPane(%7), got {other:?}"),
71
+ }
72
+ }
73
+
74
+ #[test]
75
+ fn no_tmux_pane_returns_needs_user_attach() {
76
+ let env = vec![("PATH".to_string(), "/bin".to_string())];
77
+ assert_eq!(recover_leader_pane(env), RecoveryOutcome::NeedsUserAttach);
78
+ }
79
+
80
+ #[test]
81
+ fn empty_tmux_pane_returns_needs_user_attach() {
82
+ let env = vec![("TMUX_PANE".to_string(), "".to_string())];
83
+ assert_eq!(recover_leader_pane(env), RecoveryOutcome::NeedsUserAttach);
84
+ }
85
+
86
+ #[test]
87
+ fn recovery_never_spawns_co_located_leader_window() {
88
+ // Python invariant #12: recovery requires TMUX_PANE; without it
89
+ // runtime emits rebind_required and CONTINUES. No silent co-located
90
+ // spawn into the worker session (the E57-3 root cause).
91
+ let env = vec![("WORKER_SESSION_LIVE".to_string(), "1".to_string())];
92
+ match recover_leader_pane(env) {
93
+ RecoveryOutcome::NeedsUserAttach => {} // expected
94
+ RecoveryOutcome::AutobindToPane(_) => {
95
+ panic!("recovery must not autobind without TMUX_PANE — \
96
+ the worker session must NEVER carry a co-located leader \
97
+ window in managed mode (E57-3 root)")
98
+ }
99
+ }
100
+ }
101
+ }