@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.
Files changed (61) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +7 -0
  4. package/crates/team-agent/src/cli/adapters.rs +8 -3
  5. package/crates/team-agent/src/cli/diagnose.rs +12 -3
  6. package/crates/team-agent/src/cli/emit.rs +1 -0
  7. package/crates/team-agent/src/cli/mod.rs +250 -9
  8. package/crates/team-agent/src/cli/send.rs +11 -2
  9. package/crates/team-agent/src/cli/status_port.rs +39 -4
  10. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
  11. package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
  12. package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
  13. package/crates/team-agent/src/cli/types.rs +5 -0
  14. package/crates/team-agent/src/coordinator/mod.rs +1 -0
  15. package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
  16. package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
  17. package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
  19. package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
  20. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
  21. package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
  22. package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
  23. package/crates/team-agent/src/coordinator/tick.rs +126 -30
  24. package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
  25. package/crates/team-agent/src/db/mod.rs +1 -0
  26. package/crates/team-agent/src/layout/mod.rs +7 -0
  27. package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
  28. package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
  29. package/crates/team-agent/src/leader/start.rs +27 -1
  30. package/crates/team-agent/src/leader/tests/identity.rs +50 -0
  31. package/crates/team-agent/src/lib.rs +6 -2
  32. package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
  33. package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
  34. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
  35. package/crates/team-agent/src/lifecycle/launch.rs +34 -0
  36. package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
  37. package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
  38. package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
  39. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
  40. package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
  41. package/crates/team-agent/src/lifecycle/restart.rs +6 -0
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
  43. package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
  44. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/types.rs +20 -1
  46. package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
  47. package/crates/team-agent/src/mcp_server/tools.rs +29 -4
  48. package/crates/team-agent/src/provider/adapter.rs +31 -1
  49. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
  50. package/crates/team-agent/src/provider/classify.rs +138 -0
  51. package/crates/team-agent/src/provider/command.rs +71 -0
  52. package/crates/team-agent/src/provider/mod.rs +3 -0
  53. package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
  54. package/crates/team-agent/src/provider/session/mod.rs +13 -0
  55. package/crates/team-agent/src/provider/session/resume.rs +417 -0
  56. package/crates/team-agent/src/provider/session_scan.rs +63 -0
  57. package/crates/team-agent/src/provider/types.rs +5 -0
  58. package/crates/team-agent/src/state/persist.rs +238 -0
  59. package/crates/team-agent/src/tmux_backend.rs +25 -0
  60. package/npm/install.mjs +27 -1
  61. 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
- obj.insert("session_name".to_string(), serde_json::json!(session));
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
- pub mod message_store;
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
- pub mod session_capture;
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
+ }