@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.
Files changed (55) hide show
  1. package/Cargo.lock +120 -9
  2. package/Cargo.toml +2 -2
  3. package/crates/team-agent/Cargo.toml +21 -0
  4. package/crates/team-agent/src/app_server_test_support.rs +258 -0
  5. package/crates/team-agent/src/cli/adapters.rs +32 -3
  6. package/crates/team-agent/src/cli/attach_app_server_leader.rs +16 -0
  7. package/crates/team-agent/src/cli/diagnose.rs +14 -5
  8. package/crates/team-agent/src/cli/emit.rs +79 -1
  9. package/crates/team-agent/src/cli/mod.rs +190 -80
  10. package/crates/team-agent/src/cli/named_address.rs +111 -0
  11. package/crates/team-agent/src/cli/send.rs +98 -3
  12. package/crates/team-agent/src/cli/status_port.rs +44 -6
  13. package/crates/team-agent/src/cli/tests/named_address.rs +135 -0
  14. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  15. package/crates/team-agent/src/cli/types.rs +17 -0
  16. package/crates/team-agent/src/codex_app_server.rs +549 -0
  17. package/crates/team-agent/src/conpty/backend.rs +822 -0
  18. package/crates/team-agent/src/conpty/mod.rs +32 -0
  19. package/crates/team-agent/src/coordinator/backoff.rs +132 -7
  20. package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
  21. package/crates/team-agent/src/coordinator/health.rs +97 -11
  22. package/crates/team-agent/src/coordinator/mod.rs +8 -0
  23. package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
  24. package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +29 -10
  26. package/crates/team-agent/src/leader/lease.rs +144 -7
  27. package/crates/team-agent/src/leader/owner_bind.rs +5 -2
  28. package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
  29. package/crates/team-agent/src/leader/start.rs +18 -5
  30. package/crates/team-agent/src/leader/tests/lease_api.rs +179 -0
  31. package/crates/team-agent/src/lib.rs +36 -0
  32. package/crates/team-agent/src/lifecycle/launch.rs +207 -94
  33. package/crates/team-agent/src/lifecycle/lock.rs +40 -33
  34. package/crates/team-agent/src/lifecycle/restart/agent.rs +10 -18
  35. package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
  36. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
  37. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
  38. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +63 -0
  39. package/crates/team-agent/src/mcp_server/wire.rs +6 -7
  40. package/crates/team-agent/src/messaging/delivery.rs +329 -9
  41. package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
  42. package/crates/team-agent/src/messaging/mod.rs +2 -2
  43. package/crates/team-agent/src/messaging/results.rs +78 -21
  44. package/crates/team-agent/src/messaging/tests/runtime.rs +237 -0
  45. package/crates/team-agent/src/packaging/tests.rs +41 -6
  46. package/crates/team-agent/src/packaging/types.rs +31 -3
  47. package/crates/team-agent/src/platform/argv.rs +324 -0
  48. package/crates/team-agent/src/platform/errors.rs +95 -0
  49. package/crates/team-agent/src/platform/file_lock.rs +418 -0
  50. package/crates/team-agent/src/platform/mod.rs +66 -0
  51. package/crates/team-agent/src/platform/process.rs +555 -0
  52. package/crates/team-agent/src/state/persist.rs +205 -25
  53. package/crates/team-agent/src/tmux_backend.rs +94 -13
  54. package/crates/team-agent/src/transport_factory.rs +751 -0
  55. package/package.json +4 -4
@@ -302,3 +302,182 @@ fn r8_requeued_exhausted_watchers_event_payload_golden_shape() {
302
302
  assert_eq!(payload.get("count").and_then(|v| v.as_u64()), Some(1), "count == number of requeued watchers");
303
303
  assert_eq!(payload.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
304
304
  }
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)]
311
+ #[test]
312
+ fn app_server_attach_writes_transport_kind_tuple_and_advances_epoch() {
313
+ let ws = std::env::temp_dir().join(format!(
314
+ "ta_rs_app_attach_{}_{}",
315
+ std::process::id(),
316
+ chrono::Utc::now().timestamp_millis()
317
+ ));
318
+ std::fs::create_dir_all(&ws).unwrap();
319
+ crate::state::persist::save_runtime_state(
320
+ &ws,
321
+ &serde_json::json!({
322
+ "active_team_key": "team-a",
323
+ "teams": {"team-a": {"team_key": "team-a", "agents": {}}},
324
+ }),
325
+ )
326
+ .unwrap();
327
+ let fake = crate::app_server_test_support::FakeAppServer::start(
328
+ "attach-ok",
329
+ crate::app_server_test_support::FakeAppServerScript::happy(
330
+ "thread-live",
331
+ "session-live",
332
+ ws.to_str().unwrap(),
333
+ ),
334
+ );
335
+
336
+ let out = attach_app_server_leader(&ws, Some("team-a"), fake.endpoint(), "thread-live")
337
+ .expect("app-server attach should succeed");
338
+
339
+ assert_eq!(out["ok"], serde_json::json!(true));
340
+ assert_eq!(out["leader_receiver"]["transport_kind"], serde_json::json!("codex_app_server"));
341
+ assert_eq!(out["leader_receiver"]["mode"], serde_json::json!("codex_app_server"));
342
+ assert_eq!(
343
+ out["leader_receiver"]["app_server"]["thread_id"],
344
+ serde_json::json!("thread-live")
345
+ );
346
+ assert_eq!(
347
+ out["leader_receiver"]["app_server"]["session_id"],
348
+ serde_json::json!("session-live")
349
+ );
350
+ assert_eq!(
351
+ out["leader_receiver"]["app_server"]["cwd"],
352
+ serde_json::json!(ws.to_str().unwrap())
353
+ );
354
+
355
+ let saved = crate::state::projection::select_runtime_state(&ws, Some("team-a")).unwrap();
356
+ assert_eq!(
357
+ saved["leader_receiver"]["app_server"]["thread_id"],
358
+ serde_json::json!("thread-live")
359
+ );
360
+ assert_eq!(saved["leader_receiver"].get("pane_id"), None);
361
+ assert_eq!(saved["team_owner"]["transport_kind"], serde_json::json!("codex_app_server"));
362
+ assert_eq!(saved["owner_epoch"], serde_json::json!(1));
363
+ }
364
+
365
+ #[cfg(unix)]
366
+ #[test]
367
+ fn app_server_attach_rejects_world_writable_socket_without_state_write() {
368
+ let ws = std::env::temp_dir().join(format!(
369
+ "ta_rs_app_attach_badmode_{}_{}",
370
+ std::process::id(),
371
+ chrono::Utc::now().timestamp_millis()
372
+ ));
373
+ std::fs::create_dir_all(&ws).unwrap();
374
+ crate::state::persist::save_runtime_state(
375
+ &ws,
376
+ &serde_json::json!({"active_team_key": "team-a", "teams": {"team-a": {"agents": {}}}}),
377
+ )
378
+ .unwrap();
379
+ let fake = crate::app_server_test_support::FakeAppServer::start(
380
+ "attach-world",
381
+ crate::app_server_test_support::FakeAppServerScript::happy(
382
+ "thread-live",
383
+ "session-live",
384
+ ws.to_str().unwrap(),
385
+ ),
386
+ );
387
+ let mut perms = std::fs::metadata(fake.path()).unwrap().permissions();
388
+ use std::os::unix::fs::PermissionsExt;
389
+ perms.set_mode(0o777);
390
+ std::fs::set_permissions(fake.path(), perms).unwrap();
391
+
392
+ let err = attach_app_server_leader(&ws, Some("team-a"), fake.endpoint(), "thread-live")
393
+ .expect_err("world-writable app-server socket must be rejected");
394
+ assert!(
395
+ err.to_string().contains("socket_ownership_invalid"),
396
+ "unexpected error: {err}"
397
+ );
398
+ let saved = crate::state::projection::select_runtime_state(&ws, Some("team-a")).unwrap();
399
+ assert!(
400
+ saved.get("leader_receiver").is_none(),
401
+ "failed attach must not write leader_receiver: {saved}"
402
+ );
403
+ }
404
+
405
+ #[cfg(unix)]
406
+ #[test]
407
+ fn app_server_attach_rejects_missing_user_agent_without_state_write() {
408
+ let ws = std::env::temp_dir().join(format!(
409
+ "ta_rs_app_attach_no_ua_{}_{}",
410
+ std::process::id(),
411
+ chrono::Utc::now().timestamp_millis()
412
+ ));
413
+ std::fs::create_dir_all(&ws).unwrap();
414
+ crate::state::persist::save_runtime_state(
415
+ &ws,
416
+ &serde_json::json!({"active_team_key": "team-a", "teams": {"team-a": {"agents": {}}}}),
417
+ )
418
+ .unwrap();
419
+ let mut script = crate::app_server_test_support::FakeAppServerScript::happy(
420
+ "thread-live",
421
+ "session-live",
422
+ ws.to_str().unwrap(),
423
+ );
424
+ script.user_agent = None;
425
+ let fake = crate::app_server_test_support::FakeAppServer::start("attach-no-ua", script);
426
+
427
+ let err = attach_app_server_leader(&ws, Some("team-a"), fake.endpoint(), "thread-live")
428
+ .expect_err("missing initialize.userAgent must fail closed");
429
+ assert!(
430
+ err.to_string()
431
+ .contains("protocol_mismatch_missing_user_agent"),
432
+ "unexpected error: {err}"
433
+ );
434
+ let saved = crate::state::projection::select_runtime_state(&ws, Some("team-a")).unwrap();
435
+ assert!(
436
+ saved.get("leader_receiver").is_none(),
437
+ "failed attach must not write leader_receiver: {saved}"
438
+ );
439
+ }
440
+
441
+ #[test]
442
+ fn app_server_delivery_paths_are_read_only_and_binding_entry_is_explicit() {
443
+ let delivery = include_str!("../../messaging/delivery.rs");
444
+ assert!(
445
+ !delivery.contains("write_owner(")
446
+ && !delivery.contains("with_leader_receiver(")
447
+ && !delivery.contains("with_team_owner("),
448
+ "MUST-12/I-RN-1: delivery may read typed leader_receiver fields but must not write ownership"
449
+ );
450
+ let lease = include_str!("../lease.rs");
451
+ assert!(
452
+ lease.contains("fn write_leader_receiver_transport("),
453
+ "C-5: mode and transport_kind must be stamped by a single receiver transport helper"
454
+ );
455
+ let cli = include_str!("../../cli/attach_app_server_leader.rs");
456
+ assert!(
457
+ cli.contains("attach_app_server_leader("),
458
+ "I-RN-2: app-server ownership mutation must be reachable through the explicit CLI entry"
459
+ );
460
+ }
461
+
462
+ #[test]
463
+ fn messaging_leader_receiver_module_exports_no_ownership_writer() {
464
+ let leader_receiver = include_str!("../../messaging/leader_receiver.rs");
465
+ for forbidden in [
466
+ "pub fn claim_leader_receiver",
467
+ "write_owner(",
468
+ "with_leader_receiver(",
469
+ "with_team_owner(",
470
+ "save_runtime_state",
471
+ ] {
472
+ assert!(
473
+ !leader_receiver.contains(forbidden),
474
+ "MUST-12/I-RN-1: messaging/leader_receiver.rs may read receiver fields but must not expose ownership writes; forbidden={forbidden}"
475
+ );
476
+ }
477
+
478
+ let messaging_mod = include_str!("../../messaging/mod.rs");
479
+ assert!(
480
+ !messaging_mod.contains("claim_leader_receiver"),
481
+ "MUST-12/I-RN-2: messaging/mod.rs must not re-export ownership writer APIs"
482
+ );
483
+ }
@@ -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;
@@ -92,4 +99,33 @@ pub mod fake_worker;
92
99
  // tmux_backend — concrete tmux Transport executor (Command::new tmux via a CommandRunner seam);
93
100
  // the real spawn/capture/inject/has_session backend the daemon + launch use (step 9 shipped only
94
101
  // the trait + argv-builders). Real subprocess execution is the #[ignore] real-machine boundary.
102
+ pub mod codex_app_server;
95
103
  pub mod tmux_backend;
104
+
105
+ // 0.5.x Windows-native transport Phase 1: ConPTY backend + named-pipe
106
+ // protocol. `protocol` is portable (pure logic, tested on all hosts);
107
+ // `backend` compiles on all hosts and returns typed MuxUnavailable when
108
+ // no pipe client is wired (honest degradation, not silent success).
109
+ //
110
+ // Truth source:
111
+ // - Design: `.team/artifacts/0.5.x-windows-native-transport-design.md`
112
+ // §Transport Boundary + §Named Pipe Control Protocol + §Phase 1
113
+ // - CR verdict: `.team/artifacts/0.5.x-windows-transport-cr-verdict.md`
114
+ // (7 constraints; C-1/C-2/C-3/C-5 anchor into this module)
115
+ pub mod conpty;
116
+
117
+ // 0.5.x Phase 1d Batch 0: backend assembly factory.
118
+ // Truth sources:
119
+ // - Design: `.team/artifacts/0.5.x-backend-assembly-factory-design.md`
120
+ // - CR verdict: `.team/artifacts/0.5.x-backend-factory-cr-verdict.md`
121
+ // (6 constraints anchored inside the module doc)
122
+ pub mod transport_factory;
123
+
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))]
131
+ pub(crate) mod app_server_test_support;
@@ -2835,16 +2835,161 @@ pub fn quick_start_in_workspace_with_display(
2835
2835
  yes: bool,
2836
2836
  team_id: Option<&str>,
2837
2837
  open_display: bool,
2838
+ ) -> Result<QuickStartReport, LifecycleError> {
2839
+ // Legacy entrypoint: no `--backend` override. Preserves the exact
2840
+ // tmux behavior we shipped in Phase 1c (byte-equivalent tmux path
2841
+ // when no explicit backend is asked for).
2842
+ quick_start_in_workspace_with_display_and_backend(
2843
+ workspace,
2844
+ agents_dir,
2845
+ name,
2846
+ yes,
2847
+ team_id,
2848
+ open_display,
2849
+ None,
2850
+ )
2851
+ }
2852
+
2853
+ /// 0.5.x Phase 1d Batch 2: quick-start with an optional
2854
+ /// `--backend <tmux|conpty>` override. When `backend` is `None` or
2855
+ /// `Some("tmux")` the transport is the same one the legacy entrypoint
2856
+ /// built (byte-equivalent to Phase 1c), so tmux users see no
2857
+ /// behavioral change.
2858
+ ///
2859
+ /// When `backend` is `Some("conpty")`, this call routes through the
2860
+ /// factory. On a host without a live shim client (i.e. every
2861
+ /// non-Windows host today), the resulting `ConPtyBackend` degrades
2862
+ /// its spawn/inject/capture calls to `TransportError::MuxUnavailable`
2863
+ /// honestly — MUST-NOT-13 + CR C-1 ①. Users see a real "conpty
2864
+ /// unavailable" error rather than a silent tmux fallback.
2865
+ pub fn quick_start_in_workspace_with_display_and_backend(
2866
+ workspace: &Path,
2867
+ agents_dir: &Path,
2868
+ name: Option<&str>,
2869
+ yes: bool,
2870
+ team_id: Option<&str>,
2871
+ open_display: bool,
2872
+ backend: Option<&str>,
2838
2873
  ) -> Result<QuickStartReport, LifecycleError> {
2839
2874
  let workspace = explicit_quick_start_workspace(workspace);
2840
- let transport = quick_start_tmux_backend(&workspace);
2875
+ // Default / `tmux` literal: preserve the existing tmux path
2876
+ // BYTE-FOR-BYTE. This is the CR §Batch 2 Verification anchor:
2877
+ // `quick-start` without `--backend` produces byte-equivalent tmux
2878
+ // behavior.
2879
+ let literal = backend.map(str::trim);
2880
+ let is_tmux_or_default = matches!(literal, None | Some("") | Some("tmux") | Some("TMUX"));
2881
+ if is_tmux_or_default {
2882
+ let transport = quick_start_tmux_backend(&workspace);
2883
+ return quick_start_with_transport_in_workspace_with_display(
2884
+ &workspace,
2885
+ agents_dir,
2886
+ name,
2887
+ yes,
2888
+ team_id,
2889
+ &transport,
2890
+ open_display,
2891
+ );
2892
+ }
2893
+ // Explicit non-tmux backend: route through the factory. Parse the
2894
+ // literal, then delegate. On unsupported literals or refused
2895
+ // preconditions we return `LifecycleError` — never silently pick
2896
+ // tmux (CR C-1 ①/②).
2897
+ let requested = crate::transport_factory::RequestedTransportBackend::parse_literal(
2898
+ literal.unwrap_or_default(),
2899
+ )
2900
+ .ok_or_else(|| {
2901
+ LifecycleError::TeamSelect(format!(
2902
+ "unsupported --backend literal {literal:?}; expected `tmux` or `conpty` \
2903
+ (Phase 1d does not auto-map `pty` to conpty — CR C-1 ②)"
2904
+ ))
2905
+ })?;
2906
+ let team_key_for_factory = team_id;
2907
+ // 0.5.x Windows portability Batch 8 F7 (leader msg_590b4dce0f68):
2908
+ // shim ownership moves to the coordinator. quick-start no longer
2909
+ // calls `spawn_shim_and_handshake` directly. Instead we ensure
2910
+ // the coordinator daemon is running; the coordinator's boot
2911
+ // path calls `conpty_shim::ensure_shim_running` (idempotent —
2912
+ // spawns if no live shim, reconnects if one is recorded).
2913
+ //
2914
+ // Rationale (from Batch 7 gate report F7): quick-start is a
2915
+ // one-shot process; if it owns the shim, the shim dies when
2916
+ // quick-start exits. Moving ownership to the coord daemon
2917
+ // gives us the "coord can die, shim survives" invariant the
2918
+ // design's §Shim Lifecycle chapter requires.
2919
+ //
2920
+ // The `ensure_coordinator_running` call is idempotent per
2921
+ // `coordinator/health.rs::start_coordinator`. On non-Windows
2922
+ // this whole block is cfg'd out.
2923
+ #[cfg(windows)]
2924
+ if matches!(
2925
+ requested,
2926
+ crate::transport_factory::RequestedTransportBackend::ConPty
2927
+ ) {
2928
+ let team_key_str = team_key_for_factory.ok_or_else(|| {
2929
+ LifecycleError::TeamSelect(
2930
+ "team_key required for --backend conpty on Windows (Batch 9 F8)".to_string(),
2931
+ )
2932
+ })?;
2933
+ // 0.5.x Windows portability Batch 9 F8 (leader msg_2a4cc1fa54c0):
2934
+ // Batch 8's seed-state pattern (writing active_team_key +
2935
+ // transport.kind to state.json before start_coordinator)
2936
+ // caused downstream launch code to see "existing runtime, use
2937
+ // restart" and skip spec compile. F8 fix: pass `--team`
2938
+ // directly to the coord daemon via `start_coordinator_with_team`
2939
+ // so state doesn't need pre-seeding.
2940
+ //
2941
+ // The coord daemon's `run_daemon_with_coordinator_and_boot_tmux`
2942
+ // then calls `ensure_shim_running` with the CLI-supplied
2943
+ // team_key. When quick-start's downstream code runs, state.json
2944
+ // still has whatever it had before (empty on fresh launch), so
2945
+ // the spec-compile + worker-spawn path runs normally.
2946
+ let run_ws = crate::coordinator::WorkspacePath::new(workspace.clone());
2947
+ let start_report =
2948
+ crate::coordinator::health::start_coordinator_with_team(&run_ws, Some(team_key_str))
2949
+ .map_err(|e| LifecycleError::TeamSelect(format!("coordinator start: {e}")))?;
2950
+ if !start_report.ok {
2951
+ return Err(LifecycleError::TeamSelect(format!(
2952
+ "coordinator start failed: schema_error={:?}, action={:?}",
2953
+ start_report.schema_error, start_report.action
2954
+ )));
2955
+ }
2956
+ // Give the coordinator a beat to write its transport.shim
2957
+ // block so the factory's `pipe_ready` gate opens on the
2958
+ // next resolve. The coordinator's `run_daemon` calls
2959
+ // `ensure_shim_running` inside its boot code path (see
2960
+ // `coordinator::backoff::run_daemon`).
2961
+ std::thread::sleep(std::time::Duration::from_millis(2500));
2962
+ }
2963
+ let input = crate::transport_factory::TransportFactoryInput::new(
2964
+ &workspace,
2965
+ crate::transport_factory::TransportPurpose::Launch,
2966
+ )
2967
+ .with_team_key(team_key_for_factory)
2968
+ .with_explicit_backend(Some(requested));
2969
+ // On Windows the factory needs to SEE the freshly-persisted
2970
+ // `state.transport.shim.pipe_ready = true` marker so its
2971
+ // `conpty_pipe_ready` gate opens.
2972
+ #[cfg(windows)]
2973
+ let state_value = std::fs::read_to_string(crate::state::persist::runtime_state_path(
2974
+ &workspace,
2975
+ ))
2976
+ .ok()
2977
+ .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok());
2978
+ #[cfg(windows)]
2979
+ let input = match state_value.as_ref() {
2980
+ Some(v) => input.with_state(Some(v)),
2981
+ None => input,
2982
+ };
2983
+ let resolved = crate::transport_factory::resolve_transport(input)
2984
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
2985
+ // Hand the boxed backend down as `&dyn Transport`.
2841
2986
  quick_start_with_transport_in_workspace_with_display(
2842
2987
  &workspace,
2843
2988
  agents_dir,
2844
2989
  name,
2845
2990
  yes,
2846
2991
  team_id,
2847
- &transport,
2992
+ &*resolved.backend,
2848
2993
  open_display,
2849
2994
  )
2850
2995
  }
@@ -3068,7 +3213,16 @@ pub fn quick_start_with_transport_in_workspace_with_display(
3068
3213
  let resolved_spec_path =
3069
3214
  std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
3070
3215
  let mut state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
3071
- annotate_runtime_tmux_endpoint(&mut state, transport, &workspace);
3216
+ // 0.5.x Phase 1d hot-path 接线(裁决1 msg_76e1d98202b8): use the
3217
+ // generic annotator that writes `state.transport = { kind, source }`
3218
+ // for every backend AND (for tmux) preserves the existing
3219
+ // `tmux_endpoint`/`tmux_socket`/`tmux_socket_source` fields via the
3220
+ // inner `annotate_runtime_tmux_endpoint` call. Source is threaded
3221
+ // from the caller-side factory selection when available; the
3222
+ // legacy quick-start entrypoint currently passes `None` (defaults
3223
+ // to "unknown" in the payload) — Phase 2 refactor will thread
3224
+ // ResolvedTransport.source through the launch signature.
3225
+ annotate_runtime_transport(&mut state, transport, &workspace, None);
3072
3226
  save_launched_team_state_for_key(&workspace, &state, Some(&state_team_key))?;
3073
3227
  annotate_persisted_team_depth(
3074
3228
  &workspace,
@@ -3164,6 +3318,46 @@ pub fn quick_start_with_transport_in_workspace_with_display(
3164
3318
  /// the persisted endpoint synchronized with the transport they actually used,
3165
3319
  /// closing the silent socket-drift gap** (single state-save path; no parallel
3166
3320
  /// "annotate after spawn" race with coordinator).
3321
+ /// 0.5.x Phase 1d Batch 2: generic runtime-transport annotator.
3322
+ ///
3323
+ /// Writes `state.transport = { kind, source }` for every backend
3324
+ /// (kind = wire string `"tmux"` | `"conpty"`; source = wire string
3325
+ /// from `ResolvedTransport.source` when known, else `"unknown"`).
3326
+ ///
3327
+ /// For tmux, ALSO forwards to `annotate_runtime_tmux_endpoint` so the
3328
+ /// existing `tmux_endpoint` / `tmux_socket` / `tmux_socket_source`
3329
+ /// fields remain populated byte-equivalent to today's shape.
3330
+ ///
3331
+ /// The tmux-specific fields are NOT written for ConPTY (CR C-4: no
3332
+ /// tmux-only fields under a conpty state; keeps compact status
3333
+ /// stable). ConPTY discovery fields (`pipe_name`, `shim_pid`) are
3334
+ /// added in Batch 3 when the shim boot path lands; this function just
3335
+ /// pins the top-level `transport.kind`/`source`.
3336
+ pub fn annotate_runtime_transport(
3337
+ state: &mut serde_json::Value,
3338
+ transport: &dyn Transport,
3339
+ workspace: &Path,
3340
+ source: Option<&str>,
3341
+ ) {
3342
+ use crate::transport::BackendKind;
3343
+ let kind_wire = match transport.kind() {
3344
+ BackendKind::Tmux => "tmux",
3345
+ BackendKind::WezTerm => "wezterm",
3346
+ BackendKind::ConPty => "conpty",
3347
+ };
3348
+ if let Some(obj) = state.as_object_mut() {
3349
+ let transport_block = serde_json::json!({
3350
+ "kind": kind_wire,
3351
+ "source": source.unwrap_or("unknown"),
3352
+ });
3353
+ obj.insert("transport".to_string(), transport_block);
3354
+ }
3355
+ // Tmux: preserve the existing tmux endpoint annotation shape.
3356
+ if matches!(transport.kind(), BackendKind::Tmux) {
3357
+ annotate_runtime_tmux_endpoint(state, transport, workspace);
3358
+ }
3359
+ }
3360
+
3167
3361
  pub(crate) fn annotate_runtime_tmux_endpoint(
3168
3362
  state: &mut serde_json::Value,
3169
3363
  transport: &dyn Transport,
@@ -3520,102 +3714,21 @@ fn process_ancestry_argv(pid: u32) -> Vec<Vec<String>> {
3520
3714
  out
3521
3715
  }
3522
3716
 
3523
- #[cfg(target_os = "linux")]
3524
- fn process_argv_tokens(pid: u32) -> Option<Vec<String>> {
3525
- let bytes = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
3526
- let argv_tokens = String::from_utf8_lossy(&bytes)
3527
- .split('\0')
3528
- .filter(|token| !token.is_empty())
3529
- .map(str::to_string)
3530
- .collect::<Vec<_>>();
3531
- (!argv_tokens.is_empty()).then_some(argv_tokens)
3532
- }
3717
+ // 0.5.x Windows portability Batch 4: `process_argv_tokens` and
3718
+ // `process_parent_pid` route through `crate::platform::argv`. Unix
3719
+ // impls are byte-preserving (Linux `/proc/<pid>/cmdline`, macOS
3720
+ // `sysctl(KERN_PROCARGS2)`, other Unix `ps -p ... -o command=`).
3721
+ // Windows returns `None` per design §Batch 4 conservative anchor:
3722
+ // unknown argv must never infer elevated approval; callers already
3723
+ // treat `None` as "no elevation inherited" via
3724
+ // `disabled_dangerous_approval()`.
3533
3725
 
3534
- #[cfg(target_os = "macos")]
3535
3726
  fn process_argv_tokens(pid: u32) -> Option<Vec<String>> {
3536
- use std::mem::size_of;
3537
-
3538
- let mut mib = [
3539
- libc::CTL_KERN,
3540
- libc::KERN_PROCARGS2,
3541
- i32::try_from(pid).ok()?,
3542
- ];
3543
- let mut size = 0usize;
3544
- let rc = unsafe {
3545
- libc::sysctl(
3546
- mib.as_mut_ptr(),
3547
- mib.len() as u32,
3548
- std::ptr::null_mut(),
3549
- &mut size,
3550
- std::ptr::null_mut(),
3551
- 0,
3552
- )
3553
- };
3554
- if rc != 0 || size <= size_of::<libc::c_int>() {
3555
- return None;
3556
- }
3557
- let mut buf = vec![0u8; size];
3558
- let rc = unsafe {
3559
- libc::sysctl(
3560
- mib.as_mut_ptr(),
3561
- mib.len() as u32,
3562
- buf.as_mut_ptr().cast(),
3563
- &mut size,
3564
- std::ptr::null_mut(),
3565
- 0,
3566
- )
3567
- };
3568
- if rc != 0 || size <= size_of::<libc::c_int>() {
3569
- return None;
3570
- }
3571
- let argc = i32::from_ne_bytes(buf.get(..size_of::<libc::c_int>())?.try_into().ok()?) as usize;
3572
- let mut offset = size_of::<libc::c_int>();
3573
- while offset < size && buf[offset] != 0 {
3574
- offset += 1;
3575
- }
3576
- while offset < size && buf[offset] == 0 {
3577
- offset += 1;
3578
- }
3579
- let raw = String::from_utf8_lossy(&buf[offset..size]);
3580
- let argv_tokens = raw
3581
- .split('\0')
3582
- .filter(|token| !token.is_empty())
3583
- .take(argc)
3584
- .map(str::to_string)
3585
- .collect::<Vec<_>>();
3586
- (!argv_tokens.is_empty()).then_some(argv_tokens)
3587
- }
3588
-
3589
- #[cfg(not(any(target_os = "linux", target_os = "macos")))]
3590
- fn process_argv_tokens(pid: u32) -> Option<Vec<String>> {
3591
- let output = Command::new("ps")
3592
- .args(["-p", &pid.to_string(), "-o", "command="])
3593
- .output()
3594
- .ok()?;
3595
- if !output.status.success() {
3596
- return None;
3597
- }
3598
- let text = String::from_utf8_lossy(&output.stdout);
3599
- let argv_tokens = text
3600
- .split_whitespace()
3601
- .filter(|token| !token.is_empty())
3602
- .map(str::to_string)
3603
- .collect::<Vec<_>>();
3604
- (!argv_tokens.is_empty()).then_some(argv_tokens)
3727
+ crate::platform::argv::argv_tokens(pid)
3605
3728
  }
3606
3729
 
3607
3730
  fn process_parent_pid(pid: u32) -> Option<u32> {
3608
- let output = Command::new("ps")
3609
- .args(["-p", &pid.to_string(), "-o", "ppid="])
3610
- .output()
3611
- .ok()?;
3612
- if !output.status.success() {
3613
- return None;
3614
- }
3615
- String::from_utf8_lossy(&output.stdout)
3616
- .trim()
3617
- .parse::<u32>()
3618
- .ok()
3731
+ crate::platform::argv::parent_pid(pid)
3619
3732
  }
3620
3733
 
3621
3734
  /// `add_agent(workspace, agent_id, role_file_path, open_display, team)`
@@ -85,6 +85,16 @@ fn acquire_agent_lifecycle_lock_with_deadlines(
85
85
  timeout: Duration,
86
86
  held_long: Duration,
87
87
  ) -> Result<LifecycleLockGuard, LifecycleError> {
88
+ // 0.5.x Windows portability Batch 2: migrated to
89
+ // `crate::platform::file_lock::{try_lock_once_nonblocking, unlock}`
90
+ // so the same polling loop + waiter file + 5s `lock_held_long`
91
+ // event + 30s N38 timeout error shape works on both Unix (`flock`)
92
+ // and Windows (`LockFileEx`). The Batch 0 non-Unix stub that
93
+ // returned `lock_timeout_error` unconditionally is now gone —
94
+ // Windows callers get real lock behavior. Byte-preserving on Unix:
95
+ // the polling cadence, waiter file writes, `lock_held_long_event`
96
+ // emission, and error shape are all unchanged relative to the
97
+ // pre-Batch-2 unix branch.
88
98
  let lock_path = agent_lifecycle_lock_path(request.workspace);
89
99
  if let Some(parent) = lock_path.parent() {
90
100
  std::fs::create_dir_all(parent)
@@ -100,42 +110,38 @@ fn acquire_agent_lifecycle_lock_with_deadlines(
100
110
  let started = Instant::now();
101
111
  let mut long_event_written = false;
102
112
  let mut waiter = None;
103
- #[cfg(unix)]
104
- {
105
- use std::os::unix::io::AsRawFd;
106
- let fd = file.as_raw_fd();
107
- loop {
108
- let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
109
- if rc == 0 {
113
+ loop {
114
+ match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
115
+ Ok(true) => {
110
116
  write_lock_metadata(&mut file, &request, &lock_path)?;
111
117
  return Ok(LifecycleLockGuard { file });
112
118
  }
113
- let elapsed = started.elapsed();
114
- if waiter.is_none() {
115
- waiter = WaiterFile::create(&request, &lock_path).ok();
119
+ Ok(false) => {}
120
+ Err(e) => {
121
+ return Err(LifecycleError::StatePersist(format!(
122
+ "lifecycle lock acquire io error at {}: {e}",
123
+ lock_path.display()
124
+ )));
116
125
  }
117
- if let Some(waiter) = waiter.as_ref() {
118
- let _ = waiter.write(&request, elapsed);
119
- }
120
- if !long_event_written && elapsed >= held_long {
121
- let _ =
122
- write_lock_held_long_event(&request, &lock_path, elapsed, held_long, timeout);
123
- long_event_written = true;
124
- }
125
- if elapsed >= timeout {
126
- return Err(lock_timeout_error(&request, &lock_path, elapsed));
127
- }
128
- std::thread::sleep(std::cmp::min(
129
- Duration::from_millis(50),
130
- timeout.saturating_sub(elapsed),
131
- ));
132
126
  }
133
- }
134
- #[cfg(not(unix))]
135
- {
136
- let _ = file;
137
127
  let elapsed = started.elapsed();
138
- Err(lock_timeout_error(&request, &lock_path, elapsed))
128
+ if waiter.is_none() {
129
+ waiter = WaiterFile::create(&request, &lock_path).ok();
130
+ }
131
+ if let Some(waiter) = waiter.as_ref() {
132
+ let _ = waiter.write(&request, elapsed);
133
+ }
134
+ if !long_event_written && elapsed >= held_long {
135
+ let _ = write_lock_held_long_event(&request, &lock_path, elapsed, held_long, timeout);
136
+ long_event_written = true;
137
+ }
138
+ if elapsed >= timeout {
139
+ return Err(lock_timeout_error(&request, &lock_path, elapsed));
140
+ }
141
+ std::thread::sleep(std::cmp::min(
142
+ Duration::from_millis(50),
143
+ timeout.saturating_sub(elapsed),
144
+ ));
139
145
  }
140
146
  }
141
147
 
@@ -293,10 +299,11 @@ impl Drop for WaiterFile {
293
299
  }
294
300
  }
295
301
 
296
- #[cfg(unix)]
297
302
  impl Drop for LifecycleLockGuard {
298
303
  fn drop(&mut self) {
299
- use std::os::unix::io::AsRawFd;
300
- unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
304
+ // Batch 2: unlock via platform primitive. Best-effort — OS
305
+ // releases when the file handle closes anyway. Uniform on
306
+ // both `flock(LOCK_UN)` (unix) and `UnlockFileEx` (windows).
307
+ let _ = crate::platform::file_lock::unlock(&self.file);
301
308
  }
302
309
  }