@nowcrew/daemon 0.6.67 → 0.6.69

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.
@@ -70,7 +70,8 @@ async function exists(path) {
70
70
  throw error;
71
71
  }
72
72
  }
73
- async function copyVerifiedFile(source, target, stagingWorkspaceRoot, entry, replaceExisting, afterSourceLstat, afterTargetParentOpen) {
73
+ async function copyVerifiedFile(source, target, stagingWorkspaceRoot, entry, replaceExisting, afterSourceLstat, afterTargetParentOpen, assertHealthy) {
74
+ assertHealthy?.();
74
75
  if (entry.sha256 === undefined)
75
76
  throw new Error(`manifest file digest is missing: ${source}`);
76
77
  const sourceHandle = await openAnchoredFile(source, constants.O_RDONLY).catch((error) => {
@@ -102,10 +103,12 @@ async function copyVerifiedFile(source, target, stagingWorkspaceRoot, entry, rep
102
103
  const sourceStream = sourceHandle.createReadStream({ autoClose: false });
103
104
  try {
104
105
  for await (const rawChunk of sourceStream) {
106
+ assertHealthy?.();
105
107
  const chunk = Buffer.from(rawChunk);
106
108
  digest.update(chunk);
107
109
  await targetHandle.write(chunk, 0, chunk.length, null);
108
110
  }
111
+ assertHealthy?.();
109
112
  await targetHandle.sync();
110
113
  await targetHandle.chmod(Number.parseInt(entry.mode, 8));
111
114
  }
@@ -174,7 +177,8 @@ async function mapInBatches(items, concurrency, operation) {
174
177
  }
175
178
  return results;
176
179
  }
177
- async function transformFile(source, target, stagingWorkspaceRoot, entry, replaceExisting, afterSourceLstat, afterTargetParentOpen) {
180
+ async function transformFile(source, target, stagingWorkspaceRoot, entry, replaceExisting, afterSourceLstat, afterTargetParentOpen, assertHealthy) {
181
+ assertHealthy?.();
178
182
  if (entry.sha256 === undefined)
179
183
  throw new Error(`manifest file digest is missing: ${source}`);
180
184
  const sourceHandle = await openAnchoredFile(source, constants.O_RDONLY).catch((error) => {
@@ -227,6 +231,7 @@ async function transformFile(source, target, stagingWorkspaceRoot, entry, replac
227
231
  });
228
232
  const targetOpened = await targetHandle.stat();
229
233
  try {
234
+ assertHealthy?.();
230
235
  await targetHandle.writeFile(output, "utf8");
231
236
  await targetHandle.sync();
232
237
  await targetHandle.chmod(Number.parseInt(entry.targetMode ?? "0600", 8));
@@ -248,6 +253,7 @@ async function transformFile(source, target, stagingWorkspaceRoot, entry, replac
248
253
  return Buffer.byteLength(output);
249
254
  }
250
255
  async function materializeChunkedManifest(input, injected) {
256
+ injected.assertHealthy?.();
251
257
  if (await exists(input.stagingWorkspaceRoot)) {
252
258
  if (!(await lstat(input.stagingWorkspaceRoot)).isDirectory()
253
259
  || (!input.mergeIntoExisting
@@ -273,13 +279,15 @@ async function materializeChunkedManifest(input, injected) {
273
279
  return;
274
280
  const batch = pending;
275
281
  pending = [];
282
+ injected.assertHealthy?.();
276
283
  const results = await mapInBatches(batch, FILE_OPERATION_CONCURRENCY, async (entry) => {
284
+ injected.assertHealthy?.();
277
285
  const target = containedPath(input.stagingWorkspaceRoot, entry.logicalDestination);
278
286
  const source = sourcePath({ sourceRoots: input.sourceRoots, entry });
279
287
  if (entry.disposition === "transform") {
280
- return { transformed: true, bytesWritten: await transformFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen) };
288
+ return { transformed: true, bytesWritten: await transformFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen, injected.assertHealthy) };
281
289
  }
282
- await copyVerifiedFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen);
290
+ await copyVerifiedFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen, injected.assertHealthy);
283
291
  return { transformed: false, bytesWritten: entry.sizeBytes };
284
292
  });
285
293
  for (const result of results) {
@@ -291,6 +299,7 @@ async function materializeChunkedManifest(input, injected) {
291
299
  }
292
300
  };
293
301
  for await (const entry of iterateManifestEntries(input.manifest)) {
302
+ injected.assertHealthy?.();
294
303
  if (entry.disposition === "skip" || entry.logicalDestination === null)
295
304
  continue;
296
305
  const target = containedPath(input.stagingWorkspaceRoot, entry.logicalDestination);
@@ -309,6 +318,7 @@ async function materializeChunkedManifest(input, injected) {
309
318
  }
310
319
  await flushFiles();
311
320
  for await (const entry of iterateManifestEntries(input.manifest)) {
321
+ injected.assertHealthy?.();
312
322
  if (entry.disposition === "skip" || entry.logicalDestination === null || entry.type !== "symlink")
313
323
  continue;
314
324
  if (entry.link?.classification !== "internal" || !entry.link.portable || entry.link.relativeTarget === undefined)
@@ -322,6 +332,7 @@ async function materializeChunkedManifest(input, injected) {
322
332
  copiedSymlinks += 1;
323
333
  }
324
334
  for await (const entry of iterateManifestEntries(input.manifest)) {
335
+ injected.assertHealthy?.();
325
336
  if (entry.disposition === "skip" || entry.logicalDestination === null || entry.type !== "directory")
326
337
  continue;
327
338
  chmodAnchoredDirectory(input.stagingWorkspaceRoot, containedPath(input.stagingWorkspaceRoot, entry.logicalDestination), Number.parseInt(entry.mode, 8));
@@ -329,6 +340,7 @@ async function materializeChunkedManifest(input, injected) {
329
340
  return Object.freeze({ copiedFiles, transformedFiles, copiedSymlinks, createdDirectories, bytesWritten });
330
341
  }
331
342
  export async function materializeStorageManifest(input, injected = {}) {
343
+ injected.assertHealthy?.();
332
344
  if (input.manifest.chunkStore !== undefined)
333
345
  return materializeChunkedManifest(input, injected);
334
346
  const manifest = input.manifest;
@@ -375,28 +387,32 @@ export async function materializeStorageManifest(input, injected = {}) {
375
387
  throw new Error(`unsupported copied manifest entry: ${entry.sourcePath}`);
376
388
  }
377
389
  const fileResults = await mapInBatches(files, FILE_OPERATION_CONCURRENCY, async (entry) => {
390
+ injected.assertHealthy?.();
378
391
  const target = containedPath(input.stagingWorkspaceRoot, entry.logicalDestination);
379
392
  const source = sourcePath({ sourceRoots: input.sourceRoots, entry });
380
393
  if (entry.disposition === "transform") {
381
394
  return {
382
395
  transformed: true,
383
- bytesWritten: await transformFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen),
396
+ bytesWritten: await transformFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen, injected.assertHealthy),
384
397
  };
385
398
  }
386
- await copyVerifiedFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen);
399
+ await copyVerifiedFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen, injected.assertHealthy);
387
400
  return { transformed: false, bytesWritten: entry.sizeBytes };
388
401
  });
389
402
  for (const { entry, target } of symlinks) {
403
+ injected.assertHealthy?.();
390
404
  await createSafeParent(input.stagingWorkspaceRoot, target);
391
405
  if (input.mergeIntoExisting)
392
406
  removeReplaceableTarget(input.stagingWorkspaceRoot, target, entry.logicalDestination ?? entry.sourcePath);
393
407
  createAnchoredSymlink(input.stagingWorkspaceRoot, target, entry.link.relativeTarget);
394
408
  }
395
409
  for (const { entry, target } of symlinks) {
410
+ injected.assertHealthy?.();
396
411
  await assertRealpathWithin(input.stagingWorkspaceRoot, target, `staged symlink escapes workspace: ${entry.sourcePath}`);
397
412
  }
398
413
  directoryModes.sort((left, right) => right.path.length - left.path.length);
399
414
  for (const directory of directoryModes) {
415
+ injected.assertHealthy?.();
400
416
  chmodAnchoredDirectory(input.stagingWorkspaceRoot, directory.path, directory.mode);
401
417
  }
402
418
  const transformedFiles = fileResults.filter((result) => result.transformed).length;
@@ -454,6 +470,7 @@ async function pruneStaleChunkPaths(root, destinations) {
454
470
  }
455
471
  }
456
472
  export async function reconcileStorageManifest(input) {
473
+ input.assertHealthy?.();
457
474
  if (input.previousManifest.migrationId !== input.manifest.migrationId
458
475
  || input.previousManifest.workspaceId !== input.manifest.workspaceId
459
476
  || input.previousManifest.targetWorkspaceRoot !== input.manifest.targetWorkspaceRoot) {
@@ -467,13 +484,14 @@ export async function reconcileStorageManifest(input) {
467
484
  // Build a bounded destination index, prune stale staging paths, then replay
468
485
  // the authenticated final snapshot without retaining manifest entries.
469
486
  const destinations = await collectChunkDestinations(input.manifest);
487
+ input.assertHealthy?.();
470
488
  await pruneStaleChunkPaths(input.stagingWorkspaceRoot, destinations);
471
489
  return materializeChunkedManifest({
472
490
  manifest: input.manifest,
473
491
  sourceRoots: input.sourceRoots,
474
492
  stagingWorkspaceRoot: input.stagingWorkspaceRoot,
475
493
  mergeIntoExisting: true,
476
- }, {});
494
+ }, input.assertHealthy === undefined ? {} : { assertHealthy: input.assertHealthy });
477
495
  }
478
496
  const previous = copiedEntries(input.previousManifest);
479
497
  const next = copiedEntries(input.manifest);
@@ -482,6 +500,7 @@ export async function reconcileStorageManifest(input) {
482
500
  return current === undefined || current.type !== entry.type;
483
501
  }).sort(([left], [right]) => right.length - left.length);
484
502
  for (const [logical] of removals) {
503
+ input.assertHealthy?.();
485
504
  const target = containedPath(input.stagingWorkspaceRoot, logical);
486
505
  try {
487
506
  removeAnchoredEntry(input.stagingWorkspaceRoot, target);
@@ -501,7 +520,7 @@ export async function reconcileStorageManifest(input) {
501
520
  sourceRoots: input.sourceRoots,
502
521
  stagingWorkspaceRoot: input.stagingWorkspaceRoot,
503
522
  mergeIntoExisting: true,
504
- });
523
+ }, input.assertHealthy === undefined ? {} : { assertHealthy: input.assertHealthy });
505
524
  }
506
525
  async function inspectStorageFile(root, path, afterTargetLstat) {
507
526
  const handle = await openAnchoredFile(path, constants.O_RDONLY).catch((error) => {
@@ -296,7 +296,9 @@ export async function runStorageMigration(input, dependencies) {
296
296
  let targetTouched = false;
297
297
  let failureCode = "storage_migration_failed";
298
298
  try {
299
+ input.assertHealthy?.();
299
300
  await transition("preflight_passed");
301
+ input.assertHealthy?.();
300
302
  await transition("backing_up");
301
303
  receipt = await backupArtifacts(input, receipt).catch((error) => {
302
304
  failureCode = "backup_failed";
@@ -308,7 +310,7 @@ export async function runStorageMigration(input, dependencies) {
308
310
  manifest: input.doctor.manifest,
309
311
  sourceRoots: input.sourceRoots,
310
312
  stagingWorkspaceRoot: input.stagingWorkspaceRoot,
311
- }).catch((error) => {
313
+ }, input.assertHealthy === undefined ? {} : { assertHealthy: input.assertHealthy }).catch((error) => {
312
314
  failureCode = "initial_sync_failed";
313
315
  throw error;
314
316
  });
@@ -353,6 +355,7 @@ export async function runStorageMigration(input, dependencies) {
353
355
  manifest: finalDoctor.manifest,
354
356
  sourceRoots: input.sourceRoots,
355
357
  stagingWorkspaceRoot: input.stagingWorkspaceRoot,
358
+ ...(input.assertHealthy === undefined ? {} : { assertHealthy: input.assertHealthy }),
356
359
  }).catch((error) => {
357
360
  failureCode = "final_sync_failed";
358
361
  throw error;
@@ -12,8 +12,18 @@ export async function runStorageWorkerCommand(args, execute = executeStorageMigr
12
12
  throw new Error("Invalid storage migration worker command");
13
13
  }
14
14
  const stopHeartbeat = await startHeartbeat(path);
15
+ const assertHeartbeatHealthy = stopHeartbeat.assertHealthy;
15
16
  try {
16
- await (command === "storage-migration-recover" ? recover(path) : execute(path));
17
+ if (command === "storage-migration-recover") {
18
+ await (assertHeartbeatHealthy === undefined
19
+ ? recover(path)
20
+ : recover(path, { assertHeartbeatHealthy }));
21
+ }
22
+ else {
23
+ await (assertHeartbeatHealthy === undefined
24
+ ? execute(path)
25
+ : execute(path, { assertHeartbeatHealthy }));
26
+ }
17
27
  }
18
28
  catch (error) {
19
29
  await writeFailure(path, error).catch(() => undefined);
@@ -7,6 +7,7 @@ import { readManagedServiceRegistry, normalizeManagedServiceRecord, serviceDescr
7
7
  import { readInstallations, writeInstallations, } from "./storage-config.js";
8
8
  import { runStorageDoctor } from "./storage-doctor.js";
9
9
  import { readStorageMigrationReceipt, recoverStorageMigration, runStorageMigration, StorageMigrationFailure, } from "./storage-migration-controller.js";
10
+ import { materializeStorageManifest, publishStagedStorage, reconcileStorageManifest, verifyStagedStorage, } from "./storage-copy.js";
10
11
  import { readStorageWorkerHandoff } from "./storage-worker-handoff.js";
11
12
  import { readStorageWorkerOperation } from "./storage-worker-operation.js";
12
13
  import { storageIdFingerprint } from "./storage-identity.js";
@@ -306,6 +307,7 @@ export async function executeStorageMigrationWorker(handoffPath, injected = {})
306
307
  };
307
308
  const handoff = await readStorageWorkerHandoff(handoffPath);
308
309
  const operation = await readStorageWorkerOperation(handoff.operationPath, handoff.operationSha256);
310
+ const assertHeartbeatHealthy = dependencies.assertHeartbeatHealthy ?? (() => undefined);
309
311
  if (handoff.migrationId !== operation.migrationId || handoff.profile !== operation.profile) {
310
312
  throw new StorageMigrationFailure("worker_handoff_mismatch", "worker handoff does not match operation identity");
311
313
  }
@@ -323,10 +325,42 @@ export async function executeStorageMigrationWorker(handoffPath, injected = {})
323
325
  migrationId: operation.migrationId,
324
326
  }, { ...lifecycle, now: dependencies.now });
325
327
  }
328
+ assertHeartbeatHealthy();
326
329
  await lifecycle.preflight();
330
+ assertHeartbeatHealthy();
327
331
  const doctor = await dependencies.runDoctor(workerDoctorInput(operation));
332
+ assertHeartbeatHealthy();
328
333
  validateFreshDoctor(operation, doctor);
329
- return dependencies.runMigration({
334
+ const guardedMaterialize = async (input, hooks = {}) => materializeStorageManifest(input, {
335
+ ...hooks,
336
+ afterSourceLstat: async (path) => {
337
+ await hooks.afterSourceLstat?.(path);
338
+ assertHeartbeatHealthy();
339
+ },
340
+ afterTargetParentOpen: async (path) => {
341
+ await hooks.afterTargetParentOpen?.(path);
342
+ assertHeartbeatHealthy();
343
+ },
344
+ });
345
+ const guardedVerify = async (input, hooks = {}) => verifyStagedStorage(input, {
346
+ ...hooks,
347
+ afterTargetLstat: async (path) => {
348
+ await hooks.afterTargetLstat?.(path);
349
+ assertHeartbeatHealthy();
350
+ },
351
+ });
352
+ const guardedReconcile = async (input) => {
353
+ assertHeartbeatHealthy();
354
+ const result = await reconcileStorageManifest(input);
355
+ assertHeartbeatHealthy();
356
+ return result;
357
+ };
358
+ const guardedPublish = async (input, deps = {}) => {
359
+ assertHeartbeatHealthy();
360
+ await publishStagedStorage(input, deps);
361
+ assertHeartbeatHealthy();
362
+ };
363
+ const receipt = await dependencies.runMigration({
330
364
  doctor,
331
365
  sourceRoots: workerSourceRoots(operation),
332
366
  stagingWorkspaceRoot: operation.migration.stagingWorkspaceRoot,
@@ -334,9 +368,15 @@ export async function executeStorageMigrationWorker(handoffPath, injected = {})
334
368
  backupsRoot: operation.migration.backupsRoot,
335
369
  backupArtifacts: operation.migration.backupArtifacts,
336
370
  acceptedWarnings: operation.migration.acceptedWarnings,
371
+ onStatus: () => assertHeartbeatHealthy(),
372
+ assertHealthy: assertHeartbeatHealthy,
337
373
  }, {
338
374
  ...lifecycle,
339
375
  now: dependencies.now,
376
+ materialize: guardedMaterialize,
377
+ reconcile: guardedReconcile,
378
+ verify: guardedVerify,
379
+ publish: guardedPublish,
340
380
  refreshDoctor: async () => {
341
381
  const refreshed = await dependencies.runDoctor(workerDoctorInput(operation, dependencies.now().toISOString()));
342
382
  if (refreshed.manifest.migrationId !== operation.migrationId
@@ -348,6 +388,8 @@ export async function executeStorageMigrationWorker(handoffPath, injected = {})
348
388
  return refreshed;
349
389
  },
350
390
  });
391
+ assertHeartbeatHealthy();
392
+ return receipt;
351
393
  }
352
394
  export async function recoverStorageMigrationWorker(handoffPath, injected = {}) {
353
395
  const dependencies = {
@@ -361,6 +403,8 @@ export async function recoverStorageMigrationWorker(handoffPath, injected = {})
361
403
  };
362
404
  const handoff = await readStorageWorkerHandoff(handoffPath);
363
405
  const operation = await readStorageWorkerOperation(handoff.operationPath, handoff.operationSha256);
406
+ const assertHeartbeatHealthy = dependencies.assertHeartbeatHealthy ?? (() => undefined);
407
+ assertHeartbeatHealthy();
364
408
  if (handoff.migrationId !== operation.migrationId || handoff.profile !== operation.profile) {
365
409
  throw new StorageMigrationFailure("worker_handoff_mismatch", "worker handoff does not match operation identity");
366
410
  }
@@ -374,10 +418,12 @@ export async function recoverStorageMigrationWorker(handoffPath, injected = {})
374
418
  }
375
419
  if (receipt.status === "completed" || receipt.status === "rolled_back")
376
420
  return receipt;
377
- return dependencies.recoverMigration({
421
+ const recovered = await dependencies.recoverMigration({
378
422
  backupsRoot: operation.migration.backupsRoot,
379
423
  migrationId: operation.migrationId,
380
424
  }, { ...dependencies.createServiceLifecycle(operation), now: dependencies.now });
425
+ assertHeartbeatHealthy();
426
+ return recovered;
381
427
  }
382
428
  export function storageWorkerOperationDigest(operation) {
383
429
  return createHash("sha256").update(`${JSON.stringify(operation, null, 2)}\n`).digest("hex");
@@ -27,9 +27,9 @@ async function unlinkHeartbeat(path) {
27
27
  }
28
28
  /**
29
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.
30
+ * intentionally plain JavaScript because a worker module cannot resolve the
31
+ * source tree's `.js` specifiers under tsx/Vitest; production builds and tests
32
+ * therefore exercise the same isolated execution boundary.
33
33
  */
34
34
  const HEARTBEAT_WORKER_SOURCE = String.raw `
35
35
  const { parentPort, workerData } = require("node:worker_threads");
@@ -58,14 +58,14 @@ const removeHeartbeat = async () => {
58
58
  let pending = Promise.resolve();
59
59
  let stopping = false;
60
60
  let firstWrite = true;
61
- const write = () => {
61
+ const write = (requestedAt) => {
62
62
  const initial = firstWrite;
63
63
  firstWrite = false;
64
64
  pending = pending.catch(() => undefined).then(() => atomicPrivateWrite(workerData.path, JSON.stringify({
65
65
  version: 1,
66
66
  migrationId: workerData.migrationId,
67
67
  pid: process.pid,
68
- at: initial && workerData.initialAt ? workerData.initialAt : new Date().toISOString(),
68
+ at: initial && workerData.initialAt ? workerData.initialAt : requestedAt || new Date().toISOString(),
69
69
  }, null, 2) + "\n"));
70
70
  return pending;
71
71
  };
@@ -73,14 +73,20 @@ const report = (message) => { try { parentPort.postMessage(message); } catch {}
73
73
  (async () => {
74
74
  try { await write(); report({ type: "ready" }); }
75
75
  catch (error) { report({ type: "error", message: error instanceof Error ? error.message : String(error) }); return; }
76
- const timer = setInterval(() => {
76
+ const timer = workerData.useParentClock ? null : setInterval(() => {
77
77
  if (!stopping) void write().catch((error) => report({ type: "error", message: error instanceof Error ? error.message : String(error) }));
78
78
  }, workerData.intervalMs);
79
- timer.unref?.();
79
+ timer?.unref?.();
80
80
  parentPort.on("message", (message) => {
81
- if (stopping || !message || message.type !== "stop") return;
81
+ if (stopping || !message) return;
82
+ if (message.type === "tick") {
83
+ if (!workerData.useParentClock) return;
84
+ void write(message.at).catch((error) => report({ type: "error", message: error instanceof Error ? error.message : String(error) }));
85
+ return;
86
+ }
87
+ if (message.type !== "stop") return;
82
88
  stopping = true;
83
- clearInterval(timer);
89
+ if (timer !== null) clearInterval(timer);
84
90
  void pending.then(async () => { await removeHeartbeat(); report({ type: "stopped" }); }, async () => {
85
91
  await removeHeartbeat().catch(() => undefined); report({ type: "stopped" });
86
92
  });
@@ -112,8 +118,9 @@ export function storageWorkerHeartbeatPath(stateRoot, migrationId) {
112
118
  }
113
119
  export async function startStorageWorkerHeartbeat(handoffPath, input = {}) {
114
120
  const identity = identityFromHandoffPath(handoffPath);
115
- const intervalMs = z.number().int().min(100).max(60_000).parse(input.intervalMs ?? 5_000);
121
+ const intervalMs = z.number().int().min(100).max(15_000).parse(input.intervalMs ?? 5_000);
116
122
  const path = storageWorkerHeartbeatPath(identity.stateRoot, identity.migrationId);
123
+ const initialAt = input.now?.().toISOString();
117
124
  // The worker owns heartbeat I/O so a synchronous CPU-heavy scan in the
118
125
  // migration process cannot delay the monitor's 30-second freshness window.
119
126
  const worker = new Worker(HEARTBEAT_WORKER_SOURCE, {
@@ -126,7 +133,8 @@ export async function startStorageWorkerHeartbeat(handoffPath, input = {}) {
126
133
  path,
127
134
  migrationId: identity.migrationId,
128
135
  intervalMs,
129
- ...(input.now === undefined ? {} : { initialAt: input.now().toISOString() }),
136
+ useParentClock: input.now !== undefined,
137
+ ...(initialAt === undefined ? {} : { initialAt }),
130
138
  },
131
139
  });
132
140
  worker.unref();
@@ -173,11 +181,22 @@ export async function startStorageWorkerHeartbeat(handoffPath, input = {}) {
173
181
  await unlinkHeartbeat(path).catch(() => undefined);
174
182
  throw error;
175
183
  });
184
+ const parentTimer = input.now === undefined ? null : setInterval(() => {
185
+ try {
186
+ worker.postMessage({ type: "tick", at: input.now().toISOString() });
187
+ }
188
+ catch (error) {
189
+ heartbeatError = error instanceof Error ? error : new Error(String(error));
190
+ }
191
+ }, intervalMs);
192
+ parentTimer?.unref?.();
176
193
  let stopped = false;
177
- return async () => {
194
+ const stop = async () => {
178
195
  if (stopped)
179
196
  return;
180
197
  stopped = true;
198
+ if (parentTimer !== null)
199
+ clearInterval(parentTimer);
181
200
  await new Promise((resolvePromise) => {
182
201
  const onExit = () => {
183
202
  worker.off("exit", onExit);
@@ -193,6 +212,14 @@ export async function startStorageWorkerHeartbeat(handoffPath, input = {}) {
193
212
  worker.off("error", onWorkerError);
194
213
  await unlinkHeartbeat(path);
195
214
  };
215
+ Object.defineProperty(stop, "assertHealthy", {
216
+ enumerable: false,
217
+ value: () => {
218
+ if (heartbeatError !== null)
219
+ throw heartbeatError;
220
+ },
221
+ });
222
+ return stop;
196
223
  }
197
224
  export async function writeStorageWorkerFailureStatus(handoffPath, error) {
198
225
  const identity = identityFromHandoffPath(handoffPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.67",
3
+ "version": "0.6.69",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",