@team-agent/installer 0.3.39 → 0.4.0
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/Cargo.toml +7 -0
- package/crates/team-agent/src/cli/adapters.rs +8 -3
- package/crates/team-agent/src/cli/diagnose.rs +12 -3
- package/crates/team-agent/src/cli/emit.rs +1 -0
- package/crates/team-agent/src/cli/mod.rs +250 -9
- package/crates/team-agent/src/cli/send.rs +11 -2
- package/crates/team-agent/src/cli/status_port.rs +39 -4
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
- package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
- package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +5 -0
- package/crates/team-agent/src/coordinator/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
- package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
- package/crates/team-agent/src/coordinator/tick.rs +126 -30
- package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
- package/crates/team-agent/src/db/mod.rs +1 -0
- package/crates/team-agent/src/layout/mod.rs +7 -0
- package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
- package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
- package/crates/team-agent/src/leader/start.rs +27 -1
- package/crates/team-agent/src/leader/tests/identity.rs +50 -0
- package/crates/team-agent/src/lib.rs +6 -2
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
- package/crates/team-agent/src/lifecycle/launch.rs +34 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
- package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
- package/crates/team-agent/src/lifecycle/restart.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
- package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +20 -1
- package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
- package/crates/team-agent/src/mcp_server/tools.rs +29 -4
- package/crates/team-agent/src/provider/adapter.rs +31 -1
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
- package/crates/team-agent/src/provider/classify.rs +138 -0
- package/crates/team-agent/src/provider/command.rs +71 -0
- package/crates/team-agent/src/provider/mod.rs +3 -0
- package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
- package/crates/team-agent/src/provider/session/mod.rs +13 -0
- package/crates/team-agent/src/provider/session/resume.rs +417 -0
- package/crates/team-agent/src/provider/session_scan.rs +63 -0
- package/crates/team-agent/src/provider/types.rs +5 -0
- package/crates/team-agent/src/state/persist.rs +238 -0
- package/crates/team-agent/src/tmux_backend.rs +25 -0
- package/npm/install.mjs +27 -1
- package/package.json +4 -4
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
//! unit-1 (Stage 1) — typed runtime session identity.
|
|
2
|
+
//!
|
|
3
|
+
//! Provides two compile-time-distinct newtypes plus a `RuntimeSessions`
|
|
4
|
+
//! reader that pulls them out of `state.json` in one shot.
|
|
5
|
+
//!
|
|
6
|
+
//! Why: every place that reads `state.session_name` or
|
|
7
|
+
//! `leader_receiver.session_name` today does an ad-hoc `starts_with("team-
|
|
8
|
+
//! agent-leader-")` check (or, in the false-positive cases, doesn't). The
|
|
9
|
+
//! 0.3.39 leader-mis-kill class of bugs traces back to a string crossing
|
|
10
|
+
//! a boundary as the wrong identity.
|
|
11
|
+
//!
|
|
12
|
+
//! By going through `WorkerSession::new` / `LeaderLauncherSession::new`, a
|
|
13
|
+
//! leader-prefixed name can never be presented as a worker session by
|
|
14
|
+
//! construction. `LeaderLauncherSession::new` enforces the inverse — only
|
|
15
|
+
//! a leader-prefixed name passes.
|
|
16
|
+
//!
|
|
17
|
+
//! This unit is PURELY ADDITIVE: no existing call site is rewired yet.
|
|
18
|
+
//! unit-3 (restart preflight guard) and unit-4 (managed leader persistence
|
|
19
|
+
//! fix) are the adoption sites; they will route their state reads through
|
|
20
|
+
//! `RuntimeSessions::from_state`.
|
|
21
|
+
|
|
22
|
+
use crate::layout::sessions::{is_leader_session, LEADER_SESSION_PREFIX};
|
|
23
|
+
use crate::transport::SessionName;
|
|
24
|
+
use serde_json::Value;
|
|
25
|
+
|
|
26
|
+
/// A tmux session name that is guaranteed NOT to be a leader launcher
|
|
27
|
+
/// session (does not start with `team-agent-leader-`).
|
|
28
|
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
29
|
+
pub struct WorkerSession(SessionName);
|
|
30
|
+
|
|
31
|
+
/// A tmux session name that IS a leader launcher session (starts with
|
|
32
|
+
/// `team-agent-leader-`).
|
|
33
|
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
34
|
+
pub struct LeaderLauncherSession(SessionName);
|
|
35
|
+
|
|
36
|
+
/// Why `WorkerSession::new` rejected a name.
|
|
37
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
38
|
+
pub enum WorkerSessionError {
|
|
39
|
+
/// The name is empty.
|
|
40
|
+
Empty,
|
|
41
|
+
/// The name carries the leader launcher prefix — must not be a worker
|
|
42
|
+
/// session by construction.
|
|
43
|
+
LeaderPrefixed { name: String },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Why `LeaderLauncherSession::new` rejected a name.
|
|
47
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
48
|
+
pub enum LeaderLauncherSessionError {
|
|
49
|
+
/// The name is empty.
|
|
50
|
+
Empty,
|
|
51
|
+
/// The name does not carry the required leader launcher prefix.
|
|
52
|
+
MissingLeaderPrefix { name: String },
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
impl WorkerSession {
|
|
56
|
+
pub fn new(name: impl Into<String>) -> Result<Self, WorkerSessionError> {
|
|
57
|
+
let raw: String = name.into();
|
|
58
|
+
if raw.is_empty() {
|
|
59
|
+
return Err(WorkerSessionError::Empty);
|
|
60
|
+
}
|
|
61
|
+
if raw.starts_with(LEADER_SESSION_PREFIX) {
|
|
62
|
+
return Err(WorkerSessionError::LeaderPrefixed { name: raw });
|
|
63
|
+
}
|
|
64
|
+
Ok(WorkerSession(SessionName::new(raw)))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
pub fn as_str(&self) -> &str {
|
|
68
|
+
self.0.as_str()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pub fn into_session_name(self) -> SessionName {
|
|
72
|
+
self.0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
pub fn session_name(&self) -> &SessionName {
|
|
76
|
+
&self.0
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
impl LeaderLauncherSession {
|
|
81
|
+
pub fn new(name: impl Into<String>) -> Result<Self, LeaderLauncherSessionError> {
|
|
82
|
+
let raw: String = name.into();
|
|
83
|
+
if raw.is_empty() {
|
|
84
|
+
return Err(LeaderLauncherSessionError::Empty);
|
|
85
|
+
}
|
|
86
|
+
if !raw.starts_with(LEADER_SESSION_PREFIX) {
|
|
87
|
+
return Err(LeaderLauncherSessionError::MissingLeaderPrefix { name: raw });
|
|
88
|
+
}
|
|
89
|
+
Ok(LeaderLauncherSession(SessionName::new(raw)))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pub fn as_str(&self) -> &str {
|
|
93
|
+
self.0.as_str()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pub fn into_session_name(self) -> SessionName {
|
|
97
|
+
self.0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
pub fn session_name(&self) -> &SessionName {
|
|
101
|
+
&self.0
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// What `RuntimeSessions::from_state` learned about the runtime topology.
|
|
106
|
+
///
|
|
107
|
+
/// Both fields are `Option` because state.json may be partial: a workspace
|
|
108
|
+
/// that has never seen a successful quick-start has no `session_name`, and
|
|
109
|
+
/// `leader_receiver` is absent until a leader binds.
|
|
110
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
111
|
+
pub struct RuntimeSessions {
|
|
112
|
+
/// The worker session derived from `state.session_name`, if present
|
|
113
|
+
/// AND well-formed (not leader-prefixed).
|
|
114
|
+
pub worker: Option<WorkerSession>,
|
|
115
|
+
/// The leader launcher session derived from
|
|
116
|
+
/// `state.leader_receiver.session_name`, if present AND leader-prefixed.
|
|
117
|
+
pub leader: Option<LeaderLauncherSession>,
|
|
118
|
+
/// State fields that exist but failed validation (e.g.
|
|
119
|
+
/// `state.session_name` is leader-prefixed = the 0.3.39 bug shape).
|
|
120
|
+
/// Callers (unit-3 preflight) use this to refuse cleanly rather than
|
|
121
|
+
/// proceed with a wrong-identity name.
|
|
122
|
+
pub anomalies: Vec<RuntimeSessionAnomaly>,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
126
|
+
pub enum RuntimeSessionAnomaly {
|
|
127
|
+
/// `state.session_name` carried the leader launcher prefix — classic
|
|
128
|
+
/// 0.3.39 bug shape. cli/lifecycle MUST refuse before taking a kill
|
|
129
|
+
/// action against this string.
|
|
130
|
+
WorkerSessionNameIsLeaderPrefixed { value: String },
|
|
131
|
+
/// `leader_receiver.session_name` was present but not leader-prefixed.
|
|
132
|
+
LeaderReceiverSessionNameNotLeaderPrefixed { value: String },
|
|
133
|
+
/// `state.session_name == leader_receiver.session_name` — collision
|
|
134
|
+
/// that unit-4 explicitly fixes by stopping the persistence overwrite.
|
|
135
|
+
WorkerSessionEqualsLeaderSession { value: String },
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
impl RuntimeSessions {
|
|
139
|
+
/// Read state.json's session-identity fields and wrap them in typed
|
|
140
|
+
/// values. Never panics on bad state — anomalies are surfaced for
|
|
141
|
+
/// callers to act on.
|
|
142
|
+
pub fn from_state(state: &Value) -> Self {
|
|
143
|
+
let raw_worker = state
|
|
144
|
+
.get("session_name")
|
|
145
|
+
.and_then(Value::as_str)
|
|
146
|
+
.map(str::to_string);
|
|
147
|
+
let raw_leader = state
|
|
148
|
+
.get("leader_receiver")
|
|
149
|
+
.and_then(|r| r.get("session_name"))
|
|
150
|
+
.and_then(Value::as_str)
|
|
151
|
+
.map(str::to_string);
|
|
152
|
+
|
|
153
|
+
let mut anomalies = Vec::new();
|
|
154
|
+
|
|
155
|
+
let worker = match raw_worker.as_deref() {
|
|
156
|
+
None | Some("") => None,
|
|
157
|
+
Some(name) => match WorkerSession::new(name) {
|
|
158
|
+
Ok(w) => Some(w),
|
|
159
|
+
Err(WorkerSessionError::LeaderPrefixed { name }) => {
|
|
160
|
+
anomalies.push(RuntimeSessionAnomaly::WorkerSessionNameIsLeaderPrefixed {
|
|
161
|
+
value: name,
|
|
162
|
+
});
|
|
163
|
+
None
|
|
164
|
+
}
|
|
165
|
+
Err(WorkerSessionError::Empty) => None,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
let leader = match raw_leader.as_deref() {
|
|
170
|
+
None | Some("") => None,
|
|
171
|
+
Some(name) => match LeaderLauncherSession::new(name) {
|
|
172
|
+
Ok(l) => Some(l),
|
|
173
|
+
Err(LeaderLauncherSessionError::MissingLeaderPrefix { name }) => {
|
|
174
|
+
anomalies.push(
|
|
175
|
+
RuntimeSessionAnomaly::LeaderReceiverSessionNameNotLeaderPrefixed {
|
|
176
|
+
value: name,
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
None
|
|
180
|
+
}
|
|
181
|
+
Err(LeaderLauncherSessionError::Empty) => None,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if let (Some(rw), Some(rl)) = (raw_worker.as_deref(), raw_leader.as_deref()) {
|
|
186
|
+
if !rw.is_empty() && rw == rl {
|
|
187
|
+
anomalies.push(RuntimeSessionAnomaly::WorkerSessionEqualsLeaderSession {
|
|
188
|
+
value: rw.to_string(),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
Self {
|
|
194
|
+
worker,
|
|
195
|
+
leader,
|
|
196
|
+
anomalies,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// True if any session-identity anomaly was detected in state.
|
|
201
|
+
pub fn has_anomalies(&self) -> bool {
|
|
202
|
+
!self.anomalies.is_empty()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/// True if a `state.session_name` looked like a leader launcher session
|
|
206
|
+
/// — the exact 0.3.39 shape unit-3 must refuse before any kill.
|
|
207
|
+
pub fn worker_session_name_is_leader_prefixed(&self) -> bool {
|
|
208
|
+
self.anomalies
|
|
209
|
+
.iter()
|
|
210
|
+
.any(|a| matches!(a, RuntimeSessionAnomaly::WorkerSessionNameIsLeaderPrefixed { .. }))
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// True when `name` is a leader launcher session (mirrors
|
|
215
|
+
/// `layout::sessions::is_leader_session` on a raw &str).
|
|
216
|
+
pub fn name_is_leader_launcher(name: &str) -> bool {
|
|
217
|
+
let session = SessionName::new(name);
|
|
218
|
+
is_leader_session(&session)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#[cfg(test)]
|
|
222
|
+
mod tests {
|
|
223
|
+
use super::*;
|
|
224
|
+
use serde_json::json;
|
|
225
|
+
|
|
226
|
+
#[test]
|
|
227
|
+
fn worker_session_new_rejects_leader_prefixed() {
|
|
228
|
+
let err = WorkerSession::new("team-agent-leader-claude-x").unwrap_err();
|
|
229
|
+
assert!(matches!(err, WorkerSessionError::LeaderPrefixed { .. }));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#[test]
|
|
233
|
+
fn worker_session_new_rejects_empty() {
|
|
234
|
+
assert!(matches!(
|
|
235
|
+
WorkerSession::new("").unwrap_err(),
|
|
236
|
+
WorkerSessionError::Empty
|
|
237
|
+
));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#[test]
|
|
241
|
+
fn worker_session_new_accepts_team_prefixed() {
|
|
242
|
+
let w = WorkerSession::new("team-foo").unwrap();
|
|
243
|
+
assert_eq!(w.as_str(), "team-foo");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#[test]
|
|
247
|
+
fn leader_launcher_session_new_requires_leader_prefix() {
|
|
248
|
+
assert!(matches!(
|
|
249
|
+
LeaderLauncherSession::new("team-foo").unwrap_err(),
|
|
250
|
+
LeaderLauncherSessionError::MissingLeaderPrefix { .. }
|
|
251
|
+
));
|
|
252
|
+
let l = LeaderLauncherSession::new("team-agent-leader-codex-abc").unwrap();
|
|
253
|
+
assert_eq!(l.as_str(), "team-agent-leader-codex-abc");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[test]
|
|
257
|
+
fn runtime_sessions_from_state_clean() {
|
|
258
|
+
let state = json!({
|
|
259
|
+
"session_name": "team-foo",
|
|
260
|
+
"leader_receiver": { "session_name": "team-agent-leader-codex-abc" },
|
|
261
|
+
});
|
|
262
|
+
let r = RuntimeSessions::from_state(&state);
|
|
263
|
+
assert_eq!(r.worker.unwrap().as_str(), "team-foo");
|
|
264
|
+
assert_eq!(
|
|
265
|
+
r.leader.unwrap().as_str(),
|
|
266
|
+
"team-agent-leader-codex-abc"
|
|
267
|
+
);
|
|
268
|
+
assert!(r.anomalies.is_empty());
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#[test]
|
|
272
|
+
fn runtime_sessions_from_state_flags_leader_prefixed_worker_name() {
|
|
273
|
+
// The 0.3.39 bug shape.
|
|
274
|
+
let state = json!({ "session_name": "team-agent-leader-bad" });
|
|
275
|
+
let r = RuntimeSessions::from_state(&state);
|
|
276
|
+
assert!(r.worker.is_none());
|
|
277
|
+
assert!(r.worker_session_name_is_leader_prefixed());
|
|
278
|
+
assert!(r.has_anomalies());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#[test]
|
|
282
|
+
fn runtime_sessions_from_state_flags_worker_equals_leader() {
|
|
283
|
+
let state = json!({
|
|
284
|
+
"session_name": "team-agent-leader-x",
|
|
285
|
+
"leader_receiver": { "session_name": "team-agent-leader-x" },
|
|
286
|
+
});
|
|
287
|
+
let r = RuntimeSessions::from_state(&state);
|
|
288
|
+
// Both anomalies fire: leader-prefixed worker + worker==leader.
|
|
289
|
+
assert!(r.anomalies.iter().any(|a| matches!(
|
|
290
|
+
a,
|
|
291
|
+
RuntimeSessionAnomaly::WorkerSessionNameIsLeaderPrefixed { .. }
|
|
292
|
+
)));
|
|
293
|
+
assert!(r.anomalies.iter().any(|a| matches!(
|
|
294
|
+
a,
|
|
295
|
+
RuntimeSessionAnomaly::WorkerSessionEqualsLeaderSession { .. }
|
|
296
|
+
)));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[test]
|
|
300
|
+
fn runtime_sessions_from_state_empty_state_is_clean() {
|
|
301
|
+
let r = RuntimeSessions::from_state(&json!({}));
|
|
302
|
+
assert!(r.worker.is_none());
|
|
303
|
+
assert!(r.leader.is_none());
|
|
304
|
+
assert!(r.anomalies.is_empty());
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#[test]
|
|
308
|
+
fn name_is_leader_launcher_matches_constant() {
|
|
309
|
+
assert!(name_is_leader_launcher("team-agent-leader-x"));
|
|
310
|
+
assert!(!name_is_leader_launcher("team-x"));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
//! unit-9 (Stage 4) — tmux endpoint selection policy, separated from the
|
|
2
|
+
//! concrete `TmuxBackend` execution layer.
|
|
3
|
+
//!
|
|
4
|
+
//! Today the endpoint-selection rule lives inside `crate::tmux_backend`
|
|
5
|
+
//! (private fn `runtime_tmux_endpoint_from_state` + helpers) intertwined
|
|
6
|
+
//! with the concrete command-execution backend. The policy is independently
|
|
7
|
+
//! testable — given a `state.json` value, pick which endpoint string to
|
|
8
|
+
//! bind a tmux client to — but you can't reach it without spinning up the
|
|
9
|
+
//! whole backend.
|
|
10
|
+
//!
|
|
11
|
+
//! This module exposes the policy as a small, pure, layout-layer concern.
|
|
12
|
+
//! Concrete `TmuxBackend` stays in `tmux_backend.rs` for command execution.
|
|
13
|
+
//! Callers that only want to KNOW the endpoint (cli printers, diagnostics,
|
|
14
|
+
//! event-log fields) can ask this module instead of constructing a backend.
|
|
15
|
+
//!
|
|
16
|
+
//! Migration: additive. The existing `tmux_backend` API is unchanged; the
|
|
17
|
+
//! policy here mirrors it byte-for-byte (verified by `endpoint_priority_matches_backend`
|
|
18
|
+
//! contract tests).
|
|
19
|
+
|
|
20
|
+
use std::path::Path;
|
|
21
|
+
use serde_json::Value;
|
|
22
|
+
|
|
23
|
+
/// Which state field (or fallback) supplied the chosen endpoint.
|
|
24
|
+
///
|
|
25
|
+
/// Mirrors `crate::tmux_backend::RuntimeTmuxEndpointSource` 1:1 — this
|
|
26
|
+
/// public surface lets layout-layer callers reason about the source
|
|
27
|
+
/// without depending on a `pub(crate)` enum.
|
|
28
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
29
|
+
pub enum TmuxEndpointSource {
|
|
30
|
+
/// `state.tmux_endpoint` (highest priority).
|
|
31
|
+
StateTmuxEndpoint,
|
|
32
|
+
/// `state.tmux_socket` (fallback when `tmux_endpoint` is absent).
|
|
33
|
+
StateTmuxSocket,
|
|
34
|
+
/// Derived from the workspace path (lowest priority, used when state
|
|
35
|
+
/// has no endpoint info at all — typical for never-quick-started ws).
|
|
36
|
+
WorkspaceFallback,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
impl TmuxEndpointSource {
|
|
40
|
+
pub fn as_str(self) -> &'static str {
|
|
41
|
+
match self {
|
|
42
|
+
Self::StateTmuxEndpoint => "state.tmux_endpoint",
|
|
43
|
+
Self::StateTmuxSocket => "state.tmux_socket",
|
|
44
|
+
Self::WorkspaceFallback => "workspace_fallback",
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Selected endpoint description: the string to pass to tmux + which state
|
|
50
|
+
/// field it came from. The endpoint may be either a short socket name
|
|
51
|
+
/// (`-L <name>`) or a path (`-S <path>`); callers map that to flags.
|
|
52
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
53
|
+
pub struct TmuxEndpointSelection {
|
|
54
|
+
pub endpoint: String,
|
|
55
|
+
pub source: TmuxEndpointSource,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Pure policy: pick the tmux endpoint to use given a (maybe-present)
|
|
59
|
+
/// `state.json` Value.
|
|
60
|
+
///
|
|
61
|
+
/// Priority — keep in sync with `crate::tmux_backend::
|
|
62
|
+
/// runtime_tmux_endpoint_from_state`:
|
|
63
|
+
/// 1. `state.tmux_endpoint` (non-empty string)
|
|
64
|
+
/// 2. `state.tmux_socket` (non-empty string)
|
|
65
|
+
/// 3. workspace fallback (caller supplies the derived value)
|
|
66
|
+
///
|
|
67
|
+
/// Returns `None` when state has neither field; callers fall back to a
|
|
68
|
+
/// workspace-derived value via [`select_endpoint_for_workspace`].
|
|
69
|
+
pub fn select_endpoint_from_state(state: Option<&Value>) -> Option<TmuxEndpointSelection> {
|
|
70
|
+
let state = state?;
|
|
71
|
+
if let Some(endpoint) = state
|
|
72
|
+
.get("tmux_endpoint")
|
|
73
|
+
.and_then(Value::as_str)
|
|
74
|
+
.filter(|s| !s.is_empty())
|
|
75
|
+
{
|
|
76
|
+
return Some(TmuxEndpointSelection {
|
|
77
|
+
endpoint: endpoint.to_string(),
|
|
78
|
+
source: TmuxEndpointSource::StateTmuxEndpoint,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if let Some(socket) = state
|
|
82
|
+
.get("tmux_socket")
|
|
83
|
+
.and_then(Value::as_str)
|
|
84
|
+
.filter(|s| !s.is_empty())
|
|
85
|
+
{
|
|
86
|
+
return Some(TmuxEndpointSelection {
|
|
87
|
+
endpoint: socket.to_string(),
|
|
88
|
+
source: TmuxEndpointSource::StateTmuxSocket,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
None
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// State-first endpoint selection with workspace fallback. Equivalent to
|
|
95
|
+
/// what `tmux_backend_for_runtime_state_or_workspace` does internally,
|
|
96
|
+
/// minus the backend construction.
|
|
97
|
+
pub fn select_endpoint_for_workspace(
|
|
98
|
+
workspace: &Path,
|
|
99
|
+
state: Option<&Value>,
|
|
100
|
+
) -> TmuxEndpointSelection {
|
|
101
|
+
if let Some(sel) = select_endpoint_from_state(state) {
|
|
102
|
+
return sel;
|
|
103
|
+
}
|
|
104
|
+
TmuxEndpointSelection {
|
|
105
|
+
endpoint: crate::tmux_backend::socket_name_for_workspace(workspace),
|
|
106
|
+
source: TmuxEndpointSource::WorkspaceFallback,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#[cfg(test)]
|
|
111
|
+
mod tests {
|
|
112
|
+
use super::*;
|
|
113
|
+
use serde_json::json;
|
|
114
|
+
use std::path::PathBuf;
|
|
115
|
+
|
|
116
|
+
#[test]
|
|
117
|
+
fn state_tmux_endpoint_wins_over_socket() {
|
|
118
|
+
let state = json!({
|
|
119
|
+
"tmux_endpoint": "/private/tmp/tmux-501/abc",
|
|
120
|
+
"tmux_socket": "abc",
|
|
121
|
+
});
|
|
122
|
+
let sel = select_endpoint_from_state(Some(&state)).unwrap();
|
|
123
|
+
assert_eq!(sel.endpoint, "/private/tmp/tmux-501/abc");
|
|
124
|
+
assert_eq!(sel.source, TmuxEndpointSource::StateTmuxEndpoint);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#[test]
|
|
128
|
+
fn state_tmux_socket_used_when_endpoint_absent() {
|
|
129
|
+
let state = json!({ "tmux_socket": "ta-abc" });
|
|
130
|
+
let sel = select_endpoint_from_state(Some(&state)).unwrap();
|
|
131
|
+
assert_eq!(sel.endpoint, "ta-abc");
|
|
132
|
+
assert_eq!(sel.source, TmuxEndpointSource::StateTmuxSocket);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#[test]
|
|
136
|
+
fn empty_strings_are_ignored() {
|
|
137
|
+
let state = json!({ "tmux_endpoint": "", "tmux_socket": "" });
|
|
138
|
+
assert!(select_endpoint_from_state(Some(&state)).is_none());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#[test]
|
|
142
|
+
fn none_state_returns_none() {
|
|
143
|
+
assert!(select_endpoint_from_state(None).is_none());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[test]
|
|
147
|
+
fn workspace_fallback_kicks_in_with_no_state_endpoint() {
|
|
148
|
+
let ws = PathBuf::from("/tmp/ta-unit9-fallback");
|
|
149
|
+
let sel = select_endpoint_for_workspace(&ws, None);
|
|
150
|
+
assert_eq!(sel.source, TmuxEndpointSource::WorkspaceFallback);
|
|
151
|
+
assert!(!sel.endpoint.is_empty());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#[test]
|
|
155
|
+
fn workspace_fallback_skipped_when_state_carries_endpoint() {
|
|
156
|
+
let ws = PathBuf::from("/tmp/ta-unit9-prefer-state");
|
|
157
|
+
let state = json!({ "tmux_endpoint": "state-endpoint-x" });
|
|
158
|
+
let sel = select_endpoint_for_workspace(&ws, Some(&state));
|
|
159
|
+
assert_eq!(sel.endpoint, "state-endpoint-x");
|
|
160
|
+
assert_eq!(sel.source, TmuxEndpointSource::StateTmuxEndpoint);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -546,7 +546,33 @@ fn persist_managed_leader_binding(
|
|
|
546
546
|
"os_user": identity.os_user,
|
|
547
547
|
});
|
|
548
548
|
if let Some(obj) = state.as_object_mut() {
|
|
549
|
-
|
|
549
|
+
// unit-4 (Stage 1) ROOT CAUSE FIX of 0.3.39 leader mis-kill:
|
|
550
|
+
//
|
|
551
|
+
// BEFORE: `obj.insert("session_name", json!(session));` wrote the
|
|
552
|
+
// leader launcher session (always `team-agent-leader-*`) into the
|
|
553
|
+
// top-level worker-session-name field, hijacking the identity used
|
|
554
|
+
// by restart/shutdown when they decided what tmux session to kill.
|
|
555
|
+
//
|
|
556
|
+
// AFTER: the launcher session is recorded ONLY in
|
|
557
|
+
// `leader_receiver.session_name` (the `receiver` block above) and
|
|
558
|
+
// `team_owner.pane_id`. The top-level `state.session_name` keeps
|
|
559
|
+
// whatever value the worker quick-start put there (the real worker
|
|
560
|
+
// session). If the workspace has never been quick-started yet
|
|
561
|
+
// (no `session_name` field at all), we leave the field absent —
|
|
562
|
+
// restart and shutdown have safe default branches for that case.
|
|
563
|
+
//
|
|
564
|
+
// unit-3's preflight is the belt-and-suspenders backstop: even if a
|
|
565
|
+
// future regression reintroduces this overwrite, restart now refuses
|
|
566
|
+
// before killing a leader-prefixed session_name.
|
|
567
|
+
if !crate::layout::sessions::LEADER_SESSION_PREFIX.is_empty()
|
|
568
|
+
&& session.starts_with(crate::layout::sessions::LEADER_SESSION_PREFIX)
|
|
569
|
+
{
|
|
570
|
+
// Explicit: skip the overwrite for leader-prefixed launcher
|
|
571
|
+
// sessions. The receiver block records the launcher session in
|
|
572
|
+
// its proper home (`leader_receiver.session_name`).
|
|
573
|
+
} else {
|
|
574
|
+
obj.insert("session_name".to_string(), serde_json::json!(session));
|
|
575
|
+
}
|
|
550
576
|
obj.insert(
|
|
551
577
|
"active_team_key".to_string(),
|
|
552
578
|
serde_json::json!(identity.team_id.as_str()),
|
|
@@ -340,3 +340,53 @@ use super::*;
|
|
|
340
340
|
// ── 14a. _claim_lease_no_incident OUTCOMES (golden __init__.py:598) ──────
|
|
341
341
|
//
|
|
342
342
|
// claim_lease_no_incident is unimplemented!() → every test here PANICS today
|
|
343
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
344
|
+
// unit-0 (Stage 0) characterization tests
|
|
345
|
+
//
|
|
346
|
+
// Pin two invariants that unit-1/3/4 will refactor behind typed
|
|
347
|
+
// identity helpers. If the constant or the prefix shape changes, the
|
|
348
|
+
// refactor must also update these tests.
|
|
349
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
350
|
+
|
|
351
|
+
#[test]
|
|
352
|
+
fn unit0_leader_session_prefix_constant_is_stable() {
|
|
353
|
+
// Pinned: the literal leader session prefix that gates "is this a
|
|
354
|
+
// leader session" decisions everywhere in the runtime.
|
|
355
|
+
assert_eq!(
|
|
356
|
+
crate::leader::start::LEADER_SESSION_PREFIX,
|
|
357
|
+
"team-agent-leader-"
|
|
358
|
+
);
|
|
359
|
+
// The layout module re-exports the same value (kept in sync via
|
|
360
|
+
// const re-export).
|
|
361
|
+
assert_eq!(
|
|
362
|
+
crate::layout::sessions::LEADER_SESSION_PREFIX,
|
|
363
|
+
crate::leader::start::LEADER_SESSION_PREFIX
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[test]
|
|
368
|
+
fn unit0_leader_prefixed_name_must_never_be_taken_as_worker_session() {
|
|
369
|
+
// Pinned invariant fed into unit-1 typed identity: any session
|
|
370
|
+
// name starting with `LEADER_SESSION_PREFIX` is a leader launcher
|
|
371
|
+
// session, never a worker session. unit-1 will wrap this rule in
|
|
372
|
+
// typed constructors (`WorkerSession::new` rejects the prefix,
|
|
373
|
+
// `LeaderLauncherSession::new` requires it).
|
|
374
|
+
let leader = "team-agent-leader-claude-x-aaaaaaaaaaaa";
|
|
375
|
+
let worker = "team-real-worker";
|
|
376
|
+
let prefix = crate::leader::start::LEADER_SESSION_PREFIX;
|
|
377
|
+
assert!(leader.starts_with(prefix));
|
|
378
|
+
assert!(!worker.starts_with(prefix));
|
|
379
|
+
// Symmetry: the runtime today uses this exact check
|
|
380
|
+
// (cli/mod.rs:261 `starts_with(LEADER_SESSION_PREFIX)`) to spare
|
|
381
|
+
// leader sessions during shutdown.
|
|
382
|
+
let leader_name = crate::transport::SessionName::new(leader);
|
|
383
|
+
let worker_name = crate::transport::SessionName::new(worker);
|
|
384
|
+
assert!(
|
|
385
|
+
crate::layout::sessions::is_leader_session(&leader_name),
|
|
386
|
+
"is_leader_session must accept a leader-prefixed session",
|
|
387
|
+
);
|
|
388
|
+
assert!(
|
|
389
|
+
!crate::layout::sessions::is_leader_session(&worker_name),
|
|
390
|
+
"is_leader_session must reject a non-prefixed session",
|
|
391
|
+
);
|
|
392
|
+
}
|
|
@@ -50,12 +50,16 @@ pub mod state;
|
|
|
50
50
|
pub mod compiler;
|
|
51
51
|
|
|
52
52
|
// step 7 (message_store) — team.db 上的核心消息生命周期(create/claim/mark/通知去重)。
|
|
53
|
-
|
|
53
|
+
// unit-10 (Stage 4) compat shim. Physical home is now `crate::db::message_store`.
|
|
54
|
+
pub use crate::db::message_store;
|
|
54
55
|
|
|
55
56
|
// step 8 (provider) — ProviderAdapter trait + typed provider/turn-state/liveness 等(ROUND-0 骨架;
|
|
56
57
|
// fn body unimplemented!(),P2 porter 落实现)。MUST-NOT-13:provider 调用全走 trait。
|
|
57
58
|
pub mod provider;
|
|
58
|
-
|
|
59
|
+
/// unit-6 (Stage 2) compat shim. Physical home is now
|
|
60
|
+
/// `crate::provider::session::capture`; this re-export keeps every
|
|
61
|
+
/// `crate::session_capture::*` caller working without modification.
|
|
62
|
+
pub use crate::provider::session::capture as session_capture;
|
|
59
63
|
pub(crate) mod os_probe;
|
|
60
64
|
|
|
61
65
|
// step 9 (transport) — Transport trait(控制面)+ Target/PaneId/InjectReport 等(ROUND-0 骨架;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//! unit-8 (Stage 3) — `lifecycle::launch::readiness` phase boundary.
|
|
2
|
+
//!
|
|
3
|
+
//! Dedicated home for coordinator-start + readiness-verdict computation.
|
|
4
|
+
//! Future commits migrate the inline phase fns at launch.rs:2928-2944
|
|
5
|
+
//! here.
|
|
6
|
+
|
|
7
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
8
|
+
pub enum ReadinessPhase {
|
|
9
|
+
StartCoordinator,
|
|
10
|
+
ComputeVerdict,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impl ReadinessPhase {
|
|
14
|
+
pub fn as_str(self) -> &'static str {
|
|
15
|
+
match self {
|
|
16
|
+
Self::StartCoordinator => "launch.readiness.start_coordinator",
|
|
17
|
+
Self::ComputeVerdict => "launch.readiness.compute_verdict",
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[cfg(test)]
|
|
23
|
+
mod tests {
|
|
24
|
+
use super::*;
|
|
25
|
+
|
|
26
|
+
#[test]
|
|
27
|
+
fn phase_labels_are_dotted_paths_under_launch_readiness() {
|
|
28
|
+
assert!(ReadinessPhase::StartCoordinator
|
|
29
|
+
.as_str()
|
|
30
|
+
.starts_with("launch.readiness."));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//! unit-8 (Stage 3) — `lifecycle::launch::spawn` phase boundary.
|
|
2
|
+
//!
|
|
3
|
+
//! Dedicated home for the worker spawn executor. Future commits migrate
|
|
4
|
+
//! `spawn_first_with_env_unset` and `spawn_into_with_env_unset` from
|
|
5
|
+
//! launch.rs:376-405 here.
|
|
6
|
+
|
|
7
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
8
|
+
pub enum SpawnPhase {
|
|
9
|
+
SpawnFirstWithEnvUnset,
|
|
10
|
+
SpawnIntoWithEnvUnset,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impl SpawnPhase {
|
|
14
|
+
pub fn as_str(self) -> &'static str {
|
|
15
|
+
match self {
|
|
16
|
+
Self::SpawnFirstWithEnvUnset => "launch.spawn.first_with_env_unset",
|
|
17
|
+
Self::SpawnIntoWithEnvUnset => "launch.spawn.into_with_env_unset",
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[cfg(test)]
|
|
23
|
+
mod tests {
|
|
24
|
+
use super::*;
|
|
25
|
+
|
|
26
|
+
#[test]
|
|
27
|
+
fn phase_labels_are_dotted_paths_under_launch_spawn() {
|
|
28
|
+
assert!(SpawnPhase::SpawnFirstWithEnvUnset
|
|
29
|
+
.as_str()
|
|
30
|
+
.starts_with("launch.spawn."));
|
|
31
|
+
}
|
|
32
|
+
}
|