@team-agent/installer 0.5.1 → 0.5.3
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 +120 -9
- package/Cargo.toml +2 -2
- package/crates/team-agent/Cargo.toml +21 -0
- package/crates/team-agent/src/app_server_test_support.rs +258 -0
- package/crates/team-agent/src/cli/adapters.rs +32 -3
- package/crates/team-agent/src/cli/attach_app_server_leader.rs +16 -0
- package/crates/team-agent/src/cli/diagnose.rs +14 -5
- package/crates/team-agent/src/cli/emit.rs +79 -1
- package/crates/team-agent/src/cli/mod.rs +190 -80
- package/crates/team-agent/src/cli/named_address.rs +111 -0
- package/crates/team-agent/src/cli/send.rs +98 -3
- package/crates/team-agent/src/cli/status_port.rs +44 -6
- package/crates/team-agent/src/cli/tests/named_address.rs +135 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +17 -0
- package/crates/team-agent/src/codex_app_server.rs +549 -0
- package/crates/team-agent/src/conpty/backend.rs +822 -0
- package/crates/team-agent/src/conpty/mod.rs +32 -0
- package/crates/team-agent/src/coordinator/backoff.rs +132 -7
- package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
- package/crates/team-agent/src/coordinator/health.rs +97 -11
- package/crates/team-agent/src/coordinator/mod.rs +8 -0
- package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
- package/crates/team-agent/src/diagnose/orphans.rs +29 -10
- package/crates/team-agent/src/leader/lease.rs +144 -7
- package/crates/team-agent/src/leader/owner_bind.rs +5 -2
- package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
- package/crates/team-agent/src/leader/start.rs +18 -5
- package/crates/team-agent/src/leader/tests/lease_api.rs +179 -0
- package/crates/team-agent/src/lib.rs +36 -0
- package/crates/team-agent/src/lifecycle/launch.rs +207 -94
- package/crates/team-agent/src/lifecycle/lock.rs +40 -33
- package/crates/team-agent/src/lifecycle/restart/agent.rs +10 -18
- package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +63 -0
- package/crates/team-agent/src/mcp_server/wire.rs +6 -7
- package/crates/team-agent/src/messaging/delivery.rs +329 -9
- package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
- package/crates/team-agent/src/messaging/mod.rs +2 -2
- package/crates/team-agent/src/messaging/results.rs +78 -21
- package/crates/team-agent/src/messaging/tests/runtime.rs +237 -0
- package/crates/team-agent/src/packaging/tests.rs +41 -6
- package/crates/team-agent/src/packaging/types.rs +31 -3
- package/crates/team-agent/src/platform/argv.rs +324 -0
- package/crates/team-agent/src/platform/errors.rs +95 -0
- package/crates/team-agent/src/platform/file_lock.rs +418 -0
- package/crates/team-agent/src/platform/mod.rs +66 -0
- package/crates/team-agent/src/platform/process.rs +555 -0
- package/crates/team-agent/src/state/persist.rs +205 -25
- package/crates/team-agent/src/tmux_backend.rs +94 -13
- package/crates/team-agent/src/transport_factory.rs +751 -0
- 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
|
+
}
|