@team-agent/installer 0.4.1 → 0.4.2
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/adapters.rs +82 -22
- package/crates/team-agent/src/cli/diagnose.rs +6 -2
- package/crates/team-agent/src/cli/emit.rs +301 -38
- package/crates/team-agent/src/cli/mod.rs +7 -14
- package/crates/team-agent/src/cli/status_port.rs +12 -1
- package/crates/team-agent/src/cli/tests/base.rs +6 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
- package/crates/team-agent/src/cli/tests/leader_watch.rs +5 -3
- package/crates/team-agent/src/cli/tests/main_preserved.rs +28 -2
- package/crates/team-agent/src/cli/tests/peer_allow.rs +19 -0
- package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +72 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +35 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +7 -2
- package/crates/team-agent/src/diagnose/comms.rs +10 -2
- package/crates/team-agent/src/leader/lease.rs +98 -19
- package/crates/team-agent/src/leader/rediscover/tests.rs +21 -7
- package/crates/team-agent/src/leader/rediscover.rs +16 -7
- package/crates/team-agent/src/leader/start.rs +25 -12
- package/crates/team-agent/src/leader/tests/byte_findings.rs +11 -2
- package/crates/team-agent/src/leader/tests/lease_claim.rs +248 -7
- package/crates/team-agent/src/lifecycle/launch.rs +116 -100
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -12
- package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +16 -15
- package/crates/team-agent/src/lifecycle/types.rs +1 -1
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -8
- package/crates/team-agent/src/messaging/send.rs +21 -4
- package/crates/team-agent/src/provider/adapter.rs +22 -0
- package/crates/team-agent/src/provider/session/capture.rs +228 -0
- package/crates/team-agent/src/state/identity.rs +42 -12
- package/crates/team-agent/src/state/identity_keys.rs +134 -0
- package/crates/team-agent/src/state/mod.rs +8 -0
- package/crates/team-agent/src/state/owner_gate.rs +11 -1
- package/crates/team-agent/src/state/ownership.rs +556 -0
- package/crates/team-agent/src/state/paths.rs +358 -0
- package/crates/team-agent/src/state/persist.rs +43 -5
- package/crates/team-agent/src/state/projection.rs +13 -5
- package/crates/team-agent/src/tmux_backend.rs +12 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +3 -3
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
//! Stage 0 of the identity-boundary unified plan (architect direction
|
|
2
|
+
//! 2026-06-23): `TeamScope` + `TeamRuntimePaths` foundation.
|
|
3
|
+
//!
|
|
4
|
+
//! This module is **pure additive** — it does NOT change existing on-disk
|
|
5
|
+
//! paths or behaviour. It introduces a shared vocabulary that Stage 2 (owner
|
|
6
|
+
//! repository), Stage 5 (per-team runtime state), and Stage 6 (per-team
|
|
7
|
+
//! coordinator) will all consume, so that no later stage has to hand-build
|
|
8
|
+
//! `.team/runtime/<team_key>/...` paths or guess the canonical team_key from
|
|
9
|
+
//! a display name.
|
|
10
|
+
//!
|
|
11
|
+
//! Canonical rules:
|
|
12
|
+
//! - `team_key` is the IDENTITY of a team within a workspace. Display name
|
|
13
|
+
//! (`team_dir.file_name()`) is NOT identity — two `agentX` dirs under
|
|
14
|
+
//! different parents must end up with different team_keys.
|
|
15
|
+
//! - For the foundation slice, `TeamScope::new(team_key)` accepts whatever
|
|
16
|
+
//! the caller already resolved (state/selector + state/persist already do
|
|
17
|
+
//! this work). Slug/hash promotion to global uniqueness lands in Stage 5.
|
|
18
|
+
//! - `TeamRuntimePaths` is the SINGLE place where the
|
|
19
|
+
//! `.team/runtime/<team_key>/` layout is constructed. Anywhere downstream
|
|
20
|
+
//! code today hand-joins `runtime_dir(ws).join(team_key).join(...)` it
|
|
21
|
+
//! should migrate to call a method on `TeamRuntimePaths` in a later stage.
|
|
22
|
+
//!
|
|
23
|
+
//! Single-team behaviour: unchanged. The foundation does not move data, does
|
|
24
|
+
//! not introduce new files, and is invoked by zero existing call sites yet.
|
|
25
|
+
|
|
26
|
+
use std::path::{Path, PathBuf};
|
|
27
|
+
|
|
28
|
+
use crate::model::paths::{runtime_dir, runtime_spec_path};
|
|
29
|
+
use crate::state::persist::load_runtime_state;
|
|
30
|
+
use crate::state::projection::team_state_candidates;
|
|
31
|
+
|
|
32
|
+
/// The identity of a team within a workspace. Carries the *workspace root*
|
|
33
|
+
/// and the *canonical team_key* (the directory name that state/selector
|
|
34
|
+
/// resolves; see `runtime_spec_path(workspace, team_key)`).
|
|
35
|
+
///
|
|
36
|
+
/// `TeamScope` is the input to `TeamRuntimePaths`. It deliberately does not
|
|
37
|
+
/// store a display name — display name is a UX label, not an identity.
|
|
38
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
39
|
+
pub struct TeamScope {
|
|
40
|
+
workspace: PathBuf,
|
|
41
|
+
team_key: String,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
impl TeamScope {
|
|
45
|
+
/// Construct a TeamScope from an already-resolved workspace + team_key.
|
|
46
|
+
/// Callers that have a display name must resolve it through the existing
|
|
47
|
+
/// `state::selector` machinery first; this constructor refuses an empty
|
|
48
|
+
/// team_key because that would silently fall through to the workspace
|
|
49
|
+
/// root (the exact ambiguity Stage 5 is trying to eliminate).
|
|
50
|
+
pub fn new(workspace: impl Into<PathBuf>, team_key: impl Into<String>) -> Option<Self> {
|
|
51
|
+
let team_key = team_key.into();
|
|
52
|
+
if team_key.is_empty() {
|
|
53
|
+
return None;
|
|
54
|
+
}
|
|
55
|
+
Some(Self {
|
|
56
|
+
workspace: workspace.into(),
|
|
57
|
+
team_key,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
pub fn workspace(&self) -> &Path {
|
|
62
|
+
&self.workspace
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pub fn team_key(&self) -> &str {
|
|
66
|
+
&self.team_key
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Convenience: derive the path helper from this scope.
|
|
70
|
+
pub fn paths(&self) -> TeamRuntimePaths {
|
|
71
|
+
TeamRuntimePaths::for_scope(self)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Single source of `.team/runtime/<team_key>/...` path construction.
|
|
76
|
+
///
|
|
77
|
+
/// Stage 2 (owner repository), Stage 5 (per-team state), Stage 6 (per-team
|
|
78
|
+
/// coordinator), and Stage 7 (per-team tmux socket) will all migrate from
|
|
79
|
+
/// hand-built paths to `TeamRuntimePaths`. Today nothing reads from these
|
|
80
|
+
/// methods yet — the foundation just owns the layout decision so when later
|
|
81
|
+
/// stages start writing `.team/runtime/<team_key>/state.json` (Stage 5) or
|
|
82
|
+
/// `.team/runtime/<team_key>/coordinator.pid` (Stage 6), there is exactly
|
|
83
|
+
/// one place to change the layout.
|
|
84
|
+
#[derive(Debug, Clone)]
|
|
85
|
+
pub struct TeamRuntimePaths {
|
|
86
|
+
workspace: PathBuf,
|
|
87
|
+
team_key: String,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
impl TeamRuntimePaths {
|
|
91
|
+
pub fn new(workspace: impl Into<PathBuf>, team_key: impl Into<String>) -> Self {
|
|
92
|
+
Self {
|
|
93
|
+
workspace: workspace.into(),
|
|
94
|
+
team_key: team_key.into(),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
pub fn for_scope(scope: &TeamScope) -> Self {
|
|
99
|
+
Self::new(scope.workspace().to_path_buf(), scope.team_key().to_string())
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pub fn workspace(&self) -> &Path {
|
|
103
|
+
&self.workspace
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
pub fn team_key(&self) -> &str {
|
|
107
|
+
&self.team_key
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// `.team/runtime/<team_key>/` — the team's runtime directory. Today this
|
|
111
|
+
/// already exists (runtime_spec lives under it). Stage 5 will start
|
|
112
|
+
/// writing `state.json` here too.
|
|
113
|
+
pub fn team_dir(&self) -> PathBuf {
|
|
114
|
+
runtime_dir(&self.workspace).join(&self.team_key)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// `.team/runtime/<team_key>/team.spec.yaml` — runtime spec. Mirrors the
|
|
118
|
+
/// existing `runtime_spec_path` helper; provided here so downstream
|
|
119
|
+
/// callers don't need to import two layout APIs.
|
|
120
|
+
pub fn spec_path(&self) -> PathBuf {
|
|
121
|
+
runtime_spec_path(&self.workspace, &self.team_key)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// `.team/runtime/<team_key>/state.json` — the canonical per-team state
|
|
125
|
+
/// path that Stage 5 will start writing. Not used by Stage 0 product
|
|
126
|
+
/// code; callers must continue using `runtime_state_path` until Stage 5
|
|
127
|
+
/// migrates the truth source.
|
|
128
|
+
pub fn state_path(&self) -> PathBuf {
|
|
129
|
+
self.team_dir().join("state.json")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// `.team/runtime/<team_key>/coordinator.pid` — Stage 6 sidecar location.
|
|
133
|
+
pub fn coordinator_pid_path(&self) -> PathBuf {
|
|
134
|
+
self.team_dir().join("coordinator.pid")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// `.team/runtime/<team_key>/coordinator.log` — Stage 6 sidecar location.
|
|
138
|
+
pub fn coordinator_log_path(&self) -> PathBuf {
|
|
139
|
+
self.team_dir().join("coordinator.log")
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Stage 4 of identity-boundary unified plan (architect direction
|
|
144
|
+
/// 2026-06-24, .team/artifacts/identity-boundary-unified-plan.md §2 Stage
|
|
145
|
+
/// 4): the CLI ambiguity gate for destructive commands. Pre-Stage-4 a
|
|
146
|
+
/// bare `shutdown` / `restart` / `reset-agent` / etc. on a workspace with
|
|
147
|
+
/// two alive teams silently picked the first one — exactly the
|
|
148
|
+
/// "active_team_key as destructive command authority" anti-pattern §4 不可改项
|
|
149
|
+
/// rejects. `CommandScope` makes this state explicit.
|
|
150
|
+
///
|
|
151
|
+
/// Usage from CLI dispatch:
|
|
152
|
+
/// let scope = CommandScope::resolve(workspace, args.team.as_deref());
|
|
153
|
+
/// scope.require_unambiguous_for_destructive(&workspace)?;
|
|
154
|
+
///
|
|
155
|
+
/// For single-team workspaces (the 0.4.x baseline) `Unambiguous` is
|
|
156
|
+
/// returned and the gate is a no-op. For two+ alive teams the gate
|
|
157
|
+
/// refuses with a `MissingTeamScope` error listing the candidates.
|
|
158
|
+
#[derive(Debug, Clone)]
|
|
159
|
+
pub enum CommandScope {
|
|
160
|
+
/// Caller passed `--team X` explicitly (or there's only one alive team
|
|
161
|
+
/// and we resolved it). Carries the canonical team_key.
|
|
162
|
+
Resolved(String),
|
|
163
|
+
/// No `--team` and multiple alive teams. Destructive commands MUST
|
|
164
|
+
/// refuse with this list of candidates so the operator chooses
|
|
165
|
+
/// explicitly. Read-only commands may still proceed (their scope is
|
|
166
|
+
/// "all teams" by default).
|
|
167
|
+
Ambiguous(Vec<String>),
|
|
168
|
+
/// No `--team` and no teams alive yet (fresh workspace) — bare
|
|
169
|
+
/// commands fall through to legacy single-team behaviour.
|
|
170
|
+
EmptyWorkspace,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
impl CommandScope {
|
|
174
|
+
/// Resolve the CLI's `--team` argument against the workspace state.
|
|
175
|
+
/// On any I/O error the empty case is returned — destructive commands
|
|
176
|
+
/// will run their own selector and surface the real error.
|
|
177
|
+
pub fn resolve(workspace: &Path, requested_team: Option<&str>) -> Self {
|
|
178
|
+
if let Some(team) = requested_team.filter(|t| !t.is_empty()) {
|
|
179
|
+
return Self::Resolved(team.to_string());
|
|
180
|
+
}
|
|
181
|
+
let Ok(state) = load_runtime_state(workspace) else {
|
|
182
|
+
return Self::EmptyWorkspace;
|
|
183
|
+
};
|
|
184
|
+
let alive = team_state_candidates(&state);
|
|
185
|
+
match alive.len() {
|
|
186
|
+
0 => Self::EmptyWorkspace,
|
|
187
|
+
1 => Self::Resolved(
|
|
188
|
+
alive.keys().next().cloned().unwrap_or_default(),
|
|
189
|
+
),
|
|
190
|
+
_ => {
|
|
191
|
+
let mut keys: Vec<String> = alive.keys().cloned().collect();
|
|
192
|
+
keys.sort();
|
|
193
|
+
Self::Ambiguous(keys)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/// Convert to an `Option<String>` for backward compatibility with
|
|
199
|
+
/// existing `args.team.as_deref()` call sites.
|
|
200
|
+
pub fn team_key(&self) -> Option<&str> {
|
|
201
|
+
match self {
|
|
202
|
+
Self::Resolved(key) => Some(key.as_str()),
|
|
203
|
+
_ => None,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// True iff there are 2+ alive teams and no explicit `--team`.
|
|
208
|
+
pub fn is_ambiguous(&self) -> bool {
|
|
209
|
+
matches!(self, Self::Ambiguous(_))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// The candidate list when ambiguous; empty otherwise.
|
|
213
|
+
pub fn candidates(&self) -> &[String] {
|
|
214
|
+
match self {
|
|
215
|
+
Self::Ambiguous(keys) => keys,
|
|
216
|
+
_ => &[],
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#[cfg(test)]
|
|
222
|
+
mod tests {
|
|
223
|
+
use super::*;
|
|
224
|
+
|
|
225
|
+
#[test]
|
|
226
|
+
fn team_scope_refuses_empty_team_key() {
|
|
227
|
+
assert!(TeamScope::new(PathBuf::from("/ws"), "").is_none());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#[test]
|
|
231
|
+
fn team_scope_accepts_workspace_and_team_key() {
|
|
232
|
+
let scope = TeamScope::new(PathBuf::from("/ws"), "alpha").unwrap();
|
|
233
|
+
assert_eq!(scope.workspace(), Path::new("/ws"));
|
|
234
|
+
assert_eq!(scope.team_key(), "alpha");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#[test]
|
|
238
|
+
fn team_runtime_paths_layout_matches_existing_runtime_dir_layout() {
|
|
239
|
+
let paths = TeamRuntimePaths::new(PathBuf::from("/ws/proj"), "alpha");
|
|
240
|
+
assert_eq!(paths.team_dir(), PathBuf::from("/ws/proj/.team/runtime/alpha"));
|
|
241
|
+
assert_eq!(
|
|
242
|
+
paths.spec_path(),
|
|
243
|
+
PathBuf::from("/ws/proj/.team/runtime/alpha/team.spec.yaml")
|
|
244
|
+
);
|
|
245
|
+
assert_eq!(
|
|
246
|
+
paths.state_path(),
|
|
247
|
+
PathBuf::from("/ws/proj/.team/runtime/alpha/state.json")
|
|
248
|
+
);
|
|
249
|
+
assert_eq!(
|
|
250
|
+
paths.coordinator_pid_path(),
|
|
251
|
+
PathBuf::from("/ws/proj/.team/runtime/alpha/coordinator.pid")
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#[test]
|
|
256
|
+
fn two_different_team_keys_in_same_workspace_get_different_paths() {
|
|
257
|
+
let alpha = TeamRuntimePaths::new(PathBuf::from("/ws"), "alpha");
|
|
258
|
+
let beta = TeamRuntimePaths::new(PathBuf::from("/ws"), "beta");
|
|
259
|
+
assert_ne!(alpha.team_dir(), beta.team_dir());
|
|
260
|
+
assert_ne!(alpha.state_path(), beta.state_path());
|
|
261
|
+
assert_ne!(alpha.coordinator_pid_path(), beta.coordinator_pid_path());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[test]
|
|
265
|
+
fn team_scope_to_paths_round_trip() {
|
|
266
|
+
let scope = TeamScope::new(PathBuf::from("/ws"), "alpha").unwrap();
|
|
267
|
+
let paths = scope.paths();
|
|
268
|
+
assert_eq!(paths.workspace(), scope.workspace());
|
|
269
|
+
assert_eq!(paths.team_key(), scope.team_key());
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─────────────── Stage 4: CommandScope tests ───────────────
|
|
273
|
+
|
|
274
|
+
fn tmp_workspace(label: &str) -> PathBuf {
|
|
275
|
+
let n = std::time::SystemTime::now()
|
|
276
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
277
|
+
.map(|d| d.as_nanos())
|
|
278
|
+
.unwrap_or(0);
|
|
279
|
+
let ws = std::env::temp_dir().join(format!(
|
|
280
|
+
"ta_cmdscope_{}_{}_{}",
|
|
281
|
+
label,
|
|
282
|
+
std::process::id(),
|
|
283
|
+
n
|
|
284
|
+
));
|
|
285
|
+
std::fs::create_dir_all(&ws).unwrap();
|
|
286
|
+
ws
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#[test]
|
|
290
|
+
fn command_scope_explicit_team_always_wins() {
|
|
291
|
+
let ws = tmp_workspace("explicit");
|
|
292
|
+
let scope = CommandScope::resolve(&ws, Some("alpha"));
|
|
293
|
+
assert_eq!(scope.team_key(), Some("alpha"));
|
|
294
|
+
assert!(!scope.is_ambiguous());
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#[test]
|
|
298
|
+
fn command_scope_empty_workspace_falls_through() {
|
|
299
|
+
let ws = tmp_workspace("empty");
|
|
300
|
+
// No state file written.
|
|
301
|
+
let scope = CommandScope::resolve(&ws, None);
|
|
302
|
+
assert!(scope.team_key().is_none());
|
|
303
|
+
assert!(!scope.is_ambiguous());
|
|
304
|
+
assert!(matches!(scope, CommandScope::EmptyWorkspace));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#[test]
|
|
308
|
+
fn command_scope_single_alive_team_resolves_automatically() {
|
|
309
|
+
let ws = tmp_workspace("single");
|
|
310
|
+
crate::state::persist::save_runtime_state(
|
|
311
|
+
&ws,
|
|
312
|
+
&serde_json::json!({
|
|
313
|
+
"teams": {"alpha": {"status": "alive"}},
|
|
314
|
+
}),
|
|
315
|
+
)
|
|
316
|
+
.unwrap();
|
|
317
|
+
let scope = CommandScope::resolve(&ws, None);
|
|
318
|
+
assert_eq!(scope.team_key(), Some("alpha"));
|
|
319
|
+
assert!(!scope.is_ambiguous());
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#[test]
|
|
323
|
+
fn command_scope_two_alive_teams_no_explicit_team_is_ambiguous() {
|
|
324
|
+
let ws = tmp_workspace("multi");
|
|
325
|
+
crate::state::persist::save_runtime_state(
|
|
326
|
+
&ws,
|
|
327
|
+
&serde_json::json!({
|
|
328
|
+
"teams": {
|
|
329
|
+
"alpha": {"status": "alive"},
|
|
330
|
+
"beta": {"status": "alive"},
|
|
331
|
+
},
|
|
332
|
+
}),
|
|
333
|
+
)
|
|
334
|
+
.unwrap();
|
|
335
|
+
let scope = CommandScope::resolve(&ws, None);
|
|
336
|
+
assert!(scope.is_ambiguous());
|
|
337
|
+
assert_eq!(scope.candidates(), &["alpha".to_string(), "beta".to_string()]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#[test]
|
|
341
|
+
fn command_scope_archived_team_does_not_count_as_alive() {
|
|
342
|
+
let ws = tmp_workspace("archived");
|
|
343
|
+
crate::state::persist::save_runtime_state(
|
|
344
|
+
&ws,
|
|
345
|
+
&serde_json::json!({
|
|
346
|
+
"teams": {
|
|
347
|
+
"alpha": {"status": "alive"},
|
|
348
|
+
"beta": {"archived_at": "2026-06-01T00:00:00Z"},
|
|
349
|
+
},
|
|
350
|
+
}),
|
|
351
|
+
)
|
|
352
|
+
.unwrap();
|
|
353
|
+
let scope = CommandScope::resolve(&ws, None);
|
|
354
|
+
// Only alpha counts → not ambiguous, resolves to alpha.
|
|
355
|
+
assert_eq!(scope.team_key(), Some("alpha"));
|
|
356
|
+
assert!(!scope.is_ambiguous());
|
|
357
|
+
}
|
|
358
|
+
}
|
|
@@ -204,14 +204,22 @@ fn save_runtime_state_with_merge_exceptions(
|
|
|
204
204
|
skip_capture_backfill_agent_ids: &[&str],
|
|
205
205
|
) -> Result<(), StateError> {
|
|
206
206
|
let path = runtime_state_path(workspace);
|
|
207
|
-
if cache_equals(&path, state) {
|
|
208
|
-
return Ok(());
|
|
209
|
-
}
|
|
210
207
|
// Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
|
|
211
208
|
// 我们 `&Value` 不可变 → 克隆后迁移,后续比较/写入/缓存/self-heal 全走 `migrated`。
|
|
212
209
|
// 该步**不**包 try/except → 错误 propagate(对齐 Python)。
|
|
213
210
|
let mut migrated = state.clone();
|
|
214
211
|
migrate_state_identity(&mut migrated, &SystemEnv, workspace)?;
|
|
212
|
+
// Stage 3 save-output canonical-aware strip (architect direction
|
|
213
|
+
// 2026-06-24, .team/artifacts/stage3-save-strip-fix.md): when the
|
|
214
|
+
// state carries a canonical `teams.<active>.team_owner` record, drop
|
|
215
|
+
// the legacy top-level `team_owner / leader_receiver / owner_epoch`
|
|
216
|
+
// before any cache comparison. The pre-fix order ran a `cache_equals`
|
|
217
|
+
// check against the RAW (pre-strip) state at the function entry — if
|
|
218
|
+
// disk and cache were already in the dual-source (post-restart /
|
|
219
|
+
// post-shutdown) shape, the early return would skip the cleanup
|
|
220
|
+
// entirely. Moving migrate+strip ahead of every cache_equals makes
|
|
221
|
+
// the canonical-only shape the cache invariant for new writes.
|
|
222
|
+
crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut migrated);
|
|
215
223
|
if cache_equals(&path, &migrated) {
|
|
216
224
|
return Ok(());
|
|
217
225
|
}
|
|
@@ -224,6 +232,14 @@ fn save_runtime_state_with_merge_exceptions(
|
|
|
224
232
|
if let Ok(mut existing) = serde_json::from_str::<Value>(&text) {
|
|
225
233
|
normalize_agent_session_state(&mut existing);
|
|
226
234
|
let _ = migrate_state_identity(&mut existing, &SystemEnv, workspace);
|
|
235
|
+
// Stage 3 save-output strip applied to `existing` too so
|
|
236
|
+
// the equality comparison is between two canonical-only
|
|
237
|
+
// shapes. Without this, a pre-fix dual-source disk state
|
|
238
|
+
// would never match the post-fix canonical-only `migrated`
|
|
239
|
+
// and would force a spurious rewrite on every save.
|
|
240
|
+
crate::state::ownership::strip_top_level_ownership_if_canonical_present(
|
|
241
|
+
&mut existing,
|
|
242
|
+
);
|
|
227
243
|
if existing == migrated {
|
|
228
244
|
cache_set(&path, &migrated);
|
|
229
245
|
return Ok(());
|
|
@@ -257,6 +273,12 @@ fn save_runtime_state_with_merge_exceptions(
|
|
|
257
273
|
&skip_capture_backfill,
|
|
258
274
|
);
|
|
259
275
|
}
|
|
276
|
+
// Stage 3 save-output strip second pass (defence-in-depth): after the
|
|
277
|
+
// `preserve_latest_roster_entries` lock-held merge, a future addition
|
|
278
|
+
// to the preserve path could re-introduce root owner fields from the
|
|
279
|
+
// on-disk latest. Re-strip here so the serialized payload is always
|
|
280
|
+
// canonical-only when a canonical teams.<key>.team_owner is present.
|
|
281
|
+
crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut migrated);
|
|
260
282
|
// 字节对拍 Python json.dumps(indent=2, ensure_ascii=False)(无尾换行)。
|
|
261
283
|
let payload = serde_json::to_string_pretty(&migrated)?;
|
|
262
284
|
let delays = [0.05_f64, 0.2, 0.5];
|
|
@@ -331,7 +353,16 @@ fn preserve_latest_roster_entries(
|
|
|
331
353
|
skip_top_level_capture_backfill,
|
|
332
354
|
skip_capture_backfill_agent_ids,
|
|
333
355
|
);
|
|
334
|
-
|
|
356
|
+
// Stage 3c (identity-boundary unified plan, architect direction
|
|
357
|
+
// 2026-06-23): top-level owner copy-back removed. Pre-3c this
|
|
358
|
+
// copied legacy `state.{team_owner, leader_receiver, owner_epoch}`
|
|
359
|
+
// from disk's latest back into the incoming save — the persist-side
|
|
360
|
+
// mirror of the projection promote that 3b removed. With Stage 3a's
|
|
361
|
+
// write_owner funneling all owner mutations and 3b's projection
|
|
362
|
+
// cleanup, top-level owner truth is no longer authoritative; the
|
|
363
|
+
// teams.<key> entry-scoped preservation at :370 remains so legacy
|
|
364
|
+
// teams.<key>.team_owner entries still survive a save that omits
|
|
365
|
+
// them.
|
|
335
366
|
}
|
|
336
367
|
|
|
337
368
|
if projection_matches {
|
|
@@ -381,7 +412,14 @@ fn preserve_latest_roster_entries(
|
|
|
381
412
|
should_skip_capture_backfill(Some(active_team), skip_capture_backfill_team_key),
|
|
382
413
|
skip_capture_backfill_agent_ids,
|
|
383
414
|
);
|
|
384
|
-
|
|
415
|
+
// Stage 3c (identity-boundary unified plan, architect direction
|
|
416
|
+
// 2026-06-23): top-level → teams.<active> owner cross-promote
|
|
417
|
+
// removed. This was the persist-side mirror of the projection
|
|
418
|
+
// copy-back (3b) and a quiet migration path that let stale
|
|
419
|
+
// top-level owner re-seed teams.<active>. With write_owner
|
|
420
|
+
// (3a) producing both top-level and teams.<key> directly,
|
|
421
|
+
// this auto-promotion is redundant and reintroduces the dual
|
|
422
|
+
// source the unified plan eliminates.
|
|
385
423
|
}
|
|
386
424
|
}
|
|
387
425
|
}
|
|
@@ -317,17 +317,25 @@ pub fn project_top_level_view(state: &Value, team_key: &str) -> Value {
|
|
|
317
317
|
.get("teams")
|
|
318
318
|
.and_then(Value::as_object)
|
|
319
319
|
.is_some_and(|teams| !teams.is_empty());
|
|
320
|
-
//
|
|
320
|
+
// Stage 3b (identity-boundary unified plan, architect direction 2026-06-23):
|
|
321
|
+
// remove the top-level owner promote. Pre-3b, when `teams.<key>` lacked
|
|
322
|
+
// an owner entry the projection promoted the legacy top-level
|
|
323
|
+
// `state.team_owner` into the projected view — that's the
|
|
324
|
+
// "copy-back/promotion" the architect §Stage 3 calls out as the
|
|
325
|
+
// dual-source bug origin (stale top-level owner could be served to
|
|
326
|
+
// callers of a different team's projection). Now only the canonical
|
|
327
|
+
// teams.<key> branch promotes; callers needing the legacy top-level
|
|
328
|
+
// path must go through `state::ownership::read_owner_for_team` whose
|
|
329
|
+
// precedence rule applies the migration-precedence semantics
|
|
330
|
+
// explicitly (architect §3: teams > top-level only when
|
|
331
|
+
// team_state_key matches).
|
|
321
332
|
if let Some(v) = entry_obj.get("team_owner") {
|
|
322
333
|
p.insert("team_owner".to_string(), v.clone());
|
|
323
|
-
} else if !has_team_entries && state.get("team_owner").is_some_and(|v| !v.is_null()) {
|
|
324
|
-
p.insert("team_owner".to_string(), state["team_owner"].clone());
|
|
325
334
|
}
|
|
326
335
|
if let Some(v) = entry_obj.get("leader_receiver") {
|
|
327
336
|
p.insert("leader_receiver".to_string(), v.clone());
|
|
328
|
-
} else if !has_team_entries && state.get("leader_receiver").is_some_and(|v| !v.is_null()) {
|
|
329
|
-
p.insert("leader_receiver".to_string(), state["leader_receiver"].clone());
|
|
330
337
|
}
|
|
338
|
+
let _ = has_team_entries; // silence unused warning; kept for clarity.
|
|
331
339
|
// coordinator:仅顶层有 key 时 setdefault(投影里没有才插)。
|
|
332
340
|
if state.as_object().is_some_and(|o| o.contains_key("coordinator")) && !p.contains_key("coordinator") {
|
|
333
341
|
p.insert("coordinator".to_string(), state["coordinator"].clone());
|
|
@@ -502,6 +502,18 @@ pub(crate) fn attach_command_for_workspace(
|
|
|
502
502
|
))
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
pub(crate) fn attach_command_for_session(
|
|
506
|
+
workspace: &Path,
|
|
507
|
+
session_name: &SessionName,
|
|
508
|
+
) -> Option<String> {
|
|
509
|
+
let socket_path = socket_path_for_workspace(workspace)?;
|
|
510
|
+
Some(format!(
|
|
511
|
+
"tmux -S {} attach -t {}",
|
|
512
|
+
socket_path.display(),
|
|
513
|
+
session_name.as_str()
|
|
514
|
+
))
|
|
515
|
+
}
|
|
516
|
+
|
|
505
517
|
/// Bug #7 (prerelease 0.4.0 gate review §6): when the runtime state carries a
|
|
506
518
|
/// persisted `tmux_endpoint` / `tmux_socket` (e.g. `/private/tmp/tmux-501/default`),
|
|
507
519
|
/// the attach command MUST point at THAT endpoint, not the workspace-hash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.4.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.4.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.4.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.4.2",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.4.2",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.4.2"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|
|
@@ -157,7 +157,7 @@ For diagnosis, run `team-agent profile show deepseek --workspace . --json`; neve
|
|
|
157
157
|
- For real workers, `quick-start` requires a current tmux leader pane. If it says the leader must run inside tmux, restart the leader with `team-agent codex`/`team-agent claude` or use an existing tmux-managed layout, then run quick-start again.
|
|
158
158
|
- Quick-start generated files stay inside the selected team directory, for example `.team/current/` or `.team/alpha/`; do not create or expect root `team.spec.yaml` or `team_state.md`.
|
|
159
159
|
- Use `team-agent quick-start ./roles --team-id alpha` to create a second generated team under `.team/alpha/`, or pass an existing team directory directly such as `team-agent quick-start .team/alpha`.
|
|
160
|
-
- `quick-start` is for
|
|
160
|
+
- `quick-start` is only for first-time team creation from role docs. If that team already has runtime state, use `team-agent restart . --team <session_name_or_team_name>` to resume it. If restart cannot recover context, explain the loss and wait for explicit user consent before using `team-agent restart . --allow-fresh`; never reset context through quick-start.
|
|
161
161
|
- If the user explicitly asks a worker to create or operate a nested child team, first read `references/team-in-team.md`. Child teams must use an independent child workspace, never the parent `.team/current`.
|
|
162
162
|
- `team-agent send --watch-result coder "Do the bounded task"` sends a direct worker message, returns after delivery, and lets the coordinator collect/report completion asynchronously.
|
|
163
163
|
- After `send --watch-result` succeeds, do not run `sleep`, `status`, `inbox`, or `collect` polling loops unless the user explicitly asks for diagnosis; the coordinator will notify the leader when the result arrives.
|
|
@@ -181,7 +181,7 @@ Use `team-agent start-agent <agent_id> --workspace .` only as a narrow repair wh
|
|
|
181
181
|
|
|
182
182
|
## Adding A New Worker At Runtime
|
|
183
183
|
|
|
184
|
-
To add a new worker to a running team, write the role doc and run **one command** — do not shutdown/restart, do not regenerate the compiled spec, do not
|
|
184
|
+
To add a new worker to a running team, write the role doc and run **one command** — do not shutdown/restart, do not regenerate the compiled spec, and do not quick-start an existing team:
|
|
185
185
|
|
|
186
186
|
```bash
|
|
187
187
|
cat > .team/current/agents/reviewer.md <<'EOF'
|
|
@@ -209,7 +209,7 @@ Semantic distinction:
|
|
|
209
209
|
- `team-agent add-agent <agent> --role-file <file>` — add a **new** worker not yet in team state.
|
|
210
210
|
- `team-agent start-agent <agent>` — (re)launch a worker that **already exists** in team state but whose window is missing.
|
|
211
211
|
- `team-agent restart .` — resume a fully **stopped** team from stored worker sessions.
|
|
212
|
-
- `team-agent quick-start <dir>` —
|
|
212
|
+
- `team-agent quick-start <dir>` — first-time team creation from role docs; for existing teams use `restart`, and use `restart --allow-fresh` only after explicit user consent to discard context.
|
|
213
213
|
|
|
214
214
|
Removing a worker at runtime is the symmetric `team-agent remove-agent <agent> --workspace . --confirm`.
|
|
215
215
|
|