@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
@@ -53,6 +53,21 @@ pub fn coordinator_health(workspace: &WorkspacePath) -> HealthReport {
53
53
  /// `start_coordinator`(`lifecycle.py:49-121`):幂等 — 已健康 no-op(AlreadyRunning);metadata 不兼容
54
54
  /// 先 stop 再起;schema 不兼容拒启 + hint;否则 spawn `team-agent coordinator --workspace <ws>`。
55
55
  pub fn start_coordinator(workspace: &WorkspacePath) -> Result<StartReport, StartError> {
56
+ start_coordinator_with_team(workspace, None)
57
+ }
58
+
59
+ /// 0.5.x Windows portability Batch 9 F8 (leader msg_2a4cc1fa54c0):
60
+ /// forward `--team` to the spawned coord daemon so it doesn't have
61
+ /// to derive team_key from `state.active_team_key` at boot. The
62
+ /// derivation stays as fallback (see `coordinator::backoff::run_daemon`),
63
+ /// so Unix daemons and pre-existing test harnesses are byte-preserving.
64
+ ///
65
+ /// Callers that CAN pass team_key (Batch 9 quick-start Windows path)
66
+ /// SHOULD — that avoids Batch 8's F8 seed-state trap.
67
+ pub fn start_coordinator_with_team(
68
+ workspace: &WorkspacePath,
69
+ team_key: Option<&str>,
70
+ ) -> Result<StartReport, StartError> {
56
71
  let health = coordinator_health(workspace);
57
72
  if health.ok {
58
73
  return Ok(StartReport {
@@ -101,7 +116,13 @@ pub fn start_coordinator(workspace: &WorkspacePath) -> Result<StartReport, Start
101
116
  let mut command = Command::new(std::env::current_exe()?);
102
117
  command
103
118
  .args(["coordinator", "--workspace"])
104
- .arg(workspace.as_path())
119
+ .arg(workspace.as_path());
120
+ if let Some(tk) = team_key {
121
+ if !tk.is_empty() {
122
+ command.args(["--team", tk]);
123
+ }
124
+ }
125
+ command
105
126
  .stdin(Stdio::null())
106
127
  .stdout(Stdio::from(log))
107
128
  .stderr(Stdio::from(log_err));
@@ -135,7 +156,23 @@ fn detach_daemon_child(command: &mut Command) {
135
156
  }
136
157
  }
137
158
 
138
- #[cfg(not(unix))]
159
+ #[cfg(windows)]
160
+ fn detach_daemon_child(command: &mut Command) {
161
+ // 0.5.x Windows portability Batch 8 F7 (leader msg_590b4dce0f68):
162
+ // detach the coordinator daemon on Windows via
163
+ // `DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB` creation flags,
164
+ // matching what `coordinator::conpty_shim::spawn_shim_and_handshake`
165
+ // does for the shim. Without these flags, an SSH-launched
166
+ // quick-start blocks waiting for the coord daemon's process
167
+ // tree to exit (never happens — daemon runs forever), and the
168
+ // quick-start caller sees a hung command.
169
+ use std::os::windows::process::CommandExt;
170
+ const DETACHED_PROCESS: u32 = 0x00000008;
171
+ const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x01000000;
172
+ command.creation_flags(DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB);
173
+ }
174
+
175
+ #[cfg(not(any(unix, windows)))]
139
176
  fn detach_daemon_child(_command: &mut Command) {}
140
177
 
141
178
  /// `stop_coordinator`(`lifecycle.py:228-247`):SIGTERM pid + 清 pid/meta → typed report。
@@ -272,16 +309,32 @@ fn coordinator_command_matches_workspace(command: &str, workspaces: &[String]) -
272
309
  }
273
310
 
274
311
  fn terminate_pid(pid: Pid) -> bool {
312
+ // 0.5.x Windows portability Batch 3: routes signal delivery through
313
+ // `platform::process::terminate_pid`. Unix keeps
314
+ // SIGTERM → 5s grace → SIGKILL semantics byte-for-byte
315
+ // (`SignalKind::TerminateGraceful` → SIGTERM,
316
+ // `SignalKind::TerminateForce` → SIGKILL). Windows performs
317
+ // `TerminateProcess` for both kinds; the `TerminationOutcome::ForceOnly`
318
+ // return on the graceful call is what a future audit-event
319
+ // emitter (CR C-6) will trigger `platform.terminate_force_only`
320
+ // on. For this batch the return value is discarded, matching the
321
+ // current inline `let _ = send_signal(...)` pattern.
275
322
  if pid_is_running(pid).ok() == Some(false) {
276
323
  return true;
277
324
  }
278
325
  let pids = process_tree_pids(pid);
279
326
  for child in pids.iter().rev() {
280
- let _ = send_signal(*child, libc::SIGTERM);
327
+ let _ = crate::platform::process::terminate_pid(
328
+ child.get(),
329
+ crate::platform::process::SignalKind::TerminateGraceful,
330
+ );
281
331
  }
282
332
  if !wait_until_all_not_running(&pids, Duration::from_secs(5)) {
283
333
  for child in pids.iter().rev() {
284
- let _ = send_signal(*child, libc::SIGKILL);
334
+ let _ = crate::platform::process::terminate_pid(
335
+ child.get(),
336
+ crate::platform::process::SignalKind::TerminateForce,
337
+ );
285
338
  }
286
339
  }
287
340
  wait_until_all_not_running(&pids, Duration::from_secs(5))
@@ -348,16 +401,17 @@ fn wait_until_all_not_running(pids: &[Pid], timeout: Duration) -> bool {
348
401
  }
349
402
 
350
403
  fn reap_child_if_possible(pid: Pid) {
351
- let Ok(pid_t) = libc::pid_t::try_from(pid.get()) else {
352
- return;
353
- };
354
- let mut status = 0;
355
- unsafe {
356
- libc::waitpid(pid_t, &mut status, libc::WNOHANG);
357
- }
404
+ // Batch 3: routed through `platform::process`. Unix `waitpid
405
+ // (WNOHANG)`; Windows no-op (no zombie model).
406
+ crate::platform::process::reap_child_if_possible(pid.get());
358
407
  }
359
408
 
409
+ #[cfg(unix)]
410
+ #[allow(dead_code)]
360
411
  fn send_signal(pid: Pid, signal: libc::c_int) -> bool {
412
+ // Retained (dead code post-Batch-3) as a Unix-only helper for any
413
+ // future non-standard signal delivery. All product paths now use
414
+ // `crate::platform::process::terminate_pid` with `SignalKind`.
361
415
  let Ok(pid_t) = libc::pid_t::try_from(pid.get()) else {
362
416
  return false;
363
417
  };
@@ -383,6 +437,21 @@ fn wait_until_not_running(pid: Pid, timeout: Duration) -> bool {
383
437
 
384
438
  /// `pid_is_running`(`metadata.py:16-25`):`os.kill(pid, 0)` + `ps -o stat=` 查 zombie(Z* → 不算活)。
385
439
  /// §10 fallible:进程探测 I/O 可失败 → Result。
440
+ ///
441
+ /// 0.5.x Windows portability Batch 4: this function has UNIQUE
442
+ /// coordinator-metadata semantics — it treats `EPERM` as "not
443
+ /// running" (different from `platform::process::pid_liveness` which
444
+ /// treats `EPERM` as Live) because the coordinator only owns
445
+ /// processes it can signal, and uses `ps -o stat=` for zombie
446
+ /// detection. The `ps` shellout is Unix-only.
447
+ ///
448
+ /// On Windows the coordinator-metadata identity check runs through
449
+ /// `platform::process::pid_liveness` instead (Windows has no zombie
450
+ /// state — a process is either Live or Dead — so the additional
451
+ /// `ps stat=` step has no analogue). This preserves the coordinator's
452
+ /// "am I the owner" check without silently reporting stale pids as
453
+ /// alive.
454
+ #[cfg(unix)]
386
455
  pub fn pid_is_running(pid: Pid) -> Result<bool, std::io::Error> {
387
456
  let Ok(pid_t) = libc::pid_t::try_from(pid.get()) else {
388
457
  return Ok(false);
@@ -407,6 +476,23 @@ pub fn pid_is_running(pid: Pid) -> Result<bool, std::io::Error> {
407
476
  Ok(!stat.is_empty() && !stat.starts_with('Z'))
408
477
  }
409
478
 
479
+ /// Windows shim: no `ps stat=` zombie detection needed (Windows has
480
+ /// no zombie state — a process is either Live or Dead). Route through
481
+ /// `platform::process::pid_liveness` and map to bool. The EPERM
482
+ /// semantic ("we can't signal → treat as not running") maps to
483
+ /// Windows `ERROR_ACCESS_DENIED` which the platform layer already
484
+ /// treats as `Live`; so on Windows the coordinator sees a process
485
+ /// it can't query as still-running (safer than pretending it's gone
486
+ /// and losing the ownership handle).
487
+ #[cfg(not(unix))]
488
+ pub fn pid_is_running(pid: Pid) -> Result<bool, std::io::Error> {
489
+ match crate::platform::process::pid_liveness(pid.get())? {
490
+ crate::platform::process::ProcessLiveness::Live => Ok(true),
491
+ crate::platform::process::ProcessLiveness::Dead => Ok(false),
492
+ crate::platform::process::ProcessLiveness::Unknown { .. } => Ok(false),
493
+ }
494
+ }
495
+
410
496
  /// `read_coordinator_metadata`(`metadata.py:28-34`)。读 `coordinator.json`;损坏/缺失/非 dict → `None`。
411
497
  pub fn read_coordinator_metadata(workspace: &WorkspacePath) -> Option<CoordinatorMetadata> {
412
498
  let text = std::fs::read_to_string(coordinator_meta_path(workspace)).ok()?;
@@ -62,6 +62,14 @@ use crate::provider::{ProviderAdapter, TurnId, TurnState};
62
62
  use serde_json::Value;
63
63
 
64
64
  pub mod backoff;
65
+ // 0.5.x Windows portability Batch 6 Option A: shim lifecycle
66
+ // manager. Windows-only per design §Shim Lifecycle (the shim binary
67
+ // exists only on Windows; Unix uses tmux). Cfg-gated at the module
68
+ // level so callers can `crate::coordinator::conpty_shim::...`
69
+ // uniformly on Windows and get a compile error (rather than a
70
+ // silent no-op) on Unix — Unix code paths never reach for the shim.
71
+ #[cfg(windows)]
72
+ pub mod conpty_shim;
65
73
  pub mod health;
66
74
  pub mod orphan;
67
75
  pub mod runtime_detectors;
@@ -87,7 +87,7 @@ fn start_coordinator_fresh_workspace_decides_started() {
87
87
  fn run_daemon_once_writes_boot_metadata_and_returns_ok() {
88
88
  let (wp, dir) = daemon_ws();
89
89
  crate::state::persist::save_runtime_state(&dir, &serde_json::json!({"session_name": "team-x", "agents": {}})).unwrap();
90
- let r = run_daemon(DaemonArgs { workspace: wp.clone(), once: true, tick_interval_sec: None });
90
+ let r = run_daemon(DaemonArgs { workspace: wp.clone(), once: true, tick_interval_sec: None, team_key: None });
91
91
  assert!(r.is_ok(), "run_daemon --once must return Ok; got {r:?}");
92
92
  assert!(coordinator_meta_path(&wp).exists(), "run_daemon must write the coordinator boot metadata");
93
93
  }
@@ -438,6 +438,7 @@ fn run_daemon_backs_off_on_transient_tick_err_then_recovers_without_exiting() {
438
438
  workspace: WorkspacePath::new(dir.clone()),
439
439
  once: false,
440
440
  tick_interval_sec: Some(0.01), // tiny backoff so the test is fast
441
+ team_key: None,
441
442
  };
442
443
  let result = run_daemon_with_coordinator(&args, &coord);
443
444
  assert!(
@@ -192,6 +192,12 @@ fn p2_tick_skips_tmux_gate_when_session_name_absent() {
192
192
 
193
193
  // P1 — pid_is_running must use os.kill(pid,0) first: a pid owned by another user (pid 1 /
194
194
  // launchd, root) is EPERM → not signalable → False. Current only `ps -p` (rc=0) → True.
195
+ //
196
+ // 0.5.x Windows portability Batch 5: this test's PREMISE is Unix
197
+ // specific (pid 1 = init/launchd, EPERM semantics). On Windows,
198
+ // pid_is_running routes through `platform::process::pid_liveness` which
199
+ // has different accessible-pid semantics. Unix-only test.
200
+ #[cfg(unix)]
195
201
  #[test]
196
202
  fn p2_pid_is_running_false_for_cross_user_pid() {
197
203
  // The cross-user semantics only hold for a non-root caller (root CAN signal pid 1).
@@ -553,6 +553,19 @@ impl Coordinator {
553
553
  }),
554
554
  )?;
555
555
  }
556
+ for mismatch in &report.identity_mismatches {
557
+ event_log.write(
558
+ "provider.session.identity_mismatch",
559
+ serde_json::json!({
560
+ "agent_id": mismatch.agent_id,
561
+ "expected_agent_id": mismatch.expected_agent_id,
562
+ "embedded_agent_id": mismatch.embedded_agent_id,
563
+ "session_id": mismatch.session_id,
564
+ "rollout_path": mismatch.rollout_path,
565
+ "spawn_cwd": mismatch.spawn_cwd,
566
+ }),
567
+ )?;
568
+ }
556
569
  for ambiguous in report.ambiguous {
557
570
  let candidate_count = report
558
571
  .candidate_count_by_agent
@@ -342,14 +342,25 @@ fn tmux_socket_names() -> Vec<String> {
342
342
  }
343
343
 
344
344
  fn tmux_socket_roots() -> Vec<PathBuf> {
345
- let uid = unsafe { libc::geteuid() };
346
- let mut roots = vec![PathBuf::from(format!("/tmp/tmux-{uid}"))];
347
- if let Some(tmpdir) = std::env::var_os("TMPDIR") {
348
- roots.push(PathBuf::from(tmpdir).join(format!("tmux-{uid}")));
345
+ // 0.5.x Windows portability Batch 1: tmux orphan scan is Unix-only
346
+ // (design §Layering Strategy "ConPTY orphan diagnostics should be
347
+ // a separate shim registry command later"). On Windows return
348
+ // empty so the caller loops zero times honestly.
349
+ #[cfg(unix)]
350
+ {
351
+ let uid = unsafe { libc::geteuid() };
352
+ let mut roots = vec![PathBuf::from(format!("/tmp/tmux-{uid}"))];
353
+ if let Some(tmpdir) = std::env::var_os("TMPDIR") {
354
+ roots.push(PathBuf::from(tmpdir).join(format!("tmux-{uid}")));
355
+ }
356
+ roots.sort();
357
+ roots.dedup();
358
+ roots
359
+ }
360
+ #[cfg(not(unix))]
361
+ {
362
+ Vec::new()
349
363
  }
350
- roots.sort();
351
- roots.dedup();
352
- roots
353
364
  }
354
365
 
355
366
  fn tmux_list_panes(socket: &str) -> Vec<TmuxPaneRow> {
@@ -98,44 +98,29 @@ fn provider_wire_for_command_attribution(provider: Provider) -> &'static str {
98
98
  }
99
99
  }
100
100
 
101
- #[cfg(target_os = "linux")]
101
+ // 0.5.x Windows portability Batch 4: process command-line + environ
102
+ // probes route through `crate::platform::argv`. Unix keeps
103
+ // byte-preserving semantics (Linux `/proc/<pid>/cmdline` + `environ`;
104
+ // other Unix `ps -p ... -o command=`; macOS environ is `None` because
105
+ // the pre-batch code also returned `None` on non-Linux). Windows
106
+ // returns `None` per design §Batch 4 conservative anchor (unknown
107
+ // argv must never infer elevated approval).
108
+ //
109
+ // The command-line format expected by the caller is a
110
+ // whitespace-joined single string, so we join the argv tokens after
111
+ // the platform probe. On the non-Linux Unix `ps` fallback path the
112
+ // tokens come already whitespace-split from `ps -o command=`; joining
113
+ // them back with spaces reproduces the exact pre-batch shape.
102
114
  fn process_command_line(pid: u32) -> Option<String> {
103
- let bytes = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
104
- let parts = bytes
105
- .split(|byte| *byte == 0)
106
- .filter(|part| !part.is_empty())
107
- .map(|part| String::from_utf8_lossy(part).to_string())
108
- .collect::<Vec<_>>();
109
- (!parts.is_empty()).then(|| parts.join(" "))
110
- }
111
-
112
- #[cfg(target_os = "linux")]
113
- fn process_environment(pid: u32) -> Option<String> {
114
- String::from_utf8(std::fs::read(format!("/proc/{pid}/environ")).ok()?).ok()
115
- }
116
-
117
- #[cfg(not(target_os = "linux"))]
118
- fn process_command_line(pid: u32) -> Option<String> {
119
- let output = crate::os_probe::bounded_command_output_with_probe(
120
- Command::new("ps")
121
- .arg("-p")
122
- .arg(pid.to_string())
123
- .args(["-o", "command="]),
124
- "provider_argv",
125
- Some(pid),
126
- )
127
- .ok()?;
128
- output
129
- .status
130
- .success()
131
- .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
132
- .filter(|text| !text.is_empty())
115
+ let argv = crate::platform::argv::argv_tokens(pid)?;
116
+ if argv.is_empty() {
117
+ return None;
118
+ }
119
+ Some(argv.join(" "))
133
120
  }
134
121
 
135
- #[cfg(not(target_os = "linux"))]
136
122
  fn process_environment(pid: u32) -> Option<String> {
137
- let _ = pid;
138
- None
123
+ crate::platform::argv::environ_text(pid)
139
124
  }
140
125
 
141
126
  #[cfg(test)]
@@ -303,6 +303,11 @@ fn r8_requeued_exhausted_watchers_event_payload_golden_shape() {
303
303
  assert_eq!(payload.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
304
304
  }
305
305
 
306
+ // 0.5.x Windows portability Batch 5: `FakeAppServer` uses UNIX domain
307
+ // sockets to fake the Codex app-server. Codex app-server client is
308
+ // Unix-only (Windows returns typed `SocketUnreachable`), so these
309
+ // tests are Unix-only too.
310
+ #[cfg(unix)]
306
311
  #[test]
307
312
  fn app_server_attach_writes_transport_kind_tuple_and_advances_epoch() {
308
313
  let ws = std::env::temp_dir().join(format!(
@@ -357,6 +362,7 @@ fn app_server_attach_writes_transport_kind_tuple_and_advances_epoch() {
357
362
  assert_eq!(saved["owner_epoch"], serde_json::json!(1));
358
363
  }
359
364
 
365
+ #[cfg(unix)]
360
366
  #[test]
361
367
  fn app_server_attach_rejects_world_writable_socket_without_state_write() {
362
368
  let ws = std::env::temp_dir().join(format!(
@@ -396,6 +402,7 @@ fn app_server_attach_rejects_world_writable_socket_without_state_write() {
396
402
  );
397
403
  }
398
404
 
405
+ #[cfg(unix)]
399
406
  #[test]
400
407
  fn app_server_attach_rejects_missing_user_agent_without_state_write() {
401
408
  let ws = std::env::temp_dir().join(format!(
@@ -78,6 +78,13 @@ pub mod diagnose;
78
78
  // 富返回类型,fn body unimplemented!(),P2 porter 落实现)。lifecycle=quick-start/restart/display;
79
79
  // mcp_server=stdio MCP tool handlers;cli=clap 子命令;packaging=install/migrate/repair。
80
80
  pub mod lifecycle;
81
+
82
+ // 0.5.x Windows portability Batch 0: platform abstraction layer.
83
+ // Truth sources:
84
+ // - Design: `.team/artifacts/0.5.x-windows-portability-survey-design.md`
85
+ // - CR verdict: `.team/artifacts/0.5.x-windows-portability-cr-verdict.md`
86
+ // (6 constraints anchored inside the module doc)
87
+ pub mod platform;
81
88
  // 0.3.28 — unified adaptive layout manager (single source of truth for tmux
82
89
  // topology decisions). See `.team/artifacts/adaptive-layout-full-architecture-locate.md`.
83
90
  pub mod layout;
@@ -114,5 +121,11 @@ pub mod conpty;
114
121
  // (6 constraints anchored inside the module doc)
115
122
  pub mod transport_factory;
116
123
 
117
- #[cfg(test)]
124
+ // 0.5.x Windows portability Batch 5: `app_server_test_support` is a
125
+ // Unix-domain-socket fake for the Codex app-server client. It is
126
+ // Unix-only because the code-under-test (`codex_app_server`) is
127
+ // Unix-only via cfg (Windows gets typed `SocketUnreachable`
128
+ // unsupported returns). Cfg-gating the module keeps `cargo check
129
+ // --tests --target x86_64-pc-windows-msvc` compilable.
130
+ #[cfg(all(test, unix))]
118
131
  pub(crate) mod app_server_test_support;
@@ -374,6 +374,8 @@ fn spawn_agents(
374
374
  );
375
375
  }
376
376
  }
377
+ let spawn_epoch = u64::try_from(started.len()).unwrap_or(u64::MAX);
378
+ let spawned_at = spawn_timestamp_for_agent(u32::try_from(spawn_epoch).unwrap_or(u32::MAX));
377
379
  // E6 层1 实证3 + 诊断留痕:落最终 worker argv(spawn 前的真实形态)。
378
380
  // 任何"--session-id 预定 UUID 没生效"必须能从 events.jsonl 回答:argv 里到底有没有它。
379
381
  // 抽出 --session-id 值单列,方便和盘上 ~/.claude/projects/<cwd> 实际落的 UUID 对账。
@@ -393,6 +395,10 @@ fn spawn_agents(
393
395
  "argv": plan.argv,
394
396
  "session_id_in_argv": session_id_in_argv,
395
397
  "expected_session_id": plan.expected_session_id.as_ref().map(|s| s.as_str()),
398
+ "spawn_cwd": workspace.to_string_lossy(),
399
+ "spawned_at": spawned_at.as_str(),
400
+ "source": "launch",
401
+ "spawn_epoch": spawn_epoch,
396
402
  }),
397
403
  );
398
404
  }
@@ -473,6 +479,7 @@ fn spawn_agents(
473
479
  agent_id,
474
480
  start_mode: StartMode::Fresh,
475
481
  target: spawn.pane_id.as_str().to_string(),
482
+ spawned_at,
476
483
  session_id: None,
477
484
  rollout_path: None,
478
485
  pending_session_id: plan.expected_session_id.clone(),
@@ -872,7 +879,9 @@ fn persist_spawn_agent_state(
872
879
  continue;
873
880
  }
874
881
  let pane_pid = pane_pids_by_agent.get(id).copied();
875
- let spawned_at = spawn_timestamp_for_agent(spawn_index);
882
+ let spawned_at = started_agent
883
+ .map(|started| started.spawned_at.clone())
884
+ .unwrap_or_else(|| spawn_timestamp_for_agent(spawn_index));
876
885
  spawn_index = spawn_index.saturating_add(1);
877
886
  agents.insert(
878
887
  id.to_string(),
@@ -2904,12 +2913,81 @@ pub fn quick_start_in_workspace_with_display_and_backend(
2904
2913
  ))
2905
2914
  })?;
2906
2915
  let team_key_for_factory = team_id;
2916
+ // 0.5.x Windows portability Batch 8 F7 (leader msg_590b4dce0f68):
2917
+ // shim ownership moves to the coordinator. quick-start no longer
2918
+ // calls `spawn_shim_and_handshake` directly. Instead we ensure
2919
+ // the coordinator daemon is running; the coordinator's boot
2920
+ // path calls `conpty_shim::ensure_shim_running` (idempotent —
2921
+ // spawns if no live shim, reconnects if one is recorded).
2922
+ //
2923
+ // Rationale (from Batch 7 gate report F7): quick-start is a
2924
+ // one-shot process; if it owns the shim, the shim dies when
2925
+ // quick-start exits. Moving ownership to the coord daemon
2926
+ // gives us the "coord can die, shim survives" invariant the
2927
+ // design's §Shim Lifecycle chapter requires.
2928
+ //
2929
+ // The `ensure_coordinator_running` call is idempotent per
2930
+ // `coordinator/health.rs::start_coordinator`. On non-Windows
2931
+ // this whole block is cfg'd out.
2932
+ #[cfg(windows)]
2933
+ if matches!(
2934
+ requested,
2935
+ crate::transport_factory::RequestedTransportBackend::ConPty
2936
+ ) {
2937
+ let team_key_str = team_key_for_factory.ok_or_else(|| {
2938
+ LifecycleError::TeamSelect(
2939
+ "team_key required for --backend conpty on Windows (Batch 9 F8)".to_string(),
2940
+ )
2941
+ })?;
2942
+ // 0.5.x Windows portability Batch 9 F8 (leader msg_2a4cc1fa54c0):
2943
+ // Batch 8's seed-state pattern (writing active_team_key +
2944
+ // transport.kind to state.json before start_coordinator)
2945
+ // caused downstream launch code to see "existing runtime, use
2946
+ // restart" and skip spec compile. F8 fix: pass `--team`
2947
+ // directly to the coord daemon via `start_coordinator_with_team`
2948
+ // so state doesn't need pre-seeding.
2949
+ //
2950
+ // The coord daemon's `run_daemon_with_coordinator_and_boot_tmux`
2951
+ // then calls `ensure_shim_running` with the CLI-supplied
2952
+ // team_key. When quick-start's downstream code runs, state.json
2953
+ // still has whatever it had before (empty on fresh launch), so
2954
+ // the spec-compile + worker-spawn path runs normally.
2955
+ let run_ws = crate::coordinator::WorkspacePath::new(workspace.clone());
2956
+ let start_report =
2957
+ crate::coordinator::health::start_coordinator_with_team(&run_ws, Some(team_key_str))
2958
+ .map_err(|e| LifecycleError::TeamSelect(format!("coordinator start: {e}")))?;
2959
+ if !start_report.ok {
2960
+ return Err(LifecycleError::TeamSelect(format!(
2961
+ "coordinator start failed: schema_error={:?}, action={:?}",
2962
+ start_report.schema_error, start_report.action
2963
+ )));
2964
+ }
2965
+ // Give the coordinator a beat to write its transport.shim
2966
+ // block so the factory's `pipe_ready` gate opens on the
2967
+ // next resolve. The coordinator's `run_daemon` calls
2968
+ // `ensure_shim_running` inside its boot code path (see
2969
+ // `coordinator::backoff::run_daemon`).
2970
+ std::thread::sleep(std::time::Duration::from_millis(2500));
2971
+ }
2907
2972
  let input = crate::transport_factory::TransportFactoryInput::new(
2908
2973
  &workspace,
2909
2974
  crate::transport_factory::TransportPurpose::Launch,
2910
2975
  )
2911
2976
  .with_team_key(team_key_for_factory)
2912
2977
  .with_explicit_backend(Some(requested));
2978
+ // On Windows the factory needs to SEE the freshly-persisted
2979
+ // `state.transport.shim.pipe_ready = true` marker so its
2980
+ // `conpty_pipe_ready` gate opens.
2981
+ #[cfg(windows)]
2982
+ let state_value =
2983
+ std::fs::read_to_string(crate::state::persist::runtime_state_path(&workspace))
2984
+ .ok()
2985
+ .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok());
2986
+ #[cfg(windows)]
2987
+ let input = match state_value.as_ref() {
2988
+ Some(v) => input.with_state(Some(v)),
2989
+ None => input,
2990
+ };
2913
2991
  let resolved = crate::transport_factory::resolve_transport(input)
2914
2992
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
2915
2993
  // Hand the boxed backend down as `&dyn Transport`.
@@ -3644,102 +3722,21 @@ fn process_ancestry_argv(pid: u32) -> Vec<Vec<String>> {
3644
3722
  out
3645
3723
  }
3646
3724
 
3647
- #[cfg(target_os = "linux")]
3648
- fn process_argv_tokens(pid: u32) -> Option<Vec<String>> {
3649
- let bytes = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
3650
- let argv_tokens = String::from_utf8_lossy(&bytes)
3651
- .split('\0')
3652
- .filter(|token| !token.is_empty())
3653
- .map(str::to_string)
3654
- .collect::<Vec<_>>();
3655
- (!argv_tokens.is_empty()).then_some(argv_tokens)
3656
- }
3657
-
3658
- #[cfg(target_os = "macos")]
3659
- fn process_argv_tokens(pid: u32) -> Option<Vec<String>> {
3660
- use std::mem::size_of;
3661
-
3662
- let mut mib = [
3663
- libc::CTL_KERN,
3664
- libc::KERN_PROCARGS2,
3665
- i32::try_from(pid).ok()?,
3666
- ];
3667
- let mut size = 0usize;
3668
- let rc = unsafe {
3669
- libc::sysctl(
3670
- mib.as_mut_ptr(),
3671
- mib.len() as u32,
3672
- std::ptr::null_mut(),
3673
- &mut size,
3674
- std::ptr::null_mut(),
3675
- 0,
3676
- )
3677
- };
3678
- if rc != 0 || size <= size_of::<libc::c_int>() {
3679
- return None;
3680
- }
3681
- let mut buf = vec![0u8; size];
3682
- let rc = unsafe {
3683
- libc::sysctl(
3684
- mib.as_mut_ptr(),
3685
- mib.len() as u32,
3686
- buf.as_mut_ptr().cast(),
3687
- &mut size,
3688
- std::ptr::null_mut(),
3689
- 0,
3690
- )
3691
- };
3692
- if rc != 0 || size <= size_of::<libc::c_int>() {
3693
- return None;
3694
- }
3695
- let argc = i32::from_ne_bytes(buf.get(..size_of::<libc::c_int>())?.try_into().ok()?) as usize;
3696
- let mut offset = size_of::<libc::c_int>();
3697
- while offset < size && buf[offset] != 0 {
3698
- offset += 1;
3699
- }
3700
- while offset < size && buf[offset] == 0 {
3701
- offset += 1;
3702
- }
3703
- let raw = String::from_utf8_lossy(&buf[offset..size]);
3704
- let argv_tokens = raw
3705
- .split('\0')
3706
- .filter(|token| !token.is_empty())
3707
- .take(argc)
3708
- .map(str::to_string)
3709
- .collect::<Vec<_>>();
3710
- (!argv_tokens.is_empty()).then_some(argv_tokens)
3711
- }
3725
+ // 0.5.x Windows portability Batch 4: `process_argv_tokens` and
3726
+ // `process_parent_pid` route through `crate::platform::argv`. Unix
3727
+ // impls are byte-preserving (Linux `/proc/<pid>/cmdline`, macOS
3728
+ // `sysctl(KERN_PROCARGS2)`, other Unix `ps -p ... -o command=`).
3729
+ // Windows returns `None` per design §Batch 4 conservative anchor:
3730
+ // unknown argv must never infer elevated approval; callers already
3731
+ // treat `None` as "no elevation inherited" via
3732
+ // `disabled_dangerous_approval()`.
3712
3733
 
3713
- #[cfg(not(any(target_os = "linux", target_os = "macos")))]
3714
3734
  fn process_argv_tokens(pid: u32) -> Option<Vec<String>> {
3715
- let output = Command::new("ps")
3716
- .args(["-p", &pid.to_string(), "-o", "command="])
3717
- .output()
3718
- .ok()?;
3719
- if !output.status.success() {
3720
- return None;
3721
- }
3722
- let text = String::from_utf8_lossy(&output.stdout);
3723
- let argv_tokens = text
3724
- .split_whitespace()
3725
- .filter(|token| !token.is_empty())
3726
- .map(str::to_string)
3727
- .collect::<Vec<_>>();
3728
- (!argv_tokens.is_empty()).then_some(argv_tokens)
3735
+ crate::platform::argv::argv_tokens(pid)
3729
3736
  }
3730
3737
 
3731
3738
  fn process_parent_pid(pid: u32) -> Option<u32> {
3732
- let output = Command::new("ps")
3733
- .args(["-p", &pid.to_string(), "-o", "ppid="])
3734
- .output()
3735
- .ok()?;
3736
- if !output.status.success() {
3737
- return None;
3738
- }
3739
- String::from_utf8_lossy(&output.stdout)
3740
- .trim()
3741
- .parse::<u32>()
3742
- .ok()
3739
+ crate::platform::argv::parent_pid(pid)
3743
3740
  }
3744
3741
 
3745
3742
  /// `add_agent(workspace, agent_id, role_file_path, open_display, team)`