@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.
- package/CHANGELOG.md +52 -14
- package/README.md +14 -10
- package/assets/.agents/ACE/ACE_coders/AGENTS.md +23 -30
- package/assets/.agents/ACE/ACE_coders/INSTRUCTIONS.md +21 -30
- package/assets/.agents/skills/landing-review-watcher/SKILL.md +1 -1
- package/assets/.agents/skills/learn-from-work/SKILL.md +41 -0
- package/assets/agent-state/ACE_WORKFLOW.md +25 -2
- package/assets/agent-state/MODULES/schemas/ACE_RUNTIME_PROFILE.schema.json +143 -2
- package/assets/agent-state/MODULES/schemas/RUNTIME_EXECUTOR_SESSION_REGISTRY.schema.json +2 -2
- package/assets/agent-state/MODULES/schemas/SWARM_HANDOFF.schema.json +10 -0
- package/assets/eval-battery/battery.json +1 -1
- package/assets/instructions/ACE_Coder.instructions.md +9 -13
- package/assets/scripts/ace-hook-dispatch.mjs +16 -411
- package/assets/tasks/SWARM_HANDOFF.example_ui_to_coders.json +1 -2
- package/assets/vendor/README.md +9 -0
- package/assets/vendor/vericify-reader.mjs +272 -0
- package/assets/vendor/vericify-reader.provenance.json +10 -0
- package/dist/ace-autonomy.d.ts +58 -0
- package/dist/ace-autonomy.js +211 -6
- package/dist/ace-internal-tools.d.ts +1 -0
- package/dist/ace-internal-tools.js +3 -3
- package/dist/ace-server-instructions.d.ts +3 -3
- package/dist/ace-server-instructions.js +46 -192
- package/dist/cli.js +44 -6
- package/dist/experience-reflector.d.ts +7 -0
- package/dist/experience-reflector.js +39 -0
- package/dist/goal-engine.d.ts +64 -7
- package/dist/goal-engine.js +197 -41
- package/dist/handoff-registry.d.ts +10 -0
- package/dist/handoff-registry.js +59 -0
- package/dist/helpers/artifacts.d.ts +6 -2
- package/dist/helpers/artifacts.js +8 -6
- package/dist/helpers/bootstrap.js +13 -20
- package/dist/helpers/path-utils.js +2 -0
- package/dist/helpers/store-resolution.js +18 -3
- package/dist/helpers/workspace-root.d.ts +2 -0
- package/dist/helpers/workspace-root.js +9 -0
- package/dist/helpers.d.ts +1 -0
- package/dist/helpers.js +1 -0
- package/dist/hermes/bridge-protocol.d.ts +1 -1
- package/dist/hermes/session-manager.d.ts +17 -0
- package/dist/hermes/session-manager.js +67 -14
- package/dist/host-goal-engine.d.ts +138 -0
- package/dist/host-goal-engine.js +793 -0
- package/dist/host-goal-status.d.ts +113 -0
- package/dist/host-goal-status.js +175 -0
- package/dist/host-learning.d.ts +68 -0
- package/dist/host-learning.js +115 -0
- package/dist/host-observation.d.ts +12 -0
- package/dist/host-observation.js +91 -0
- package/dist/host-plan-revision.d.ts +14 -0
- package/dist/host-plan-revision.js +101 -0
- package/dist/host-verification-job.d.ts +12 -0
- package/dist/host-verification-job.js +183 -0
- package/dist/host-work-context.d.ts +12 -0
- package/dist/host-work-context.js +8 -0
- package/dist/job-scheduler.d.ts +58 -13
- package/dist/job-scheduler.js +298 -48
- package/dist/job-worker.d.ts +3 -1
- package/dist/job-worker.js +10 -3
- package/dist/kanban.d.ts +4 -1
- package/dist/kanban.js +44 -16
- package/dist/learned-artifacts.d.ts +64 -0
- package/dist/learned-artifacts.js +321 -0
- package/dist/learning-controls.d.ts +142 -0
- package/dist/learning-controls.js +135 -0
- package/dist/learning-input-context.d.ts +9 -0
- package/dist/learning-input-context.js +62 -0
- package/dist/learning-lifecycle.d.ts +10 -0
- package/dist/learning-lifecycle.js +11 -0
- package/dist/learning-overlays.d.ts +3 -0
- package/dist/learning-overlays.js +17 -0
- package/dist/learning-policy.d.ts +40 -0
- package/dist/learning-policy.js +38 -0
- package/dist/learning-records.d.ts +35 -0
- package/dist/learning-records.js +17 -0
- package/dist/learning-scorecard.d.ts +82 -0
- package/dist/learning-scorecard.js +86 -0
- package/dist/learning-serving.d.ts +29 -0
- package/dist/learning-serving.js +134 -0
- package/dist/linked-work.d.ts +9 -0
- package/dist/linked-work.js +28 -0
- package/dist/local-model-runtime.js +1 -1
- package/dist/mcp-command-progress.d.ts +16 -0
- package/dist/mcp-command-progress.js +70 -0
- package/dist/model-bridge.d.ts +14 -13
- package/dist/model-bridge.js +73 -41
- package/dist/orchestrator-supervisor.d.ts +111 -6
- package/dist/orchestrator-supervisor.js +67 -43
- package/dist/plan-proposal.js +3 -3
- package/dist/playbook-curator.d.ts +1 -0
- package/dist/playbook-curator.js +11 -9
- package/dist/process-identity.d.ts +8 -0
- package/dist/process-identity.js +38 -0
- package/dist/progress-command.d.ts +34 -0
- package/dist/progress-command.js +153 -0
- package/dist/prompts.js +15 -15
- package/dist/resources.js +2 -2
- package/dist/runtime-command.d.ts +8 -1
- package/dist/runtime-command.js +48 -7
- package/dist/runtime-executor.js +33 -11
- package/dist/runtime-profile-syntax.d.ts +6 -0
- package/dist/runtime-profile-syntax.js +121 -0
- package/dist/runtime-profile.d.ts +37 -5
- package/dist/runtime-profile.js +36 -129
- package/dist/runtime-tool-specs.js +1 -1
- package/dist/safe-edit.d.ts +7 -3
- package/dist/safe-edit.js +40 -47
- package/dist/schemas.js +4 -2
- package/dist/server.d.ts +2 -16
- package/dist/server.js +14 -20
- package/dist/shared.d.ts +2 -0
- package/dist/shared.js +2 -0
- package/dist/skill-auditor.d.ts +19 -0
- package/dist/skill-auditor.js +32 -2
- package/dist/skill-catalog.d.ts +4 -1
- package/dist/skill-catalog.js +6 -5
- package/dist/store/ace-packed-store.d.ts +5 -1
- package/dist/store/ace-packed-store.js +33 -1
- package/dist/store/bootstrap-store.js +19 -12
- package/dist/store/cache-workspace.d.ts +8 -0
- package/dist/store/cache-workspace.js +94 -27
- package/dist/store/importer.js +11 -20
- package/dist/store/knowledge-bake.d.ts +3 -1
- package/dist/store/knowledge-bake.js +10 -1
- package/dist/store/legacy-vericify-posts.d.ts +8 -0
- package/dist/store/legacy-vericify-posts.js +76 -0
- package/dist/store/materializers/hook-context-materializer.d.ts +2 -1
- package/dist/store/materializers/hook-context-materializer.js +6 -2
- package/dist/store/materializers/host-file-materializer.js +2 -2
- package/dist/store/materializers/projection-manager.d.ts +4 -1
- package/dist/store/materializers/projection-manager.js +10 -3
- package/dist/store/materializers/todo-syncer.d.ts +39 -20
- package/dist/store/materializers/todo-syncer.js +208 -110
- package/dist/store/materializers/vericify-projector.js +15 -26
- package/dist/store/repositories/goal-repository.d.ts +51 -2
- package/dist/store/repositories/goal-repository.js +114 -48
- package/dist/store/repositories/playbook-repository.d.ts +41 -3
- package/dist/store/repositories/playbook-repository.js +61 -5
- package/dist/store/repositories/todo-repository.d.ts +8 -2
- package/dist/store/repositories/todo-repository.js +1 -1
- package/dist/store/repositories/vericify-repository.d.ts +4 -0
- package/dist/store/repositories/vericify-repository.js +46 -0
- package/dist/store/skills-install.d.ts +1 -0
- package/dist/store/skills-install.js +20 -16
- package/dist/store/source-preservation.d.ts +24 -0
- package/dist/store/source-preservation.js +79 -0
- package/dist/store/store-artifacts.js +8 -1
- package/dist/store/test-write-boundary.d.ts +16 -0
- package/dist/store/test-write-boundary.js +175 -0
- package/dist/store/types.d.ts +5 -1
- package/dist/store/write-coordinator.js +16 -0
- package/dist/task-plan-context.d.ts +11 -0
- package/dist/task-plan-context.js +59 -0
- package/dist/todo-state.d.ts +19 -9
- package/dist/todo-state.js +204 -321
- package/dist/tools-agent.d.ts +1 -1
- package/dist/tools-agent.js +38 -62
- package/dist/tools-files.d.ts +0 -3
- package/dist/tools-files.js +21 -11
- package/dist/tools-framework.js +16 -25
- package/dist/tools-goal.d.ts +2 -4
- package/dist/tools-goal.js +119 -16
- package/dist/tools-handoff.d.ts +3 -2
- package/dist/tools-handoff.js +53 -23
- package/dist/tools-lifecycle.js +1 -1
- package/dist/tools-memory.d.ts +0 -3
- package/dist/tools-memory.js +51 -6
- package/dist/tools-scheduler.js +20 -17
- package/dist/tools-todo.js +160 -148
- package/dist/vericify-bridge.d.ts +8 -0
- package/dist/vericify-bridge.js +18 -0
- package/dist/vericify-context.d.ts +4 -0
- package/dist/vericify-context.js +26 -5
- package/dist/vericify-native.d.ts +101 -0
- package/dist/vericify-native.js +221 -0
- package/dist/verification-worker.d.ts +2 -0
- package/dist/verification-worker.js +31 -0
- package/docs/mcp-tool-reference.md +50 -10
- package/docs/troubleshooting.md +29 -0
- package/package.json +19 -6
- package/scripts/dev/run-tests-isolated.mjs +165 -0
- package/scripts/dev/run_hermes_crispr_live.mjs +1 -0
- package/scripts/dev/test-isolation-guard.mjs +20 -0
- package/scripts/dev/test-preload.mjs +153 -0
- package/scripts/dev/test-source-inventory.mjs +31 -0
- package/docs/development.md +0 -46
package/dist/job-scheduler.js
CHANGED
|
@@ -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(
|
|
391
|
+
safeWrite(SCHEDULER_LEASE_REL, JSON.stringify(lease ?? null, null, 2));
|
|
345
392
|
return;
|
|
346
393
|
}
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(),
|
package/dist/job-worker.d.ts
CHANGED
|
@@ -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;
|
package/dist/job-worker.js
CHANGED
|
@@ -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
|
|
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) };
|