@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
@@ -146,7 +146,14 @@ fn errno_name(errno: Option<i32>) -> Option<&'static str> {
146
146
  }
147
147
 
148
148
  /// `runtime.py:_runtime_lock` 的 flock 版(RAII;Drop 释放)。state-save 不发锁事件。
149
- /// POSIX flock(unix);Windows 锁(LockFileEx)延平台层(step 9+)。
149
+ ///
150
+ /// 0.5.x Windows portability Batch 2: migrated to
151
+ /// `crate::platform::file_lock::{try_lock_once_nonblocking, unlock}` so
152
+ /// the same polling loop + timeout + `StateError::Locked(name)` shape
153
+ /// works on both Unix (`flock`) and Windows (`LockFileEx`) — 1:1
154
+ /// semantic mapping. The Batch 0 non-Unix `not_yet_implemented`
155
+ /// fallback is now removed (CR C-2 fallback burn-down; grep guard
156
+ /// `platform_fallback_burndown_batch0.rs` flipped in this batch).
150
157
  struct RuntimeLock {
151
158
  #[allow(dead_code)]
152
159
  file: std::fs::File,
@@ -160,42 +167,37 @@ impl RuntimeLock {
160
167
  }
161
168
  let file = std::fs::OpenOptions::new()
162
169
  .create(true)
170
+ .read(true)
163
171
  .write(true)
164
172
  .truncate(false)
165
173
  .open(&lock_path)?;
166
- #[cfg(unix)]
167
- {
168
- use std::os::unix::io::AsRawFd;
169
- let fd = file.as_raw_fd();
170
- let start = Instant::now();
171
- loop {
172
- // SAFETY: fd 来自打开的 lock_file,LOCK_EX|LOCK_NB 非阻塞。
173
- let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
174
- if rc == 0 {
175
- return Ok(Self { file });
174
+ let start = Instant::now();
175
+ loop {
176
+ match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
177
+ Ok(true) => return Ok(Self { file }),
178
+ Ok(false) => {
179
+ if start.elapsed().as_secs_f64() >= timeout {
180
+ return Err(StateError::Locked(name.to_string()));
181
+ }
182
+ std::thread::sleep(Duration::from_millis(50));
176
183
  }
177
- if start.elapsed().as_secs_f64() >= timeout {
178
- return Err(StateError::Locked(name.to_string()));
184
+ Err(e) => {
185
+ // Byte-preserving: a real I/O error surfaces as a
186
+ // `StateError::Io` (via `From<io::Error>`), same as
187
+ // the current inline code would if `file.open()`
188
+ // failed. The lock-would-block case took the
189
+ // Ok(false) arm above.
190
+ return Err(StateError::from(e));
179
191
  }
180
- std::thread::sleep(Duration::from_millis(50));
181
192
  }
182
193
  }
183
- #[cfg(not(unix))]
184
- {
185
- let _ = timeout;
186
- Err(StateError::Locked(format!(
187
- "{name} (runtime lock not yet implemented on non-unix)"
188
- )))
189
- }
190
194
  }
191
195
  }
192
196
 
193
- #[cfg(unix)]
194
197
  impl Drop for RuntimeLock {
195
198
  fn drop(&mut self) {
196
- use std::os::unix::io::AsRawFd;
197
- // SAFETY: 释放本进程持有的 flock。
198
- unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
199
+ // Best-effort unlock. OS releases on handle close if this fails.
200
+ let _ = crate::platform::file_lock::unlock(&self.file);
199
201
  }
200
202
  }
201
203
 
@@ -451,6 +453,18 @@ fn apply_persist_merge_contract(
451
453
  // teams.<key> entry-scoped preservation at :370 remains so legacy
452
454
  // teams.<key>.team_owner entries still survive a save that omits
453
455
  // them.
456
+
457
+ // 0.5.x Windows portability Batch 7 F6 (leader msg_700a016675d0):
458
+ // preserve `state.transport.shim` across saves. The coordinator
459
+ // writes this block when it spawns `windows-shim.exe` during
460
+ // quick-start (see `coordinator::conpty_shim::finalize`).
461
+ // Downstream saves inside the same quick-start (leader-persist,
462
+ // worker-persist) construct their `incoming` state from an
463
+ // in-memory copy that predates the shim-spawn, so without this
464
+ // preserve step the shim block is silently dropped. That
465
+ // clobber broke Batch 6 CHECK 6 (`shutdown` couldn't route to
466
+ // the shim pid because `state.transport.shim.pid` was gone).
467
+ preserve_transport_shim(incoming, latest);
454
468
  }
455
469
 
456
470
  let latest_teams = latest.get("teams").and_then(Value::as_object);
@@ -478,6 +492,47 @@ fn apply_persist_merge_contract(
478
492
  Ok(())
479
493
  }
480
494
 
495
+ /// 0.5.x Windows portability Batch 7 F6: copy `latest.transport.shim`
496
+ /// into `incoming` if the incoming save omitted it. The coordinator's
497
+ /// `conpty_shim::finalize` writes `state.transport.shim = {pid,
498
+ /// pipe_name, pipe_ready}` after Hello succeeds. If a subsequent save
499
+ /// in the same quick-start starts from a stale in-memory `Value` that
500
+ /// predates the finalize, this preserve step keeps the on-disk shim
501
+ /// block alive.
502
+ ///
503
+ /// Only runs when the top-level projection matches (same active team
504
+ /// key + same session name) — no risk of leaking a shim block into
505
+ /// an unrelated projection.
506
+ ///
507
+ /// Windows-relevant behavior; on Unix `transport.shim` is never
508
+ /// written, so this fn is a no-op (both branches see `None`).
509
+ fn preserve_transport_shim(incoming: &mut Value, latest: &Value) {
510
+ // The shim block only matters when `latest` has one AND
511
+ // `incoming` doesn't (or `incoming.transport` doesn't exist).
512
+ let Some(latest_shim) = latest
513
+ .get("transport")
514
+ .and_then(|t| t.get("shim"))
515
+ .cloned()
516
+ else {
517
+ return;
518
+ };
519
+ let Some(incoming_obj) = incoming.as_object_mut() else {
520
+ return;
521
+ };
522
+ let transport = incoming_obj
523
+ .entry("transport".to_string())
524
+ .or_insert_with(|| serde_json::json!({}));
525
+ let Some(transport_obj) = transport.as_object_mut() else {
526
+ return;
527
+ };
528
+ // If the incoming already has a shim block, respect it (the
529
+ // caller has authoritative newer state — e.g. shim was rotated).
530
+ if transport_obj.contains_key("shim") {
531
+ return;
532
+ }
533
+ transport_obj.insert("shim".to_string(), latest_shim);
534
+ }
535
+
481
536
  fn should_skip_capture_backfill(
482
537
  current_team_key: Option<&str>,
483
538
  skip_team_key: Option<&str>,
@@ -1696,6 +1751,131 @@ mod tests {
1696
1751
  );
1697
1752
  }
1698
1753
 
1754
+ // 0.5.x Windows portability Batch 7 F6 (leader msg_700a016675d0):
1755
+ // `state.transport.shim` must survive a downstream save whose
1756
+ // `incoming` was constructed from a stale in-memory Value.
1757
+ #[test]
1758
+ fn transport_shim_block_is_preserved_across_merge() {
1759
+ let ws = temp_ws();
1760
+ // Disk state has the shim block (coordinator wrote it after
1761
+ // Hello succeeded).
1762
+ write_state(
1763
+ &ws,
1764
+ &json!({
1765
+ "session_name": "team-a",
1766
+ "active_team_key": "team-a",
1767
+ "agents": {},
1768
+ "transport": {
1769
+ "kind": "conpty",
1770
+ "shim": { "pid": 4242, "pipe_name": r"\\.\pipe\team-agent-conpty-abc-team-a", "pipe_ready": true }
1771
+ }
1772
+ }),
1773
+ );
1774
+ // Incoming save (leader-persist or worker-persist) doesn't
1775
+ // know about the shim block.
1776
+ let incoming = json!({
1777
+ "session_name": "team-a",
1778
+ "active_team_key": "team-a",
1779
+ "agents": {},
1780
+ "transport": {
1781
+ "kind": "conpty"
1782
+ }
1783
+ });
1784
+ save_runtime_state(&ws, &incoming).unwrap();
1785
+ let saved = read_state(&ws);
1786
+ // Shim block must survive the merge.
1787
+ assert_eq!(
1788
+ saved.pointer("/transport/shim/pid").and_then(Value::as_u64),
1789
+ Some(4242),
1790
+ "shim.pid must be preserved: {saved}"
1791
+ );
1792
+ assert_eq!(
1793
+ saved
1794
+ .pointer("/transport/shim/pipe_name")
1795
+ .and_then(Value::as_str),
1796
+ Some(r"\\.\pipe\team-agent-conpty-abc-team-a"),
1797
+ );
1798
+ assert_eq!(
1799
+ saved
1800
+ .pointer("/transport/shim/pipe_ready")
1801
+ .and_then(Value::as_bool),
1802
+ Some(true),
1803
+ );
1804
+ // And the incoming's `transport.kind` still wins.
1805
+ assert_eq!(
1806
+ saved.pointer("/transport/kind").and_then(Value::as_str),
1807
+ Some("conpty"),
1808
+ );
1809
+ }
1810
+
1811
+ #[test]
1812
+ fn transport_shim_incoming_authoritative_overrides_latest() {
1813
+ // If the caller has a NEWER shim block (e.g. rotation), it
1814
+ // wins — preserve_transport_shim only fills in the gap.
1815
+ let ws = temp_ws();
1816
+ write_state(
1817
+ &ws,
1818
+ &json!({
1819
+ "session_name": "team-a",
1820
+ "active_team_key": "team-a",
1821
+ "agents": {},
1822
+ "transport": {
1823
+ "kind": "conpty",
1824
+ "shim": { "pid": 1000, "pipe_name": "old", "pipe_ready": true }
1825
+ }
1826
+ }),
1827
+ );
1828
+ let incoming = json!({
1829
+ "session_name": "team-a",
1830
+ "active_team_key": "team-a",
1831
+ "agents": {},
1832
+ "transport": {
1833
+ "kind": "conpty",
1834
+ "shim": { "pid": 9999, "pipe_name": "new", "pipe_ready": true }
1835
+ }
1836
+ });
1837
+ save_runtime_state(&ws, &incoming).unwrap();
1838
+ let saved = read_state(&ws);
1839
+ assert_eq!(
1840
+ saved.pointer("/transport/shim/pid").and_then(Value::as_u64),
1841
+ Some(9999),
1842
+ "incoming shim block must win when both sides have one"
1843
+ );
1844
+ assert_eq!(
1845
+ saved
1846
+ .pointer("/transport/shim/pipe_name")
1847
+ .and_then(Value::as_str),
1848
+ Some("new"),
1849
+ );
1850
+ }
1851
+
1852
+ #[test]
1853
+ fn transport_shim_no_leak_when_latest_lacks_block() {
1854
+ // If disk has no shim block, save must not synthesize one.
1855
+ let ws = temp_ws();
1856
+ write_state(
1857
+ &ws,
1858
+ &json!({
1859
+ "session_name": "team-a",
1860
+ "active_team_key": "team-a",
1861
+ "agents": {},
1862
+ "transport": {"kind": "tmux"}
1863
+ }),
1864
+ );
1865
+ let incoming = json!({
1866
+ "session_name": "team-a",
1867
+ "active_team_key": "team-a",
1868
+ "agents": {},
1869
+ "transport": {"kind": "tmux"}
1870
+ });
1871
+ save_runtime_state(&ws, &incoming).unwrap();
1872
+ let saved = read_state(&ws);
1873
+ assert!(
1874
+ saved.pointer("/transport/shim").is_none(),
1875
+ "shim must not appear when neither side had one: {saved}"
1876
+ );
1877
+ }
1878
+
1699
1879
  #[test]
1700
1880
  fn projection_paths_do_not_cross_backfill_topology() {
1701
1881
  let ws = temp_ws();
@@ -18,6 +18,13 @@
18
18
  use std::collections::BTreeMap;
19
19
  use std::hash::{Hash, Hasher};
20
20
  use std::io::{Read, Write};
21
+ // 0.5.x Windows portability Batch 1: gate the sole Unix API surface
22
+ // in this concrete tmux backend (FileTypeExt::is_socket + libc::geteuid
23
+ // tmux-socket-root derivation). The rest of the file is Command::new
24
+ // "tmux" shellouts that compile on Windows but return runtime errors
25
+ // honestly (tmux binary absent → typed subprocess error).
26
+ // Truth source: `.team/artifacts/0.5.x-windows-portability-survey-design.md` §Batch 1.
27
+ #[cfg(unix)]
21
28
  use std::os::unix::fs::FileTypeExt;
22
29
  use std::path::{Path, PathBuf};
23
30
  use std::process::Stdio;
@@ -430,6 +437,37 @@ fn runtime_tmux_endpoint_from_state(
430
437
  /// stable FNV-1a hash over the canonicalized path. AF_UNIX `sun_path` is ~104 chars and the socket
431
438
  /// lives at `/tmp/tmux-<uid>/<name>`, so we must NOT use the (~88-char) session name. §10: a
432
439
  /// canonicalize failure falls back to the raw path (never panics).
440
+ /// Public re-export of the crate-private canonical workspace hash used
441
+ /// by the tmux short-socket derivation. `transport_factory` uses this
442
+ /// same hash for the ConPTY `workspace_hash` so both backends see
443
+ /// identical workspace identity. Adding a wrapper (not calling
444
+ /// `socket_name_for_workspace` directly outside this file) keeps the
445
+ /// existing internal API stable.
446
+ pub fn workspace_short_hash_pub(workspace: &Path) -> String {
447
+ // The tmux short-socket name is `ta-<12 hex>`; the workspace-hash
448
+ // used by the ConPTY workspace identity is the raw 12-hex portion
449
+ // (no `ta-` prefix), which is what the shim/pipe name derivation
450
+ // expects. Reuse the exact same hash so drift is impossible.
451
+ let name = socket_name_for_workspace(workspace);
452
+ name.strip_prefix("ta-").unwrap_or(&name).to_string()
453
+ }
454
+
455
+ /// Public re-export of the crate-private `runtime_tmux_endpoint_from_state`
456
+ /// probe. `transport_factory` uses this to spot the legacy tmux endpoint
457
+ /// in `state` without duplicating the field-precedence logic.
458
+ pub fn runtime_tmux_endpoint_from_state_pub(
459
+ state: Option<&serde_json::Value>,
460
+ ) -> Option<(String, &'static str)> {
461
+ runtime_tmux_endpoint_from_state(state).map(|(endpoint, source)| {
462
+ let src = match source {
463
+ RuntimeTmuxEndpointSource::StateTmuxEndpoint => "state.tmux_endpoint",
464
+ RuntimeTmuxEndpointSource::StateTmuxSocket => "state.tmux_socket",
465
+ RuntimeTmuxEndpointSource::WorkspaceFallback => "workspace_fallback",
466
+ };
467
+ (endpoint.to_string(), src)
468
+ })
469
+ }
470
+
433
471
  pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
434
472
  let canonical = workspace
435
473
  .canonicalize()
@@ -458,10 +496,24 @@ pub(crate) fn socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
458
496
  if let Some(existing) = existing_socket_path_for_name(socket_name) {
459
497
  return Some(existing);
460
498
  }
461
- let uid = unsafe { libc::geteuid() };
462
- let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
463
- let default_root = default_root.canonicalize().unwrap_or(default_root);
464
- Some(default_root.join(socket_name))
499
+ // Batch 1: tmux uses `/tmp/tmux-<uid>/` on Unix; on Windows this
500
+ // helper is dead code (Command::new "tmux" fails at spawn time
501
+ // before this path is dereferenced). N38 typed unsupported honored
502
+ // at the shellout boundary.
503
+ #[cfg(unix)]
504
+ {
505
+ let uid = unsafe { libc::geteuid() };
506
+ let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
507
+ let default_root = default_root.canonicalize().unwrap_or(default_root);
508
+ Some(default_root.join(socket_name))
509
+ }
510
+ #[cfg(not(unix))]
511
+ {
512
+ // Windows: tmux socket-root derivation is meaningless. Return
513
+ // None so the caller sees the honest "no such socket" branch.
514
+ let _ = socket_name;
515
+ None
516
+ }
465
517
  }
466
518
 
467
519
  fn existing_socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
@@ -551,14 +603,25 @@ pub(crate) fn attach_commands_for_windows<'a>(
551
603
  }
552
604
 
553
605
  pub(crate) fn tmux_socket_roots() -> Vec<PathBuf> {
554
- let uid = unsafe { libc::geteuid() };
555
- let mut roots = vec![PathBuf::from(format!("/tmp/tmux-{uid}"))];
556
- if let Some(tmpdir) = std::env::var_os("TMPDIR") {
557
- roots.push(PathBuf::from(tmpdir).join(format!("tmux-{uid}")));
606
+ // Batch 1: `/tmp/tmux-<uid>` root enumeration is Unix-only tmux
607
+ // convention. On Windows return empty so the caller loops zero
608
+ // times (honest "no tmux socket roots" — tmux is not deployed on
609
+ // native Windows without WSL, and WSL is out of scope).
610
+ #[cfg(unix)]
611
+ {
612
+ let uid = unsafe { libc::geteuid() };
613
+ let mut roots = vec![PathBuf::from(format!("/tmp/tmux-{uid}"))];
614
+ if let Some(tmpdir) = std::env::var_os("TMPDIR") {
615
+ roots.push(PathBuf::from(tmpdir).join(format!("tmux-{uid}")));
616
+ }
617
+ roots.sort();
618
+ roots.dedup();
619
+ roots
620
+ }
621
+ #[cfg(not(unix))]
622
+ {
623
+ Vec::new()
558
624
  }
559
- roots.sort();
560
- roots.dedup();
561
- roots
562
625
  }
563
626
 
564
627
  pub(crate) fn tmux_socket_endpoints() -> Vec<String> {
@@ -571,7 +634,20 @@ pub(crate) fn tmux_socket_endpoints() -> Vec<String> {
571
634
  let Ok(file_type) = entry.file_type() else {
572
635
  continue;
573
636
  };
574
- if !file_type.is_socket() {
637
+ // Batch 1: `FileTypeExt::is_socket` is Unix-only. Dead code
638
+ // on Windows because `tmux_socket_roots()` returns empty
639
+ // there (no /tmp/tmux-<uid> convention). We still cfg the
640
+ // method call so `cargo check --target x86_64-pc-windows-msvc`
641
+ // sees no `std::os::unix::fs::FileTypeExt` reference.
642
+ #[cfg(unix)]
643
+ {
644
+ if !file_type.is_socket() {
645
+ continue;
646
+ }
647
+ }
648
+ #[cfg(not(unix))]
649
+ {
650
+ let _ = file_type;
575
651
  continue;
576
652
  }
577
653
  let path = entry.path();
@@ -2218,5 +2294,10 @@ fn non_empty(raw: &str) -> Option<&str> {
2218
2294
  }
2219
2295
  }
2220
2296
 
2221
- #[cfg(test)]
2297
+ // 0.5.x Windows portability Batch 5: the `tmux_backend/tests.rs`
2298
+ // module uses `std::os::unix::net::UnixListener` for its mock
2299
+ // runner + verifies Unix-specific socket-root derivation. Since the
2300
+ // tmux backend itself only functions on Unix (design § Route B —
2301
+ // tmux is a Unix concept), the test module stays Unix-only.
2302
+ #[cfg(all(test, unix))]
2222
2303
  mod tests;