@nowcrew/daemon 0.5.12 → 0.5.13

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.
@@ -0,0 +1,678 @@
1
+ import { execFile } from "node:child_process";
2
+ import { open, mkdir, readFile, readdir, rename, rm, unlink, } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
4
+ import { z } from "zod";
5
+ import { ExecutionCompletedSchema, EffectivePermissionSchema, } from "./execution-protocol.js";
6
+ import { JournalLockedError, createJournalLease, } from "./execution-journal-lock.js";
7
+ export { JournalLockedError, JournalLockCorruptionError } from "./execution-journal-lock.js";
8
+ const ExecutionIdSchema = z.string().uuid();
9
+ const TimestampSchema = z.string().datetime({ offset: true });
10
+ const RuntimeSchema = z.enum(["claude", "codex", "kimi"]);
11
+ const RawJournalEntrySchema = z.object({
12
+ executionId: ExecutionIdSchema,
13
+ specHash: z.string().min(1),
14
+ state: z.enum(["accepted", "running", "completed", "interrupted"]),
15
+ pid: z.number().int().positive().nullable(),
16
+ completion: ExecutionCompletedSchema.nullable(),
17
+ completionAcknowledged: z.boolean(),
18
+ updatedAt: TimestampSchema,
19
+ requestedRuntime: RuntimeSchema,
20
+ requestedModel: z.string().nullable(),
21
+ resumed: z.boolean(),
22
+ acceptedAt: TimestampSchema,
23
+ processStartedAt: TimestampSchema.nullable(),
24
+ processIdentity: z.string().min(1).refine((value) => value.trim().length > 0).nullable(),
25
+ // Optional for journals written before execution permission persistence was introduced.
26
+ effectivePermission: EffectivePermissionSchema.nullable().optional(),
27
+ }).strict();
28
+ export const JournalEntrySchema = RawJournalEntrySchema.superRefine((entry, ctx) => {
29
+ const issue = (message, path) => {
30
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message, path: [path] });
31
+ };
32
+ if (entry.state === "accepted") {
33
+ if (entry.pid !== null)
34
+ issue("accepted entry cannot have a pid", "pid");
35
+ if (entry.processStartedAt !== null) {
36
+ issue("accepted entry cannot have processStartedAt", "processStartedAt");
37
+ }
38
+ if (entry.processIdentity !== null)
39
+ issue("accepted entry cannot have processIdentity", "processIdentity");
40
+ if (entry.completion !== null)
41
+ issue("accepted entry cannot have a completion", "completion");
42
+ }
43
+ if (entry.state === "running") {
44
+ if (entry.pid === null)
45
+ issue("running entry requires a pid", "pid");
46
+ if (entry.processStartedAt === null) {
47
+ issue("running entry requires processStartedAt", "processStartedAt");
48
+ }
49
+ if (entry.processIdentity === null)
50
+ issue("running entry requires processIdentity", "processIdentity");
51
+ if (entry.completion !== null)
52
+ issue("running entry cannot have a completion", "completion");
53
+ }
54
+ if (entry.state === "completed" || entry.state === "interrupted") {
55
+ if (entry.pid !== null)
56
+ issue("terminal entry cannot have a pid", "pid");
57
+ if (entry.completion === null)
58
+ issue("terminal entry requires a completion", "completion");
59
+ if (entry.completion !== null && entry.completion.executionId !== entry.executionId) {
60
+ issue("terminal completion executionId must match the entry", "completion");
61
+ }
62
+ }
63
+ if (entry.state === "interrupted" && entry.completion !== null) {
64
+ if (entry.completion.outcome !== "failed" || entry.completion.errorCode !== "interrupted") {
65
+ issue("interrupted entry requires an interrupted failed completion", "completion");
66
+ }
67
+ }
68
+ if ((entry.state === "accepted" || entry.state === "running") && entry.completionAcknowledged) {
69
+ issue("active entry cannot acknowledge completion", "completionAcknowledged");
70
+ }
71
+ });
72
+ const runningProcessIdentityBrand = Symbol("runningProcessIdentity");
73
+ export class JournalConflictError extends Error {
74
+ constructor(message) {
75
+ super(message);
76
+ this.name = "JournalConflictError";
77
+ }
78
+ }
79
+ export class JournalTransitionError extends Error {
80
+ constructor(message) {
81
+ super(message);
82
+ this.name = "JournalTransitionError";
83
+ }
84
+ }
85
+ export class JournalCorruptionError extends Error {
86
+ constructor(path, cause) {
87
+ super(`Invalid execution journal record: ${path}`, { cause });
88
+ this.name = "JournalCorruptionError";
89
+ }
90
+ }
91
+ export class JournalRecoveryError extends Error {
92
+ constructor(message, cause) {
93
+ super(message, cause === undefined ? undefined : { cause });
94
+ this.name = "JournalRecoveryError";
95
+ }
96
+ }
97
+ export async function captureRunningProcessIdentity(pid, processStartedAt, controller = defaultProcessController) {
98
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
99
+ throw new TypeError("pid must be a positive safe integer");
100
+ }
101
+ TimestampSchema.parse(processStartedAt);
102
+ let processIdentity;
103
+ try {
104
+ processIdentity = await controller.inspectIdentity(pid);
105
+ }
106
+ catch (error) {
107
+ throw new JournalRecoveryError(`Failed to inspect process ${pid} after spawn`, error);
108
+ }
109
+ if (processIdentity === null || processIdentity.trim().length === 0) {
110
+ throw new JournalRecoveryError(`Process ${pid} has no verifiable identity after spawn`);
111
+ }
112
+ return {
113
+ pid,
114
+ processStartedAt,
115
+ processIdentity,
116
+ [runningProcessIdentityBrand]: true,
117
+ };
118
+ }
119
+ const DEFAULT_RETENTION_DAYS = 7;
120
+ const DEFAULT_MAX_ENTRIES = 10_000;
121
+ const DEFAULT_TERMINATION_GRACE_MS = 30_000;
122
+ const DEFAULT_KILL_VERIFICATION_DELAY_MS = 100;
123
+ const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
124
+ const directoryQueues = new Map();
125
+ function runSerialized(directory, operation) {
126
+ const previous = directoryQueues.get(directory) ?? Promise.resolve();
127
+ const result = previous.then(operation, operation);
128
+ directoryQueues.set(directory, result.then(() => undefined, () => undefined));
129
+ return result;
130
+ }
131
+ function runCommand(command, args, options = {}) {
132
+ return new Promise((resolve, reject) => {
133
+ execFile(command, [...args], { encoding: "utf8", ...options }, (error, stdout) => {
134
+ if (error !== null)
135
+ reject(error);
136
+ else
137
+ resolve({ stdout });
138
+ });
139
+ });
140
+ }
141
+ function errorCode(error) {
142
+ return error instanceof Error && "code" in error && (typeof error.code === "string"
143
+ || typeof error.code === "number")
144
+ ? error.code
145
+ : undefined;
146
+ }
147
+ function linuxIdentity(raw) {
148
+ const commandEnd = raw.lastIndexOf(")");
149
+ if (commandEnd < 0)
150
+ throw new Error("Malformed /proc stat: missing command terminator");
151
+ const fields = raw.slice(commandEnd + 1).trim().split(/\s+/);
152
+ const startTimeTicks = fields[19];
153
+ if (startTimeTicks === undefined || !/^\d+$/.test(startTimeTicks)) {
154
+ throw new Error("Malformed /proc stat: missing starttime");
155
+ }
156
+ return startTimeTicks;
157
+ }
158
+ function linuxBootId(raw) {
159
+ const bootId = raw.trim().toLowerCase();
160
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(bootId)) {
161
+ throw new Error("Malformed Linux boot identity");
162
+ }
163
+ return bootId;
164
+ }
165
+ export function createProcessController(dependencies = {}) {
166
+ const platform = dependencies.platform ?? process.platform;
167
+ const readTextFile = dependencies.readTextFile ?? ((path) => readFile(path, "utf8"));
168
+ const execute = dependencies.runCommand ?? runCommand;
169
+ const commandTimeoutMs = dependencies.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
170
+ const supervisorTree = dependencies.supervisorTree ?? false;
171
+ if (!Number.isFinite(commandTimeoutMs) || commandTimeoutMs <= 0) {
172
+ throw new RangeError("commandTimeoutMs must be a positive finite number");
173
+ }
174
+ const executeWithTimeout = async (command, args, options = {}) => {
175
+ const abortController = new AbortController();
176
+ const commandPromise = execute(command, args, { ...options, signal: abortController.signal });
177
+ commandPromise.catch(() => undefined);
178
+ let timer;
179
+ const timeout = new Promise((_resolve, reject) => {
180
+ timer = setTimeout(() => {
181
+ abortController.abort();
182
+ reject(new Error(`Process identity command timed out after ${commandTimeoutMs}ms`));
183
+ }, commandTimeoutMs);
184
+ });
185
+ try {
186
+ return await Promise.race([commandPromise, timeout]);
187
+ }
188
+ finally {
189
+ if (timer !== undefined)
190
+ clearTimeout(timer);
191
+ }
192
+ };
193
+ const inspectIdentity = async (pid) => {
194
+ if (platform === "linux") {
195
+ let startTimeTicks;
196
+ try {
197
+ startTimeTicks = linuxIdentity(await readTextFile(`/proc/${pid}/stat`));
198
+ }
199
+ catch (error) {
200
+ if (errorCode(error) === "ENOENT")
201
+ return null;
202
+ throw error;
203
+ }
204
+ const bootId = linuxBootId(await readTextFile("/proc/sys/kernel/random/boot_id"));
205
+ return `linux:${bootId}:${startTimeTicks}`;
206
+ }
207
+ if (platform === "win32") {
208
+ const script = `$p=Get-Process -Id ${pid} -ErrorAction SilentlyContinue; `
209
+ + "if ($null -eq $p) { Write-Output '__ABSENT__'; exit 0 }; "
210
+ + "Write-Output ('windows:' + $p.StartTime.ToUniversalTime().ToFileTimeUtc())";
211
+ const { stdout } = await executeWithTimeout("powershell.exe", [
212
+ "-NoProfile", "-NonInteractive", "-Command", script,
213
+ ]);
214
+ const token = stdout.trim();
215
+ if (token === "__ABSENT__")
216
+ return null;
217
+ if (!/^windows:\d+$/.test(token))
218
+ throw new Error("Malformed PowerShell process identity");
219
+ return token;
220
+ }
221
+ try {
222
+ const { stdout } = await executeWithTimeout("ps", ["-p", String(pid), "-o", "lstart="], { env: { ...process.env, LC_ALL: "C", LANG: "C" } });
223
+ const token = stdout.trim().replace(/\s+/g, " ");
224
+ if (token.length === 0)
225
+ throw new Error("Malformed ps process identity");
226
+ return `unix:${token}`;
227
+ }
228
+ catch (error) {
229
+ if (errorCode(error) === 1)
230
+ return null;
231
+ throw error;
232
+ }
233
+ };
234
+ return {
235
+ inspectIdentity,
236
+ signal: dependencies.signal ?? (async (pid, signal) => {
237
+ if (!supervisorTree) {
238
+ process.kill(pid, signal);
239
+ return;
240
+ }
241
+ if (platform === "win32") {
242
+ await executeWithTimeout("taskkill.exe", [
243
+ "/PID", String(pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : []),
244
+ ]);
245
+ return;
246
+ }
247
+ process.kill(-pid, signal);
248
+ }),
249
+ wait: dependencies.wait ?? (async (milliseconds) => {
250
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
251
+ }),
252
+ };
253
+ }
254
+ export const defaultProcessController = createProcessController();
255
+ export const defaultSupervisorProcessController = createProcessController({
256
+ supervisorTree: true,
257
+ });
258
+ function assertNonnegativeFinite(value, name) {
259
+ if (!Number.isFinite(value) || value < 0) {
260
+ throw new RangeError(`${name} must be a nonnegative finite number`);
261
+ }
262
+ }
263
+ function isMissingFile(error) {
264
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
265
+ }
266
+ function sameValue(left, right) {
267
+ return JSON.stringify(left) === JSON.stringify(right);
268
+ }
269
+ function validateExecutionId(executionId) {
270
+ if (!ExecutionIdSchema.safeParse(executionId).success) {
271
+ throw new TypeError("Invalid executionId: expected UUID");
272
+ }
273
+ }
274
+ export function createExecutionJournal(agentsRoot, options = {}) {
275
+ const directory = join(agentsRoot, ".crew", "executions");
276
+ const now = options.now ?? (() => new Date());
277
+ const retentionDays = options.retentionDays ?? DEFAULT_RETENTION_DAYS;
278
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
279
+ const terminationGraceMs = options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS;
280
+ const killVerificationDelayMs = options.killVerificationDelayMs
281
+ ?? DEFAULT_KILL_VERIFICATION_DELAY_MS;
282
+ const processController = options.processController ?? defaultSupervisorProcessController;
283
+ const lockProcessController = options.lockProcessController ?? defaultProcessController;
284
+ const platform = options.platform ?? process.platform;
285
+ assertNonnegativeFinite(retentionDays, "retentionDays");
286
+ assertNonnegativeFinite(maxEntries, "maxEntries");
287
+ assertNonnegativeFinite(terminationGraceMs, "terminationGraceMs");
288
+ assertNonnegativeFinite(killVerificationDelayMs, "killVerificationDelayMs");
289
+ if (!Number.isInteger(maxEntries))
290
+ throw new RangeError("maxEntries must be an integer");
291
+ let initialized = false;
292
+ const recordPath = (executionId) => join(directory, `${executionId}.json`);
293
+ const parseRecord = (path, raw) => {
294
+ try {
295
+ const entry = JournalEntrySchema.parse(JSON.parse(raw));
296
+ const filename = basename(path, ".json");
297
+ if (entry.executionId !== filename) {
298
+ throw new Error("executionId does not match the journal filename");
299
+ }
300
+ return entry;
301
+ }
302
+ catch (error) {
303
+ throw new JournalCorruptionError(path, error);
304
+ }
305
+ };
306
+ const readRecord = async (executionId) => {
307
+ const path = recordPath(executionId);
308
+ try {
309
+ return parseRecord(path, await readFile(path, "utf8"));
310
+ }
311
+ catch (error) {
312
+ if (isMissingFile(error))
313
+ return null;
314
+ throw error;
315
+ }
316
+ };
317
+ const readAll = async () => {
318
+ const names = (await readdir(directory))
319
+ .filter((name) => name.endsWith(".json"))
320
+ .sort();
321
+ const entries = [];
322
+ for (const name of names) {
323
+ const path = join(directory, name);
324
+ entries.push(parseRecord(path, await readFile(path, "utf8")));
325
+ }
326
+ return entries;
327
+ };
328
+ const defaultSyncDirectory = async (path) => {
329
+ if (platform === "win32")
330
+ return;
331
+ const handle = await open(path, "r");
332
+ try {
333
+ await handle.sync();
334
+ }
335
+ finally {
336
+ await handle.close();
337
+ }
338
+ };
339
+ const fsyncDirectory = options.syncDirectory ?? defaultSyncDirectory;
340
+ const fsyncFinalFile = async (path) => {
341
+ const handle = await open(path, "r+");
342
+ try {
343
+ await handle.sync();
344
+ }
345
+ finally {
346
+ await handle.close();
347
+ }
348
+ };
349
+ const lease = createJournalLease({
350
+ directory,
351
+ currentPid: process.pid,
352
+ captureCurrentIdentity: async () => {
353
+ try {
354
+ const identity = await lockProcessController.inspectIdentity(process.pid);
355
+ if (identity === null)
356
+ throw new Error("current process identity is absent");
357
+ return identity;
358
+ }
359
+ catch (error) {
360
+ throw new JournalRecoveryError("Failed to capture journal lock owner identity", error);
361
+ }
362
+ },
363
+ inspectIdentity: async (pid) => {
364
+ try {
365
+ return await lockProcessController.inspectIdentity(pid);
366
+ }
367
+ catch (error) {
368
+ throw new JournalRecoveryError("Failed to inspect journal lock owner", error);
369
+ }
370
+ },
371
+ syncDirectory: fsyncDirectory,
372
+ ...(options.lockFileSystem === undefined ? {} : { fileSystem: options.lockFileSystem }),
373
+ ...(options.lockHooks === undefined ? {} : { hooks: options.lockHooks }),
374
+ ...(options.lockNow === undefined ? {} : { now: options.lockNow }),
375
+ ...(options.lockOrphanGraceMs === undefined
376
+ ? {}
377
+ : { orphanGraceMs: options.lockOrphanGraceMs }),
378
+ });
379
+ const confirmDurable = async (entry) => {
380
+ const diskEntry = await readRecord(entry.executionId);
381
+ if (diskEntry === null || !sameValue(diskEntry, entry)) {
382
+ throw new JournalCorruptionError(recordPath(entry.executionId), new Error("Durable journal record does not match expected entry"));
383
+ }
384
+ await fsyncFinalFile(recordPath(entry.executionId));
385
+ await fsyncDirectory(directory);
386
+ return diskEntry;
387
+ };
388
+ const writeRecord = async (entry) => {
389
+ const validated = JournalEntrySchema.parse(entry);
390
+ const temporaryPath = join(directory, `${entry.executionId}.tmp`);
391
+ let handle = null;
392
+ try {
393
+ handle = await open(temporaryPath, "w", 0o600);
394
+ await handle.writeFile(`${JSON.stringify(validated, null, 2)}\n`, "utf8");
395
+ await handle.sync();
396
+ await handle.close();
397
+ handle = null;
398
+ const finalPath = recordPath(entry.executionId);
399
+ await rename(temporaryPath, finalPath);
400
+ await confirmDurable(validated);
401
+ }
402
+ catch (error) {
403
+ if (handle !== null)
404
+ await handle.close().catch(() => undefined);
405
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
406
+ throw error;
407
+ }
408
+ };
409
+ const pruneInternal = async () => {
410
+ const entries = await readAll();
411
+ const acknowledged = entries
412
+ .filter((entry) => (entry.state === "completed" || entry.state === "interrupted")
413
+ && entry.completionAcknowledged)
414
+ .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)
415
+ || left.executionId.localeCompare(right.executionId));
416
+ const cutoff = now().valueOf() - retentionDays * 24 * 60 * 60 * 1_000;
417
+ const expired = acknowledged.filter((entry) => Date.parse(entry.updatedAt) < cutoff);
418
+ const retained = acknowledged.filter((entry) => Date.parse(entry.updatedAt) >= cutoff);
419
+ const overLimit = retained.slice(0, Math.max(0, retained.length - maxEntries));
420
+ const toDelete = new Set([...expired, ...overLimit].map((entry) => entry.executionId));
421
+ for (const executionId of toDelete) {
422
+ await unlink(recordPath(executionId));
423
+ }
424
+ if (toDelete.size > 0)
425
+ await fsyncDirectory(directory);
426
+ };
427
+ const initialize = async () => {
428
+ if (initialized)
429
+ return;
430
+ await mkdir(directory, { recursive: true, mode: 0o700 });
431
+ await lease.acquire();
432
+ const names = await readdir(directory);
433
+ for (const name of names.filter((candidate) => candidate.endsWith(".tmp"))) {
434
+ await rm(join(directory, name), { force: true });
435
+ }
436
+ await pruneInternal();
437
+ initialized = true;
438
+ };
439
+ const serialized = (operation) => runSerialized(directory, async () => {
440
+ lease.assertUsable();
441
+ await initialize();
442
+ return operation();
443
+ });
444
+ const requireRecord = async (executionId) => {
445
+ const entry = await readRecord(executionId);
446
+ if (entry === null)
447
+ throw new JournalTransitionError(`Unknown execution: ${executionId}`);
448
+ return entry;
449
+ };
450
+ const interruptedCompletion = (entry) => {
451
+ const startedAt = entry.state === "running" && entry.processStartedAt !== null
452
+ ? entry.processStartedAt
453
+ : entry.acceptedAt;
454
+ return ExecutionCompletedSchema.parse({
455
+ type: "execution:completed",
456
+ protocolVersion: 1,
457
+ executionId: entry.executionId,
458
+ outcome: "failed",
459
+ errorCode: "interrupted",
460
+ runtime: entry.requestedRuntime,
461
+ ...(entry.requestedModel === null ? {} : { model: entry.requestedModel }),
462
+ resumed: entry.resumed,
463
+ startedAt,
464
+ finishedAt: now().toISOString(),
465
+ });
466
+ };
467
+ const writeInterrupted = async (entry) => {
468
+ await writeRecord({
469
+ ...entry,
470
+ state: "interrupted",
471
+ pid: null,
472
+ completion: interruptedCompletion(entry),
473
+ completionAcknowledged: false,
474
+ updatedAt: now().toISOString(),
475
+ });
476
+ };
477
+ const inspectForRecovery = async (pid) => {
478
+ try {
479
+ return await processController.inspectIdentity(pid);
480
+ }
481
+ catch (error) {
482
+ throw new JournalRecoveryError(`Failed to inspect running process ${pid}`, error);
483
+ }
484
+ };
485
+ const waitForRecovery = async (milliseconds, phase) => {
486
+ try {
487
+ await processController.wait(milliseconds);
488
+ }
489
+ catch (error) {
490
+ throw new JournalRecoveryError(`Failed while waiting ${phase}`, error);
491
+ }
492
+ };
493
+ const proveRunningProcessStopped = async (entry) => {
494
+ if (entry.pid === null || entry.processIdentity === null) {
495
+ throw new JournalRecoveryError(`Execution ${entry.executionId} lacks process identity`);
496
+ }
497
+ const { pid, processIdentity } = entry;
498
+ const initialIdentity = await inspectForRecovery(pid);
499
+ if (initialIdentity === null || initialIdentity !== processIdentity)
500
+ return;
501
+ try {
502
+ await processController.signal(pid, "SIGTERM");
503
+ }
504
+ catch (error) {
505
+ if (errorCode(error) === "ESRCH")
506
+ return;
507
+ throw new JournalRecoveryError(`Failed to terminate process ${pid}`, error);
508
+ }
509
+ await waitForRecovery(terminationGraceMs, "for process termination");
510
+ const afterTerm = await inspectForRecovery(pid);
511
+ if (afterTerm === null || afterTerm !== processIdentity)
512
+ return;
513
+ try {
514
+ await processController.signal(pid, "SIGKILL");
515
+ }
516
+ catch (error) {
517
+ if (errorCode(error) === "ESRCH")
518
+ return;
519
+ throw new JournalRecoveryError(`Failed to kill process ${pid}`, error);
520
+ }
521
+ await waitForRecovery(killVerificationDelayMs, "to verify process kill");
522
+ const afterKill = await inspectForRecovery(pid);
523
+ if (afterKill === null || afterKill !== processIdentity)
524
+ return;
525
+ throw new JournalRecoveryError(`Process ${pid} still matches after SIGKILL`);
526
+ };
527
+ return {
528
+ accept: async (executionId, specHash, recoveryFacts) => {
529
+ validateExecutionId(executionId);
530
+ if (specHash.length === 0)
531
+ throw new TypeError("specHash must not be empty");
532
+ return serialized(async () => {
533
+ const existing = await readRecord(executionId);
534
+ if (existing !== null) {
535
+ if (existing.specHash !== specHash) {
536
+ throw new JournalConflictError(`Execution ${executionId} already has a different specHash`);
537
+ }
538
+ return { kind: "existing", ...await confirmDurable(existing) };
539
+ }
540
+ const timestamp = now().toISOString();
541
+ const entry = JournalEntrySchema.parse({
542
+ executionId,
543
+ specHash,
544
+ state: "accepted",
545
+ pid: null,
546
+ completion: null,
547
+ completionAcknowledged: false,
548
+ updatedAt: timestamp,
549
+ requestedRuntime: recoveryFacts.runtime,
550
+ requestedModel: recoveryFacts.model ?? null,
551
+ resumed: recoveryFacts.resumed ?? false,
552
+ acceptedAt: timestamp,
553
+ processStartedAt: null,
554
+ processIdentity: null,
555
+ effectivePermission: recoveryFacts.effectivePermission ?? null,
556
+ });
557
+ await writeRecord(entry);
558
+ return { kind: "created", ...entry };
559
+ });
560
+ },
561
+ startGuarded: async (executionId, processStartedAt, startDormant) => {
562
+ validateExecutionId(executionId);
563
+ TimestampSchema.parse(processStartedAt);
564
+ return serialized(async () => {
565
+ const entry = await requireRecord(executionId);
566
+ if (entry.state !== "accepted") {
567
+ return { kind: "existing", entry: await confirmDurable(entry) };
568
+ }
569
+ const dormant = await startDormant();
570
+ const abortWith = async (error) => {
571
+ try {
572
+ await dormant.abort();
573
+ }
574
+ catch (abortError) {
575
+ throw new AggregateError([error, abortError], "Dormant runtime abort failed");
576
+ }
577
+ throw error;
578
+ };
579
+ try {
580
+ if (dormant.parentExitGuard !== "pipe-eof") {
581
+ throw new TypeError("Dormant runtime must use a pipe-eof parent exit guard");
582
+ }
583
+ const identity = await captureRunningProcessIdentity(dormant.pid, processStartedAt, processController);
584
+ const updated = JournalEntrySchema.parse({
585
+ ...entry,
586
+ state: "running",
587
+ pid: identity.pid,
588
+ processStartedAt: identity.processStartedAt,
589
+ processIdentity: identity.processIdentity,
590
+ updatedAt: now().toISOString(),
591
+ });
592
+ await writeRecord(updated);
593
+ try {
594
+ await dormant.release();
595
+ }
596
+ catch (error) {
597
+ return abortWith(error);
598
+ }
599
+ return { kind: "started", entry: updated, handle: dormant };
600
+ }
601
+ catch (error) {
602
+ return abortWith(error);
603
+ }
604
+ });
605
+ },
606
+ complete: async (executionId, completionInput) => {
607
+ validateExecutionId(executionId);
608
+ const completion = ExecutionCompletedSchema.parse(completionInput);
609
+ if (completion.executionId !== executionId) {
610
+ throw new JournalConflictError("Completion executionId does not match the journal record");
611
+ }
612
+ return serialized(async () => {
613
+ const entry = await requireRecord(executionId);
614
+ if (entry.state === "completed" || entry.state === "interrupted") {
615
+ if (sameValue(entry.completion, completion))
616
+ return confirmDurable(entry);
617
+ throw new JournalConflictError(`Execution ${executionId} already has a different completion`);
618
+ }
619
+ const updated = JournalEntrySchema.parse({
620
+ ...entry,
621
+ state: "completed",
622
+ pid: null,
623
+ completion,
624
+ completionAcknowledged: false,
625
+ updatedAt: now().toISOString(),
626
+ });
627
+ await writeRecord(updated);
628
+ return updated;
629
+ });
630
+ },
631
+ acknowledgeCompletion: async (executionId) => {
632
+ validateExecutionId(executionId);
633
+ return serialized(async () => {
634
+ const entry = await requireRecord(executionId);
635
+ if (entry.state !== "completed" && entry.state !== "interrupted") {
636
+ throw new JournalTransitionError(`Cannot acknowledge ${entry.state} execution`);
637
+ }
638
+ if (entry.completionAcknowledged)
639
+ return confirmDurable(entry);
640
+ const updated = JournalEntrySchema.parse({
641
+ ...entry,
642
+ completionAcknowledged: true,
643
+ updatedAt: now().toISOString(),
644
+ });
645
+ await writeRecord(updated);
646
+ await pruneInternal();
647
+ return updated;
648
+ });
649
+ },
650
+ get: async (executionId) => {
651
+ validateExecutionId(executionId);
652
+ return serialized(() => readRecord(executionId));
653
+ },
654
+ replay: async () => serialized(async () => (await readAll())
655
+ .filter((entry) => entry.state === "accepted"
656
+ || entry.state === "running"
657
+ || !entry.completionAcknowledged)
658
+ .sort((left, right) => left.executionId.localeCompare(right.executionId))),
659
+ reconcileAfterRestart: async () => serialized(async () => {
660
+ const active = (await readAll()).filter((entry) => entry.state === "accepted" || entry.state === "running");
661
+ const results = await Promise.allSettled(active.map(async (entry) => {
662
+ if (entry.state === "running")
663
+ await proveRunningProcessStopped(entry);
664
+ await writeInterrupted(entry);
665
+ }));
666
+ const failures = results
667
+ .filter((result) => result.status === "rejected")
668
+ .map((result) => result.reason);
669
+ if (failures.length > 0) {
670
+ throw new AggregateError(failures, "One or more executions could not be reconciled");
671
+ }
672
+ }),
673
+ prune: async () => serialized(pruneInternal),
674
+ close: async () => {
675
+ await runSerialized(directory, lease.close);
676
+ },
677
+ };
678
+ }