@nowcrew/daemon 0.6.88-beta.16 → 0.6.88-beta.18

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.
@@ -1,7 +1,10 @@
1
+ import { retryHarnessProbe } from "./runtimes/harness-probe-retry.js";
1
2
  /**
2
3
  * 采集本机信息上报给控制面 (machine:hello):hostname / os / daemon 版本 / 已装 runtimes。
3
4
  * runtimes 探测靠 `which <bin>`(win32 用 `where`),只报真实可执行的 CLI(用于展示 Detected Runtimes)。
4
5
  */
6
+ import { randomUUID } from "node:crypto";
7
+ import { dslog } from "./slog.js";
5
8
  import { hostname, arch, platform as osPlatform } from "node:os";
6
9
  import { lookupCmd } from "./platform.js";
7
10
  import { augmentedPath } from "./runtime-path.js";
@@ -14,7 +17,7 @@ import { createRequire } from "node:module";
14
17
  import { dirname, resolve } from "node:path";
15
18
  import { executableRuntimes } from "./runtime-capabilities.js";
16
19
  import { executionBackendCapability } from "./execution-backend.js";
17
- import { probeKimiAcp } from "./runtimes/kimi-acp-runner.js";
20
+ import { probeKimiAcp, AcpProbeCleanupError } from "./runtimes/kimi-acp-runner.js";
18
21
  import { probeHermesAcp } from "./runtimes/hermes.js";
19
22
  import { probeOpenCodeRun } from "./runtimes/opencode.js";
20
23
  import { RUNTIME_HEALTH_PROBE_CAPABILITY } from "./runtime-health.js";
@@ -117,31 +120,54 @@ export async function detectRuntimes() {
117
120
  }));
118
121
  return checks.filter((x) => x !== null);
119
122
  }
120
- async function supportsKimiAcp(signal) {
121
- return probeKimiAcp({ bin: "kimi", ...(signal ? { signal } : {}) });
123
+ async function supportsKimiAcp(signal, probeId) {
124
+ return probeKimiAcp({ bin: "kimi", ...(probeId ? { probeId } : {}), ...(signal ? { signal } : {}) });
122
125
  }
123
- async function supportsHermesAcp(signal) {
124
- return probeHermesAcp({ bin: "hermes", ...(signal ? { signal } : {}) });
126
+ async function supportsHermesAcp(signal, probeId) {
127
+ return probeHermesAcp({ bin: "hermes", ...(probeId ? { probeId } : {}), ...(signal ? { signal } : {}) });
125
128
  }
126
129
  async function supportsOpenCodeRun(signal) {
127
130
  return probeOpenCodeRun(signal);
128
131
  }
129
- async function supportsDeepSeekHarnessAcp(signal) {
132
+ async function supportsDeepSeekHarnessAcp(signal, probeId) {
130
133
  try {
131
- return await probeDeepSeekHarnessAcp({ bin: deepSeekHarnessLauncher().bin, ...(signal ? { signal } : {}) });
134
+ return await probeDeepSeekHarnessAcp({ bin: deepSeekHarnessLauncher().bin, ...(probeId ? { probeId } : {}), ...(signal ? { signal } : {}) });
132
135
  }
133
- catch {
136
+ catch (error) {
137
+ if (error instanceof AcpProbeCleanupError)
138
+ throw error;
134
139
  return false;
135
140
  }
136
141
  }
137
142
  /** Runtime adapters that can satisfy the durable protocol-v1 process contract. */
138
- export async function detectExecutionRuntimes(installed = detectRuntimes(), kimiAcpProbe = supportsKimiAcp, hermesAcpProbe = supportsHermesAcp, openCodeProbe = supportsOpenCodeRun, deepSeekHarnessProbe = supportsDeepSeekHarnessAcp, signal) {
143
+ export async function detectExecutionRuntimes(installed = detectRuntimes(), kimiAcpProbe = supportsKimiAcp, hermesAcpProbe = supportsHermesAcp, openCodeProbe = supportsOpenCodeRun, deepSeekHarnessProbe = supportsDeepSeekHarnessAcp, signal, probeId = randomUUID()) {
139
144
  const present = await installed;
145
+ const probe = (runtime, detect) => {
146
+ const startedAt = Date.now();
147
+ dslog("runtime_probe.optional_start", "开始可选 runtime 探测", { probe_id: probeId, runtime, aborted: signal?.aborted ?? false });
148
+ const complete = (outcome, ready) => {
149
+ dslog("runtime_probe.optional_complete", "可选 runtime 探测结束", {
150
+ probe_id: probeId, runtime, outcome, duration_ms: Date.now() - startedAt,
151
+ aborted: signal?.aborted ?? false, ...(ready === undefined ? {} : { ready }),
152
+ });
153
+ };
154
+ let pending;
155
+ try {
156
+ pending = detect(signal, probeId);
157
+ }
158
+ catch (error) {
159
+ complete("threw");
160
+ throw error;
161
+ }
162
+ return pending.then((ready) => { complete("resolved", ready); return ready; }, (error) => { complete("threw"); throw error; });
163
+ };
140
164
  const [kimiReady, hermesReady, openCodeReady, deepSeekHarnessReady] = await Promise.all([
141
- present.includes("kimi") ? kimiAcpProbe(signal) : false,
142
- present.includes("hermes") ? hermesAcpProbe(signal) : false,
143
- present.includes("opencode") ? openCodeProbe(signal) : false,
144
- present.includes("deepseek-harness") ? deepSeekHarnessProbe(signal) : false,
165
+ present.includes("kimi") ? probe("kimi", kimiAcpProbe) : false,
166
+ present.includes("hermes") ? probe("hermes", hermesAcpProbe) : false,
167
+ present.includes("opencode") ? probe("opencode", openCodeProbe) : false,
168
+ present.includes("deepseek-harness")
169
+ ? retryHarnessProbe(() => probe("deepseek-harness", deepSeekHarnessProbe), signal, probeId)
170
+ : false,
145
171
  ]);
146
172
  return executableRuntimes(present).filter((runtime) => (runtime === "kimi" ? kimiReady
147
173
  : runtime === "hermes" ? hermesReady
@@ -149,7 +175,7 @@ export async function detectExecutionRuntimes(installed = detectRuntimes(), kimi
149
175
  : runtime === "deepseek-harness" ? deepSeekHarnessReady
150
176
  : true));
151
177
  }
152
- export const detectExecutionRuntimesWithSignal = (installed, signal) => detectExecutionRuntimes(installed, supportsKimiAcp, supportsHermesAcp, supportsOpenCodeRun, supportsDeepSeekHarnessAcp, signal);
178
+ export const detectExecutionRuntimesWithSignal = (installed, signal, probeId) => detectExecutionRuntimes(installed, supportsKimiAcp, supportsHermesAcp, supportsOpenCodeRun, supportsDeepSeekHarnessAcp, signal, probeId);
153
179
  export function daemonVersion() {
154
180
  try {
155
181
  const here = dirname(fileURLToPath(import.meta.url));
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { dslog } from "./slog.js";
1
3
  import { executableRuntimes } from "./runtime-capabilities.js";
2
4
  export function conservativeExecutionRuntimes(installed) {
3
5
  return executableRuntimes(installed)
@@ -7,16 +9,43 @@ export function createRuntimeProbeCoordinator() {
7
9
  const abort = new AbortController();
8
10
  const flights = new Map();
9
11
  return {
10
- detect(installed, detector) {
12
+ detect(installed, detector, connectionId) {
11
13
  const key = [...installed].sort().join("\0");
12
14
  const existing = flights.get(key);
13
- if (existing)
14
- return existing;
15
- const pending = detector(installed, abort.signal).catch((error) => {
15
+ const probeId = existing?.probeId ?? randomUUID();
16
+ const fields = { probe_id: probeId, connection_id: connectionId, installed_runtimes: JSON.stringify(installed) };
17
+ dslog("runtime_probe.request", "请求 runtime 执行能力探测", fields);
18
+ if (existing) {
19
+ dslog("runtime_probe.cache_hit", "复用 runtime 探测结果", { ...fields, cache_state: existing.state });
20
+ return existing.promise;
21
+ }
22
+ const startedAt = Date.now();
23
+ const complete = (outcome, executionRuntimes) => {
24
+ dslog("runtime_probe.complete", "runtime 探测聚合结束", {
25
+ ...fields, outcome, duration_ms: Date.now() - startedAt, aborted: abort.signal.aborted,
26
+ ...(executionRuntimes === undefined ? {} : { execution_runtimes: JSON.stringify(executionRuntimes) }),
27
+ });
28
+ };
29
+ let detection;
30
+ try {
31
+ detection = detector(installed, abort.signal, probeId);
32
+ }
33
+ catch (error) {
34
+ complete("threw");
35
+ throw error;
36
+ }
37
+ const pending = detection.then((result) => {
38
+ const flight = flights.get(key);
39
+ if (flight)
40
+ flights.set(key, { ...flight, state: "resolved" });
41
+ complete("resolved", result);
42
+ return result;
43
+ }, (error) => {
16
44
  flights.delete(key);
45
+ complete("threw");
17
46
  throw error;
18
47
  });
19
- flights.set(key, pending);
48
+ flights.set(key, { promise: pending, probeId, state: "pending" });
20
49
  return pending;
21
50
  },
22
51
  stop() {
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { prepareHarnessProfile } from "./deepseek-harness-profile.js";
7
- import { probeKimiAcp } from "./kimi-acp-runner.js";
7
+ import { probeKimiAcp, AcpProbeCleanupError } from "./kimi-acp-runner.js";
8
8
  export const DEEPSEEK_HARNESS_CONFIG_ENV = "NOWCREW_DEEPSEEK_HARNESS_CONFIG";
9
9
  export const DEEPSEEK_HARNESS_PATCH_ENV = "NOWCREW_DEEPSEEK_HARNESS_PATCH";
10
10
  export function deepSeekHarnessLauncher(env = process.env) {
@@ -61,7 +61,9 @@ export async function probeDeepSeekHarnessAcp(options, spawnProcess = spawn) {
61
61
  launchCwd: root,
62
62
  }, spawnProcess);
63
63
  }
64
- catch {
64
+ catch (error) {
65
+ if (error instanceof AcpProbeCleanupError)
66
+ throw error;
65
67
  return false;
66
68
  }
67
69
  finally {
@@ -0,0 +1,42 @@
1
+ import { dslog } from "../slog.js";
2
+ const RETRY_DELAYS_MS = [1_000, 2_000];
3
+ /** 只延迟当前启动探测,不设后台定时任务;stop 立即清除等待。 */
4
+ function waitForRetry(ms, signal) {
5
+ if (signal?.aborted)
6
+ return Promise.resolve(false);
7
+ return new Promise((resolve) => {
8
+ const finish = (ready) => {
9
+ clearTimeout(timer);
10
+ signal?.removeEventListener("abort", onAbort);
11
+ resolve(ready);
12
+ };
13
+ const onAbort = () => finish(false);
14
+ const timer = setTimeout(() => finish(true), ms);
15
+ signal?.addEventListener("abort", onAbort, { once: true });
16
+ });
17
+ }
18
+ /** 成功才公布能力;每次 probe 返回前已完成进程清理,异常保持原有传播。 */
19
+ export async function retryHarnessProbe(probe, signal, probeId) {
20
+ for (let attempt = 1; attempt <= RETRY_DELAYS_MS.length + 1; attempt++) {
21
+ if (signal?.aborted)
22
+ return false;
23
+ const ready = await probe();
24
+ dslog("runtime_probe.harness_attempt", "Harness 启动探测尝试结束", {
25
+ probe_id: probeId, runtime: "deepseek-harness", attempt, ready,
26
+ aborted: signal?.aborted ?? false,
27
+ });
28
+ if (signal?.aborted)
29
+ return false;
30
+ if (ready)
31
+ return true;
32
+ const delayMs = RETRY_DELAYS_MS[attempt - 1];
33
+ if (delayMs === undefined)
34
+ return false;
35
+ dslog("runtime_probe.harness_retry", "延迟重试 Harness 启动探测", {
36
+ probe_id: probeId, runtime: "deepseek-harness", next_attempt: attempt + 1, delay_ms: delayMs,
37
+ });
38
+ if (!await waitForRetry(delayMs, signal))
39
+ return false;
40
+ }
41
+ return false;
42
+ }
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { dslog } from "../slog.js";
1
3
  import { once } from "node:events";
2
4
  import { isAbsolute } from "node:path";
3
5
  import { parseArgs } from "node:util";
@@ -194,10 +196,20 @@ async function stopChild(child) {
194
196
  await closed;
195
197
  }
196
198
  }
199
+ export class AcpProbeCleanupError extends Error {
200
+ constructor() { super("ACP probe process cleanup unconfirmed"); this.name = "AcpProbeCleanupError"; }
201
+ }
197
202
  /** Probe the ACP transport without starting a session or forcing an optional interactive login flow. */
198
203
  export async function probeKimiAcp(options, spawnProcess = spawn) {
199
204
  const provider = options.provider ?? "kimi";
200
205
  const permission = options.effectivePermission ?? "sandboxed";
206
+ const probeId = options.probeId ?? randomUUID();
207
+ const started = Date.now();
208
+ let reason = "initializing", protocolVersion;
209
+ let stderrSeen = false;
210
+ const fields = () => ({ probe_id: probeId, runtime: provider, duration_ms: Date.now() - started,
211
+ reason, protocol_version: protocolVersion, stderr_seen: stderrSeen, aborted: options.signal?.aborted ?? false });
212
+ dslog("execution.acp_probe_started", "ACP 探测开始", { probe_id: probeId, runtime: provider, node_version: process.version });
201
213
  const child = spawnProcess(options.bin, options.launchArgs ?? providerArgs(provider, options.configPath), {
202
214
  cwd: options.launchCwd ?? process.cwd(),
203
215
  // probe 在 daemon 自身 PATH 下运行,补上用户级 CLI 目录,与 which 探测保持一致。
@@ -206,6 +218,7 @@ export async function probeKimiAcp(options, spawnProcess = spawn) {
206
218
  });
207
219
  if (child.stdin === null || child.stdout === null || child.stderr === null)
208
220
  return false;
221
+ child.stderr.on("data", () => { stderrSeen = true; });
209
222
  child.stderr.resume();
210
223
  const app = client({ name: `nowcrew-daemon-${provider}-probe` })
211
224
  .onRequest(methods.client.session.requestPermission, () => ({
@@ -222,18 +235,27 @@ export async function probeKimiAcp(options, spawnProcess = spawn) {
222
235
  clientCapabilities: {},
223
236
  clientInfo: { name: "nowcrew-daemon", version: "1" },
224
237
  });
225
- return initialized.protocolVersion === PROTOCOL_VERSION;
238
+ protocolVersion = initialized.protocolVersion;
239
+ const ready = protocolVersion === PROTOCOL_VERSION;
240
+ if (reason === "initializing")
241
+ reason = ready ? "ready" : "protocol_mismatch";
242
+ dslog("execution.acp_probe_handshake", "ACP 握手结果", { ...fields(), ready });
243
+ return ready;
226
244
  });
227
245
  const result = await Promise.race([
228
246
  connected,
229
247
  new Promise((resolve) => {
230
248
  timeout = setTimeout(() => {
249
+ if (reason === "initializing")
250
+ reason = "timeout";
231
251
  void stopChild(child);
232
252
  resolve(false);
233
253
  }, PROBE_TIMEOUT_MS);
234
254
  }),
235
255
  new Promise((resolve) => {
236
256
  onAbort = () => {
257
+ if (reason === "initializing")
258
+ reason = "aborted";
237
259
  void stopChild(child);
238
260
  resolve(false);
239
261
  };
@@ -246,6 +268,8 @@ export async function probeKimiAcp(options, spawnProcess = spawn) {
246
268
  return result;
247
269
  }
248
270
  catch {
271
+ if (reason === "initializing")
272
+ reason = options.signal?.aborted ? "aborted" : "transport_error";
249
273
  return false;
250
274
  }
251
275
  finally {
@@ -253,7 +277,20 @@ export async function probeKimiAcp(options, spawnProcess = spawn) {
253
277
  clearTimeout(timeout);
254
278
  if (onAbort !== undefined)
255
279
  options.signal?.removeEventListener("abort", onAbort);
256
- await stopChild(child);
280
+ dslog("execution.acp_probe_cleanup_started", "ACP 探测清理开始", fields());
281
+ let cleanupFinished = false;
282
+ try {
283
+ await stopChild(child);
284
+ cleanupFinished = true;
285
+ }
286
+ catch {
287
+ throw new AcpProbeCleanupError();
288
+ }
289
+ finally {
290
+ dslog("execution.acp_probe_finished", "ACP 探测结束", { ...fields(),
291
+ cleanup_finished: cleanupFinished, exit_code: child.exitCode, exit_signal: child.signalCode,
292
+ level: reason === "ready" && cleanupFinished ? "INFO" : "WARN" });
293
+ }
257
294
  }
258
295
  }
259
296
  export async function runKimiAcp(requestedOptions) {
package/dist/serve.js CHANGED
@@ -291,10 +291,11 @@ export function serve(config, opts = {}) {
291
291
  connectionEpochToken = projectSkillsController.beginConnectionEpoch();
292
292
  storageIdentityAdmission.beginIdentityBinding();
293
293
  const openedSocket = ws;
294
+ const connectionId = randomUUID();
294
295
  backoff = 1000;
295
296
  connectedAt = Date.now();
296
297
  log(`🔌 已连接控制面 ${config.serverUrl}`);
297
- dslog("daemon.ws_open", "已连接控制面", { server_url: config.serverUrl });
298
+ dslog("daemon.ws_open", "已连接控制面", { server_url: config.serverUrl, connection_id: connectionId });
298
299
  // 连上了才有机会把离线期间(断连原因/退出前)落盘的日志补传上去
299
300
  void drainSpool();
300
301
  // 上报本机信息 (hostname/os/daemon 版本/已装 runtimes)
@@ -312,21 +313,36 @@ export function serve(config, opts = {}) {
312
313
  .then(async (hello) => {
313
314
  if (hello.executionProtocol === undefined) {
314
315
  executionRuntimesKnown = true;
316
+ dslog("runtime_probe.hello_refresh", "runtime hello 刷新决策", {
317
+ connection_id: connectionId, reason: "protocol_unavailable",
318
+ current_socket: ws === openedSocket, socket_open: openedSocket.readyState === WebSocket.OPEN,
319
+ socket_ready_state: openedSocket.readyState, installed_runtimes: JSON.stringify(hello.runtimes),
320
+ execution_runtimes: JSON.stringify(hello.executionRuntimes),
321
+ });
315
322
  return [];
316
323
  }
317
- const detected = await runtimeProbe.detect(hello.runtimes, detectExecutable);
324
+ const detected = await runtimeProbe.detect(hello.runtimes, detectExecutable, connectionId);
318
325
  executionRuntimesKnown = true;
319
- if (ws === openedSocket && openedSocket.readyState === WebSocket.OPEN
320
- && detected.some((runtime) => !hello.executionRuntimes.includes(runtime))) {
326
+ const currentSocket = ws === openedSocket;
327
+ const socketOpen = openedSocket.readyState === WebSocket.OPEN;
328
+ const hasNewRuntime = detected.some((runtime) => !hello.executionRuntimes.includes(runtime));
329
+ dslog("runtime_probe.hello_refresh", "runtime hello 刷新决策", {
330
+ connection_id: connectionId, current_socket: currentSocket, socket_open: socketOpen,
331
+ socket_ready_state: openedSocket.readyState, installed_runtimes: JSON.stringify(hello.runtimes),
332
+ initial_execution_runtimes: JSON.stringify(hello.executionRuntimes), execution_runtimes: JSON.stringify(detected),
333
+ reason: !currentSocket ? "stale_connection" : !socketOpen ? "socket_not_open" : !hasNewRuntime ? "unchanged" : "publish",
334
+ });
335
+ if (currentSocket && socketOpen && hasNewRuntime) {
321
336
  detectedExecutionRuntimes = detected;
322
337
  machineHelloPublisher.publish({ ...hello, executionRuntimes: [...detected] }, openedSocket);
323
338
  }
324
339
  return detected;
325
340
  })
326
- .catch((error) => {
341
+ .catch(() => {
327
342
  executionRuntimesKnown = true;
328
343
  dslog("execution.runtime_detection_failed", "runtime 探测失败", {
329
- level: "ERROR", error_message: error.message,
344
+ level: "ERROR", reason: "detection_threw", connection_id: connectionId,
345
+ current_socket: ws === openedSocket, socket_open: openedSocket.readyState === WebSocket.OPEN,
330
346
  });
331
347
  return [];
332
348
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.88-beta.16",
3
+ "version": "0.6.88-beta.18",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",