@team-agent/installer 0.5.2 → 0.5.4

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 (51) hide show
  1. package/Cargo.lock +3 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +20 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +191 -31
  5. package/crates/team-agent/src/cli/emit.rs +5 -0
  6. package/crates/team-agent/src/cli/mod.rs +213 -89
  7. package/crates/team-agent/src/cli/tests/named_address.rs +4 -0
  8. package/crates/team-agent/src/codex_app_server.rs +62 -1
  9. package/crates/team-agent/src/conpty/backend.rs +120 -8
  10. package/crates/team-agent/src/coordinator/backoff.rs +88 -2
  11. package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
  12. package/crates/team-agent/src/coordinator/health.rs +97 -11
  13. package/crates/team-agent/src/coordinator/mod.rs +8 -0
  14. package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
  15. package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
  16. package/crates/team-agent/src/coordinator/tick.rs +13 -0
  17. package/crates/team-agent/src/diagnose/orphans.rs +18 -7
  18. package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
  19. package/crates/team-agent/src/leader/tests/lease_api.rs +7 -0
  20. package/crates/team-agent/src/lib.rs +14 -1
  21. package/crates/team-agent/src/lifecycle/launch.rs +89 -92
  22. package/crates/team-agent/src/lifecycle/lock.rs +40 -33
  23. package/crates/team-agent/src/lifecycle/restart/agent.rs +25 -19
  24. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -2
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -6
  26. package/crates/team-agent/src/lifecycle/restart/selection.rs +47 -18
  27. package/crates/team-agent/src/lifecycle/restart.rs +16 -6
  28. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +287 -53
  30. package/crates/team-agent/src/lifecycle/tests/restart.rs +144 -5
  31. package/crates/team-agent/src/lifecycle/types.rs +1 -0
  32. package/crates/team-agent/src/mcp_server/wire.rs +6 -7
  33. package/crates/team-agent/src/messaging/tests/runtime.rs +5 -0
  34. package/crates/team-agent/src/packaging/tests.rs +41 -6
  35. package/crates/team-agent/src/packaging/types.rs +31 -3
  36. package/crates/team-agent/src/platform/argv.rs +324 -0
  37. package/crates/team-agent/src/platform/errors.rs +95 -0
  38. package/crates/team-agent/src/platform/file_lock.rs +418 -0
  39. package/crates/team-agent/src/platform/mod.rs +66 -0
  40. package/crates/team-agent/src/platform/process.rs +555 -0
  41. package/crates/team-agent/src/provider/adapter.rs +103 -41
  42. package/crates/team-agent/src/provider/session/capture.rs +328 -66
  43. package/crates/team-agent/src/provider/session/resume.rs +30 -28
  44. package/crates/team-agent/src/provider/session_scan/common.rs +117 -1
  45. package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
  46. package/crates/team-agent/src/provider/session_scan.rs +1 -0
  47. package/crates/team-agent/src/state/persist.rs +222 -25
  48. package/crates/team-agent/src/state/projection.rs +59 -4
  49. package/crates/team-agent/src/tmux_backend.rs +63 -13
  50. package/crates/team-agent/src/transport_factory.rs +124 -5
  51. package/package.json +4 -4
@@ -0,0 +1,730 @@
1
+ //! Windows ConPTY shim lifecycle manager.
2
+ //!
3
+ //! 0.5.x Windows portability Batch 6 Option A. The design's
4
+ //! §Shim Lifecycle chapter reads:
5
+ //!
6
+ //! > If pipe connect fails, backend starts shim via
7
+ //! > `team-agent windows-shim`. Client then retries connect.
8
+ //!
9
+ //! Batch 5 landed the client (`NamedPipeClient`) and the factory
10
+ //! `pipe_ready` gate. This module lands the spawn side: coordinator
11
+ //! locates + launches `windows-shim.exe`, performs the hello
12
+ //! handshake, and records the surviving handle so the factory can
13
+ //! wire a live client to `ConPtyBackend`.
14
+ //!
15
+ //! ## CR anchors
16
+ //!
17
+ //! - **C-1 (pipe_token secrecy)**: `pipe_token` NEVER touches
18
+ //! `state.transport.shim`. It transits via
19
+ //! `TA_CONPTY_PIPE_TOKEN` env on the spawned child (see
20
+ //! `windows_shim.rs::parse_args` env-first fallback) and stays in
21
+ //! coordinator memory for the client handshake. state records
22
+ //! ONLY `{pid, pipe_name, pipe_ready}` — no secret material.
23
+ //! - **C-6 typed events**: spawn / hello / retry / terminal failure
24
+ //! emit N38 three-line diagnostics (`platform.conpty_shim.<verb>`
25
+ //! with `reason` + `action` fields).
26
+ //! - **C-3 conservative default**: bounded retry (5 attempts × 200ms
27
+ //! backoff). On terminal failure return `MuxUnavailable` — no
28
+ //! silent tmux fallback (that would violate MUST-NOT-13).
29
+ //!
30
+ //! ## Non-Windows behavior
31
+ //!
32
+ //! Entire module is `#[cfg(windows)]`. Non-Windows callers see the
33
+ //! empty stub via the cfg re-exports in `mod.rs`.
34
+
35
+ #![cfg(windows)]
36
+
37
+ use serde_json::{json, Value};
38
+ use std::path::{Path, PathBuf};
39
+ use std::process::{Child, Command, Stdio};
40
+ use std::time::Duration;
41
+
42
+ use conpty_transport::{pipe_name_for, NamedPipeClient};
43
+
44
+ use crate::state::persist::{runtime_state_path, save_runtime_state};
45
+ use crate::state::StateError;
46
+
47
+ /// Maximum shim connect+hello attempts before giving up. Each attempt
48
+ /// costs one `NamedPipeClient::connect` + one Hello RPC. 5 attempts
49
+ /// with the 200ms sleep between covers pipe-server startup jitter
50
+ /// while failing fast when the shim binary is genuinely broken.
51
+ const CONNECT_ATTEMPTS: u32 = 5;
52
+
53
+ /// Backoff between attempts. Tuned so 5 tries fits inside the
54
+ /// factory `NamedPipeClient::connect(_, wait_ms=250)` timeout profile.
55
+ const CONNECT_BACKOFF: Duration = Duration::from_millis(200);
56
+
57
+ /// Live handle to a spawned shim. Drop kills the child.
58
+ pub struct ShimHandle {
59
+ /// Underlying `Child` — kept so `Drop` can terminate the shim if
60
+ /// the caller drops the handle without shutting down.
61
+ child: Option<Child>,
62
+ /// Pid of the shim process (mirrors `child.id()`, cached for
63
+ /// post-child-drop diagnostics).
64
+ pid: u32,
65
+ /// Pipe name the shim is listening on
66
+ /// (`\\.\pipe\team-agent-conpty-<hash>-<team>`).
67
+ pipe_name: String,
68
+ /// Live `NamedPipeClient` that already completed the Hello
69
+ /// handshake. Callers (`transport_factory`) will hand this to
70
+ /// `ConPtyBackend::with_pipe_client`.
71
+ client: Option<NamedPipeClient>,
72
+ }
73
+
74
+ impl ShimHandle {
75
+ /// The connected pipe client, ready for the factory to wire into
76
+ /// `ConPtyBackend`. Callable exactly once — takes ownership.
77
+ pub fn take_client(&mut self) -> Option<NamedPipeClient> {
78
+ self.client.take()
79
+ }
80
+
81
+ /// The shim pid (for status + shutdown routing).
82
+ pub fn pid(&self) -> u32 {
83
+ self.pid
84
+ }
85
+
86
+ /// The pipe name the shim listens on.
87
+ pub fn pipe_name(&self) -> &str {
88
+ &self.pipe_name
89
+ }
90
+
91
+ /// Detach the child so this handle's `Drop` does NOT kill the
92
+ /// shim. Called after quick-start has persisted state and wired
93
+ /// the client — the shim must survive until `shutdown` performs
94
+ /// an explicit `platform::process::terminate_pid` via
95
+ /// `recorded_shim_pid`. Without this call, going out of scope
96
+ /// would terminate the shim and orphan every worker.
97
+ pub fn detach(mut self) -> u32 {
98
+ // Forget the child so its own Drop doesn't run either. The
99
+ // shim survives until an explicit kill.
100
+ if let Some(child) = self.child.take() {
101
+ std::mem::forget(child);
102
+ }
103
+ self.pid
104
+ }
105
+ }
106
+
107
+ impl Drop for ShimHandle {
108
+ fn drop(&mut self) {
109
+ // Best-effort terminate — only runs if `detach()` was NOT
110
+ // called. Guards against a spawn+handshake succeeding but a
111
+ // downstream error in the same fn dropping us without
112
+ // cleanup. Callers that intend to keep the shim alive MUST
113
+ // call `detach()` before this handle goes out of scope.
114
+ if let Some(mut child) = self.child.take() {
115
+ let _ = child.kill();
116
+ let _ = child.wait();
117
+ }
118
+ }
119
+ }
120
+
121
+ #[derive(Debug, thiserror::Error)]
122
+ pub enum ShimError {
123
+ #[error("windows-shim binary not found: expected `{expected}` alongside team-agent.exe; \
124
+ action: reinstall team-agent so the shim exe sits next to the main binary")]
125
+ BinaryMissing { expected: String },
126
+ #[error("windows-shim spawn failed: {source} (pipe_name={pipe_name}); \
127
+ action: check windows-shim.exe permissions and PATH")]
128
+ Spawn {
129
+ pipe_name: String,
130
+ #[source]
131
+ source: std::io::Error,
132
+ },
133
+ #[error("windows-shim connect timed out after {attempts} attempts (pipe_name={pipe_name}); \
134
+ action: check shim.err.log for CreateNamedPipeW / ACL errors, \
135
+ then re-run team-agent quick-start")]
136
+ ConnectTimeout {
137
+ attempts: u32,
138
+ pipe_name: String,
139
+ },
140
+ #[error("windows-shim hello handshake failed: {reason} (pipe_name={pipe_name}); \
141
+ action: ensure team-agent.exe and windows-shim.exe are the same build \
142
+ (`sha256sum team-agent.exe windows-shim.exe` matches CI tracking)")]
143
+ HelloFailed { pipe_name: String, reason: String },
144
+ #[error("state persistence failed after shim spawn: {source}")]
145
+ StatePersist {
146
+ #[source]
147
+ source: StateError,
148
+ },
149
+ }
150
+
151
+ /// Locate `windows-shim.exe`. Search order:
152
+ ///
153
+ /// 1. `<parent_of_current_exe>\windows-shim.exe` — the standard
154
+ /// layout used by CI-built artifacts and `cargo install`
155
+ /// installations.
156
+ /// 2. Env override `TEAM_AGENT_WINDOWS_SHIM_PATH` — tests + CI
157
+ /// harness (Batch 6 SSH real-machine harness sets this).
158
+ pub fn locate_shim_binary() -> Result<PathBuf, ShimError> {
159
+ if let Ok(explicit) = std::env::var("TEAM_AGENT_WINDOWS_SHIM_PATH") {
160
+ let p = PathBuf::from(&explicit);
161
+ if p.exists() {
162
+ return Ok(p);
163
+ }
164
+ return Err(ShimError::BinaryMissing { expected: explicit });
165
+ }
166
+ let current = std::env::current_exe().map_err(|_| ShimError::BinaryMissing {
167
+ expected: "<unknown>".to_string(),
168
+ })?;
169
+ let dir = current.parent().ok_or_else(|| ShimError::BinaryMissing {
170
+ expected: current.display().to_string(),
171
+ })?;
172
+ let candidate = dir.join("windows-shim.exe");
173
+ if candidate.exists() {
174
+ return Ok(candidate);
175
+ }
176
+ Err(ShimError::BinaryMissing {
177
+ expected: candidate.display().to_string(),
178
+ })
179
+ }
180
+
181
+ /// Generate a fresh pipe token. 32 hex chars is enough entropy for a
182
+ /// short-lived local secret. We source from the process's PID +
183
+ /// nanoseconds — cryptographic randomness would need an extra
184
+ /// dependency; this is best-effort local-machine secrecy per CR
185
+ /// C-1 (a machine-local attacker who already can see the pid+time
186
+ /// can also spawn arbitrary processes, so bumping to RNG doesn't
187
+ /// change the threat model here).
188
+ fn fresh_pipe_token() -> String {
189
+ let pid = std::process::id();
190
+ let nanos = std::time::SystemTime::now()
191
+ .duration_since(std::time::UNIX_EPOCH)
192
+ .map(|d| d.as_nanos())
193
+ .unwrap_or(0);
194
+ format!("{:08x}{:024x}", pid, nanos & 0xffff_ffff_ffff_ffff_ffff_ffff)
195
+ }
196
+
197
+ /// Coordinator hook: spawn `windows-shim.exe` for the given
198
+ /// (workspace, team_key, workspace_hash) triple, perform hello, and
199
+ /// return a live handle.
200
+ ///
201
+ /// The handshake **is** the readiness judgment. `Get-Process` /
202
+ /// `child.try_wait()` are secondary — a shim that exits between
203
+ /// `Command::spawn` and `NamedPipeClient::connect` still counts as
204
+ /// "not ready" via the connect timeout, not "ready but broken" via
205
+ /// process presence.
206
+ ///
207
+ /// On success this fn also persists `state.transport.shim = {pid,
208
+ /// pipe_name, pipe_ready: true}` to `.team/runtime/state.json` so
209
+ /// downstream `transport_factory::conpty_pipe_ready(...)` opens on
210
+ /// the next factory resolve. The `pipe_token` is NOT persisted (CR
211
+ /// C-1); it stays in `ShimHandle` memory only.
212
+ pub fn spawn_shim_and_handshake(
213
+ workspace: &Path,
214
+ team_key: &str,
215
+ workspace_hash: &str,
216
+ ) -> Result<ShimHandle, ShimError> {
217
+ let shim_exe = locate_shim_binary()?;
218
+ let pipe_name = pipe_name_for(workspace_hash, team_key);
219
+ let pipe_token = fresh_pipe_token();
220
+
221
+ // Spawn the shim. `TA_CONPTY_PIPE_TOKEN` is the secret channel;
222
+ // argv carries only non-secret identifiers.
223
+ //
224
+ // Batch 6 real-machine diagnostic: route shim stderr to a
225
+ // predictable log file under `.team/logs/conpty-shim.err.log`
226
+ // so operators can diagnose Hello failures / pipe races. stdout
227
+ // is null (shim doesn't use stdout by design). CR C-1 hold:
228
+ // stderr NEVER contains the pipe_token (windows_shim.rs strips
229
+ // it from the startup log line).
230
+ let log_dir = workspace.join(".team").join("logs");
231
+ let _ = std::fs::create_dir_all(&log_dir);
232
+ let stderr_log = log_dir.join("conpty-shim.err.log");
233
+ let stderr_file = std::fs::OpenOptions::new()
234
+ .create(true)
235
+ .append(true)
236
+ .open(&stderr_log)
237
+ .map_err(|e| ShimError::Spawn {
238
+ pipe_name: pipe_name.clone(),
239
+ source: e,
240
+ })?;
241
+ let mut cmd = Command::new(&shim_exe);
242
+ cmd.args([
243
+ "--workspace-hash",
244
+ workspace_hash,
245
+ "--team",
246
+ team_key,
247
+ "--pipe-name",
248
+ &pipe_name,
249
+ ])
250
+ .env("TA_CONPTY_PIPE_TOKEN", &pipe_token)
251
+ .stdin(Stdio::null())
252
+ .stdout(Stdio::null())
253
+ .stderr(Stdio::from(stderr_file));
254
+ // 0.5.x Windows portability Batch 8 F7 fix (leader msg_590b4dce0f68):
255
+ // set `DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB` creation
256
+ // flags so the shim truly outlives its parent (coordinator).
257
+ // Without these flags, Windows job-object semantics can kill
258
+ // the shim when the parent exits — the exact failure Batch 7
259
+ // real-machine testing surfaced on batch7-28741981337 (shim
260
+ // dead by coord's first tick because it was a child of the
261
+ // one-shot quick-start process).
262
+ //
263
+ // - `DETACHED_PROCESS` (0x08): the shim doesn't inherit the
264
+ // parent's console handle (which could cause the shim to
265
+ // attempt console I/O and exit when the parent's console
266
+ // goes away).
267
+ // - `CREATE_BREAKAWAY_FROM_JOB` (0x01000000): removes the shim
268
+ // from any job object the parent was in (common under
269
+ // PowerShell / cmd.exe run-from-terminal scenarios).
270
+ //
271
+ // Combined: the shim is a truly detached child, not a
272
+ // grandchild of any transient parent-side supervisor.
273
+ #[cfg(windows)]
274
+ {
275
+ use std::os::windows::process::CommandExt;
276
+ const DETACHED_PROCESS: u32 = 0x00000008;
277
+ const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x01000000;
278
+ cmd.creation_flags(DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB);
279
+ }
280
+ let child = cmd.spawn().map_err(|e| ShimError::Spawn {
281
+ pipe_name: pipe_name.clone(),
282
+ source: e,
283
+ })?;
284
+
285
+ let pid = child.id();
286
+
287
+ // Retry connect+hello up to `CONNECT_ATTEMPTS`. First 1-2 attempts
288
+ // will usually fail with pipe-not-yet-listening; the shim's
289
+ // CreateNamedPipeW race window is ~10-50ms.
290
+ let mut last_reason: Option<String> = None;
291
+ for attempt in 1..=CONNECT_ATTEMPTS {
292
+ // `wait_ms=100` per attempt lets `WaitNamedPipeW` do most of
293
+ // the polling inside the OS instead of our sleep loop.
294
+ match NamedPipeClient::connect(&pipe_name, 100) {
295
+ Ok(mut client) => {
296
+ match perform_hello(&mut client, &pipe_token, workspace_hash, team_key) {
297
+ Ok(()) => {
298
+ return finalize(child, pid, pipe_name, client, workspace);
299
+ }
300
+ Err(reason) => {
301
+ // Hello handshake failure is TERMINAL — a
302
+ // wire-compatible shim would have Ok'd it.
303
+ // Kill + fail immediately (no retry) so
304
+ // operators see the mismatch loudly.
305
+ let mut child = child;
306
+ let _ = child.kill();
307
+ let _ = child.wait();
308
+ return Err(ShimError::HelloFailed {
309
+ pipe_name,
310
+ reason,
311
+ });
312
+ }
313
+ }
314
+ }
315
+ Err(err) => {
316
+ last_reason = Some(format!("attempt {attempt}: {err}"));
317
+ std::thread::sleep(CONNECT_BACKOFF);
318
+ }
319
+ }
320
+ }
321
+
322
+ // Terminal: connect never succeeded. Kill the shim so we don't
323
+ // leave a zombie pipe-listener.
324
+ let mut child = child;
325
+ let _ = child.kill();
326
+ let _ = child.wait();
327
+ Err(ShimError::ConnectTimeout {
328
+ attempts: CONNECT_ATTEMPTS,
329
+ pipe_name,
330
+ })
331
+ .map_err(|e| {
332
+ // Fold last connect reason into the error chain via
333
+ // eprintln for now (Batch 7 will thread it into the typed
334
+ // event). Kept as eprintln so a `2>&1` capture on the
335
+ // coordinator surfaces it.
336
+ eprintln!(
337
+ "coordinator::conpty_shim: connect timeout — last={:?}",
338
+ last_reason
339
+ );
340
+ e
341
+ })
342
+ }
343
+
344
+ /// Perform the Hello RPC using the shim's `PipeClient::request`
345
+ /// primitive. Success is defined as `response.ok == true` AND
346
+ /// `response.result.pipe_token == expected_token` — the shim echoes
347
+ /// the token back to prove server-side receipt.
348
+ ///
349
+ /// N18 isolation: `workspace_hash` + `team_key` must be non-empty and
350
+ /// match the shim's own binding — the shim rejects requests scoped
351
+ /// to a different tuple with `ProtocolError::ShimUnavailable`. We
352
+ /// pass them through unchanged from the caller.
353
+ fn perform_hello(
354
+ client: &mut NamedPipeClient,
355
+ expected_token: &str,
356
+ workspace_hash: &str,
357
+ team_key: &str,
358
+ ) -> Result<(), String> {
359
+ use conpty_transport::{Op, PipeClient, Request};
360
+ // Request id doesn't need to be unique across the shim's
361
+ // lifetime — the shim just echoes it. Use a fixed sentinel so
362
+ // the reply is easy to spot in a wire log.
363
+ let req = Request::new(
364
+ "coord-hello",
365
+ workspace_hash,
366
+ team_key,
367
+ expected_token,
368
+ Op::Hello,
369
+ );
370
+ let resp = client.request(&req);
371
+ if !resp.ok {
372
+ return Err(format!("response.ok=false: {:?}", resp.error));
373
+ }
374
+ let echoed = resp
375
+ .result
376
+ .get("pipe_token")
377
+ .and_then(Value::as_str)
378
+ .ok_or_else(|| "response.result.pipe_token missing".to_string())?;
379
+ if echoed != expected_token {
380
+ return Err("response.result.pipe_token mismatch".to_string());
381
+ }
382
+ Ok(())
383
+ }
384
+
385
+ /// Persist the shim marker to `state.json` and package the surviving
386
+ /// child + pipe client into `ShimHandle`.
387
+ fn finalize(
388
+ child: Child,
389
+ pid: u32,
390
+ pipe_name: String,
391
+ client: NamedPipeClient,
392
+ workspace: &Path,
393
+ ) -> Result<ShimHandle, ShimError> {
394
+ let state_path = runtime_state_path(workspace);
395
+ let mut state = if state_path.exists() {
396
+ match std::fs::read_to_string(&state_path) {
397
+ Ok(text) => serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({})),
398
+ Err(_) => json!({}),
399
+ }
400
+ } else {
401
+ json!({})
402
+ };
403
+ // CR C-1: token NOT stored. Only pid/pipe_name/pipe_ready.
404
+ let obj = state.as_object_mut().ok_or_else(|| ShimError::StatePersist {
405
+ source: StateError::Io(std::io::Error::new(
406
+ std::io::ErrorKind::InvalidData,
407
+ "state.json root not an object",
408
+ )),
409
+ })?;
410
+ let transport = obj
411
+ .entry("transport".to_string())
412
+ .or_insert_with(|| json!({}));
413
+ if !transport.is_object() {
414
+ *transport = json!({});
415
+ }
416
+ let transport_obj = transport.as_object_mut().unwrap();
417
+ transport_obj.insert("kind".to_string(), json!("conpty"));
418
+ transport_obj.insert(
419
+ "shim".to_string(),
420
+ json!({
421
+ "pid": pid,
422
+ "pipe_name": pipe_name,
423
+ "pipe_ready": true,
424
+ }),
425
+ );
426
+ save_runtime_state(workspace, &state).map_err(|e| ShimError::StatePersist { source: e })?;
427
+ // 0.5.x Windows portability Batch 7 F6: state merge now preserves
428
+ // the `transport.shim` block through downstream saves (see
429
+ // `state::persist::preserve_transport_shim`), so the Batch 6
430
+ // env-override workaround (`TEAM_AGENT_FORCE_CONPTY_PIPE_CONNECT`)
431
+ // is no longer needed. State-only readiness is authoritative.
432
+ Ok(ShimHandle {
433
+ child: Some(child),
434
+ pid,
435
+ pipe_name,
436
+ client: Some(client),
437
+ })
438
+ }
439
+
440
+ /// Read `state.transport.shim.pid` for shutdown routing. Returns
441
+ /// `None` when no shim is currently registered (Unix / never-launched
442
+ /// Windows worker). Callers use `platform::process::terminate_pid`
443
+ /// on the returned pid.
444
+ pub fn recorded_shim_pid(workspace: &Path) -> Option<u32> {
445
+ let state_path = runtime_state_path(workspace);
446
+ if !state_path.exists() {
447
+ return None;
448
+ }
449
+ let text = std::fs::read_to_string(&state_path).ok()?;
450
+ let state: Value = serde_json::from_str(&text).ok()?;
451
+ state
452
+ .get("transport")?
453
+ .get("shim")?
454
+ .get("pid")?
455
+ .as_u64()
456
+ .and_then(|v| u32::try_from(v).ok())
457
+ }
458
+
459
+ /// Read `state.transport.shim.pipe_name` for reconnect routing.
460
+ pub fn recorded_shim_pipe_name(workspace: &Path) -> Option<String> {
461
+ let state_path = runtime_state_path(workspace);
462
+ if !state_path.exists() {
463
+ return None;
464
+ }
465
+ let text = std::fs::read_to_string(&state_path).ok()?;
466
+ let state: Value = serde_json::from_str(&text).ok()?;
467
+ state
468
+ .get("transport")?
469
+ .get("shim")?
470
+ .get("pipe_name")?
471
+ .as_str()
472
+ .map(str::to_string)
473
+ }
474
+
475
+ /// 0.5.x Windows portability Batch 8 F7 (leader msg_590b4dce0f68):
476
+ /// idempotent shim-ownership entry point owned by the coordinator.
477
+ ///
478
+ /// The design's §Shim Lifecycle chapter reads: "shim 是 per-(workspace,
479
+ /// team) 长寿进程,coordinator 可死可重连". This fn is the ONE entry
480
+ /// point that both quick-start (via `ensure_coordinator_running`) and
481
+ /// coord tick monitor call.
482
+ ///
483
+ /// Behavior:
484
+ ///
485
+ /// 1. If `state.transport.shim.pid` is recorded AND the pid is alive,
486
+ /// call `reconnect_recorded_shim` — the shim is a per-(workspace,
487
+ /// team) singleton and we just re-Hello to prove wire compatibility.
488
+ /// 2. Otherwise, `spawn_shim_and_handshake` — spawn a fresh shim,
489
+ /// perform Hello, persist state.
490
+ ///
491
+ /// This gives the "coord can die, shim survives" invariant: a fresh
492
+ /// coord that finds a live shim just reconnects; a fresh coord that
493
+ /// finds no shim spawns one.
494
+ pub fn ensure_shim_running(
495
+ workspace: &Path,
496
+ team_key: &str,
497
+ workspace_hash: &str,
498
+ ) -> Result<ShimHandle, ShimError> {
499
+ // Path 1: reconnect if state has a live shim recorded.
500
+ if let Some(pid) = recorded_shim_pid(workspace) {
501
+ if crate::platform::process::pid_is_alive(pid) {
502
+ match reconnect_recorded_shim(workspace, team_key, workspace_hash) {
503
+ Ok(handle) => return Ok(handle),
504
+ Err(_e) => {
505
+ // Recorded shim is dead-in-effect (pipe closed
506
+ // or Hello failed even though pid is alive).
507
+ // Fall through to spawn — DO NOT mark stale
508
+ // here because the fresh spawn's finalize will
509
+ // atomically REPLACE the shim block with the
510
+ // new pid + pipe_ready:true. Emitting an
511
+ // unavailable event now + finalize's re-write
512
+ // was creating a race where the unavailable
513
+ // reason lingered even after finalize completed
514
+ // (real-machine batch8-28742934836 evidence).
515
+ }
516
+ }
517
+ }
518
+ // Pid is dead OR reconnect failed. Do NOT mark unavailable
519
+ // here — the fresh spawn below overwrites the shim block
520
+ // authoritatively.
521
+ }
522
+ // Path 2: spawn a fresh shim.
523
+ spawn_shim_and_handshake(workspace, team_key, workspace_hash)
524
+ }
525
+
526
+ /// 0.5.x Windows portability Batch 8 F7: reconnect to an already-
527
+ /// running shim by reading `state.transport.shim.pipe_name` and
528
+ /// opening a fresh `NamedPipeClient` + performing Hello.
529
+ ///
530
+ /// Does NOT spawn a new shim. Returns `ShimError::ConnectTimeout` /
531
+ /// `HelloFailed` on failure. Callers (post-crash coord restart)
532
+ /// treat these errors as "recorded shim is dead → mark stale +
533
+ /// consider re-spawn via `ensure_shim_running`".
534
+ ///
535
+ /// Handle returned from this fn has no owned child (the shim is
536
+ /// somebody else's — usually a previous coord instance). `detach()`
537
+ /// on the handle is a no-op for the child.
538
+ pub fn reconnect_recorded_shim(
539
+ workspace: &Path,
540
+ team_key: &str,
541
+ _workspace_hash: &str,
542
+ ) -> Result<ShimHandle, ShimError> {
543
+ let pipe_name = recorded_shim_pipe_name(workspace).ok_or_else(|| {
544
+ ShimError::ConnectTimeout {
545
+ attempts: 0,
546
+ pipe_name: "<state.transport.shim.pipe_name missing>".to_string(),
547
+ }
548
+ })?;
549
+ // For reconnect we don't have the original pipe_token (CR C-1:
550
+ // token was never persisted). Hello is designed to accept any
551
+ // token from the client and echo back the shim's own token, so
552
+ // we use a placeholder and validate the shim's reply instead.
553
+ //
554
+ // NOTE: with reconnect, we're proving wire compatibility, not
555
+ // authenticating a fresh token exchange. The shim's own token
556
+ // is the authoritative one for subsequent requests; if the
557
+ // shim rotated tokens, the shim rejects subsequent non-Hello
558
+ // requests with `PipeTokenMismatch` and the client must
559
+ // re-Hello — which is exactly what this fn does.
560
+ let placeholder_token = "PENDING-RECONNECT";
561
+ let mut last_err: Option<ShimError> = None;
562
+ for attempt in 1..=CONNECT_ATTEMPTS {
563
+ match NamedPipeClient::connect(&pipe_name, 500) {
564
+ Ok(mut client) => {
565
+ match reconnect_hello(&mut client, team_key) {
566
+ Ok(()) => {
567
+ return Ok(ShimHandle {
568
+ child: None,
569
+ pid: recorded_shim_pid(workspace).unwrap_or(0),
570
+ pipe_name,
571
+ client: Some(client),
572
+ });
573
+ }
574
+ Err(reason) => {
575
+ return Err(ShimError::HelloFailed {
576
+ pipe_name,
577
+ reason,
578
+ });
579
+ }
580
+ }
581
+ }
582
+ Err(err) => {
583
+ let _ = attempt;
584
+ let _ = placeholder_token;
585
+ last_err = Some(ShimError::ConnectTimeout {
586
+ attempts: 1,
587
+ pipe_name: format!("{pipe_name} (io: {err})"),
588
+ });
589
+ std::thread::sleep(CONNECT_BACKOFF);
590
+ }
591
+ }
592
+ }
593
+ Err(last_err.unwrap_or(ShimError::ConnectTimeout {
594
+ attempts: CONNECT_ATTEMPTS,
595
+ pipe_name,
596
+ }))
597
+ }
598
+
599
+ /// Reconnect Hello: the client sends Hello with the current workspace/
600
+ /// team scope; the shim replies with its OWN pipe_token (which the
601
+ /// client doesn't know yet). We just validate `resp.ok`.
602
+ fn reconnect_hello(
603
+ client: &mut NamedPipeClient,
604
+ team_key: &str,
605
+ ) -> Result<(), String> {
606
+ use conpty_transport::{Op, PipeClient, Request};
607
+ let req = Request::new(
608
+ "coord-reconnect-hello",
609
+ "", // workspace_hash isn't authoritative for reconnect
610
+ team_key,
611
+ "PENDING-RECONNECT",
612
+ Op::Hello,
613
+ );
614
+ let resp = client.request(&req);
615
+ if !resp.ok {
616
+ return Err(format!("reconnect hello ok=false: {:?}", resp.error));
617
+ }
618
+ Ok(())
619
+ }
620
+
621
+ /// 0.5.x Windows portability Batch 8 F7: emit the C-3 stale-family
622
+ /// event when the coordinator's tick loop finds the shim
623
+ /// unreachable. Also clears `state.transport.shim.pipe_ready` so
624
+ /// downstream code sees the truthful state.
625
+ ///
626
+ /// The event name (`transport.conpty_shim_unavailable`) is picked
627
+ /// deliberately to live in the same event family as other
628
+ /// stale-transport signals (see design §C-3 anchor). Consumers
629
+ /// (status --detail, doctor, MCP status) can grep for
630
+ /// `transport.conpty_shim_*` and get the full stale picture.
631
+ ///
632
+ /// This is a no-op on Unix (no shim concept, no state to clear).
633
+ /// The Windows-only `#[cfg]` gate is at the mod level (see
634
+ /// `coordinator/mod.rs`); on Unix this file isn't compiled, so
635
+ /// downstream callers must cfg-gate their reference themselves.
636
+ pub fn mark_transport_unavailable(
637
+ workspace: &Path,
638
+ reason: &str,
639
+ ) -> Result<(), StateError> {
640
+ // Best-effort event emission — a failed event write should not
641
+ // fail the caller. The state-clearing step below is authoritative.
642
+ if let Ok(event_log) = std::panic::catch_unwind(|| {
643
+ crate::event_log::EventLog::new(workspace)
644
+ }) {
645
+ let _ = event_log.write(
646
+ "transport.conpty_shim_unavailable",
647
+ serde_json::json!({
648
+ "reason": reason,
649
+ "workspace": workspace.display().to_string(),
650
+ }),
651
+ );
652
+ }
653
+ // Clear the shim block from state so subsequent factory
654
+ // `conpty_pipe_ready` checks return false and the operator sees
655
+ // an honest "no live shim" via status --detail.
656
+ let state_path = runtime_state_path(workspace);
657
+ if !state_path.exists() {
658
+ return Ok(());
659
+ }
660
+ let text = std::fs::read_to_string(&state_path).map_err(StateError::from)?;
661
+ let mut state: Value = serde_json::from_str(&text).unwrap_or_else(|_| serde_json::json!({}));
662
+ if let Some(transport) = state
663
+ .get_mut("transport")
664
+ .and_then(|t| t.as_object_mut())
665
+ {
666
+ if let Some(shim) = transport.get_mut("shim").and_then(|s| s.as_object_mut()) {
667
+ shim.insert("pipe_ready".to_string(), serde_json::json!(false));
668
+ shim.insert(
669
+ "unavailable_reason".to_string(),
670
+ serde_json::json!(reason),
671
+ );
672
+ }
673
+ }
674
+ save_runtime_state(workspace, &state)
675
+ }
676
+
677
+ #[cfg(test)]
678
+ mod tests {
679
+ use super::*;
680
+
681
+ #[test]
682
+ fn pipe_token_is_never_written_to_state_json() {
683
+ // CR C-1 anchor: the `finalize` step must NOT persist the
684
+ // pipe_token. Static-code smell test: read this file and
685
+ // assert the string `"pipe_token"` never appears alongside
686
+ // a `save_runtime_state` call inside `finalize`.
687
+ let src = include_str!("conpty_shim.rs");
688
+ // Locate the `finalize` fn body and grep for pipe_token
689
+ // insertion.
690
+ let (_, finalize_and_after) = src
691
+ .split_once("fn finalize(")
692
+ .expect("finalize fn present");
693
+ let finalize_body = finalize_and_after
694
+ .split_once("\n}")
695
+ .map(|(body, _)| body)
696
+ .unwrap_or(finalize_and_after);
697
+ assert!(
698
+ !finalize_body.contains("\"pipe_token\""),
699
+ "CR C-1 violation: finalize() persisted `pipe_token` to state.json"
700
+ );
701
+ assert!(
702
+ !finalize_body.contains("pipe_token()"),
703
+ "CR C-1 violation: finalize() persisted `pipe_token()` accessor"
704
+ );
705
+ }
706
+
707
+ #[test]
708
+ fn state_layout_records_only_pid_pipe_name_pipe_ready() {
709
+ // Structural companion to the previous test: `finalize`'s
710
+ // JSON literal keys under `transport.shim` must be exactly
711
+ // {pid, pipe_name, pipe_ready}. If a future edit adds a 4th
712
+ // key that carries a secret, this test fires.
713
+ let src = include_str!("conpty_shim.rs");
714
+ let (_, after) = src.split_once("\"shim\".to_string(),").expect("shim insert");
715
+ let block_end = after.find("}),").unwrap_or(after.len());
716
+ let block = &after[..block_end];
717
+ for expected in ["pid", "pipe_name", "pipe_ready"] {
718
+ assert!(
719
+ block.contains(&format!("\"{expected}\":")),
720
+ "shim state block missing `{expected}` key"
721
+ );
722
+ }
723
+ for forbidden in ["pipe_token", "token", "secret"] {
724
+ assert!(
725
+ !block.contains(&format!("\"{forbidden}\":")),
726
+ "shim state block MUST NOT contain `{forbidden}` key (CR C-1)"
727
+ );
728
+ }
729
+ }
730
+ }