@team-agent/installer 0.3.27 → 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 +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 +29 -13
- 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 +46 -64
- 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 +26 -75
- package/crates/team-agent/src/tmux_backend/tests.rs +16 -15
- package/crates/team-agent/src/tmux_backend.rs +21 -36
- package/package.json +4 -4
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
//! 0.3.28 layout step 1 — session name constructors + topology invariant guard.
|
|
2
|
+
//!
|
|
3
|
+
//! Python 0.2.11 truth source (recap):
|
|
4
|
+
//! * Leader runs in a SEPARATE tmux session named
|
|
5
|
+
//! `team-agent-leader-<provider>-<folder>-<sha1[:8]>` (the existing
|
|
6
|
+
//! `crate::leader::start::leader_session_name`).
|
|
7
|
+
//! * Worker session is `runtime.session_name` or `team-<team.name>` (the
|
|
8
|
+
//! existing `lifecycle::launch::spec_session_name`).
|
|
9
|
+
//! * These two names MUST be disjoint by prefix
|
|
10
|
+
//! (`team-agent-leader-` vs `team-...`).
|
|
11
|
+
//!
|
|
12
|
+
//! This module re-exports those name constructors under a single namespace
|
|
13
|
+
//! and adds the topology invariant guard. No behaviour change in Step 1;
|
|
14
|
+
//! the guard runs as `tracing::warn!` so any drift is logged but not fatal.
|
|
15
|
+
|
|
16
|
+
use std::path::Path;
|
|
17
|
+
|
|
18
|
+
use crate::model::enums::Provider;
|
|
19
|
+
use crate::model::yaml::Value as YamlValue;
|
|
20
|
+
use crate::transport::SessionName;
|
|
21
|
+
use serde_json::Value as JsonValue;
|
|
22
|
+
|
|
23
|
+
/// `team-agent-leader-` — disjoint prefix for the leader session.
|
|
24
|
+
/// Re-exported from `leader::start::LEADER_SESSION_PREFIX` so layout-layer
|
|
25
|
+
/// code does not have to depend on the leader module's internals.
|
|
26
|
+
pub const LEADER_SESSION_PREFIX: &str = crate::leader::start::LEADER_SESSION_PREFIX;
|
|
27
|
+
|
|
28
|
+
/// Leader's dedicated tmux session name.
|
|
29
|
+
///
|
|
30
|
+
/// Format: `team-agent-leader-<provider_wire>-<folder>-<sha1(workspace)[:8]>`.
|
|
31
|
+
///
|
|
32
|
+
/// Re-exports `crate::leader::start::leader_session_name` so the rest of the
|
|
33
|
+
/// runtime can derive this name without touching the leader module. The
|
|
34
|
+
/// single underlying impl lives in `leader/start.rs` for now to minimise the
|
|
35
|
+
/// Step-1 diff; Step 2 will move it.
|
|
36
|
+
pub fn leader_session_name(provider: Provider, workspace: &Path) -> SessionName {
|
|
37
|
+
crate::leader::start::leader_session_name(provider, workspace)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Worker tmux session name derived from the team spec.
|
|
41
|
+
///
|
|
42
|
+
/// Format: `spec.runtime.session_name` if set, else `team-<team.name>`
|
|
43
|
+
/// (default `team-agent`). This is a thin re-export of the existing
|
|
44
|
+
/// `lifecycle::launch::worker_session_name_pub` — exposed here so layout-
|
|
45
|
+
/// layer code has one place to ask. The spec is a `model::yaml::Value`
|
|
46
|
+
/// (the team spec parser's native type).
|
|
47
|
+
pub fn worker_session_name(spec: &YamlValue) -> SessionName {
|
|
48
|
+
crate::lifecycle::launch::worker_session_name_pub(spec)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// True when `name` starts with the leader session prefix.
|
|
52
|
+
pub fn is_leader_session(name: &SessionName) -> bool {
|
|
53
|
+
name.as_str().starts_with(LEADER_SESSION_PREFIX)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// True when `name` equals the worker session name derived from `spec`.
|
|
57
|
+
pub fn is_worker_session(name: &SessionName, spec: &YamlValue) -> bool {
|
|
58
|
+
worker_session_name(spec).as_str() == name.as_str()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// A detected topology invariant violation, surfaced as a warn-level log line
|
|
62
|
+
/// during the incremental migration (Steps 1–9). Hard error in Step 10.
|
|
63
|
+
#[derive(Debug, Clone)]
|
|
64
|
+
pub struct TopologyViolation {
|
|
65
|
+
pub kind: TopologyViolationKind,
|
|
66
|
+
pub detail: String,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
70
|
+
pub enum TopologyViolationKind {
|
|
71
|
+
/// Worker session name shares the leader-session prefix.
|
|
72
|
+
WorkerSessionNamedAsLeader,
|
|
73
|
+
/// A worker window inside the worker session is named `leader`.
|
|
74
|
+
WorkerWindowNamedLeader,
|
|
75
|
+
/// Two agents share the same `pane_id` in state.
|
|
76
|
+
AgentPaneIdCollision,
|
|
77
|
+
/// `leader_receiver.pane_id` matches an active agent's `pane_id`.
|
|
78
|
+
LeaderPaneIdCollidesWithAgent,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Assert the runtime-state topology invariants (Step 1 — `warn!` only).
|
|
82
|
+
///
|
|
83
|
+
/// Checks:
|
|
84
|
+
/// 1. The worker session name does NOT start with the leader prefix.
|
|
85
|
+
/// 2. No agent window in worker session is literally named `leader`.
|
|
86
|
+
/// 3. No two agents share the same `pane_id`.
|
|
87
|
+
/// 4. `state.leader_receiver.pane_id` is not equal to any agent's
|
|
88
|
+
/// `pane_id` (E51 family — co-located lease corruption).
|
|
89
|
+
///
|
|
90
|
+
/// Returns the list of violations without panicking. Callers may choose to
|
|
91
|
+
/// emit `tracing::warn!` for each (the default in Steps 1–9) or escalate.
|
|
92
|
+
pub fn assert_topology_invariants(state: &JsonValue, spec: &YamlValue) -> Vec<TopologyViolation> {
|
|
93
|
+
let mut out = Vec::new();
|
|
94
|
+
let worker_session = worker_session_name(spec);
|
|
95
|
+
if worker_session.as_str().starts_with(LEADER_SESSION_PREFIX) {
|
|
96
|
+
out.push(TopologyViolation {
|
|
97
|
+
kind: TopologyViolationKind::WorkerSessionNamedAsLeader,
|
|
98
|
+
detail: format!(
|
|
99
|
+
"worker session name `{}` starts with leader prefix `{LEADER_SESSION_PREFIX}` — \
|
|
100
|
+
leader and worker sessions must be disjoint",
|
|
101
|
+
worker_session.as_str()
|
|
102
|
+
),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
let agents = state
|
|
106
|
+
.get("agents")
|
|
107
|
+
.and_then(JsonValue::as_object);
|
|
108
|
+
if let Some(agents) = agents {
|
|
109
|
+
for (agent_id, agent) in agents {
|
|
110
|
+
let window = agent.get("window").and_then(JsonValue::as_str).unwrap_or("");
|
|
111
|
+
if window.eq_ignore_ascii_case("leader") {
|
|
112
|
+
out.push(TopologyViolation {
|
|
113
|
+
kind: TopologyViolationKind::WorkerWindowNamedLeader,
|
|
114
|
+
detail: format!(
|
|
115
|
+
"agent `{agent_id}` lives in window `leader` of worker session \
|
|
116
|
+
`{}` — workers must use `agent_id` window names",
|
|
117
|
+
worker_session.as_str()
|
|
118
|
+
),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Pane-id collision among agents.
|
|
123
|
+
let mut by_pane: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
|
|
124
|
+
for (agent_id, agent) in agents {
|
|
125
|
+
if let Some(pane) = agent
|
|
126
|
+
.get("pane_id")
|
|
127
|
+
.and_then(JsonValue::as_str)
|
|
128
|
+
.filter(|s| !s.is_empty())
|
|
129
|
+
{
|
|
130
|
+
by_pane
|
|
131
|
+
.entry(pane.to_string())
|
|
132
|
+
.or_default()
|
|
133
|
+
.push(agent_id.to_string());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (pane, ids) in by_pane.iter() {
|
|
137
|
+
if ids.len() > 1 {
|
|
138
|
+
out.push(TopologyViolation {
|
|
139
|
+
kind: TopologyViolationKind::AgentPaneIdCollision,
|
|
140
|
+
detail: format!(
|
|
141
|
+
"pane_id `{pane}` shared by agents {ids:?} — at most one agent may bind a pane_id"
|
|
142
|
+
),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// leader_receiver.pane_id ∉ any agent.pane_id.
|
|
147
|
+
let leader_pane = state
|
|
148
|
+
.get("leader_receiver")
|
|
149
|
+
.and_then(|lr| lr.get("pane_id"))
|
|
150
|
+
.and_then(JsonValue::as_str)
|
|
151
|
+
.filter(|s| !s.is_empty());
|
|
152
|
+
if let Some(leader_pane) = leader_pane {
|
|
153
|
+
for (agent_id, agent) in agents {
|
|
154
|
+
if agent
|
|
155
|
+
.get("pane_id")
|
|
156
|
+
.and_then(JsonValue::as_str)
|
|
157
|
+
.is_some_and(|p| p == leader_pane)
|
|
158
|
+
{
|
|
159
|
+
out.push(TopologyViolation {
|
|
160
|
+
kind: TopologyViolationKind::LeaderPaneIdCollidesWithAgent,
|
|
161
|
+
detail: format!(
|
|
162
|
+
"leader_receiver.pane_id `{leader_pane}` is also bound to \
|
|
163
|
+
agent `{agent_id}` — leader pane and agent pane must be disjoint"
|
|
164
|
+
),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/// Logs each violation in `violations` to stderr under the
|
|
174
|
+
/// `team_agent::layout` tag. No-op when the list is empty. This is the
|
|
175
|
+
/// Step-1 surface — `eprintln!` matches the codebase's existing logging
|
|
176
|
+
/// convention (see `lifecycle::launch::eprintln!` calls). Hard error path
|
|
177
|
+
/// is deferred to Step 10.
|
|
178
|
+
pub fn log_topology_violations(violations: &[TopologyViolation]) {
|
|
179
|
+
for v in violations {
|
|
180
|
+
eprintln!(
|
|
181
|
+
"team_agent::layout topology_invariant_violation kind={:?} detail={}",
|
|
182
|
+
v.kind, v.detail
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#[cfg(test)]
|
|
188
|
+
mod tests {
|
|
189
|
+
use super::*;
|
|
190
|
+
use serde_json::json;
|
|
191
|
+
|
|
192
|
+
fn spec_for(team: &str) -> YamlValue {
|
|
193
|
+
YamlValue::Map(vec![(
|
|
194
|
+
"team".to_string(),
|
|
195
|
+
YamlValue::Map(vec![("name".to_string(), YamlValue::Str(team.to_string()))]),
|
|
196
|
+
)])
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#[test]
|
|
200
|
+
fn leader_and_worker_session_names_are_disjoint_by_prefix() {
|
|
201
|
+
let leader = leader_session_name(Provider::Claude, Path::new("/tmp/proj"));
|
|
202
|
+
let worker = worker_session_name(&spec_for("alpha"));
|
|
203
|
+
assert!(
|
|
204
|
+
leader.as_str().starts_with(LEADER_SESSION_PREFIX),
|
|
205
|
+
"leader name `{}` must start with `{LEADER_SESSION_PREFIX}`",
|
|
206
|
+
leader.as_str()
|
|
207
|
+
);
|
|
208
|
+
assert!(
|
|
209
|
+
!worker.as_str().starts_with(LEADER_SESSION_PREFIX),
|
|
210
|
+
"worker name `{}` must NOT start with `{LEADER_SESSION_PREFIX}`",
|
|
211
|
+
worker.as_str()
|
|
212
|
+
);
|
|
213
|
+
assert_ne!(leader.as_str(), worker.as_str());
|
|
214
|
+
assert!(is_leader_session(&leader));
|
|
215
|
+
assert!(!is_leader_session(&worker));
|
|
216
|
+
assert!(is_worker_session(&worker, &spec_for("alpha")));
|
|
217
|
+
assert!(!is_worker_session(&leader, &spec_for("alpha")));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#[test]
|
|
221
|
+
fn assert_topology_clean_state_returns_empty() {
|
|
222
|
+
let state = json!({
|
|
223
|
+
"agents": {
|
|
224
|
+
"developer": { "pane_id": "%1", "window": "developer" },
|
|
225
|
+
"tester": { "pane_id": "%2", "window": "tester" }
|
|
226
|
+
},
|
|
227
|
+
"leader_receiver": { "pane_id": "%0" }
|
|
228
|
+
});
|
|
229
|
+
let v = assert_topology_invariants(&state, &spec_for("alpha"));
|
|
230
|
+
assert!(v.is_empty(), "clean state should produce no violations; got {v:?}");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#[test]
|
|
234
|
+
fn assert_topology_flags_pane_id_collision() {
|
|
235
|
+
let state = json!({
|
|
236
|
+
"agents": {
|
|
237
|
+
"developer": { "pane_id": "%1", "window": "developer" },
|
|
238
|
+
"tester": { "pane_id": "%1", "window": "tester" }
|
|
239
|
+
},
|
|
240
|
+
"leader_receiver": { "pane_id": "%0" }
|
|
241
|
+
});
|
|
242
|
+
let v = assert_topology_invariants(&state, &spec_for("alpha"));
|
|
243
|
+
assert!(
|
|
244
|
+
v.iter().any(|x| matches!(x.kind, TopologyViolationKind::AgentPaneIdCollision)),
|
|
245
|
+
"must flag AgentPaneIdCollision; got {v:?}"
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#[test]
|
|
250
|
+
fn assert_topology_flags_leader_pane_overlap_with_agent() {
|
|
251
|
+
let state = json!({
|
|
252
|
+
"agents": {
|
|
253
|
+
"developer": { "pane_id": "%0", "window": "developer" }
|
|
254
|
+
},
|
|
255
|
+
"leader_receiver": { "pane_id": "%0" }
|
|
256
|
+
});
|
|
257
|
+
let v = assert_topology_invariants(&state, &spec_for("alpha"));
|
|
258
|
+
assert!(
|
|
259
|
+
v.iter().any(|x| matches!(x.kind, TopologyViolationKind::LeaderPaneIdCollidesWithAgent)),
|
|
260
|
+
"must flag LeaderPaneIdCollidesWithAgent; got {v:?}"
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[test]
|
|
265
|
+
fn assert_topology_flags_worker_window_named_leader() {
|
|
266
|
+
let state = json!({
|
|
267
|
+
"agents": {
|
|
268
|
+
"stray": { "pane_id": "%5", "window": "leader" }
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
let v = assert_topology_invariants(&state, &spec_for("alpha"));
|
|
272
|
+
assert!(
|
|
273
|
+
v.iter().any(|x| matches!(x.kind, TopologyViolationKind::WorkerWindowNamedLeader)),
|
|
274
|
+
"must flag WorkerWindowNamedLeader; got {v:?}"
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
//! 0.3.28 layout step 3 — worker spawn env (inherit-then-strip) + worker
|
|
2
|
+
//! spawn cwd (YAML-only).
|
|
3
|
+
//!
|
|
4
|
+
//! Python truth source actual behavior (`providers.py:130-145` + contract
|
|
5
|
+
//! test `worker_spawn_env_red`): worker env INHERITS the parent
|
|
6
|
+
//! `team-agent` process environment (proxy / CA / PATH / random user
|
|
7
|
+
//! vars like `NODE_EXTRA_CA_CERTS`) just like the user typing `codex`
|
|
8
|
+
//! in the same terminal, then OVERLAYS the Team Agent identity
|
|
9
|
+
//! (`TEAM_AGENT_WORKSPACE`, `TEAM_AGENT_ID`, `TEAM_AGENT_AGENT_ID`,
|
|
10
|
+
//! `TEAM_AGENT_OWNER_TEAM_ID`).
|
|
11
|
+
//!
|
|
12
|
+
//! The leader's identity-specific env vars (`TEAM_AGENT_LEADER_*`,
|
|
13
|
+
//! `TEAM_AGENT_MACHINE_FINGERPRINT`, `TEAM_AGENT_TEAM_ID`,
|
|
14
|
+
//! `COPILOT_DISABLE_TERMINAL_TITLE`, `TMUX`, `TMUX_PANE`) are STRIPPED
|
|
15
|
+
//! to prevent E60 identity contamination.
|
|
16
|
+
//!
|
|
17
|
+
//! Pre-0.3.28-final used a WHITELIST (only specific keys pass through)
|
|
18
|
+
//! which broke `worker_spawn_env_red` by dropping legitimate user env
|
|
19
|
+
//! (`NODE_EXTRA_CA_CERTS`, etc.). Switched to inherit-then-strip per
|
|
20
|
+
//! leader verdict.
|
|
21
|
+
//!
|
|
22
|
+
//! Python uses `workspace` as the spawn cwd unconditionally; no
|
|
23
|
+
//! per-agent override field exists in the spec. Rust pre-0.3.28 honoured
|
|
24
|
+
//! `agent.spawn_cwd` from STATE which drifts (E56). Step 3 reads only
|
|
25
|
+
//! YAML `spec.agent.spawn_cwd` and falls through to `workspace`.
|
|
26
|
+
|
|
27
|
+
use std::collections::BTreeMap;
|
|
28
|
+
use std::path::{Path, PathBuf};
|
|
29
|
+
|
|
30
|
+
use crate::model::yaml::Value as YamlValue;
|
|
31
|
+
|
|
32
|
+
/// Env-key PREFIXES that are stripped from the inherited parent env
|
|
33
|
+
/// before worker spawn. These carry leader-process identity and must
|
|
34
|
+
/// not leak into worker provider TUIs (E60 root cause).
|
|
35
|
+
const STRIP_PREFIXES: &[&str] = &[
|
|
36
|
+
"TEAM_AGENT_LEADER_",
|
|
37
|
+
"TEAM_AGENT_LEADER_BYPASS",
|
|
38
|
+
"TEAM_AGENT_MACHINE_FINGERPRINT",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/// Exact env-keys stripped from the inherited parent env.
|
|
42
|
+
const STRIP_EXACT: &[&str] = &[
|
|
43
|
+
// The leader's team_id; the worker's TEAM_AGENT_OWNER_TEAM_ID overlay
|
|
44
|
+
// re-supplies the relevant scope.
|
|
45
|
+
"TEAM_AGENT_TEAM_ID",
|
|
46
|
+
// Suppresses copilot's title-rewrite — must be re-injected by
|
|
47
|
+
// `apply_copilot_instructions_overlay` based on per-agent provider,
|
|
48
|
+
// not inherited from the leader's provider.
|
|
49
|
+
"COPILOT_DISABLE_TERMINAL_TITLE",
|
|
50
|
+
// TMUX env points at the leader's pane — would attach the worker to it.
|
|
51
|
+
"TMUX",
|
|
52
|
+
"TMUX_PANE",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/// Build the worker spawn env per Python inherit-then-strip semantics.
|
|
56
|
+
///
|
|
57
|
+
/// Inputs:
|
|
58
|
+
/// * `parent_env`: the team-agent process env (typically
|
|
59
|
+
/// `std::env::vars()`). The whole env is inherited then filtered.
|
|
60
|
+
/// * `workspace`: the team workspace root.
|
|
61
|
+
/// * `agent_id`: this worker's agent_id.
|
|
62
|
+
/// * `team_id`: optional owner team id.
|
|
63
|
+
///
|
|
64
|
+
/// Output: a `BTreeMap` containing:
|
|
65
|
+
/// * All POSIX-valid shell identifier keys from `parent_env` EXCEPT
|
|
66
|
+
/// those matching `STRIP_PREFIXES` / `STRIP_EXACT`.
|
|
67
|
+
/// * Overlay: `TEAM_AGENT_WORKSPACE`, `TEAM_AGENT_ID`,
|
|
68
|
+
/// `TEAM_AGENT_AGENT_ID`, `TEAM_AGENT_OWNER_TEAM_ID` (when present).
|
|
69
|
+
///
|
|
70
|
+
/// POSIX-validity filter: shell variable names must match
|
|
71
|
+
/// `[A-Za-z_][A-Za-z0-9_]*`. Cargo's integration-test runner exports keys
|
|
72
|
+
/// like `CARGO_BIN_EXE_team-agent` that contain a dash — those would
|
|
73
|
+
/// break `sh -lc KEY=val` assignment and are dropped.
|
|
74
|
+
pub fn worker_spawn_env<I>(
|
|
75
|
+
parent_env: I,
|
|
76
|
+
workspace: &Path,
|
|
77
|
+
agent_id: &str,
|
|
78
|
+
team_id: Option<&str>,
|
|
79
|
+
) -> BTreeMap<String, String>
|
|
80
|
+
where
|
|
81
|
+
I: IntoIterator<Item = (String, String)>,
|
|
82
|
+
{
|
|
83
|
+
let mut env: BTreeMap<String, String> = BTreeMap::new();
|
|
84
|
+
for (k, v) in parent_env {
|
|
85
|
+
if is_stripped(&k) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if !is_posix_shell_identifier(&k) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
env.insert(k, v);
|
|
92
|
+
}
|
|
93
|
+
env.insert(
|
|
94
|
+
"TEAM_AGENT_WORKSPACE".to_string(),
|
|
95
|
+
workspace.to_string_lossy().to_string(),
|
|
96
|
+
);
|
|
97
|
+
env.insert("TEAM_AGENT_ID".to_string(), agent_id.to_string());
|
|
98
|
+
env.insert("TEAM_AGENT_AGENT_ID".to_string(), agent_id.to_string());
|
|
99
|
+
if let Some(tid) = team_id.filter(|s| !s.is_empty()) {
|
|
100
|
+
env.insert("TEAM_AGENT_OWNER_TEAM_ID".to_string(), tid.to_string());
|
|
101
|
+
}
|
|
102
|
+
env
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fn is_stripped(key: &str) -> bool {
|
|
106
|
+
if STRIP_EXACT.iter().any(|exact| *exact == key) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
if STRIP_PREFIXES.iter().any(|p| key.starts_with(p)) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn is_posix_shell_identifier(s: &str) -> bool {
|
|
116
|
+
let mut bytes = s.bytes();
|
|
117
|
+
let Some(first) = bytes.next() else { return false };
|
|
118
|
+
if !(first.is_ascii_alphabetic() || first == b'_') {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Compute worker spawn cwd per Python semantics: YAML
|
|
125
|
+
/// `agent.spawn_cwd` if set, else `workspace`. The persisted-state
|
|
126
|
+
/// `agent.spawn_cwd` override (the pre-0.3.28 Rust extension that caused
|
|
127
|
+
/// E56 drift) is NOT consulted.
|
|
128
|
+
pub fn worker_spawn_cwd(spec_agent: &YamlValue, workspace: &Path) -> PathBuf {
|
|
129
|
+
spec_agent
|
|
130
|
+
.get("spawn_cwd")
|
|
131
|
+
.and_then(YamlValue::as_str)
|
|
132
|
+
.filter(|s| !s.is_empty())
|
|
133
|
+
.map(PathBuf::from)
|
|
134
|
+
.unwrap_or_else(|| workspace.to_path_buf())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#[cfg(test)]
|
|
138
|
+
mod tests {
|
|
139
|
+
use super::*;
|
|
140
|
+
|
|
141
|
+
fn make_parent_env(extras: &[(&str, &str)]) -> Vec<(String, String)> {
|
|
142
|
+
let mut env = vec![
|
|
143
|
+
("PATH".to_string(), "/usr/bin:/bin".to_string()),
|
|
144
|
+
("HOME".to_string(), "/Users/test".to_string()),
|
|
145
|
+
("TERM".to_string(), "xterm-256color".to_string()),
|
|
146
|
+
("CLAUDE_API_KEY".to_string(), "sk-test".to_string()),
|
|
147
|
+
("OPENAI_API_KEY".to_string(), "sk-test".to_string()),
|
|
148
|
+
("COPILOT_TOKEN".to_string(), "tok".to_string()),
|
|
149
|
+
// Leader identity — must be stripped.
|
|
150
|
+
("TEAM_AGENT_LEADER_PROVIDER".to_string(), "claude".to_string()),
|
|
151
|
+
("TEAM_AGENT_LEADER_SESSION_UUID".to_string(), "leader-uuid".to_string()),
|
|
152
|
+
("TEAM_AGENT_MACHINE_FINGERPRINT".to_string(), "fp".to_string()),
|
|
153
|
+
("TEAM_AGENT_TEAM_ID".to_string(), "alpha".to_string()),
|
|
154
|
+
("TEAM_AGENT_LEADER_BYPASS".to_string(), "1".to_string()),
|
|
155
|
+
("COPILOT_DISABLE_TERMINAL_TITLE".to_string(), "1".to_string()),
|
|
156
|
+
// Random noise — non-whitelisted prefix.
|
|
157
|
+
("RANDOM_NOISE".to_string(), "x".to_string()),
|
|
158
|
+
("TMUX".to_string(), "leader-pane".to_string()),
|
|
159
|
+
("TMUX_PANE".to_string(), "%0".to_string()),
|
|
160
|
+
];
|
|
161
|
+
for (k, v) in extras {
|
|
162
|
+
env.push((k.to_string(), v.to_string()));
|
|
163
|
+
}
|
|
164
|
+
env
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn worker_spawn_env_strips_leader_identity_keys() {
|
|
169
|
+
let env = worker_spawn_env(
|
|
170
|
+
make_parent_env(&[]),
|
|
171
|
+
Path::new("/ws"),
|
|
172
|
+
"developer",
|
|
173
|
+
Some("alpha"),
|
|
174
|
+
);
|
|
175
|
+
for stripped in [
|
|
176
|
+
"TEAM_AGENT_LEADER_PROVIDER",
|
|
177
|
+
"TEAM_AGENT_LEADER_SESSION_UUID",
|
|
178
|
+
"TEAM_AGENT_MACHINE_FINGERPRINT",
|
|
179
|
+
"TEAM_AGENT_TEAM_ID",
|
|
180
|
+
"TEAM_AGENT_LEADER_BYPASS",
|
|
181
|
+
"COPILOT_DISABLE_TERMINAL_TITLE",
|
|
182
|
+
"TMUX",
|
|
183
|
+
"TMUX_PANE",
|
|
184
|
+
] {
|
|
185
|
+
assert!(
|
|
186
|
+
!env.contains_key(stripped),
|
|
187
|
+
"worker env must not contain leader identity key `{stripped}`; env={env:?}"
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#[test]
|
|
193
|
+
fn worker_spawn_env_injects_agent_workspace_and_team_keys() {
|
|
194
|
+
let env = worker_spawn_env(
|
|
195
|
+
make_parent_env(&[]),
|
|
196
|
+
Path::new("/ws"),
|
|
197
|
+
"developer",
|
|
198
|
+
Some("alpha"),
|
|
199
|
+
);
|
|
200
|
+
assert_eq!(env.get("TEAM_AGENT_ID").map(String::as_str), Some("developer"));
|
|
201
|
+
assert_eq!(env.get("TEAM_AGENT_AGENT_ID").map(String::as_str), Some("developer"));
|
|
202
|
+
assert_eq!(env.get("TEAM_AGENT_WORKSPACE").map(String::as_str), Some("/ws"));
|
|
203
|
+
assert_eq!(env.get("TEAM_AGENT_OWNER_TEAM_ID").map(String::as_str), Some("alpha"));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#[test]
|
|
207
|
+
fn worker_spawn_env_inherits_parent_env_minus_strip_list() {
|
|
208
|
+
let env = worker_spawn_env(
|
|
209
|
+
make_parent_env(&[]),
|
|
210
|
+
Path::new("/ws"),
|
|
211
|
+
"developer",
|
|
212
|
+
None,
|
|
213
|
+
);
|
|
214
|
+
// Inherit-then-strip: every parent key passes through EXCEPT the
|
|
215
|
+
// strip list. PATH-like + provider creds + random user vars all
|
|
216
|
+
// present.
|
|
217
|
+
assert!(env.contains_key("PATH"));
|
|
218
|
+
assert!(env.contains_key("HOME"));
|
|
219
|
+
assert!(env.contains_key("TERM"));
|
|
220
|
+
assert!(env.contains_key("CLAUDE_API_KEY"));
|
|
221
|
+
assert!(env.contains_key("OPENAI_API_KEY"));
|
|
222
|
+
// Random user env (e.g. NODE_EXTRA_CA_CERTS, TA_SPAWN_ENV_CANARY)
|
|
223
|
+
// MUST pass through — this was the worker_spawn_env_red contract
|
|
224
|
+
// that the pre-final whitelist approach broke.
|
|
225
|
+
assert!(
|
|
226
|
+
env.contains_key("RANDOM_NOISE"),
|
|
227
|
+
"0.3.28 final: worker env inherits parent — random user vars \
|
|
228
|
+
must pass through (NODE_EXTRA_CA_CERTS / TA_SPAWN_ENV_CANARY \
|
|
229
|
+
contract). Got env={env:?}"
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#[test]
|
|
234
|
+
fn worker_spawn_env_drops_posix_invalid_keys() {
|
|
235
|
+
let parent = make_parent_env(&[("CARGO_BIN_EXE_team-agent", "/bin/x")]);
|
|
236
|
+
let env = worker_spawn_env(parent, Path::new("/ws"), "developer", None);
|
|
237
|
+
assert!(!env.contains_key("CARGO_BIN_EXE_team-agent"));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#[test]
|
|
241
|
+
fn worker_spawn_cwd_returns_workspace_when_no_spec_override() {
|
|
242
|
+
// YAML agent with no spawn_cwd field.
|
|
243
|
+
let agent = YamlValue::Map(vec![("id".to_string(), YamlValue::Str("developer".to_string()))]);
|
|
244
|
+
assert_eq!(worker_spawn_cwd(&agent, Path::new("/ws")), PathBuf::from("/ws"));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#[test]
|
|
248
|
+
fn worker_spawn_cwd_returns_yaml_override_when_present() {
|
|
249
|
+
let agent = YamlValue::Map(vec![
|
|
250
|
+
("id".to_string(), YamlValue::Str("developer".to_string())),
|
|
251
|
+
("spawn_cwd".to_string(), YamlValue::Str("/explicit/cwd".to_string())),
|
|
252
|
+
]);
|
|
253
|
+
assert_eq!(
|
|
254
|
+
worker_spawn_cwd(&agent, Path::new("/ws")),
|
|
255
|
+
PathBuf::from("/explicit/cwd")
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#[test]
|
|
260
|
+
fn worker_spawn_cwd_returns_workspace_when_spec_override_is_empty() {
|
|
261
|
+
let agent = YamlValue::Map(vec![
|
|
262
|
+
("id".to_string(), YamlValue::Str("developer".to_string())),
|
|
263
|
+
("spawn_cwd".to_string(), YamlValue::Str("".to_string())),
|
|
264
|
+
]);
|
|
265
|
+
assert_eq!(worker_spawn_cwd(&agent, Path::new("/ws")), PathBuf::from("/ws"));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//! 0.3.28 layout step 2 — small provider-wire helper for layout-layer window naming.
|
|
2
|
+
//!
|
|
3
|
+
//! Mirrors `leader::helpers::provider_wire` so the layout module does not
|
|
4
|
+
//! depend on the leader module's private helpers. Single source of truth
|
|
5
|
+
//! is `provider_command_name` from `crate::provider::adapter` (the
|
|
6
|
+
//! authoritative provider→string mapping).
|
|
7
|
+
|
|
8
|
+
use crate::model::enums::Provider;
|
|
9
|
+
|
|
10
|
+
/// Window name used inside the leader session, derived from the provider's
|
|
11
|
+
/// wire identifier (e.g. `claude`, `codex`, `copilot`, `fake`). Mirrors
|
|
12
|
+
/// Python's `leader_window_name = provider` (see
|
|
13
|
+
/// `runtime/0.2.11/src/team_agent/leader/__init__.py:114-131`).
|
|
14
|
+
pub fn provider_window_name(provider: Provider) -> &'static str {
|
|
15
|
+
match provider {
|
|
16
|
+
Provider::Claude | Provider::ClaudeCode => "claude",
|
|
17
|
+
Provider::Codex => "codex",
|
|
18
|
+
Provider::Copilot => "copilot",
|
|
19
|
+
Provider::GeminiCli => "gemini",
|
|
20
|
+
Provider::Fake => "fake",
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[cfg(test)]
|
|
25
|
+
mod tests {
|
|
26
|
+
use super::*;
|
|
27
|
+
|
|
28
|
+
#[test]
|
|
29
|
+
fn provider_window_names_are_lowercase_wire_strings() {
|
|
30
|
+
assert_eq!(provider_window_name(Provider::Claude), "claude");
|
|
31
|
+
assert_eq!(provider_window_name(Provider::ClaudeCode), "claude");
|
|
32
|
+
assert_eq!(provider_window_name(Provider::Codex), "codex");
|
|
33
|
+
assert_eq!(provider_window_name(Provider::Copilot), "copilot");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -53,12 +53,18 @@ pub fn leader_start_plan(
|
|
|
53
53
|
let state = crate::state::persist::load_runtime_state(workspace).ok();
|
|
54
54
|
let identity = leader_identity_context(workspace, None, state.as_ref())?;
|
|
55
55
|
let external_path = external_leader || attach_existing || attach_session.is_some();
|
|
56
|
+
// 0.3.28 Step 2: managed mode now uses the SAME dedicated leader session
|
|
57
|
+
// as the external path (`team-agent-leader-<provider>-<folder>-<sha1[:8]>`)
|
|
58
|
+
// — Python parity. Pre-0.3.28 the managed branch used
|
|
59
|
+
// `managed_team_session_name(identity) = team-<team_id>` which is the
|
|
60
|
+
// worker session — that co-location is the structural root of
|
|
61
|
+
// E49/E51/E53/E57-3/E60.
|
|
56
62
|
let session_name = if external_path {
|
|
57
63
|
attach_session
|
|
58
64
|
.cloned()
|
|
59
65
|
.or_else(|| Some(leader_session_name(provider, workspace)))
|
|
60
66
|
} else {
|
|
61
|
-
Some(
|
|
67
|
+
Some(leader_session_name(provider, workspace))
|
|
62
68
|
};
|
|
63
69
|
let in_tmux = std::env::var_os("TMUX").is_some();
|
|
64
70
|
if !in_tmux || !external_path {
|
|
@@ -107,8 +113,14 @@ pub fn leader_start_plan(
|
|
|
107
113
|
session_name,
|
|
108
114
|
argv,
|
|
109
115
|
provider_argv,
|
|
116
|
+
// 0.3.28 Step 2: leader window inside the dedicated leader session is
|
|
117
|
+
// named after the provider wire (e.g. `claude`, `codex`, `copilot`),
|
|
118
|
+
// never the literal string `leader`. Python parity (see
|
|
119
|
+
// `leader/__init__.py:114-131`). This eliminates the `WorkerWindowNamedLeader`
|
|
120
|
+
// topology violation surface — the worker session never has a window
|
|
121
|
+
// named `leader` either, because the leader session is disjoint.
|
|
110
122
|
leader_window: (mode == LeaderStartMode::ManagedTmuxClient)
|
|
111
|
-
.then(|| WindowName::new(
|
|
123
|
+
.then(|| WindowName::new(provider_wire(provider))),
|
|
112
124
|
is_external_leader: external_path,
|
|
113
125
|
leader_env: plan_env,
|
|
114
126
|
identity: Some(identity),
|
|
@@ -265,7 +277,7 @@ fn start_argv(
|
|
|
265
277
|
let Some(session) = session_name else {
|
|
266
278
|
return Err(LeaderError::Start("managed leader session missing".to_string()));
|
|
267
279
|
};
|
|
268
|
-
managed_client_argv(workspace, session)
|
|
280
|
+
managed_client_argv(workspace, session, provider)
|
|
269
281
|
}
|
|
270
282
|
LeaderStartMode::AttachExisting => {
|
|
271
283
|
let Some(session) = session_name else {
|
|
@@ -327,17 +339,21 @@ fn normalized_provider_args(provider_args: &[String]) -> impl Iterator<Item = St
|
|
|
327
339
|
.cloned()
|
|
328
340
|
}
|
|
329
341
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
SessionName::new(format!("team-{team}"))
|
|
336
|
-
}
|
|
337
|
-
}
|
|
342
|
+
// 0.3.28 Step 2: `managed_team_session_name` deleted. Both managed and
|
|
343
|
+
// external paths now compute the dedicated leader session via
|
|
344
|
+
// `leader_session_name(provider, workspace)` directly. The old function
|
|
345
|
+
// returned `team-<team_id>` which is the WORKER session — the structural
|
|
346
|
+
// root of E49/E51/E53/E57-3/E60.
|
|
338
347
|
|
|
339
|
-
fn managed_client_argv(
|
|
340
|
-
|
|
348
|
+
fn managed_client_argv(
|
|
349
|
+
workspace: &Path,
|
|
350
|
+
session: &SessionName,
|
|
351
|
+
provider: Provider,
|
|
352
|
+
) -> Result<Vec<String>, LeaderError> {
|
|
353
|
+
// 0.3.28 Step 2: leader window inside the dedicated leader session is
|
|
354
|
+
// named after `provider_wire(provider)` (e.g. `claude`, `codex`, `fake`),
|
|
355
|
+
// never the literal `leader`. Pre-0.3.28 this hardcoded `:leader`.
|
|
356
|
+
let target = format!("{}:{}", session.as_str(), provider_wire(provider));
|
|
341
357
|
let argv = if std::env::var_os("TMUX").is_some() {
|
|
342
358
|
vec![
|
|
343
359
|
"tmux".to_string(),
|