@nowcrew/daemon 0.6.65 → 0.6.67

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.
package/dist/main.js CHANGED
File without changes
@@ -1,5 +1,6 @@
1
1
  import { readFile, unlink } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, join } from "node:path";
3
+ import { Worker } from "node:worker_threads";
3
4
  import { z } from "zod";
4
5
  import { atomicPrivateWrite } from "./atomic-private-write.js";
5
6
  const WorkerFailureSchema = z.object({
@@ -17,6 +18,75 @@ const WorkerHeartbeatSchema = z.object({
17
18
  at: z.string().datetime({ offset: true }),
18
19
  }).strict();
19
20
  const TOKEN = /(?:\bsk_(?:machine|user|agent)_[A-Za-z0-9_-]{4,}\b|\bbearer\s+[^\s]+|(?:password|secret|token)\s*[:=]\s*[^\s]+)/giu;
21
+ const HEARTBEAT_WORKER_KIND = "nowwork-storage-worker-heartbeat-v1";
22
+ async function unlinkHeartbeat(path) {
23
+ await unlink(path).catch((error) => {
24
+ if (error.code !== "ENOENT")
25
+ throw error;
26
+ });
27
+ }
28
+ /**
29
+ * Keep the liveness writer in a worker with its own event loop. The source is
30
+ * intentionally plain JavaScript because `new Worker(new URL(import.meta.url))`
31
+ * cannot resolve the source tree's `.js` specifiers under tsx/Vitest; production
32
+ * builds and tests therefore exercise the same isolated execution boundary.
33
+ */
34
+ const HEARTBEAT_WORKER_SOURCE = String.raw `
35
+ const { parentPort, workerData } = require("node:worker_threads");
36
+ const { mkdir, chmod, rename, rm, unlink, writeFile } = require("node:fs/promises");
37
+ const { dirname } = require("node:path");
38
+ const { randomUUID } = require("node:crypto");
39
+
40
+ const atomicPrivateWrite = async (path, content) => {
41
+ const directory = dirname(path);
42
+ await mkdir(directory, { recursive: true, mode: 0o700 });
43
+ await chmod(directory, 0o700);
44
+ const temporary = path + "." + randomUUID() + ".tmp";
45
+ try {
46
+ await writeFile(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
47
+ await rename(temporary, path);
48
+ await chmod(path, 0o600);
49
+ } finally {
50
+ await rm(temporary, { force: true });
51
+ }
52
+ };
53
+ const removeHeartbeat = async () => {
54
+ try { await unlink(workerData.path); } catch (error) {
55
+ if (error && error.code !== "ENOENT") throw error;
56
+ }
57
+ };
58
+ let pending = Promise.resolve();
59
+ let stopping = false;
60
+ let firstWrite = true;
61
+ const write = () => {
62
+ const initial = firstWrite;
63
+ firstWrite = false;
64
+ pending = pending.catch(() => undefined).then(() => atomicPrivateWrite(workerData.path, JSON.stringify({
65
+ version: 1,
66
+ migrationId: workerData.migrationId,
67
+ pid: process.pid,
68
+ at: initial && workerData.initialAt ? workerData.initialAt : new Date().toISOString(),
69
+ }, null, 2) + "\n"));
70
+ return pending;
71
+ };
72
+ const report = (message) => { try { parentPort.postMessage(message); } catch {} };
73
+ (async () => {
74
+ try { await write(); report({ type: "ready" }); }
75
+ catch (error) { report({ type: "error", message: error instanceof Error ? error.message : String(error) }); return; }
76
+ const timer = setInterval(() => {
77
+ if (!stopping) void write().catch((error) => report({ type: "error", message: error instanceof Error ? error.message : String(error) }));
78
+ }, workerData.intervalMs);
79
+ timer.unref?.();
80
+ parentPort.on("message", (message) => {
81
+ if (stopping || !message || message.type !== "stop") return;
82
+ stopping = true;
83
+ clearInterval(timer);
84
+ void pending.then(async () => { await removeHeartbeat(); report({ type: "stopped" }); }, async () => {
85
+ await removeHeartbeat().catch(() => undefined); report({ type: "stopped" });
86
+ });
87
+ });
88
+ })();
89
+ `;
20
90
  function identityFromHandoffPath(handoffPath) {
21
91
  if (!isAbsolute(handoffPath) || basename(handoffPath) !== "handoff.json") {
22
92
  throw new Error("storage worker handoff path is invalid");
@@ -43,28 +113,85 @@ export function storageWorkerHeartbeatPath(stateRoot, migrationId) {
43
113
  export async function startStorageWorkerHeartbeat(handoffPath, input = {}) {
44
114
  const identity = identityFromHandoffPath(handoffPath);
45
115
  const intervalMs = z.number().int().min(100).max(60_000).parse(input.intervalMs ?? 5_000);
46
- const now = input.now ?? (() => new Date());
47
116
  const path = storageWorkerHeartbeatPath(identity.stateRoot, identity.migrationId);
48
- let pending = Promise.resolve();
49
- const write = () => {
50
- pending = pending.catch(() => undefined).then(() => atomicPrivateWrite(path, `${JSON.stringify(WorkerHeartbeatSchema.parse({
51
- version: 1,
117
+ // The worker owns heartbeat I/O so a synchronous CPU-heavy scan in the
118
+ // migration process cannot delay the monitor's 30-second freshness window.
119
+ const worker = new Worker(HEARTBEAT_WORKER_SOURCE, {
120
+ eval: true,
121
+ // `--input-type=module` is inherited by workers launched from an eval
122
+ // smoke test; strip it so the plain CommonJS worker source can use require.
123
+ execArgv: process.execArgv.filter((arg) => !arg.startsWith("--input-type=")),
124
+ workerData: {
125
+ kind: HEARTBEAT_WORKER_KIND,
126
+ path,
52
127
  migrationId: identity.migrationId,
53
- pid: process.pid,
54
- at: now().toISOString(),
55
- }), null, 2)}\n`));
56
- return pending;
128
+ intervalMs,
129
+ ...(input.now === undefined ? {} : { initialAt: input.now().toISOString() }),
130
+ },
131
+ });
132
+ worker.unref();
133
+ let startupSettled = false;
134
+ let heartbeatError = null;
135
+ let stopResolve = null;
136
+ const onWorkerMessage = (message) => {
137
+ if (message.type === "error") {
138
+ heartbeatError = new Error(`storage worker heartbeat failed: ${message.message}`);
139
+ if (!startupSettled)
140
+ startupReject?.(heartbeatError);
141
+ }
142
+ else if (message.type === "stopped") {
143
+ stopResolve?.();
144
+ stopResolve = null;
145
+ }
146
+ };
147
+ let startupReject = null;
148
+ worker.on("message", onWorkerMessage);
149
+ // Keep this listener for the entire worker lifetime. A heartbeat write may
150
+ // fail after startup; dropping the listener would turn that operational
151
+ // error into an unhandled Worker 'error' event and crash the parent.
152
+ const onWorkerError = (error) => {
153
+ heartbeatError = error;
154
+ if (!startupSettled)
155
+ startupReject?.(error);
57
156
  };
58
- await write();
59
- const timer = setInterval(() => { void write(); }, intervalMs);
60
- timer.unref?.();
157
+ worker.on("error", onWorkerError);
158
+ await new Promise((resolvePromise, reject) => {
159
+ startupReject = reject;
160
+ const onReady = (message) => {
161
+ if (message.type !== "ready")
162
+ return;
163
+ startupSettled = true;
164
+ worker.off("message", onReady);
165
+ resolvePromise();
166
+ };
167
+ worker.on("message", onReady);
168
+ }).catch(async (error) => {
169
+ startupSettled = true;
170
+ await worker.terminate().catch(() => undefined);
171
+ worker.off("message", onWorkerMessage);
172
+ worker.off("error", onWorkerError);
173
+ await unlinkHeartbeat(path).catch(() => undefined);
174
+ throw error;
175
+ });
176
+ let stopped = false;
61
177
  return async () => {
62
- clearInterval(timer);
63
- await pending.catch(() => undefined);
64
- await unlink(path).catch((error) => {
65
- if (error.code !== "ENOENT")
66
- throw error;
178
+ if (stopped)
179
+ return;
180
+ stopped = true;
181
+ await new Promise((resolvePromise) => {
182
+ const onExit = () => {
183
+ worker.off("exit", onExit);
184
+ stopResolve = null;
185
+ resolvePromise();
186
+ };
187
+ stopResolve = resolvePromise;
188
+ worker.once("exit", onExit);
189
+ worker.postMessage({ type: "stop" });
67
190
  });
191
+ await worker.terminate().catch(() => undefined);
192
+ worker.off("message", onWorkerMessage);
193
+ worker.off("error", onWorkerError);
194
+ await unlinkHeartbeat(path);
68
195
  };
69
196
  }
70
197
  export async function writeStorageWorkerFailureStatus(handoffPath, error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.65",
3
+ "version": "0.6.67",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -16,14 +16,6 @@
16
16
  "publishConfig": {
17
17
  "access": "public"
18
18
  },
19
- "scripts": {
20
- "daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
21
- "build": "tsc -p tsconfig.json",
22
- "codex-home:migrate": "tsx src/runtimes/codex-home-migration-cli.ts",
23
- "prepublishOnly": "pnpm build && node ../scripts/daemon-release-artifact.mjs --strict-registry",
24
- "test": "vitest run",
25
- "typecheck": "tsc --noEmit"
26
- },
27
19
  "dependencies": {
28
20
  "@agentclientprotocol/sdk": "1.2.1",
29
21
  "@nowcrew/cli": "^0.4.13",
@@ -42,5 +34,12 @@
42
34
  "tsx": "^4.19.0",
43
35
  "typescript": "^5.6.0",
44
36
  "vitest": "^2.1.0"
37
+ },
38
+ "scripts": {
39
+ "daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
40
+ "build": "tsc -p tsconfig.json",
41
+ "codex-home:migrate": "tsx src/runtimes/codex-home-migration-cli.ts",
42
+ "test": "vitest run",
43
+ "typecheck": "tsc --noEmit"
45
44
  }
46
- }
45
+ }