@voybio/ace-swarm 2.4.8 → 2.4.10

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 (187) hide show
  1. package/CHANGELOG.md +52 -14
  2. package/README.md +14 -10
  3. package/assets/.agents/ACE/ACE_coders/AGENTS.md +23 -30
  4. package/assets/.agents/ACE/ACE_coders/INSTRUCTIONS.md +21 -30
  5. package/assets/.agents/skills/landing-review-watcher/SKILL.md +1 -1
  6. package/assets/.agents/skills/learn-from-work/SKILL.md +41 -0
  7. package/assets/agent-state/ACE_WORKFLOW.md +25 -2
  8. package/assets/agent-state/MODULES/schemas/ACE_RUNTIME_PROFILE.schema.json +143 -2
  9. package/assets/agent-state/MODULES/schemas/RUNTIME_EXECUTOR_SESSION_REGISTRY.schema.json +2 -2
  10. package/assets/agent-state/MODULES/schemas/SWARM_HANDOFF.schema.json +10 -0
  11. package/assets/eval-battery/battery.json +1 -1
  12. package/assets/instructions/ACE_Coder.instructions.md +9 -13
  13. package/assets/scripts/ace-hook-dispatch.mjs +16 -411
  14. package/assets/tasks/SWARM_HANDOFF.example_ui_to_coders.json +1 -2
  15. package/assets/vendor/README.md +9 -0
  16. package/assets/vendor/vericify-reader.mjs +272 -0
  17. package/assets/vendor/vericify-reader.provenance.json +10 -0
  18. package/dist/ace-autonomy.d.ts +58 -0
  19. package/dist/ace-autonomy.js +211 -6
  20. package/dist/ace-internal-tools.d.ts +1 -0
  21. package/dist/ace-internal-tools.js +3 -3
  22. package/dist/ace-server-instructions.d.ts +3 -3
  23. package/dist/ace-server-instructions.js +46 -192
  24. package/dist/cli.js +44 -6
  25. package/dist/experience-reflector.d.ts +7 -0
  26. package/dist/experience-reflector.js +39 -0
  27. package/dist/goal-engine.d.ts +64 -7
  28. package/dist/goal-engine.js +197 -41
  29. package/dist/handoff-registry.d.ts +10 -0
  30. package/dist/handoff-registry.js +59 -0
  31. package/dist/helpers/artifacts.d.ts +6 -2
  32. package/dist/helpers/artifacts.js +8 -6
  33. package/dist/helpers/bootstrap.js +13 -20
  34. package/dist/helpers/path-utils.js +2 -0
  35. package/dist/helpers/store-resolution.js +18 -3
  36. package/dist/helpers/workspace-root.d.ts +2 -0
  37. package/dist/helpers/workspace-root.js +9 -0
  38. package/dist/helpers.d.ts +1 -0
  39. package/dist/helpers.js +1 -0
  40. package/dist/hermes/bridge-protocol.d.ts +1 -1
  41. package/dist/hermes/session-manager.d.ts +17 -0
  42. package/dist/hermes/session-manager.js +67 -14
  43. package/dist/host-goal-engine.d.ts +138 -0
  44. package/dist/host-goal-engine.js +793 -0
  45. package/dist/host-goal-status.d.ts +113 -0
  46. package/dist/host-goal-status.js +175 -0
  47. package/dist/host-learning.d.ts +68 -0
  48. package/dist/host-learning.js +115 -0
  49. package/dist/host-observation.d.ts +12 -0
  50. package/dist/host-observation.js +91 -0
  51. package/dist/host-plan-revision.d.ts +14 -0
  52. package/dist/host-plan-revision.js +101 -0
  53. package/dist/host-verification-job.d.ts +12 -0
  54. package/dist/host-verification-job.js +183 -0
  55. package/dist/host-work-context.d.ts +12 -0
  56. package/dist/host-work-context.js +8 -0
  57. package/dist/job-scheduler.d.ts +58 -13
  58. package/dist/job-scheduler.js +298 -48
  59. package/dist/job-worker.d.ts +3 -1
  60. package/dist/job-worker.js +10 -3
  61. package/dist/kanban.d.ts +4 -1
  62. package/dist/kanban.js +44 -16
  63. package/dist/learned-artifacts.d.ts +64 -0
  64. package/dist/learned-artifacts.js +321 -0
  65. package/dist/learning-controls.d.ts +142 -0
  66. package/dist/learning-controls.js +135 -0
  67. package/dist/learning-input-context.d.ts +9 -0
  68. package/dist/learning-input-context.js +62 -0
  69. package/dist/learning-lifecycle.d.ts +10 -0
  70. package/dist/learning-lifecycle.js +11 -0
  71. package/dist/learning-overlays.d.ts +3 -0
  72. package/dist/learning-overlays.js +17 -0
  73. package/dist/learning-policy.d.ts +40 -0
  74. package/dist/learning-policy.js +38 -0
  75. package/dist/learning-records.d.ts +35 -0
  76. package/dist/learning-records.js +17 -0
  77. package/dist/learning-scorecard.d.ts +82 -0
  78. package/dist/learning-scorecard.js +86 -0
  79. package/dist/learning-serving.d.ts +29 -0
  80. package/dist/learning-serving.js +134 -0
  81. package/dist/linked-work.d.ts +9 -0
  82. package/dist/linked-work.js +28 -0
  83. package/dist/local-model-runtime.js +1 -1
  84. package/dist/mcp-command-progress.d.ts +16 -0
  85. package/dist/mcp-command-progress.js +70 -0
  86. package/dist/model-bridge.d.ts +14 -13
  87. package/dist/model-bridge.js +73 -41
  88. package/dist/orchestrator-supervisor.d.ts +111 -6
  89. package/dist/orchestrator-supervisor.js +67 -43
  90. package/dist/plan-proposal.js +3 -3
  91. package/dist/playbook-curator.d.ts +1 -0
  92. package/dist/playbook-curator.js +11 -9
  93. package/dist/process-identity.d.ts +8 -0
  94. package/dist/process-identity.js +38 -0
  95. package/dist/progress-command.d.ts +34 -0
  96. package/dist/progress-command.js +153 -0
  97. package/dist/prompts.js +15 -15
  98. package/dist/resources.js +2 -2
  99. package/dist/runtime-command.d.ts +8 -1
  100. package/dist/runtime-command.js +48 -7
  101. package/dist/runtime-executor.js +33 -11
  102. package/dist/runtime-profile-syntax.d.ts +6 -0
  103. package/dist/runtime-profile-syntax.js +121 -0
  104. package/dist/runtime-profile.d.ts +37 -5
  105. package/dist/runtime-profile.js +36 -129
  106. package/dist/runtime-tool-specs.js +1 -1
  107. package/dist/safe-edit.d.ts +7 -3
  108. package/dist/safe-edit.js +40 -47
  109. package/dist/schemas.js +4 -2
  110. package/dist/server.d.ts +2 -16
  111. package/dist/server.js +14 -20
  112. package/dist/shared.d.ts +2 -0
  113. package/dist/shared.js +2 -0
  114. package/dist/skill-auditor.d.ts +19 -0
  115. package/dist/skill-auditor.js +32 -2
  116. package/dist/skill-catalog.d.ts +4 -1
  117. package/dist/skill-catalog.js +6 -5
  118. package/dist/store/ace-packed-store.d.ts +5 -1
  119. package/dist/store/ace-packed-store.js +33 -1
  120. package/dist/store/bootstrap-store.js +19 -12
  121. package/dist/store/cache-workspace.d.ts +8 -0
  122. package/dist/store/cache-workspace.js +94 -27
  123. package/dist/store/importer.js +11 -20
  124. package/dist/store/knowledge-bake.d.ts +3 -1
  125. package/dist/store/knowledge-bake.js +10 -1
  126. package/dist/store/legacy-vericify-posts.d.ts +8 -0
  127. package/dist/store/legacy-vericify-posts.js +76 -0
  128. package/dist/store/materializers/hook-context-materializer.d.ts +2 -1
  129. package/dist/store/materializers/hook-context-materializer.js +6 -2
  130. package/dist/store/materializers/host-file-materializer.js +2 -2
  131. package/dist/store/materializers/projection-manager.d.ts +4 -1
  132. package/dist/store/materializers/projection-manager.js +10 -3
  133. package/dist/store/materializers/todo-syncer.d.ts +39 -20
  134. package/dist/store/materializers/todo-syncer.js +208 -110
  135. package/dist/store/materializers/vericify-projector.js +15 -26
  136. package/dist/store/repositories/goal-repository.d.ts +51 -2
  137. package/dist/store/repositories/goal-repository.js +114 -48
  138. package/dist/store/repositories/playbook-repository.d.ts +41 -3
  139. package/dist/store/repositories/playbook-repository.js +61 -5
  140. package/dist/store/repositories/todo-repository.d.ts +8 -2
  141. package/dist/store/repositories/todo-repository.js +1 -1
  142. package/dist/store/repositories/vericify-repository.d.ts +4 -0
  143. package/dist/store/repositories/vericify-repository.js +46 -0
  144. package/dist/store/skills-install.d.ts +1 -0
  145. package/dist/store/skills-install.js +20 -16
  146. package/dist/store/source-preservation.d.ts +24 -0
  147. package/dist/store/source-preservation.js +79 -0
  148. package/dist/store/store-artifacts.js +8 -1
  149. package/dist/store/test-write-boundary.d.ts +16 -0
  150. package/dist/store/test-write-boundary.js +175 -0
  151. package/dist/store/types.d.ts +5 -1
  152. package/dist/store/write-coordinator.js +16 -0
  153. package/dist/task-plan-context.d.ts +11 -0
  154. package/dist/task-plan-context.js +59 -0
  155. package/dist/todo-state.d.ts +19 -9
  156. package/dist/todo-state.js +204 -321
  157. package/dist/tools-agent.d.ts +1 -1
  158. package/dist/tools-agent.js +38 -62
  159. package/dist/tools-files.d.ts +0 -3
  160. package/dist/tools-files.js +21 -11
  161. package/dist/tools-framework.js +16 -25
  162. package/dist/tools-goal.d.ts +2 -4
  163. package/dist/tools-goal.js +119 -16
  164. package/dist/tools-handoff.d.ts +3 -2
  165. package/dist/tools-handoff.js +53 -23
  166. package/dist/tools-lifecycle.js +1 -1
  167. package/dist/tools-memory.d.ts +0 -3
  168. package/dist/tools-memory.js +51 -6
  169. package/dist/tools-scheduler.js +20 -17
  170. package/dist/tools-todo.js +160 -148
  171. package/dist/vericify-bridge.d.ts +8 -0
  172. package/dist/vericify-bridge.js +18 -0
  173. package/dist/vericify-context.d.ts +4 -0
  174. package/dist/vericify-context.js +26 -5
  175. package/dist/vericify-native.d.ts +101 -0
  176. package/dist/vericify-native.js +221 -0
  177. package/dist/verification-worker.d.ts +2 -0
  178. package/dist/verification-worker.js +31 -0
  179. package/docs/mcp-tool-reference.md +50 -10
  180. package/docs/troubleshooting.md +29 -0
  181. package/package.json +19 -6
  182. package/scripts/dev/run-tests-isolated.mjs +165 -0
  183. package/scripts/dev/run_hermes_crispr_live.mjs +1 -0
  184. package/scripts/dev/test-isolation-guard.mjs +20 -0
  185. package/scripts/dev/test-preload.mjs +153 -0
  186. package/scripts/dev/test-source-inventory.mjs +31 -0
  187. package/docs/development.md +0 -46
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readHandoffRegistry } from "./handoff-registry.js";
3
- import { resolveWorkspaceRoot, safeRead, safeWrite, withFileLock, wsPath } from "./helpers.js";
3
+ import { resolveWorkspaceRoot, withWorkspaceRoot, safeRead, safeWrite, withFileLock, wsPath } from "./helpers.js";
4
4
  import { isReadError } from "./shared.js";
5
5
  import { appendStatusEventSafe, readStatusEvents } from "./status-events.js";
6
6
  import { appendRunLedgerEntrySafe } from "./run-ledger.js";
@@ -10,6 +10,7 @@ import { ProjectionManager } from "./store/materializers/projection-manager.js";
10
10
  import { SchedulerRepository } from "./store/repositories/scheduler-repository.js";
11
11
  import { getWorkspaceStorePath, readStoreJsonSync, storeExistsSync, } from "./store/store-snapshot.js";
12
12
  import { withStoreWriteCoordinator } from "./store/write-coordinator.js";
13
+ import { probeProcess, readProcessIdentity } from "./process-identity.js";
13
14
  // ── Buffered transition-event infrastructure ────────────────────────
14
15
  // Events are collected during the scheduler lock and flushed after
15
16
  // the lock is released. Emission is failure-tolerant: a broken
@@ -204,6 +205,8 @@ function parseQueueFile(raw) {
204
205
  evidence_ref: typeof job.evidence_ref === "string" ? job.evidence_ref : undefined,
205
206
  error: typeof job.error === "string" ? job.error : undefined,
206
207
  reason_code: typeof job.reason_code === "string" ? job.reason_code : undefined,
208
+ execution: job.execution && typeof job.execution === "object" ? { ...job.execution } : undefined,
209
+ execution_children: Array.isArray(job.execution_children) ? job.execution_children.map(identity => ({ ...identity })) : undefined,
207
210
  });
208
211
  }
209
212
  return {
@@ -289,16 +292,28 @@ function parseLease(raw) {
289
292
  return undefined;
290
293
  }
291
294
  }
292
- export function getJobQueuePath() {
295
+ export function getJobQueuePath(workspaceRoot = resolveWorkspaceRoot()) {
296
+ return withWorkspaceRoot(workspaceRoot, () => getJobQueuePathInWorkspace());
297
+ }
298
+ function getJobQueuePathInWorkspace() {
293
299
  return wsPath(JOB_QUEUE_REL);
294
300
  }
295
- export function getJobLockTablePath() {
301
+ export function getJobLockTablePath(workspaceRoot = resolveWorkspaceRoot()) {
302
+ return withWorkspaceRoot(workspaceRoot, () => getJobLockTablePathInWorkspace());
303
+ }
304
+ function getJobLockTablePathInWorkspace() {
296
305
  return wsPath(JOB_LOCK_TABLE_REL);
297
306
  }
298
- export function getSchedulerLeasePath() {
307
+ export function getSchedulerLeasePath(workspaceRoot = resolveWorkspaceRoot()) {
308
+ return withWorkspaceRoot(workspaceRoot, () => getSchedulerLeasePathInWorkspace());
309
+ }
310
+ function getSchedulerLeasePathInWorkspace() {
299
311
  return wsPath(SCHEDULER_LEASE_REL);
300
312
  }
301
- export function readJobQueue() {
313
+ export function readJobQueue(workspaceRoot = resolveWorkspaceRoot()) {
314
+ return withWorkspaceRoot(workspaceRoot, () => readJobQueueInWorkspace());
315
+ }
316
+ function readJobQueueInWorkspace() {
302
317
  const root = resolveWorkspaceRoot();
303
318
  if (storeExistsSync(root)) {
304
319
  const stored = readStoreJsonSync(root, "state/scheduler/queue");
@@ -311,7 +326,10 @@ export function readJobQueue() {
311
326
  return defaultQueueFile();
312
327
  return parseQueueFile(raw) ?? defaultQueueFile();
313
328
  }
314
- export function readJobLockTable() {
329
+ export function readJobLockTable(workspaceRoot = resolveWorkspaceRoot()) {
330
+ return withWorkspaceRoot(workspaceRoot, () => readJobLockTableInWorkspace());
331
+ }
332
+ function readJobLockTableInWorkspace() {
315
333
  const root = resolveWorkspaceRoot();
316
334
  if (storeExistsSync(root)) {
317
335
  const stored = readStoreJsonSync(root, "state/scheduler/locks");
@@ -324,11 +342,14 @@ export function readJobLockTable() {
324
342
  return defaultJobLockFile();
325
343
  return parseJobLockFile(raw) ?? defaultJobLockFile();
326
344
  }
327
- export function readSchedulerLease() {
345
+ export function readSchedulerLease(workspaceRoot = resolveWorkspaceRoot()) {
346
+ return withWorkspaceRoot(workspaceRoot, () => readSchedulerLeaseInWorkspace());
347
+ }
348
+ function readSchedulerLeaseInWorkspace() {
328
349
  const root = resolveWorkspaceRoot();
329
350
  if (storeExistsSync(root)) {
330
351
  const stored = readStoreJsonSync(root, "state/scheduler/lease");
331
- if (stored)
352
+ if (stored !== undefined)
332
353
  return stored ?? undefined;
333
354
  }
334
355
  const raw = safeRead(SCHEDULER_LEASE_REL);
@@ -336,28 +357,54 @@ export function readSchedulerLease() {
336
357
  return undefined;
337
358
  return parseLease(raw);
338
359
  }
360
+ /** Serialize admission as well as persistence across processes. */
361
+ async function withSchedulerTransaction(fn) {
362
+ const storePath = getWorkspaceStorePath(resolveWorkspaceRoot());
363
+ return withFileLock(SCHEDULER_LOCK_REL, () => withStoreWriteCoordinator(storePath, fn, { operation_label: "scheduler_transaction" }));
364
+ }
365
+ function probeJobExecution(job) {
366
+ const identities = [...(job.execution ? [job.execution] : []), ...(job.execution_children ?? [])];
367
+ const states = identities.map(probeProcess);
368
+ if (states.includes("alive"))
369
+ return "alive";
370
+ return states.includes("unknown") ? "unknown" : "dead";
371
+ }
372
+ function hasExecutionOwnership(queue, locks) {
373
+ return queue.jobs.some((job) => job.status === "running" ||
374
+ (job.status === "unknown_recovery" && (job.reason_code === "handoff_execution_unverified" ||
375
+ probeJobExecution(job) !== "dead"))) ||
376
+ locks.locks.some((lock) => isJobLockActive(lock));
377
+ }
378
+ function leaseForAcquisition() {
379
+ // Called inside the transaction: legacy idle leases can be reclaimed immediately.
380
+ return hasExecutionOwnership(readJobQueue(), readJobLockTable()) ? readSchedulerLease() : undefined;
381
+ }
382
+ // Only called from withSchedulerTransaction. Do not reacquire the store coordinator.
339
383
  async function persistSchedulerState(input) {
340
384
  const root = resolveWorkspaceRoot();
385
+ const lease = hasExecutionOwnership(input.queue, input.locks) ? input.lease : undefined;
341
386
  if (!storeExistsSync(root)) {
387
+ // Legacy workspaces share the same cross-process transaction lock. Do not
388
+ // create a partial store that would hide their handoffs, events, and todos.
342
389
  safeWrite(JOB_QUEUE_REL, JSON.stringify({ ...input.queue, updated_at: nowIso() }, null, 2));
343
390
  safeWrite(JOB_LOCK_TABLE_REL, JSON.stringify({ ...input.locks, updated_at: nowIso() }, null, 2));
344
- safeWrite(SCHEDULER_LEASE_REL, JSON.stringify(input.lease ?? null, null, 2));
391
+ safeWrite(SCHEDULER_LEASE_REL, JSON.stringify(lease ?? null, null, 2));
345
392
  return;
346
393
  }
347
- const storePath = getWorkspaceStorePath(root);
348
- await withStoreWriteCoordinator(storePath, async () => {
349
- const store = await openStore(storePath);
350
- try {
351
- const repo = new SchedulerRepository(store);
352
- await repo.replaceState(input);
353
- await store.commit();
354
- const projections = new ProjectionManager(store, root);
355
- await projections.projectAfterCommit(["scheduler"]);
356
- }
357
- finally {
358
- await store.close();
359
- }
360
- }, { operation_label: "persistSchedulerState" });
394
+ const store = await openStore(getWorkspaceStorePath(root));
395
+ try {
396
+ const repo = new SchedulerRepository(store);
397
+ await repo.replaceState({
398
+ ...input,
399
+ lease,
400
+ });
401
+ await store.commit();
402
+ const projections = new ProjectionManager(store, root);
403
+ await projections.projectAfterCommit(["scheduler"]);
404
+ }
405
+ finally {
406
+ await store.close();
407
+ }
361
408
  }
362
409
  function compareJobs(a, b) {
363
410
  const priorityCmp = PRIORITY_WEIGHT[a.priority] - PRIORITY_WEIGHT[b.priority];
@@ -583,6 +630,11 @@ function acquireLease(existing, owner, ttlSeconds, now) {
583
630
  }
584
631
  const existingExpiry = parseDate(existing.expires_at);
585
632
  const expired = !existingExpiry || existingExpiry.getTime() <= now.getTime();
633
+ // A missed heartbeat is not evidence that an executing process has exited.
634
+ // Do not steal its owner lease while its identity is live or uncertain.
635
+ if (expired && existing.owner !== owner && readJobQueue().jobs.some((job) => (job.status === "running" || job.status === "unknown_recovery") &&
636
+ probeJobExecution(job) !== "dead"))
637
+ return { acquired: false, lease: existing, recovery_mode: false };
586
638
  if (expired || existing.owner === owner) {
587
639
  const lease = {
588
640
  version: 1,
@@ -607,7 +659,7 @@ function hasActiveLeaseForOwner(lease, owner, now) {
607
659
  return expires.getTime() > now.getTime();
608
660
  }
609
661
  function ensureLeaseForOwner(owner, now, ttlSeconds = defaultLeaseTtlSeconds()) {
610
- const leaseResult = acquireLease(readSchedulerLease(), owner, ttlSeconds, now);
662
+ const leaseResult = acquireLease(leaseForAcquisition(), owner, ttlSeconds, now);
611
663
  if (!leaseResult.acquired) {
612
664
  return leaseResult;
613
665
  }
@@ -700,9 +752,18 @@ function startJobNow(job, table, lease, now, buf, sourceTool) {
700
752
  });
701
753
  return { acquired_at: acquiredAt, resources };
702
754
  }
703
- function reconcileRecoveryState(queue, table, now, recoveryMode, buf) {
755
+ function reconcileRecoveryState(queue, table, now, recoveryMode, buf, selected) {
704
756
  let recovered = 0;
705
757
  for (const job of queue.jobs) {
758
+ if (selected && !selected.has(job.job_id))
759
+ continue;
760
+ if (job.kind === "handoff.lifecycle")
761
+ continue;
762
+ if ((job.status === "running" || job.status === "unknown_recovery") && probeJobExecution(job) !== "dead") {
763
+ // Preserve ownership even during lease recovery or missing lock metadata.
764
+ // A retained uncertain execution must never be dispatched a second time.
765
+ continue;
766
+ }
706
767
  if (job.status === "unknown_recovery") {
707
768
  releaseLocksForRecoveredJob(table, job.job_id, "recovery_hold", now);
708
769
  continue;
@@ -749,11 +810,15 @@ function reconcileRecoveryState(queue, table, now, recoveryMode, buf) {
749
810
  * Missing-lock jobs are intentionally left to `reconcileRecoveryState`, which
750
811
  * runs first; this pass only inspects jobs that still hold active locks.
751
812
  */
752
- function reapStaleRunningJobs(queue, table, now, staleMs, buf) {
813
+ function reapStaleRunningJobs(queue, table, now, staleMs, buf, selected) {
753
814
  if (staleMs <= 0)
754
815
  return [];
755
816
  const reaped = [];
756
817
  for (const job of queue.jobs) {
818
+ if (selected && !selected.has(job.job_id))
819
+ continue;
820
+ if (job.kind === "handoff.lifecycle")
821
+ continue;
757
822
  if (job.status !== "running")
758
823
  continue;
759
824
  const activeLocks = table.locks.filter((lock) => lock.job_id === job.job_id && lock.status === "active");
@@ -765,6 +830,14 @@ function reapStaleRunningJobs(queue, table, now, staleMs, buf) {
765
830
  const ageMs = now.getTime() - lastBeat;
766
831
  if (ageMs <= staleMs)
767
832
  continue;
833
+ if (job.execution || job.execution_children?.length) {
834
+ const processState = probeJobExecution(job);
835
+ if (processState !== "dead") {
836
+ job.reason_code = `heartbeat_stale_process_${processState}`;
837
+ job.error = `Worker heartbeat is stale, but execution process is ${processState}; execution and resource locks are retained pending recovery.`;
838
+ continue;
839
+ }
840
+ }
768
841
  const previousStatus = job.status;
769
842
  job.status = "unknown_recovery";
770
843
  job.reason_code = "heartbeat_stale";
@@ -799,9 +872,12 @@ function reapStaleRunningJobs(queue, table, now, staleMs, buf) {
799
872
  }
800
873
  return reaped;
801
874
  }
802
- export async function enqueueJob(input) {
875
+ export async function enqueueJob(input, workspaceRoot = resolveWorkspaceRoot()) {
876
+ return withWorkspaceRoot(workspaceRoot, () => enqueueJobInWorkspace(input));
877
+ }
878
+ async function enqueueJobInWorkspace(input) {
803
879
  const buf = newEventBuffer();
804
- const result = await withFileLock(SCHEDULER_LOCK_REL, async () => {
880
+ const result = await withSchedulerTransaction(async () => {
805
881
  if (!input.kind || input.kind.trim().length === 0) {
806
882
  const queue = readJobQueue();
807
883
  return {
@@ -900,9 +976,79 @@ export async function enqueueJob(input) {
900
976
  await flushTransitionEvents(buf);
901
977
  return result;
902
978
  }
903
- export async function acknowledgeJob(input) {
979
+ /** Mirror handoff lifecycle without admitting or executing a job. */
980
+ export async function syncHandoffLifecycleJob(input, workspaceRoot = resolveWorkspaceRoot()) {
981
+ return withWorkspaceRoot(workspaceRoot, () => syncHandoffLifecycleJobInWorkspace(input));
982
+ }
983
+ async function syncHandoffLifecycleJobInWorkspace(input) {
984
+ return withSchedulerTransaction(async () => {
985
+ const queue = readJobQueue();
986
+ const locks = readJobLockTable();
987
+ const now = parseDate(input.now_iso) ?? new Date();
988
+ let job = queue.jobs.find((row) => row.job_id === input.job_id);
989
+ if (job && (job.kind !== "handoff.lifecycle" || job.payload.handoff_id !== input.payload?.handoff_id)) {
990
+ return {
991
+ ok: false,
992
+ enqueued: false,
993
+ path: getJobQueuePath(),
994
+ queue,
995
+ nack: { reason_code: "duplicate_job_id", reason: "Job belongs to another lifecycle or execution." },
996
+ };
997
+ }
998
+ const enqueued = !job;
999
+ if (!job) {
1000
+ job = {
1001
+ job_id: input.job_id,
1002
+ kind: "handoff.lifecycle",
1003
+ payload: input.payload ?? {},
1004
+ status: "blocked",
1005
+ priority: toPriority(input.priority),
1006
+ dependencies: input.dependencies ?? [],
1007
+ resource_requirements: normalizeResources(input.resource_requirements),
1008
+ attempt: 0,
1009
+ max_attempts: 3,
1010
+ backoff_ms: 5000,
1011
+ created_at: nowIso(now),
1012
+ accepted_at: nowIso(now),
1013
+ };
1014
+ queue.jobs.push(job);
1015
+ }
1016
+ job.payload = { ...job.payload, ...input.payload };
1017
+ if (job.payload.status === "completed") {
1018
+ // The canonical handoff completion is the receipt resolving legacy bookkeeping.
1019
+ job.status = "done";
1020
+ job.completed_at = job.completed_at ?? nowIso(now);
1021
+ job.reason_code = undefined;
1022
+ job.error = undefined;
1023
+ releaseLocksForRecoveredJob(locks, job.job_id, "handoff_completed", now);
1024
+ }
1025
+ else if (job.status === "running" || job.status === "unknown_recovery" || hasActiveLockForJob(locks.locks, job.job_id)) {
1026
+ // Do not infer worker death from a legacy synthetic job's kind.
1027
+ job.status = "unknown_recovery";
1028
+ job.reason_code = "handoff_execution_unverified";
1029
+ job.error = "Legacy handoff execution requires a completion receipt; ownership and locks are preserved.";
1030
+ }
1031
+ else if (!TERMINAL_STATUSES.has(job.status)) {
1032
+ job.status = "blocked";
1033
+ job.reason_code = "handoff_metadata_only";
1034
+ job.error = "Lifecycle metadata is synchronized by the handoff registry, not dispatched.";
1035
+ }
1036
+ await persistSchedulerState({ queue, locks, lease: readSchedulerLease() });
1037
+ return {
1038
+ ok: true,
1039
+ enqueued,
1040
+ path: getJobQueuePath(),
1041
+ queue,
1042
+ ack: { job_id: job.job_id, status: job.status },
1043
+ };
1044
+ });
1045
+ }
1046
+ export async function acknowledgeJob(input, workspaceRoot = resolveWorkspaceRoot()) {
1047
+ return withWorkspaceRoot(workspaceRoot, () => acknowledgeJobInWorkspace(input));
1048
+ }
1049
+ async function acknowledgeJobInWorkspace(input) {
904
1050
  const buf = newEventBuffer();
905
- const result = await withFileLock(SCHEDULER_LOCK_REL, async () => {
1051
+ const result = await withSchedulerTransaction(async () => {
906
1052
  const queue = readJobQueue();
907
1053
  const job = queue.jobs.find((row) => row.job_id === input.job_id);
908
1054
  if (!job) {
@@ -982,22 +1128,29 @@ export async function acknowledgeJob(input) {
982
1128
  await flushTransitionEvents(buf);
983
1129
  return result;
984
1130
  }
985
- export async function dispatchJobs(input = {}) {
1131
+ export async function dispatchJobs(input = {}, workspaceRoot = resolveWorkspaceRoot()) {
1132
+ return withWorkspaceRoot(workspaceRoot, () => dispatchJobsInWorkspace(input));
1133
+ }
1134
+ async function dispatchJobsInWorkspace(input = {}) {
986
1135
  const buf = newEventBuffer();
987
- const result = await withFileLock(SCHEDULER_LOCK_REL, async () => {
1136
+ const selected = input.job_ids === undefined ? undefined : new Set(input.job_ids);
1137
+ const result = await withSchedulerTransaction(async () => {
988
1138
  const owner = deriveOwner(input.owner);
989
1139
  const now = parseDate(input.now_iso) ?? new Date();
990
1140
  const table = readJobLockTable();
991
1141
  const leaseTtl = typeof input.lease_ttl_seconds === "number" && input.lease_ttl_seconds > 0
992
1142
  ? Math.floor(input.lease_ttl_seconds)
993
1143
  : defaultLeaseTtlSeconds();
994
- const leaseResult = acquireLease(readSchedulerLease(), owner, leaseTtl, now);
1144
+ const leaseResult = acquireLease(leaseForAcquisition(), owner, leaseTtl, now);
995
1145
  if (!leaseResult.acquired) {
996
1146
  const queue = readJobQueue();
997
1147
  return {
998
1148
  ok: false,
1149
+ reason_code: "scheduler_lease_held",
1150
+ error: `Scheduler lease is held by ${leaseResult.lease.owner} until ${leaseResult.lease.expires_at}; dispatch requested by ${owner}.`,
999
1151
  owner,
1000
1152
  lease_acquired: false,
1153
+ lease_retained: true,
1001
1154
  lease: leaseResult.lease,
1002
1155
  queue_path: getJobQueuePath(),
1003
1156
  lock_path: getJobLockTablePath(),
@@ -1006,7 +1159,7 @@ export async function dispatchJobs(input = {}) {
1006
1159
  summary: {
1007
1160
  total_jobs: queue.jobs.length,
1008
1161
  blocked_jobs: queue.jobs.filter((job) => job.status === "blocked").length,
1009
- ready_jobs: queue.jobs.filter((job) => job.status === "ready").length,
1162
+ ready_jobs: queue.jobs.filter((job) => job.status === "ready" && job.kind !== "handoff.lifecycle").length,
1010
1163
  running_jobs: queue.jobs.filter((job) => job.status === "running").length,
1011
1164
  started_jobs: 0,
1012
1165
  recovered_jobs: 0,
@@ -1020,15 +1173,19 @@ export async function dispatchJobs(input = {}) {
1020
1173
  handoffState: readHandoffRegistry(),
1021
1174
  statusEvents: readStatusEvents(500),
1022
1175
  };
1023
- const recoveredJobs = reconcileRecoveryState(queue, table, now, leaseResult.recovery_mode, buf);
1176
+ const recoveredJobs = reconcileRecoveryState(queue, table, now, leaseResult.recovery_mode, buf, selected);
1024
1177
  // Heartbeat watchdog: reap running jobs whose worker heartbeat is stale.
1025
1178
  // NOTE: dispatch deliberately does NOT re-stamp running-job heartbeats here.
1026
1179
  // Only a live worker (heartbeatJob) or a fresh start proves liveness; if the
1027
1180
  // dispatcher renewed heartbeats it would mask worker death (root cause of D3).
1028
1181
  const staleMs = resolveStaleMs(input.heartbeat_stale_seconds);
1029
- const reapedJobIds = reapStaleRunningJobs(queue, table, now, staleMs, buf);
1182
+ const reapedJobIds = reapStaleRunningJobs(queue, table, now, staleMs, buf, selected);
1030
1183
  const reapedJobs = reapedJobIds.length;
1031
1184
  for (const job of queue.jobs) {
1185
+ if (selected && !selected.has(job.job_id))
1186
+ continue;
1187
+ if (job.kind === "handoff.lifecycle")
1188
+ continue;
1032
1189
  if (TERMINAL_STATUSES.has(job.status))
1033
1190
  continue;
1034
1191
  if (job.status === "running" || job.status === "unknown_recovery")
@@ -1051,7 +1208,7 @@ export async function dispatchJobs(input = {}) {
1051
1208
  }
1052
1209
  const occupancy = buildOccupancy(table.locks);
1053
1210
  const readyJobs = queue.jobs
1054
- .filter((job) => job.status === "ready")
1211
+ .filter((job) => job.status === "ready" && job.kind !== "handoff.lifecycle" && (!selected || selected.has(job.job_id)))
1055
1212
  .sort(compareJobs);
1056
1213
  let startedJobs = 0;
1057
1214
  for (const job of readyJobs) {
@@ -1080,7 +1237,7 @@ export async function dispatchJobs(input = {}) {
1080
1237
  const summary = {
1081
1238
  total_jobs: queue.jobs.length,
1082
1239
  blocked_jobs: queue.jobs.filter((job) => job.status === "blocked").length,
1083
- ready_jobs: queue.jobs.filter((job) => job.status === "ready").length,
1240
+ ready_jobs: queue.jobs.filter((job) => job.status === "ready" && job.kind !== "handoff.lifecycle").length,
1084
1241
  running_jobs: queue.jobs.filter((job) => job.status === "running").length,
1085
1242
  started_jobs: startedJobs,
1086
1243
  recovered_jobs: recoveredJobs,
@@ -1093,6 +1250,7 @@ export async function dispatchJobs(input = {}) {
1093
1250
  payload: {
1094
1251
  owner,
1095
1252
  lease_acquired: true,
1253
+ lease_retained: hasExecutionOwnership(queue, table),
1096
1254
  lease_id: refreshedLease.lease_id,
1097
1255
  recovery_mode: leaseResult.recovery_mode,
1098
1256
  ...summary,
@@ -1107,6 +1265,7 @@ export async function dispatchJobs(input = {}) {
1107
1265
  owner,
1108
1266
  lease_id: refreshedLease.lease_id,
1109
1267
  lease_acquired: true,
1268
+ lease_retained: hasExecutionOwnership(queue, table),
1110
1269
  recovery_mode: leaseResult.recovery_mode,
1111
1270
  summary,
1112
1271
  },
@@ -1115,6 +1274,7 @@ export async function dispatchJobs(input = {}) {
1115
1274
  ok: true,
1116
1275
  owner,
1117
1276
  lease_acquired: true,
1277
+ lease_retained: hasExecutionOwnership(queue, table),
1118
1278
  lease: refreshedLease,
1119
1279
  queue_path: getJobQueuePath(),
1120
1280
  lock_path: getJobLockTablePath(),
@@ -1126,9 +1286,12 @@ export async function dispatchJobs(input = {}) {
1126
1286
  await flushTransitionEvents(buf);
1127
1287
  return result;
1128
1288
  }
1129
- export async function dispatchJobNow(input) {
1289
+ export async function dispatchJobNow(input, workspaceRoot = resolveWorkspaceRoot()) {
1290
+ return withWorkspaceRoot(workspaceRoot, () => dispatchJobNowInWorkspace(input));
1291
+ }
1292
+ async function dispatchJobNowInWorkspace(input) {
1130
1293
  const buf = newEventBuffer();
1131
- const result = await withFileLock(SCHEDULER_LOCK_REL, async () => {
1294
+ const result = await withSchedulerTransaction(async () => {
1132
1295
  const owner = deriveOwner(input.owner);
1133
1296
  const now = parseDate(input.now_iso) ?? new Date();
1134
1297
  const leaseResult = ensureLeaseForOwner(owner, now);
@@ -1155,6 +1318,16 @@ export async function dispatchJobNow(input) {
1155
1318
  error: `Unknown job_id: ${input.job_id}`,
1156
1319
  };
1157
1320
  }
1321
+ if (job.kind === "handoff.lifecycle") {
1322
+ return {
1323
+ ok: false,
1324
+ queue_path: getJobQueuePath(),
1325
+ lock_path: getJobLockTablePath(),
1326
+ queue,
1327
+ locks: table,
1328
+ error: "Handoff lifecycle jobs are metadata; synchronize the handoff instead of dispatching it.",
1329
+ };
1330
+ }
1158
1331
  if (job.status === "unknown_recovery") {
1159
1332
  return {
1160
1333
  ok: false,
@@ -1251,9 +1424,72 @@ export async function dispatchJobNow(input) {
1251
1424
  await flushTransitionEvents(buf);
1252
1425
  return result;
1253
1426
  }
1254
- export async function completeJob(input) {
1427
+ /** Atomically claim the side-effecting handler, independently of scheduler owner. */
1428
+ export async function claimJobExecution(input, workspaceRoot = resolveWorkspaceRoot()) {
1429
+ return withWorkspaceRoot(workspaceRoot, () => withSchedulerTransaction(async () => {
1430
+ const now = parseDate(input.now_iso) ?? new Date();
1431
+ const owner = deriveOwner(input.owner);
1432
+ const lease = ensureLeaseForOwner(owner, now);
1433
+ if (!lease.acquired)
1434
+ return { ok: false, reason_code: "scheduler_lease_held", error: "Execution owner does not hold the scheduler lease." };
1435
+ const queue = readJobQueue();
1436
+ const locks = readJobLockTable();
1437
+ const job = queue.jobs.find(row => row.job_id === input.job_id);
1438
+ if (!job || job.status !== "running")
1439
+ return { ok: false, reason_code: "job_not_running", job };
1440
+ if (job.execution || job.execution_children?.length) {
1441
+ const state = probeJobExecution(job);
1442
+ if (state === "dead") {
1443
+ // The prior process may have performed side effects before it exited.
1444
+ // Preserve uncertainty instead of replaying the handler automatically.
1445
+ job.status = "unknown_recovery";
1446
+ job.reason_code = "execution_process_exited";
1447
+ job.error = "Claimed execution process exited; inspect retained outcomes before retrying.";
1448
+ job.next_attempt_at = undefined;
1449
+ releaseLocksForRecoveredJob(locks, job.job_id, "execution_process_exited", now);
1450
+ await persistSchedulerState({ queue, locks, lease: lease.lease });
1451
+ }
1452
+ return { ok: false, reason_code: state === "alive" ? "execution_claimed" : state === "unknown" ? "execution_uncertain" : "execution_dead", job };
1453
+ }
1454
+ if (!hasActiveLockForJob(locks.locks, job.job_id))
1455
+ return { ok: false, reason_code: "execution_missing_locks", job };
1456
+ const identity = readProcessIdentity(process.pid);
1457
+ if (!identity)
1458
+ return { ok: false, reason_code: "identity_unavailable", error: "Cannot safely identify the worker process; handler was not invoked.", job };
1459
+ const token = randomUUID();
1460
+ job.execution = { ...identity, token };
1461
+ job.execution_children = undefined;
1462
+ await persistSchedulerState({ queue, locks, lease: lease.lease });
1463
+ return { ok: true, token, job };
1464
+ }));
1465
+ }
1466
+ /** Register a command before waiting on it; only its claimed attempt may do so. */
1467
+ export async function recordJobExecutionChild(input, workspaceRoot = resolveWorkspaceRoot()) {
1468
+ return withWorkspaceRoot(workspaceRoot, () => withSchedulerTransaction(async () => {
1469
+ const queue = readJobQueue();
1470
+ const job = queue.jobs.find(row => row.job_id === input.job_id);
1471
+ if (!job || job.status !== "running")
1472
+ return { ok: false, reason_code: "job_not_running" };
1473
+ if (!input.token || job.execution?.token !== input.token)
1474
+ return { ok: false, reason_code: "execution_token_mismatch" };
1475
+ const identity = input.identity;
1476
+ if (!Number.isSafeInteger(identity.pid) || identity.pid <= 1 || !identity.started_at || !identity.hostname) {
1477
+ return { ok: false, reason_code: "execution_identity_invalid" };
1478
+ }
1479
+ job.execution_children ??= [];
1480
+ if (!job.execution_children.some(child => child.pid === identity.pid && child.started_at === identity.started_at && child.hostname === identity.hostname)) {
1481
+ job.execution_children.push({ ...identity });
1482
+ await persistSchedulerState({ queue, locks: readJobLockTable(), lease: readSchedulerLease() });
1483
+ }
1484
+ return { ok: true };
1485
+ }));
1486
+ }
1487
+ export async function completeJob(input, workspaceRoot = resolveWorkspaceRoot()) {
1488
+ return withWorkspaceRoot(workspaceRoot, () => completeJobInWorkspace(input));
1489
+ }
1490
+ async function completeJobInWorkspace(input) {
1255
1491
  const buf = newEventBuffer();
1256
- const result = await withFileLock(SCHEDULER_LOCK_REL, async () => {
1492
+ const result = await withSchedulerTransaction(async () => {
1257
1493
  const owner = deriveOwner(input.owner);
1258
1494
  const now = parseDate(input.now_iso) ?? new Date();
1259
1495
  const leaseResult = ensureLeaseForOwner(owner, now);
@@ -1302,6 +1538,9 @@ export async function completeJob(input) {
1302
1538
  };
1303
1539
  }
1304
1540
  const success = input.success !== false;
1541
+ // The claim belongs to this attempt only. A deliberate retry gets a new one.
1542
+ job.execution = undefined;
1543
+ job.execution_children = undefined;
1305
1544
  for (const lock of table.locks) {
1306
1545
  if (lock.job_id !== input.job_id)
1307
1546
  continue;
@@ -1393,8 +1632,11 @@ export async function completeJob(input) {
1393
1632
  * job's heartbeat — the dispatcher deliberately does not, so a missing
1394
1633
  * heartbeat reliably signals a dead worker.
1395
1634
  */
1396
- export async function heartbeatJob(input) {
1397
- return withFileLock(SCHEDULER_LOCK_REL, async () => {
1635
+ export async function heartbeatJob(input, workspaceRoot = resolveWorkspaceRoot()) {
1636
+ return withWorkspaceRoot(workspaceRoot, () => heartbeatJobInWorkspace(input));
1637
+ }
1638
+ async function heartbeatJobInWorkspace(input) {
1639
+ return withSchedulerTransaction(async () => {
1398
1640
  const owner = deriveOwner(input.owner);
1399
1641
  const now = parseDate(input.now_iso) ?? new Date();
1400
1642
  const leaseTtl = typeof input.lease_ttl_seconds === "number" && input.lease_ttl_seconds > 0
@@ -1438,6 +1680,10 @@ export async function heartbeatJob(input) {
1438
1680
  };
1439
1681
  }
1440
1682
  const beatAt = nowIso(now);
1683
+ if (job.reason_code?.startsWith("heartbeat_stale_process_")) {
1684
+ job.reason_code = undefined;
1685
+ job.error = undefined;
1686
+ }
1441
1687
  for (const lock of activeLocks) {
1442
1688
  lock.heartbeat_at = beatAt;
1443
1689
  lock.lease_id = leaseResult.lease.lease_id;
@@ -1462,21 +1708,25 @@ export async function heartbeatJob(input) {
1462
1708
  * watchdog cadence separate from dispatch. Does not require lease ownership —
1463
1709
  * reaping a dead worker's job must not depend on holding the lease.
1464
1710
  */
1465
- export async function reapStaleRunningJobsTick(input = {}) {
1711
+ export async function reapStaleRunningJobsTick(input = {}, workspaceRoot = resolveWorkspaceRoot()) {
1712
+ return withWorkspaceRoot(workspaceRoot, () => reapStaleRunningJobsTickInWorkspace(input));
1713
+ }
1714
+ async function reapStaleRunningJobsTickInWorkspace(input = {}) {
1466
1715
  const buf = newEventBuffer();
1467
- const result = await withFileLock(SCHEDULER_LOCK_REL, async () => {
1716
+ const result = await withSchedulerTransaction(async () => {
1468
1717
  const now = parseDate(input.now_iso) ?? new Date();
1469
1718
  const staleMs = resolveStaleMs(input.heartbeat_stale_seconds);
1470
1719
  const queue = readJobQueue();
1471
1720
  const table = readJobLockTable();
1721
+ const priorJobs = JSON.stringify(queue.jobs);
1472
1722
  const reapedJobIds = reapStaleRunningJobs(queue, table, now, staleMs, buf);
1473
- if (reapedJobIds.length > 0) {
1723
+ // Retained live/unknown claims also carry a persisted diagnostic.
1724
+ if (priorJobs !== JSON.stringify(queue.jobs))
1474
1725
  await persistSchedulerState({
1475
1726
  queue,
1476
1727
  locks: table,
1477
1728
  lease: readSchedulerLease(),
1478
1729
  });
1479
- }
1480
1730
  return {
1481
1731
  ok: true,
1482
1732
  queue_path: getJobQueuePath(),
@@ -35,6 +35,8 @@ export declare function listJobHandlerKinds(): string[];
35
35
  export declare function hasJobHandler(kind: string): boolean;
36
36
  export interface RunJobWorkerInput {
37
37
  owner?: string;
38
+ /** Execute only these jobs; unrelated jobs are not admitted by this tick. */
39
+ job_ids?: string[];
38
40
  /** Deterministic clock override; threaded through every scheduler call. */
39
41
  now_iso?: string;
40
42
  lease_ttl_seconds?: number;
@@ -60,7 +62,7 @@ export interface WorkerJobOutcome {
60
62
  export interface WorkerSkippedJob {
61
63
  job_id: string;
62
64
  kind: string;
63
- reason: "no_handler" | "max_jobs";
65
+ reason: "no_handler" | "max_jobs" | "execution_claimed" | "execution_uncertain";
64
66
  }
65
67
  export interface RunJobWorkerResult {
66
68
  owner: string;
@@ -17,7 +17,7 @@
17
17
  * across a handler invocation, so a slow or hung handler cannot wedge the
18
18
  * scheduler for other owners.
19
19
  */
20
- import { completeJob, dispatchJobs, heartbeatJob, readJobQueue, readSchedulerLease, } from "./job-scheduler.js";
20
+ import { completeJob, claimJobExecution, dispatchJobs, heartbeatJob, readJobQueue, readSchedulerLease, } from "./job-scheduler.js";
21
21
  import { resolveWorkspaceRoot } from "./helpers.js";
22
22
  const handlers = new Map();
23
23
  export function registerJobHandler(kind, handler) {
@@ -66,6 +66,7 @@ export async function runJobWorkerOnce(input = {}) {
66
66
  now_iso: input.now_iso,
67
67
  lease_ttl_seconds: input.lease_ttl_seconds,
68
68
  heartbeat_stale_seconds: input.heartbeat_stale_seconds,
69
+ job_ids: input.job_ids,
69
70
  });
70
71
  const executed = [];
71
72
  const skipped = [];
@@ -83,7 +84,8 @@ export async function runJobWorkerOnce(input = {}) {
83
84
  };
84
85
  }
85
86
  const queue = readJobQueue();
86
- const runningJobs = queue.jobs.filter((job) => job.status === "running");
87
+ const selected = input.job_ids === undefined ? undefined : new Set(input.job_ids);
88
+ const runningJobs = queue.jobs.filter((job) => job.status === "running" && (!selected || selected.has(job.job_id)));
87
89
  const maxJobs = typeof input.max_jobs === "number" && input.max_jobs > 0
88
90
  ? Math.floor(input.max_jobs)
89
91
  : Number.POSITIVE_INFINITY;
@@ -98,6 +100,11 @@ export async function runJobWorkerOnce(input = {}) {
98
100
  skipped.push({ job_id: job.job_id, kind: job.kind, reason: "max_jobs" });
99
101
  continue;
100
102
  }
103
+ const claim = await claimJobExecution({ job_id: job.job_id, owner, now_iso: input.now_iso });
104
+ if (!claim.ok) {
105
+ skipped.push({ job_id: job.job_id, kind: job.kind, reason: claim.reason_code === "execution_claimed" ? "execution_claimed" : "execution_uncertain" });
106
+ continue;
107
+ }
101
108
  executedCount += 1;
102
109
  // Prove liveness immediately, then keep beating while the handler runs.
103
110
  await heartbeatJob({ job_id: job.job_id, owner, now_iso: input.now_iso });
@@ -113,7 +120,7 @@ export async function runJobWorkerOnce(input = {}) {
113
120
  }
114
121
  let handlerResult;
115
122
  try {
116
- handlerResult = await handler({ job, owner });
123
+ handlerResult = await handler({ job: claim.job ?? job, owner });
117
124
  }
118
125
  catch (err) {
119
126
  handlerResult = { success: false, error: errorMessage(err) };