@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
@@ -82,7 +82,19 @@ pub struct ConPtyBackend {
82
82
  /// Present when a `PipeClient` is wired. When present, all
83
83
  /// Required trait methods forward through the client; when absent,
84
84
  /// they degrade to `MuxUnavailable` honest-fail.
85
- pipe_client: Option<Box<dyn PipeClientTrait>>,
85
+ ///
86
+ /// 0.5.x CR C-5 fix (leader msg_a89b5a03bbf0): behind a Mutex so
87
+ /// the lazy `ensure_pipe_client` path can populate it on first
88
+ /// Op call. Prior shape kept it in `Option<...>` outside a lock,
89
+ /// which was fine when the factory pre-wired via
90
+ /// `with_pipe_client` but is racy under lazy semantics.
91
+ pipe_client: std::sync::Mutex<Option<Box<dyn PipeClientTrait>>>,
92
+ /// Optional pipe-name hint used by lazy connect. The factory
93
+ /// stashes this when it sees `state.transport.shim.pipe_name`
94
+ /// (Batch 5+), and `ensure_pipe_client` uses it on first Op that
95
+ /// needs the pipe. `None` = factory had no hint → backend stays
96
+ /// unwired.
97
+ pipe_hint: Option<String>,
86
98
  /// Monotonic request id counter — never persisted, never surfaced
87
99
  /// to callers.
88
100
  request_counter: AtomicU64,
@@ -90,22 +102,51 @@ pub struct ConPtyBackend {
90
102
 
91
103
  impl ConPtyBackend {
92
104
  /// New backend for `(workspace_hash, team_key)`. The pipe client is
93
- /// left unset; callers wire it via `with_pipe_client`.
105
+ /// left unset; callers wire it via `with_pipe_client` (Batch 6
106
+ /// direct-handoff) or `with_pipe_hint` (Batch 5+ lazy connect).
94
107
  pub fn new(workspace_hash: impl Into<String>, team_key: impl Into<String>) -> Self {
95
108
  Self {
96
109
  workspace_hash: workspace_hash.into(),
97
110
  team_key: team_key.into(),
98
111
  pipe_token: std::sync::Mutex::new(None),
99
- pipe_client: None,
112
+ pipe_client: std::sync::Mutex::new(None),
113
+ pipe_hint: None,
100
114
  request_counter: AtomicU64::new(0),
101
115
  }
102
116
  }
103
117
 
118
+ /// 0.5.x CR C-5 fix (leader msg_a89b5a03bbf0): stash a pipe-name
119
+ /// hint for lazy connect. This is what the factory uses in
120
+ /// `build_conpty` — no I/O at resolve time; the connect is
121
+ /// deferred to the first Op that needs the pipe.
122
+ ///
123
+ /// Idempotent: calling twice with the same hint is a no-op;
124
+ /// calling with a different hint overrides the previous. The
125
+ /// factory calls this ONCE per resolve.
126
+ pub fn with_pipe_hint(mut self, pipe_name: impl Into<String>) -> Self {
127
+ self.pipe_hint = Some(pipe_name.into());
128
+ self
129
+ }
130
+
131
+ /// Read the current pipe-name hint (for factory logic that
132
+ /// derives a fallback hint from env override).
133
+ pub fn pipe_hint_ref(&self) -> Option<&str> {
134
+ self.pipe_hint.as_deref()
135
+ }
136
+
104
137
  /// Wire a live PipeClient. Immediately performs `hello` to negotiate
105
138
  /// the pipe_token; on failure, drops the client so subsequent calls
106
139
  /// degrade honestly.
140
+ ///
141
+ /// Used by `lifecycle/launch.rs` Batch 6 direct-handoff path
142
+ /// (quick-start conpty spawn on Windows), where the coordinator
143
+ /// already has a Hello-connected client from
144
+ /// `coordinator::conpty_shim::spawn_shim_and_handshake`. The
145
+ /// client becomes the backend's initial `pipe_client` cache
146
+ /// value, unifying with the lazy path (subsequent Ops just find
147
+ /// a wired client and skip the lazy `ensure_pipe_client` cost).
107
148
  pub fn with_pipe_client(
108
- mut self,
149
+ self,
109
150
  client: Box<dyn PipeClientTrait>,
110
151
  ) -> Result<Self, TransportError> {
111
152
  // Hello does NOT require token pre-match; use a placeholder.
@@ -129,10 +170,74 @@ impl ConPtyBackend {
129
170
  detail: format!("hello response malformed: {e}"),
130
171
  })?;
131
172
  *self.pipe_token.lock().unwrap() = Some(hello.pipe_token);
132
- self.pipe_client = Some(client);
173
+ *self.pipe_client.lock().unwrap() = Some(client);
133
174
  Ok(self)
134
175
  }
135
176
 
177
+ /// 0.5.x CR C-5 fix (leader msg_a89b5a03bbf0): the lazy-connect
178
+ /// helper. Called from `dispatch` on the first Op that needs the
179
+ /// pipe.
180
+ ///
181
+ /// Semantics:
182
+ /// 1. If `pipe_client` is already set (Batch 6 direct-handoff or
183
+ /// a prior lazy connect), return Ok immediately.
184
+ /// 2. Else if `pipe_hint` is set, attempt
185
+ /// `NamedPipeClient::connect(&hint, 2000)` (the same 2000ms
186
+ /// timeout the factory used pre-C-5-fix — leader constraint
187
+ /// "沿用 2000ms 语义"), perform Hello, cache the client.
188
+ /// 3. Else return `MuxUnavailable::no_pipe_client`.
189
+ ///
190
+ /// On Unix this fn is a no-op stub — `NamedPipeClient` doesn't
191
+ /// exist there. Callers that reach this path on Unix already
192
+ /// have `pipe_client == None` and fall to the error branch,
193
+ /// which is the pre-C-5-fix behavior.
194
+ fn ensure_pipe_client(&self) -> Result<(), TransportError> {
195
+ // Fast path: already wired.
196
+ if self.pipe_client.lock().unwrap().is_some() {
197
+ return Ok(());
198
+ }
199
+ // Lazy path: need a hint + Windows backend.
200
+ #[cfg(windows)]
201
+ {
202
+ let Some(pipe_name) = self.pipe_hint.as_ref() else {
203
+ return Err(self.mux_unavailable("no_pipe_hint"));
204
+ };
205
+ let client = conpty_transport::NamedPipeClient::connect(pipe_name, 2000)
206
+ .map_err(|e| self.mux_unavailable(&format!("connect_failed: {e}")))?;
207
+ let adapter = crate::conpty::PipeClientAdapter(client);
208
+ // Perform Hello inline to negotiate pipe_token.
209
+ let req = Request::new(
210
+ self.next_request_id(),
211
+ &self.workspace_hash,
212
+ &self.team_key,
213
+ "PENDING",
214
+ Op::Hello,
215
+ );
216
+ let resp = adapter.request(&req)?;
217
+ if !resp.ok {
218
+ return Err(TransportError::MuxUnavailable {
219
+ backend: BackendKind::ConPty,
220
+ detail: format!("hello_failed: {:?}", resp.error),
221
+ });
222
+ }
223
+ let hello: HelloResult = serde_json::from_value(resp.result).map_err(|e| {
224
+ TransportError::MuxUnavailable {
225
+ backend: BackendKind::ConPty,
226
+ detail: format!("hello_response_malformed: {e}"),
227
+ }
228
+ })?;
229
+ *self.pipe_token.lock().unwrap() = Some(hello.pipe_token);
230
+ *self.pipe_client.lock().unwrap() = Some(Box::new(adapter));
231
+ Ok(())
232
+ }
233
+ #[cfg(not(windows))]
234
+ {
235
+ // Unix has no NamedPipeClient. If we got here without a
236
+ // wired client, degrade honestly.
237
+ Err(self.mux_unavailable("no_pipe_client"))
238
+ }
239
+ }
240
+
136
241
  fn next_request_id(&self) -> String {
137
242
  let n = self.request_counter.fetch_add(1, Ordering::Relaxed);
138
243
  format!("req-{n}")
@@ -156,10 +261,17 @@ impl ConPtyBackend {
156
261
  }
157
262
 
158
263
  fn dispatch(&self, op: Op, payload: serde_json::Value) -> Result<Response, TransportError> {
159
- let Some(client) = self.pipe_client.as_ref() else {
160
- return Err(self.mux_unavailable(&format!("{op:?}").to_lowercase()));
161
- };
264
+ // 0.5.x CR C-5 fix (leader msg_a89b5a03bbf0): trigger the
265
+ // lazy connect + Hello on first Op that needs the pipe. If a
266
+ // client was pre-wired (Batch 6 direct-handoff) or a prior
267
+ // Op already connected, `ensure_pipe_client` is a fast
268
+ // no-op.
269
+ self.ensure_pipe_client()?;
162
270
  let req = self.build_request(op, payload)?;
271
+ let client_guard = self.pipe_client.lock().unwrap();
272
+ let client = client_guard
273
+ .as_ref()
274
+ .ok_or_else(|| self.mux_unavailable(&format!("{op:?}").to_lowercase()))?;
163
275
  let resp = client.request(&req)?;
164
276
  if !resp.ok {
165
277
  return Err(map_protocol_error(resp.error.as_ref()));
@@ -27,6 +27,14 @@ pub struct DaemonArgs {
27
27
  pub once: bool,
28
28
  /// `--tick-interval`(`__main__.py:29`)。`None` → 读 spec `runtime.tick_interval_sec`。
29
29
  pub tick_interval_sec: Option<f64>,
30
+ /// 0.5.x Windows portability Batch 9 F8 (leader msg_2a4cc1fa54c0):
31
+ /// team_key passed via `--team` CLI so the coordinator daemon
32
+ /// doesn't have to derive from state at boot time. That derivation
33
+ /// (Batch 7 F5) got trapped by Batch 8's seed-state pattern
34
+ /// (F8 root cause). Preserving the derivation as a fallback when
35
+ /// `team_key` is None keeps Unix daemons and pre-existing test
36
+ /// harnesses byte-preserving.
37
+ pub team_key: Option<String>,
30
38
  }
31
39
 
32
40
  /// daemon 主循环(`main`,`__main__.py:25-98`)。写 pid/meta(source=boot)、装信号→STOP、孤儿自检、
@@ -44,11 +52,35 @@ pub fn run_daemon(args: DaemonArgs) -> Result<(), DaemonError> {
44
52
  // legacy `tmux_endpoint` → same `tmux_backend_for_runtime_state_or_workspace`
45
53
  // shape inside the factory's `build_tmux`).
46
54
  let state = crate::state::persist::load_runtime_state(args.workspace.as_path()).ok();
47
- let factory_input = crate::transport_factory::TransportFactoryInput::new(
55
+ // 0.5.x Windows portability Batch 9 F8 (leader msg_2a4cc1fa54c0):
56
+ // team_key priority is now `args.team_key` (from `--team` CLI)
57
+ // > `state.active_team_key` fallback. The Batch 7 F5 derivation
58
+ // stays as the fallback so pre-Batch-9 tests + Unix daemons
59
+ // continue byte-preserving. Callers that CAN pass `--team`
60
+ // (Batch 9 quick-start on Windows) do — that avoids the F8
61
+ // seed-state trap where writing active_team_key to state ahead
62
+ // of coord spawn made downstream launch code think "existing
63
+ // runtime, use restart" and skip spec compile.
64
+ let team_key_from_state = args
65
+ .team_key
66
+ .clone()
67
+ .filter(|s| !s.is_empty())
68
+ .or_else(|| {
69
+ state
70
+ .as_ref()
71
+ .and_then(|s| s.get("active_team_key"))
72
+ .and_then(|v| v.as_str())
73
+ .filter(|s| !s.is_empty())
74
+ .map(str::to_string)
75
+ });
76
+ let mut factory_input = crate::transport_factory::TransportFactoryInput::new(
48
77
  args.workspace.as_path(),
49
78
  crate::transport_factory::TransportPurpose::Coordinator,
50
79
  )
51
80
  .with_state(state.as_ref());
81
+ if let Some(ref team_key) = team_key_from_state {
82
+ factory_input = factory_input.with_team_key(Some(team_key));
83
+ }
52
84
  let resolved = match crate::transport_factory::resolve_transport(factory_input) {
53
85
  Ok(resolved) => resolved,
54
86
  Err(e) => {
@@ -137,6 +169,57 @@ fn run_daemon_with_coordinator_and_boot_tmux(
137
169
  }
138
170
  }
139
171
  event_log.write("coordinator.boot", boot_event)?;
172
+
173
+ // 0.5.x Windows portability Batch 8 F7 (leader msg_590b4dce0f68):
174
+ // coordinator takes ownership of shim lifecycle. Idempotent:
175
+ // spawns a fresh shim if none is recorded, reconnects if
176
+ // `state.transport.shim.pid` names a living shim (post-crash
177
+ // restart path). Failures are non-fatal for the coord daemon —
178
+ // the tick loop's transport calls will surface honest
179
+ // MuxUnavailable errors and `mark_transport_unavailable`
180
+ // emits the C-3 stale-family event.
181
+ //
182
+ // On non-Windows this call is cfg'd out (there's no shim
183
+ // concept on Unix).
184
+ #[cfg(windows)]
185
+ {
186
+ let state_for_team_key =
187
+ crate::state::persist::load_runtime_state(args.workspace.as_path()).ok();
188
+ let team_key_opt = state_for_team_key
189
+ .as_ref()
190
+ .and_then(|s| s.get("active_team_key"))
191
+ .and_then(|v| v.as_str())
192
+ .filter(|s| !s.is_empty())
193
+ .map(str::to_string);
194
+ if let Some(team_key) = team_key_opt {
195
+ let workspace_hash =
196
+ crate::tmux_backend::workspace_short_hash_pub(args.workspace.as_path());
197
+ match crate::coordinator::conpty_shim::ensure_shim_running(
198
+ args.workspace.as_path(),
199
+ &team_key,
200
+ &workspace_hash,
201
+ ) {
202
+ Ok(handle) => {
203
+ // Detach so the shim survives beyond this
204
+ // coordinator instance (F7 core invariant).
205
+ let _shim_pid = handle.detach();
206
+ eprintln!("coordinator: shim ensured (pid={_shim_pid})");
207
+ }
208
+ Err(e) => {
209
+ // Honest degradation: emit the stale-family
210
+ // event, coord continues without a shim. Tick
211
+ // loop will surface `mux_unavailable` on any
212
+ // transport call.
213
+ eprintln!("coordinator: shim ensure failed: {e}");
214
+ let _ = crate::coordinator::conpty_shim::mark_transport_unavailable(
215
+ args.workspace.as_path(),
216
+ &format!("boot_ensure_failed: {e}"),
217
+ );
218
+ }
219
+ }
220
+ }
221
+ }
222
+
140
223
  let tick_interval = match args.tick_interval_sec {
141
224
  Some(v) if v > 0.0 => v,
142
225
  _ => resolve_tick_interval(&args.workspace)?,
@@ -254,8 +337,11 @@ fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
254
337
  }
255
338
 
256
339
  /// 当前 ppid(`os.getppid()`,孤儿自检输入)。
340
+ ///
341
+ /// 0.5.x Windows portability Batch 3: uses `platform::process::current_parent_pid`
342
+ /// so Windows sees a real Toolhelp32-derived ppid instead of `0`.
257
343
  fn current_ppid() -> u32 {
258
- u32::try_from(unsafe { libc::getppid() }).unwrap_or(0)
344
+ crate::platform::process::current_parent_pid().unwrap_or(0)
259
345
  }
260
346
 
261
347
  /// 计算 tick 间隔(`_tick_interval`,`__main__.py:104-115`)。读 spec `runtime.tick_interval_sec`,