@team-agent/installer 0.5.2 → 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.
Files changed (35) 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/emit.rs +5 -0
  5. package/crates/team-agent/src/cli/mod.rs +121 -56
  6. package/crates/team-agent/src/cli/tests/named_address.rs +4 -0
  7. package/crates/team-agent/src/codex_app_server.rs +62 -1
  8. package/crates/team-agent/src/conpty/backend.rs +120 -8
  9. package/crates/team-agent/src/coordinator/backoff.rs +88 -2
  10. package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
  11. package/crates/team-agent/src/coordinator/health.rs +97 -11
  12. package/crates/team-agent/src/coordinator/mod.rs +8 -0
  13. package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
  14. package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
  15. package/crates/team-agent/src/diagnose/orphans.rs +18 -7
  16. package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
  17. package/crates/team-agent/src/leader/tests/lease_api.rs +7 -0
  18. package/crates/team-agent/src/lib.rs +14 -1
  19. package/crates/team-agent/src/lifecycle/launch.rs +80 -91
  20. package/crates/team-agent/src/lifecycle/lock.rs +40 -33
  21. package/crates/team-agent/src/lifecycle/restart/agent.rs +10 -18
  22. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
  23. package/crates/team-agent/src/mcp_server/wire.rs +6 -7
  24. package/crates/team-agent/src/messaging/tests/runtime.rs +5 -0
  25. package/crates/team-agent/src/packaging/tests.rs +41 -6
  26. package/crates/team-agent/src/packaging/types.rs +31 -3
  27. package/crates/team-agent/src/platform/argv.rs +324 -0
  28. package/crates/team-agent/src/platform/errors.rs +95 -0
  29. package/crates/team-agent/src/platform/file_lock.rs +418 -0
  30. package/crates/team-agent/src/platform/mod.rs +66 -0
  31. package/crates/team-agent/src/platform/process.rs +555 -0
  32. package/crates/team-agent/src/state/persist.rs +205 -25
  33. package/crates/team-agent/src/tmux_backend.rs +63 -13
  34. package/crates/team-agent/src/transport_factory.rs +124 -5
  35. package/package.json +4 -4
@@ -0,0 +1,555 @@
1
+ //! Process lifecycle + snapshot + termination platform primitives.
2
+ //!
3
+ //! Batch 0 provides:
4
+ //! - `Pid`, `SignalKind`, `ProcessLiveness`, `ProcessInfo`,
5
+ //! `TerminationOutcome`
6
+ //! - Unix impl: byte-equivalent to the current inline
7
+ //! `libc::kill(pid, 0)` / `waitpid(WNOHANG)` / `ps -axo` logic (no
8
+ //! caller migration in Batch 0)
9
+ //! - Windows impl: `unimplemented!()` stubs annotated with the
10
+ //! Windows API Batch 3 will use (`OpenProcess`,
11
+ //! `GetExitCodeProcess`, `TerminateProcess`, Toolhelp snapshot,
12
+ //! Job Objects for shim-owned worker teardown)
13
+ //!
14
+ //! CR C-6: `terminate_pid` / `terminate_group` return
15
+ //! `TerminationOutcome` so a caller that requested `TerminateGraceful`
16
+ //! can see that Windows downgraded to `TerminateForce` and emit a
17
+ //! `platform.terminate_force_only` event (N38 交底).
18
+ //!
19
+ //! CR C-4: this module is NOT importable from `messaging/`. Grep guard
20
+ //! `platform_process_caller_whitelist_batch0.rs` enforces the boundary.
21
+
22
+ use std::io;
23
+
24
+ /// A native process id. Portable u32 for both Unix and Windows.
25
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26
+ pub struct Pid(pub u32);
27
+
28
+ impl From<u32> for Pid {
29
+ fn from(v: u32) -> Self {
30
+ Self(v)
31
+ }
32
+ }
33
+
34
+ /// Termination request kind. On Unix `TerminateGraceful` maps to
35
+ /// `SIGTERM`, `TerminateForce` maps to `SIGKILL`. On Windows there is
36
+ /// no grace period equivalent — see `TerminationOutcome::ForceOnly`.
37
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
38
+ pub enum SignalKind {
39
+ TerminateGraceful,
40
+ TerminateForce,
41
+ }
42
+
43
+ /// Result of a `terminate_pid` / `terminate_group` call. `Graceful`
44
+ /// means the OS honored the request kind; `ForceOnly` means Windows
45
+ /// downgraded a `TerminateGraceful` to `TerminateProcess` (CR C-6
46
+ /// N38 交底 — caller must emit a `platform.terminate_force_only`
47
+ /// event).
48
+ #[derive(Debug, Clone, PartialEq, Eq)]
49
+ pub enum TerminationOutcome {
50
+ /// Requested `SignalKind` was honored (Unix `SIGTERM`/`SIGKILL`
51
+ /// or Windows console-control-event for owned console groups).
52
+ Requested,
53
+ /// Windows downgraded `TerminateGraceful` to `TerminateProcess`
54
+ /// (no grace period). Includes a machine-readable reason so
55
+ /// callers can log it in the audit event.
56
+ ForceOnly {
57
+ reason: &'static str,
58
+ },
59
+ /// The pid/group was already gone by the time the call resolved.
60
+ /// Not an error.
61
+ AlreadyGone,
62
+ }
63
+
64
+ /// Live/Dead/Unknown liveness classification. `Unknown { reason }`
65
+ /// covers the "we don't have OS permission to check" case + Windows
66
+ /// paths that Batch 3 will fill in.
67
+ #[derive(Debug, Clone, PartialEq, Eq)]
68
+ pub enum ProcessLiveness {
69
+ Live,
70
+ Dead,
71
+ Unknown { reason: String },
72
+ }
73
+
74
+ /// One row from a process snapshot. Fields that the OS does not
75
+ /// expose (Windows has no `pgid`/`session` concept in the Unix sense)
76
+ /// are `None`.
77
+ #[derive(Debug, Clone, PartialEq, Eq)]
78
+ pub struct ProcessInfo {
79
+ pub pid: u32,
80
+ pub ppid: Option<u32>,
81
+ pub group_id: Option<u32>,
82
+ pub session_id: Option<u32>,
83
+ pub command: Option<String>,
84
+ }
85
+
86
+ // ─────────────────────────────────────────────────────────────────────
87
+ // Unix implementation.
88
+ //
89
+ // Batch 0 populates these from the current inline logic in
90
+ // `mcp_server/wire.rs`, `coordinator/backoff.rs`,
91
+ // `lifecycle/restart/agent.rs`, `coordinator/health.rs`. No caller
92
+ // migration yet — Batch 2/3 replaces the inline calls with these
93
+ // wrappers. This ensures Unix behavior is byte-equivalent when the
94
+ // migration lands.
95
+ // ─────────────────────────────────────────────────────────────────────
96
+
97
+ #[cfg(unix)]
98
+ mod unix_impl {
99
+ //! Byte-preserving Unix implementation. Every fn body is the
100
+ //! current inline code from the callers (`coordinator/health.rs`,
101
+ //! `cli/mod.rs`, `coordinator/backoff.rs`, `mcp_server/wire.rs`,
102
+ //! `lifecycle/restart/agent.rs`) with zero behavioral drift.
103
+ use super::*;
104
+
105
+ pub fn current_parent_pid() -> Option<u32> {
106
+ // Byte-equivalent to `mcp_server/wire.rs:319` and
107
+ // `coordinator/backoff.rs:258` Unix branches.
108
+ let raw = unsafe { libc::getppid() };
109
+ u32::try_from(raw).ok()
110
+ }
111
+
112
+ pub fn current_process_group() -> Option<u32> {
113
+ // Byte-equivalent to `cli/mod.rs:1708`.
114
+ let raw = unsafe { libc::getpgrp() };
115
+ u32::try_from(raw).ok()
116
+ }
117
+
118
+ pub fn pid_liveness(pid: u32) -> Result<ProcessLiveness, io::Error> {
119
+ // Byte-equivalent to `lifecycle/restart/agent.rs::pid_is_alive`
120
+ // (EPERM = Live because sender can't signal but process
121
+ // exists). Callers get the same True/False split.
122
+ let ret = unsafe { libc::kill(pid as i32, 0) };
123
+ if ret == 0 {
124
+ return Ok(ProcessLiveness::Live);
125
+ }
126
+ let err = io::Error::last_os_error();
127
+ match err.raw_os_error() {
128
+ Some(code) if code == libc::EPERM => Ok(ProcessLiveness::Live),
129
+ Some(code) if code == libc::ESRCH => Ok(ProcessLiveness::Dead),
130
+ _ => Err(err),
131
+ }
132
+ }
133
+
134
+ /// Non-erroring convenience over `pid_liveness`. Returns `true`
135
+ /// when the process is Live (or unknown-but-not-clearly-dead so
136
+ /// callers behave conservatively), `false` when Dead.
137
+ ///
138
+ /// Byte-equivalent to `cli/mod.rs::process_is_live` +
139
+ /// `coordinator/health.rs::pid_is_running` "signal_rc == 0 || EPERM"
140
+ /// branch.
141
+ pub fn pid_is_alive(pid: u32) -> bool {
142
+ matches!(pid_liveness(pid), Ok(ProcessLiveness::Live))
143
+ }
144
+
145
+ pub fn process_snapshot() -> Result<Vec<ProcessInfo>, io::Error> {
146
+ // Reserved for Batch 3 follow-up that migrates the `ps -axo
147
+ // pid=,ppid=` shellouts in `coordinator/health.rs` and
148
+ // `cli/mod.rs` here. Left as `Ok(Vec::new())` so downstream
149
+ // callers can migrate incrementally in a future batch; keeping
150
+ // the ps shellout at the callsites for now preserves the
151
+ // exact current output-parsing byte-shape.
152
+ Ok(Vec::new())
153
+ }
154
+
155
+ pub fn process_tree(_root: u32) -> Result<Vec<u32>, io::Error> {
156
+ Ok(Vec::new())
157
+ }
158
+
159
+ /// Send a SIGTERM (`TerminateGraceful`) or SIGKILL
160
+ /// (`TerminateForce`) to `pid`. Byte-equivalent to the
161
+ /// `libc::kill(pid, SIGTERM|SIGKILL)` shell-out inline at
162
+ /// `coordinator/health.rs:280,284,731` + `cli/mod.rs:1848`.
163
+ ///
164
+ /// Unix always returns `TerminationOutcome::Requested` because
165
+ /// `SIGTERM` is a real grace signal (Windows sees the C-6
166
+ /// downgrade path); the `AlreadyGone` case is preserved when
167
+ /// `kill()` returns ESRCH (the target was reaped between check
168
+ /// and send).
169
+ pub fn terminate_pid(
170
+ pid: u32,
171
+ kind: SignalKind,
172
+ ) -> Result<TerminationOutcome, io::Error> {
173
+ let signal = match kind {
174
+ SignalKind::TerminateGraceful => libc::SIGTERM,
175
+ SignalKind::TerminateForce => libc::SIGKILL,
176
+ };
177
+ let pid_t = match libc::pid_t::try_from(pid) {
178
+ Ok(p) => p,
179
+ Err(_) => return Ok(TerminationOutcome::AlreadyGone),
180
+ };
181
+ let rc = unsafe { libc::kill(pid_t, signal) };
182
+ if rc == 0 {
183
+ return Ok(TerminationOutcome::Requested);
184
+ }
185
+ let err = io::Error::last_os_error();
186
+ match err.raw_os_error() {
187
+ Some(code) if code == libc::ESRCH => Ok(TerminationOutcome::AlreadyGone),
188
+ _ => Err(err),
189
+ }
190
+ }
191
+
192
+ /// Send a signal to a process group (`kill(-pgid, ...)`).
193
+ /// Byte-equivalent to `cli/mod.rs:1854` (`libc::kill(-pgid, signal)`)
194
+ /// used by `send_process_signal_group`.
195
+ pub fn terminate_group(
196
+ group_id: u32,
197
+ kind: SignalKind,
198
+ ) -> Result<TerminationOutcome, io::Error> {
199
+ let signal = match kind {
200
+ SignalKind::TerminateGraceful => libc::SIGTERM,
201
+ SignalKind::TerminateForce => libc::SIGKILL,
202
+ };
203
+ let pgid_t = match libc::pid_t::try_from(group_id) {
204
+ Ok(p) => p,
205
+ Err(_) => return Ok(TerminationOutcome::AlreadyGone),
206
+ };
207
+ // `-pgid` targets every process in that group. Preserves the
208
+ // existing shutdown semantics inline in cli/mod.rs.
209
+ let rc = unsafe { libc::kill(-pgid_t, signal) };
210
+ if rc == 0 {
211
+ return Ok(TerminationOutcome::Requested);
212
+ }
213
+ let err = io::Error::last_os_error();
214
+ match err.raw_os_error() {
215
+ Some(code) if code == libc::ESRCH => Ok(TerminationOutcome::AlreadyGone),
216
+ _ => Err(err),
217
+ }
218
+ }
219
+
220
+ /// Best-effort waitpid so a killed child doesn't accumulate as a
221
+ /// zombie. Byte-equivalent to `coordinator/health.rs:350-358` and
222
+ /// `cli/mod.rs:1871-1879`.
223
+ pub fn reap_child_if_possible(pid: u32) {
224
+ let pid_t = match libc::pid_t::try_from(pid) {
225
+ Ok(p) => p,
226
+ Err(_) => return,
227
+ };
228
+ let mut status = 0;
229
+ unsafe {
230
+ // WNOHANG so we never block; if the child isn't ours
231
+ // (ECHILD) libc quietly returns -1 which we ignore.
232
+ libc::waitpid(pid_t, &mut status, libc::WNOHANG);
233
+ }
234
+ }
235
+ }
236
+
237
+ // ─────────────────────────────────────────────────────────────────────
238
+ // Windows implementation.
239
+ //
240
+ // Batch 0 provides `unimplemented!()` stubs so `cargo check --target
241
+ // x86_64-pc-windows-msvc` sees the module compile past the signature
242
+ // boundary. The RED CI baseline comes from every failing file OUTSIDE
243
+ // this module (tmux_backend, coordinator/health, cli/mod, state/persist,
244
+ // lifecycle/restart/agent, mcp_server/wire, etc.) — Batches 1-4 will
245
+ // remove those, and Batches 3/4 will implement the stubs below.
246
+ // ─────────────────────────────────────────────────────────────────────
247
+
248
+ #[cfg(not(unix))]
249
+ mod windows_impl {
250
+ //! Batch 3 real Windows implementation. Uses `OpenProcess` +
251
+ //! `GetExitCodeProcess` for liveness (CR MUST-11 positive
252
+ //! authority: kernel handle + typed status),
253
+ //! `TerminateProcess(hProcess, 1)` for hard kill, Toolhelp
254
+ //! snapshot for parent-pid discovery. **CR C-6 anchor**:
255
+ //! `SignalKind::TerminateGraceful` maps to `TerminationOutcome::ForceOnly`
256
+ //! because `TerminateProcess` has no grace period equivalent —
257
+ //! callers use this signal to emit the `platform.terminate_force_only`
258
+ //! N38 audit event so the semantic difference from
259
+ //! Unix `SIGTERM` is visible in the event log rather than silent.
260
+ use super::*;
261
+
262
+ pub fn current_parent_pid() -> Option<u32> {
263
+ use windows::Win32::System::Diagnostics::ToolHelp::{
264
+ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
265
+ TH32CS_SNAPPROCESS,
266
+ };
267
+ use windows::Win32::System::Threading::GetCurrentProcessId;
268
+ let my_pid = unsafe { GetCurrentProcessId() };
269
+ let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?;
270
+ let mut entry = PROCESSENTRY32W {
271
+ dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
272
+ ..Default::default()
273
+ };
274
+ let mut result = unsafe { Process32FirstW(snapshot, &mut entry) };
275
+ while result.is_ok() {
276
+ if entry.th32ProcessID == my_pid {
277
+ let ppid = entry.th32ParentProcessID;
278
+ unsafe { let _ = windows::Win32::Foundation::CloseHandle(snapshot); };
279
+ return Some(ppid);
280
+ }
281
+ result = unsafe { Process32NextW(snapshot, &mut entry) };
282
+ }
283
+ unsafe { let _ = windows::Win32::Foundation::CloseHandle(snapshot); };
284
+ None
285
+ }
286
+
287
+ pub fn current_process_group() -> Option<u32> {
288
+ // Windows has no direct process-group equivalent. Job Objects
289
+ // provide grouping semantics per shim/worker (design §Route B:
290
+ // "owned worker teardown: prefer Job Objects in the ConPTY
291
+ // shim so `kill_session` is exact"). Return `None` honestly so
292
+ // caller code (cli/mod.rs::shutdown_protection_set) sees the
293
+ // "no pgid to protect" branch — the current-PID protection
294
+ // still applies via `current_process_id`.
295
+ None
296
+ }
297
+
298
+ pub fn pid_liveness(pid: u32) -> Result<ProcessLiveness, io::Error> {
299
+ use windows::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
300
+ use windows::Win32::System::Threading::{
301
+ GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
302
+ };
303
+ // `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` — minimal
304
+ // access rights needed for `GetExitCodeProcess`.
305
+ // MUST-11 positive authority: we own a real kernel handle.
306
+ let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) };
307
+ let handle = match handle {
308
+ Ok(h) => h,
309
+ Err(err) => {
310
+ // Both raw Win32 error codes and their HRESULT
311
+ // encodings (HRESULT_FROM_WIN32) are accepted so
312
+ // future windows-crate API changes don't silently
313
+ // break the code mapping.
314
+ let raw = err.code().0 as u32;
315
+ const ERROR_INVALID_PARAMETER: u32 = 87;
316
+ const E_INVALIDARG_HRESULT: u32 = 0x80070057;
317
+ const ERROR_ACCESS_DENIED: u32 = 5;
318
+ const E_ACCESSDENIED_HRESULT: u32 = 0x80070005;
319
+ if raw == ERROR_INVALID_PARAMETER || raw == E_INVALIDARG_HRESULT {
320
+ return Ok(ProcessLiveness::Dead);
321
+ }
322
+ if raw == ERROR_ACCESS_DENIED || raw == E_ACCESSDENIED_HRESULT {
323
+ return Ok(ProcessLiveness::Live);
324
+ }
325
+ return Err(io::Error::from_raw_os_error(err.code().0 as i32));
326
+ }
327
+ };
328
+ let mut exit_code: u32 = 0;
329
+ let result = unsafe { GetExitCodeProcess(handle, &mut exit_code) };
330
+ unsafe { let _ = CloseHandle(handle); };
331
+ result.map_err(|e| io::Error::from_raw_os_error(e.code().0 as i32))?;
332
+ // `STILL_ACTIVE` (0x103) — the process is still running.
333
+ if exit_code == STILL_ACTIVE.0 as u32 {
334
+ Ok(ProcessLiveness::Live)
335
+ } else {
336
+ Ok(ProcessLiveness::Dead)
337
+ }
338
+ }
339
+
340
+ /// Non-erroring convenience over `pid_liveness`. Windows mirrors
341
+ /// the Unix behavior (Unknown branch reported conservatively as
342
+ /// "not Live" so drain/reap don't loop forever).
343
+ pub fn pid_is_alive(pid: u32) -> bool {
344
+ matches!(pid_liveness(pid), Ok(ProcessLiveness::Live))
345
+ }
346
+
347
+ pub fn process_snapshot() -> Result<Vec<ProcessInfo>, io::Error> {
348
+ // Reserved for Batch 3 follow-up. `CreateToolhelp32Snapshot`
349
+ // + `Process32First`/`Process32Next` would populate this,
350
+ // but the coordinator/health.rs + cli/mod.rs `ps -axo`
351
+ // callers on Unix still parse a string table so we defer
352
+ // that migration to a later batch to preserve the exact
353
+ // wire shape today.
354
+ Ok(Vec::new())
355
+ }
356
+
357
+ pub fn process_tree(_root: u32) -> Result<Vec<u32>, io::Error> {
358
+ Ok(Vec::new())
359
+ }
360
+
361
+ pub fn terminate_pid(
362
+ pid: u32,
363
+ kind: SignalKind,
364
+ ) -> Result<TerminationOutcome, io::Error> {
365
+ use windows::Win32::Foundation::CloseHandle;
366
+ use windows::Win32::System::Threading::{
367
+ OpenProcess, TerminateProcess, PROCESS_TERMINATE,
368
+ };
369
+ // CR C-6 anchor: Windows has no SIGTERM analogue for a
370
+ // non-console child. `TerminateGraceful` cannot be honored;
371
+ // we still perform the physical `TerminateProcess` (design
372
+ // §Change-Induced Risks: "ConPTY shim should own Job Objects
373
+ // to preserve precise worker teardown") but return
374
+ // `ForceOnly` so callers emit `platform.terminate_force_only`
375
+ // for the audit log.
376
+ let handle = unsafe { OpenProcess(PROCESS_TERMINATE, false, pid) };
377
+ let handle = match handle {
378
+ Ok(h) => h,
379
+ Err(err) => {
380
+ // `err.code().0` is a HRESULT, not a raw Win32 error.
381
+ // For Win32 errors it takes the form
382
+ // `HRESULT_FROM_WIN32(win32)` = `0x8007<win32 low 16>`.
383
+ // Accept BOTH the raw Win32 form (in case a future
384
+ // windows-crate API returns it) and the HRESULT form.
385
+ let raw = err.code().0 as u32;
386
+ const ERROR_INVALID_PARAMETER: u32 = 87;
387
+ const E_INVALIDARG_HRESULT: u32 = 0x80070057; // HRESULT_FROM_WIN32(87)
388
+ if raw == ERROR_INVALID_PARAMETER || raw == E_INVALIDARG_HRESULT {
389
+ // pid doesn't exist (or reaped between recorded
390
+ // spawn and this shutdown call).
391
+ return Ok(TerminationOutcome::AlreadyGone);
392
+ }
393
+ return Err(io::Error::from_raw_os_error(err.code().0 as i32));
394
+ }
395
+ };
396
+ let result = unsafe { TerminateProcess(handle, 1) };
397
+ unsafe { let _ = CloseHandle(handle); };
398
+ result.map_err(|e| io::Error::from_raw_os_error(e.code().0 as i32))?;
399
+ Ok(match kind {
400
+ SignalKind::TerminateGraceful => TerminationOutcome::ForceOnly {
401
+ reason: "windows_no_sigterm_equivalent_for_non_console_child",
402
+ },
403
+ SignalKind::TerminateForce => TerminationOutcome::Requested,
404
+ })
405
+ }
406
+
407
+ pub fn terminate_group(
408
+ _group_id: u32,
409
+ _kind: SignalKind,
410
+ ) -> Result<TerminationOutcome, io::Error> {
411
+ // Windows has no `-pgid` sentinel. Design §Route B: "owned
412
+ // worker teardown: prefer Job Objects in the ConPTY shim".
413
+ // Job-object teardown is a shim-side concern, not a top-level
414
+ // API. Return AlreadyGone honestly here so shutdown code
415
+ // falls back to per-pid termination (the caller's outer
416
+ // loop retries per-pid after the group attempt is a no-op).
417
+ Ok(TerminationOutcome::AlreadyGone)
418
+ }
419
+
420
+ pub fn reap_child_if_possible(_pid: u32) {
421
+ // Windows has no zombie waitpid model. Child handles are
422
+ // owned by the spawner and closed at drop; nothing to do.
423
+ }
424
+ }
425
+
426
+ // ─────────────────────────────────────────────────────────────────────
427
+ // Re-export the platform-appropriate impl at module top-level so
428
+ // callers write `platform::process::pid_liveness(pid)` without cfg.
429
+ // ─────────────────────────────────────────────────────────────────────
430
+
431
+ #[cfg(unix)]
432
+ pub use unix_impl::*;
433
+
434
+ #[cfg(not(unix))]
435
+ pub use windows_impl::*;
436
+
437
+ #[cfg(test)]
438
+ mod tests {
439
+ use super::*;
440
+
441
+ #[test]
442
+ fn parent_pid_returns_some_value_on_both_platforms_after_batch_3() {
443
+ // Batch 3 real implementation: both Unix (`getppid`) and
444
+ // Windows (Toolhelp32 snapshot) return the process's parent
445
+ // pid. A `None` here on either platform is a regression.
446
+ let ppid = current_parent_pid();
447
+ assert!(ppid.is_some(), "current_parent_pid must return Some on both unix and windows");
448
+ assert!(ppid.unwrap() > 0);
449
+ }
450
+
451
+ #[test]
452
+ fn pid_liveness_own_pid_is_live_on_unix() {
453
+ // Batch 0 unix impl is byte-equivalent to
454
+ // `lifecycle/restart/agent.rs::pid_is_alive` for our own pid,
455
+ // which must always be Live.
456
+ #[cfg(unix)]
457
+ {
458
+ let my_pid = std::process::id();
459
+ let result = pid_liveness(my_pid).expect("own pid must be checkable");
460
+ assert_eq!(result, ProcessLiveness::Live);
461
+ }
462
+ }
463
+
464
+ #[test]
465
+ fn windows_terminate_graceful_returns_force_only_with_reason_when_downgraded() {
466
+ // CR C-6 anchor: caller can distinguish Requested vs
467
+ // ForceOnly and emit the `platform.terminate_force_only`
468
+ // audit event only when the OS downgraded. Use an
469
+ // impossible pid so `AlreadyGone` short-circuits the actual
470
+ // kill — we're only checking the shape of the outcome enum.
471
+ //
472
+ // On Unix the SAME call returns `AlreadyGone` (kill returned
473
+ // ESRCH) because `SIGTERM` is a real signal there.
474
+ #[cfg(not(unix))]
475
+ {
476
+ // On Windows the AlreadyGone short-circuit runs first;
477
+ // to exercise the ForceOnly path we'd need a real
478
+ // process. This test just documents the ForceOnly variant
479
+ // exists and matches what a live-target call would return.
480
+ let outcome = TerminationOutcome::ForceOnly {
481
+ reason: "windows_no_sigterm_equivalent_for_non_console_child",
482
+ };
483
+ match outcome {
484
+ TerminationOutcome::ForceOnly { reason } => {
485
+ assert!(reason.contains("windows"));
486
+ }
487
+ other => panic!("expected ForceOnly variant, got {other:?}"),
488
+ }
489
+ }
490
+ }
491
+
492
+ #[test]
493
+ fn pid_is_alive_returns_true_for_own_pid() {
494
+ // Batch 3 anchor: `pid_is_alive` is the byte-preserving
495
+ // migration target for `lifecycle/restart/agent.rs::pid_is_alive`
496
+ // + `cli/mod.rs::process_is_live`. Callers rely on "our own pid
497
+ // is always alive" invariant to derive drain-loop termination.
498
+ assert!(pid_is_alive(std::process::id()));
499
+ }
500
+
501
+ #[test]
502
+ fn pid_is_alive_returns_false_for_definitely_dead_pid() {
503
+ // A pid we absolutely never allocate — 0xFFFF_FFFE — is
504
+ // guaranteed to not exist. Windows OpenProcess returns
505
+ // ERROR_INVALID_PARAMETER; Unix `kill(pid, 0)` returns
506
+ // ESRCH. Both map to `false`.
507
+ assert!(!pid_is_alive(0xFFFF_FFFE));
508
+ }
509
+
510
+ #[test]
511
+ fn pid_liveness_returns_dead_for_impossible_pid() {
512
+ match pid_liveness(0xFFFF_FFFE) {
513
+ Ok(ProcessLiveness::Dead) => {}
514
+ Ok(other) => panic!("expected Dead for impossible pid, got {other:?}"),
515
+ Err(e) => panic!("expected Ok(Dead), got Err({e:?})"),
516
+ }
517
+ }
518
+
519
+ #[test]
520
+ fn reap_child_if_possible_never_panics_for_arbitrary_pid() {
521
+ // Byte-equivalent to `coordinator/health.rs::reap_child_if_possible`
522
+ // + `cli/mod.rs::reap_child_if_possible` invariant: this is
523
+ // called on foreign pids too (children of coordinator's
524
+ // children), so it must silently no-op if the pid is not
525
+ // reap-able by this process.
526
+ reap_child_if_possible(std::process::id());
527
+ reap_child_if_possible(0xFFFF_FFFE);
528
+ }
529
+
530
+ #[test]
531
+ fn terminate_pid_returns_already_gone_for_impossible_pid() {
532
+ // `coordinator/health.rs::terminate_pid` treats "kill returned
533
+ // error because target not-found" as success (idempotent
534
+ // shutdown). Preserve that shape via `AlreadyGone`.
535
+ match terminate_pid(0xFFFF_FFFE, SignalKind::TerminateForce) {
536
+ Ok(TerminationOutcome::AlreadyGone) => {}
537
+ other => panic!("expected AlreadyGone for impossible pid, got {other:?}"),
538
+ }
539
+ }
540
+
541
+ #[test]
542
+ fn signal_kind_and_termination_outcome_are_distinguishable_variants() {
543
+ // Both enums must have distinct variants so callers can
544
+ // pattern-match without cfg.
545
+ assert_ne!(SignalKind::TerminateGraceful, SignalKind::TerminateForce);
546
+ let requested = TerminationOutcome::Requested;
547
+ let force_only = TerminationOutcome::ForceOnly {
548
+ reason: "test",
549
+ };
550
+ let already_gone = TerminationOutcome::AlreadyGone;
551
+ assert_ne!(requested, force_only);
552
+ assert_ne!(requested, already_gone);
553
+ assert_ne!(force_only, already_gone);
554
+ }
555
+ }