@team-agent/installer 0.3.27 → 0.3.29
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/cli/mod.rs +14 -0
- package/crates/team-agent/src/layout/manager.rs +241 -0
- package/crates/team-agent/src/layout/mod.rs +29 -0
- package/crates/team-agent/src/layout/overlay.rs +122 -0
- package/crates/team-agent/src/layout/placement.rs +47 -0
- package/crates/team-agent/src/layout/recovery.rs +101 -0
- package/crates/team-agent/src/layout/sessions.rs +277 -0
- package/crates/team-agent/src/layout/worker_env.rs +267 -0
- package/crates/team-agent/src/layout/worker_window_helpers.rs +35 -0
- package/crates/team-agent/src/leader/start.rs +32 -14
- package/crates/team-agent/src/leader/tests/identity.rs +30 -15
- package/crates/team-agent/src/lib.rs +3 -0
- package/crates/team-agent/src/lifecycle/launch.rs +58 -65
- package/crates/team-agent/src/lifecycle/restart/agent.rs +18 -10
- package/crates/team-agent/src/lifecycle/restart/common.rs +16 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +49 -75
- package/crates/team-agent/src/messaging/delivery.rs +17 -6
- package/crates/team-agent/src/messaging/results.rs +143 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +94 -15
- package/crates/team-agent/src/tmux_backend.rs +164 -62
- package/crates/team-agent/src/transport/test_support.rs +3 -1
- package/crates/team-agent/src/transport/tests/mod.rs +4 -2
- package/crates/team-agent/src/transport.rs +15 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
+
}
|