pi-oracle 0.3.4 → 0.4.0
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 +21 -0
- package/README.md +2 -0
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +249 -0
- package/extensions/oracle/index.ts +8 -1
- package/extensions/oracle/lib/commands.ts +11 -24
- package/extensions/oracle/lib/config.ts +5 -0
- package/extensions/oracle/lib/jobs.ts +117 -217
- package/extensions/oracle/lib/locks.ts +41 -209
- package/extensions/oracle/lib/poller.ts +14 -51
- package/extensions/oracle/lib/queue.ts +75 -112
- package/extensions/oracle/lib/runtime.ts +60 -14
- package/extensions/oracle/lib/tools.ts +66 -65
- package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
- package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +130 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.mjs +377 -0
- package/extensions/oracle/shared/job-observability-helpers.d.mts +59 -0
- package/extensions/oracle/shared/job-observability-helpers.mjs +143 -0
- package/extensions/oracle/shared/process-helpers.d.mts +20 -0
- package/extensions/oracle/shared/process-helpers.mjs +128 -0
- package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
- package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
- package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +76 -130
- package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
- package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
- package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +93 -9
- package/extensions/oracle/worker/run-job.mjs +166 -274
- package/extensions/oracle/worker/state-locks.mjs +31 -216
- package/package.json +4 -3
|
@@ -1,35 +1,42 @@
|
|
|
1
|
+
// Purpose: Manage durable oracle job state, lifecycle transitions, reconciliation, and worker orchestration from the extension side.
|
|
2
|
+
// Responsibilities: Persist job metadata, detect stale workers, coordinate notification/wake-up state, and spawn or terminate worker processes safely.
|
|
3
|
+
// Scope: Extension-facing job management only; low-level shared process and state primitives live in extensions/oracle/shared.
|
|
4
|
+
// Usage: Imported by oracle commands, tools, queue logic, poller flows, and runtime cleanup/reconciliation paths.
|
|
5
|
+
// Invariants/Assumptions: Job mutations happen under per-job locks, worker identity checks defend against PID reuse, and persisted jobs remain the source of truth.
|
|
1
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
4
8
|
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
9
|
+
import { isAbsolute, join, relative as relativePath, resolve, sep } from "node:path";
|
|
6
10
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import {
|
|
12
|
+
ACTIVE_ORACLE_JOB_STATUSES,
|
|
13
|
+
applyOracleJobCleanupWarnings,
|
|
14
|
+
claimOracleJobNotification,
|
|
15
|
+
clearOracleJobCleanupState,
|
|
16
|
+
getOracleJobStatusForPhase,
|
|
17
|
+
markOracleJobCreated,
|
|
18
|
+
markOracleJobNotified,
|
|
19
|
+
markOracleJobWakeupSettled,
|
|
20
|
+
noteOracleJobWakeupRequested,
|
|
21
|
+
OPEN_ORACLE_JOB_STATUSES,
|
|
22
|
+
recordOracleJobNotificationTarget,
|
|
23
|
+
releaseOracleJobNotificationClaim,
|
|
24
|
+
TERMINAL_ORACLE_JOB_STATUSES,
|
|
25
|
+
transitionOracleJobPhase,
|
|
26
|
+
} from "../shared/job-lifecycle-helpers.mjs";
|
|
27
|
+
import type { OracleJobLifecycleEvent as SharedOracleJobLifecycleEvent, OracleJobPhase as SharedOracleJobPhase, OracleJobStatus as SharedOracleJobStatus } from "../shared/job-lifecycle-helpers.mjs";
|
|
28
|
+
import { hasDurableWorkerHandoff as sharedHasDurableWorkerHandoff } from "../shared/job-coordination-helpers.mjs";
|
|
29
|
+
import { isTrackedProcessAlive, readProcessStartedAt, spawnDetachedNodeProcess, terminateTrackedProcess } from "../shared/process-helpers.mjs";
|
|
7
30
|
import type { OracleConfig, OracleResolvedSelection } from "./config.js";
|
|
8
31
|
import { withJobLock, withLock } from "./locks.js";
|
|
9
32
|
import { cleanupRuntimeArtifacts, getProjectId, getSessionId, parseConversationId, requirePersistedSessionFile, type OracleCleanupReport } from "./runtime.js";
|
|
10
33
|
|
|
11
|
-
export type OracleJobStatus =
|
|
12
|
-
export type OracleJobPhase =
|
|
13
|
-
| "queued"
|
|
14
|
-
| "submitted"
|
|
15
|
-
| "cloning_runtime"
|
|
16
|
-
| "launching_browser"
|
|
17
|
-
| "verifying_auth"
|
|
18
|
-
| "configuring_model"
|
|
19
|
-
| "uploading_archive"
|
|
20
|
-
| "awaiting_response"
|
|
21
|
-
| "extracting_response"
|
|
22
|
-
| "downloading_artifacts"
|
|
23
|
-
| "complete"
|
|
24
|
-
| "complete_with_artifact_errors"
|
|
25
|
-
| "failed"
|
|
26
|
-
| "cancelled";
|
|
34
|
+
export type OracleJobStatus = SharedOracleJobStatus;
|
|
35
|
+
export type OracleJobPhase = SharedOracleJobPhase;
|
|
27
36
|
|
|
28
37
|
export type OracleWakeupSettlementSource = "oracle_read" | "oracle_status";
|
|
29
38
|
|
|
30
|
-
export
|
|
31
|
-
export const OPEN_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["queued", ...ACTIVE_ORACLE_JOB_STATUSES];
|
|
32
|
-
export const TERMINAL_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["complete", "failed", "cancelled"];
|
|
39
|
+
export { ACTIVE_ORACLE_JOB_STATUSES, OPEN_ORACLE_JOB_STATUSES, TERMINAL_ORACLE_JOB_STATUSES };
|
|
33
40
|
export const ORACLE_MISSING_WORKER_GRACE_MS = 30_000;
|
|
34
41
|
export const ORACLE_STALE_HEARTBEAT_MS = 3 * 60 * 1000;
|
|
35
42
|
export const ORACLE_NOTIFICATION_CLAIM_TTL_MS = 60_000;
|
|
@@ -65,9 +72,7 @@ export function hasRetainedPreSubmitArchive(job: Pick<OracleJob, "submittedAt" |
|
|
|
65
72
|
export function hasDurableWorkerHandoff(
|
|
66
73
|
job: Pick<OracleJob, "status" | "phase" | "workerPid" | "workerStartedAt" | "heartbeatAt">,
|
|
67
74
|
): boolean {
|
|
68
|
-
|
|
69
|
-
if (job.workerPid) return true;
|
|
70
|
-
return false;
|
|
75
|
+
return sharedHasDurableWorkerHandoff(job);
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
export function hasPersistedOriginSession(
|
|
@@ -76,30 +81,8 @@ export function hasPersistedOriginSession(
|
|
|
76
81
|
return typeof job.originSessionFile === "string" && job.originSessionFile.length > 0 && job.sessionId === job.originSessionFile;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
function readProcessStartedAt(pid: number | undefined): string | undefined {
|
|
80
|
-
if (!pid || pid <= 0) return undefined;
|
|
81
|
-
try {
|
|
82
|
-
const startedAt = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
|
|
83
|
-
return startedAt || undefined;
|
|
84
|
-
} catch {
|
|
85
|
-
return undefined;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function waitForProcessStartedAt(pid: number | undefined, timeoutMs = 2_000): Promise<string | undefined> {
|
|
90
|
-
const deadline = Date.now() + timeoutMs;
|
|
91
|
-
while (Date.now() < deadline) {
|
|
92
|
-
const startedAt = readProcessStartedAt(pid);
|
|
93
|
-
if (startedAt) return startedAt;
|
|
94
|
-
await sleep(100);
|
|
95
|
-
}
|
|
96
|
-
return readProcessStartedAt(pid);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
84
|
export function isWorkerProcessAlive(pid: number | undefined, startedAt?: string): boolean {
|
|
100
|
-
|
|
101
|
-
if (!currentStartedAt) return false;
|
|
102
|
-
return startedAt ? currentStartedAt === startedAt : true;
|
|
85
|
+
return isTrackedProcessAlive(pid, startedAt);
|
|
103
86
|
}
|
|
104
87
|
|
|
105
88
|
export interface OracleArtifactRecord {
|
|
@@ -178,6 +161,7 @@ export interface OracleJob {
|
|
|
178
161
|
cleanupWarnings?: string[];
|
|
179
162
|
lastCleanupAt?: string;
|
|
180
163
|
cleanupPending?: boolean;
|
|
164
|
+
lifecycleEvents?: SharedOracleJobLifecycleEvent[];
|
|
181
165
|
}
|
|
182
166
|
|
|
183
167
|
export interface OracleSubmitInput {
|
|
@@ -267,13 +251,7 @@ export async function updateJob(id: string, mutate: (job: OracleJob) => OracleJo
|
|
|
267
251
|
export async function appendCleanupWarnings(jobId: string, warnings: string[], at = new Date().toISOString()): Promise<OracleJob | undefined> {
|
|
268
252
|
if (warnings.length === 0) return readJob(jobId);
|
|
269
253
|
try {
|
|
270
|
-
return await updateJob(jobId, (job) => ({
|
|
271
|
-
...job,
|
|
272
|
-
cleanupPending: false,
|
|
273
|
-
cleanupWarnings: Array.from(new Set([...(job.cleanupWarnings || []), ...warnings])),
|
|
274
|
-
lastCleanupAt: at,
|
|
275
|
-
error: [job.error, ...warnings].filter(Boolean).join("\n"),
|
|
276
|
-
}));
|
|
254
|
+
return await updateJob(jobId, (job) => applyOracleJobCleanupWarnings(job, warnings, { at, source: "oracle:jobs" }));
|
|
277
255
|
} catch {
|
|
278
256
|
return readJob(jobId);
|
|
279
257
|
}
|
|
@@ -281,21 +259,12 @@ export async function appendCleanupWarnings(jobId: string, warnings: string[], a
|
|
|
281
259
|
|
|
282
260
|
export async function clearCleanupPending(jobId: string, at = new Date().toISOString()): Promise<OracleJob | undefined> {
|
|
283
261
|
try {
|
|
284
|
-
return await updateJob(jobId, (job) => ({
|
|
285
|
-
...job,
|
|
286
|
-
cleanupPending: false,
|
|
287
|
-
cleanupWarnings: undefined,
|
|
288
|
-
lastCleanupAt: at,
|
|
289
|
-
}));
|
|
262
|
+
return await updateJob(jobId, (job) => clearOracleJobCleanupState(job, { at, source: "oracle:jobs" }));
|
|
290
263
|
} catch {
|
|
291
264
|
return readJob(jobId);
|
|
292
265
|
}
|
|
293
266
|
}
|
|
294
267
|
|
|
295
|
-
function sleep(ms: number): Promise<void> {
|
|
296
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
297
|
-
}
|
|
298
|
-
|
|
299
268
|
function parseTimestamp(value: string | undefined): number | undefined {
|
|
300
269
|
if (!value) return undefined;
|
|
301
270
|
const parsed = Date.parse(value);
|
|
@@ -335,13 +304,14 @@ export function shouldRequestWakeup(job: Pick<OracleJob, "wakeupAttemptCount" |
|
|
|
335
304
|
return now - lastRequestedAtMs >= getWakeupRetryDelayMs(attempts);
|
|
336
305
|
}
|
|
337
306
|
|
|
338
|
-
export function withJobPhase<T extends Pick<OracleJob, "phase" | "phaseAt">>(
|
|
307
|
+
export function withJobPhase<T extends Pick<OracleJob, "phase" | "phaseAt" | "status">>(
|
|
339
308
|
phase: OracleJobPhase,
|
|
340
309
|
patch?: Omit<Partial<OracleJob>, "phase" | "phaseAt">,
|
|
341
310
|
at = new Date().toISOString(),
|
|
342
311
|
): Partial<OracleJob> {
|
|
343
312
|
return {
|
|
344
313
|
...(patch || {}),
|
|
314
|
+
status: (patch?.status as OracleJobStatus | undefined) ?? getOracleJobStatusForPhase(phase),
|
|
345
315
|
phase,
|
|
346
316
|
phaseAt: at,
|
|
347
317
|
};
|
|
@@ -356,39 +326,7 @@ export async function terminateWorkerPid(
|
|
|
356
326
|
startedAt?: string,
|
|
357
327
|
options?: { termGraceMs?: number; killGraceMs?: number },
|
|
358
328
|
): Promise<boolean> {
|
|
359
|
-
|
|
360
|
-
const currentStartedAt = readProcessStartedAt(pid);
|
|
361
|
-
if (!currentStartedAt) return true;
|
|
362
|
-
if (startedAt && currentStartedAt !== startedAt) return false;
|
|
363
|
-
|
|
364
|
-
const termGraceMs = options?.termGraceMs ?? 5000;
|
|
365
|
-
const killGraceMs = options?.killGraceMs ?? 2000;
|
|
366
|
-
|
|
367
|
-
try {
|
|
368
|
-
process.kill(pid, "SIGTERM");
|
|
369
|
-
} catch {
|
|
370
|
-
return !isWorkerProcessAlive(pid, startedAt);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const termDeadline = Date.now() + termGraceMs;
|
|
374
|
-
while (Date.now() < termDeadline) {
|
|
375
|
-
if (!isWorkerProcessAlive(pid, startedAt)) return true;
|
|
376
|
-
await sleep(250);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
try {
|
|
380
|
-
process.kill(pid, "SIGKILL");
|
|
381
|
-
} catch {
|
|
382
|
-
return !isWorkerProcessAlive(pid, startedAt);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const killDeadline = Date.now() + killGraceMs;
|
|
386
|
-
while (Date.now() < killDeadline) {
|
|
387
|
-
if (!isWorkerProcessAlive(pid, startedAt)) return true;
|
|
388
|
-
await sleep(250);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
return !isWorkerProcessAlive(pid, startedAt);
|
|
329
|
+
return terminateTrackedProcess(pid, startedAt, options);
|
|
392
330
|
}
|
|
393
331
|
|
|
394
332
|
export function getStaleOracleJobReason(job: OracleJob, now = Date.now()): string | undefined {
|
|
@@ -547,13 +485,11 @@ export async function removeTerminalOracleJob(job: OracleJob): Promise<{ removed
|
|
|
547
485
|
|
|
548
486
|
const cleanupReport = await cleanupJobResources(current);
|
|
549
487
|
if (cleanupReport.warnings.length > 0) {
|
|
550
|
-
await writeJobUnlocked({
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
error: [current.error, ...cleanupReport.warnings].filter(Boolean).join("\n"),
|
|
556
|
-
});
|
|
488
|
+
await writeJobUnlocked(applyOracleJobCleanupWarnings(current, cleanupReport.warnings, {
|
|
489
|
+
at: new Date().toISOString(),
|
|
490
|
+
source: "oracle:cleanup",
|
|
491
|
+
message: "Terminal job cleanup completed with warnings during removal.",
|
|
492
|
+
}));
|
|
557
493
|
return { removed: false, cleanupReport };
|
|
558
494
|
}
|
|
559
495
|
await rm(getJobDir(current.id), { recursive: true, force: true });
|
|
@@ -644,20 +580,19 @@ export async function reconcileStaleOracleJobs(): Promise<OracleJob[]> {
|
|
|
644
580
|
? ` Terminated stale worker PID ${current.workerPid}.`
|
|
645
581
|
: ` Failed to terminate stale worker PID ${current.workerPid}.`
|
|
646
582
|
: "";
|
|
647
|
-
repairedJob = {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
583
|
+
repairedJob = transitionOracleJobPhase(current, "failed", {
|
|
584
|
+
at: recoveredAt,
|
|
585
|
+
source: "oracle:reconcile",
|
|
586
|
+
message: `Recovered stale job: ${currentStaleReason}.${suffix}`.trim(),
|
|
587
|
+
clearNotificationClaim: true,
|
|
588
|
+
patch: {
|
|
652
589
|
heartbeatAt: recoveredAt,
|
|
653
|
-
notifyClaimedAt: undefined,
|
|
654
|
-
notifyClaimedBy: undefined,
|
|
655
590
|
cleanupPending: terminated,
|
|
656
591
|
error: current.error
|
|
657
592
|
? `${current.error}\nRecovered stale job: ${currentStaleReason}.${suffix}`.trim()
|
|
658
593
|
: `Recovered stale job: ${currentStaleReason}.${suffix}`.trim(),
|
|
659
|
-
},
|
|
660
|
-
};
|
|
594
|
+
},
|
|
595
|
+
});
|
|
661
596
|
await writeJobUnlocked(repairedJob);
|
|
662
597
|
});
|
|
663
598
|
|
|
@@ -710,11 +645,7 @@ export async function tryClaimNotification(jobId: string, claimedBy: string, now
|
|
|
710
645
|
Date.now() - claimedAtMs < ORACLE_NOTIFICATION_CLAIM_TTL_MS;
|
|
711
646
|
if (claimIsLive) return undefined;
|
|
712
647
|
|
|
713
|
-
const next
|
|
714
|
-
...current,
|
|
715
|
-
notifyClaimedBy: claimedBy,
|
|
716
|
-
notifyClaimedAt: now,
|
|
717
|
-
};
|
|
648
|
+
const next = claimOracleJobNotification(current, claimedBy, now);
|
|
718
649
|
await writeJobUnlocked(next);
|
|
719
650
|
return next;
|
|
720
651
|
});
|
|
@@ -732,11 +663,12 @@ export async function recordNotificationTarget(
|
|
|
732
663
|
if (!notificationClaimIsOwnedBy(current, claimedBy)) {
|
|
733
664
|
throw new Error(`Oracle notification claim is not owned by ${claimedBy}: ${jobId}`);
|
|
734
665
|
}
|
|
735
|
-
const next
|
|
736
|
-
|
|
666
|
+
const next = recordOracleJobNotificationTarget(current, {
|
|
667
|
+
at: new Date().toISOString(),
|
|
668
|
+
source: "oracle:poller",
|
|
737
669
|
notificationSessionKey: options.notificationSessionKey,
|
|
738
670
|
notificationSessionFile: options.notificationSessionFile,
|
|
739
|
-
};
|
|
671
|
+
});
|
|
740
672
|
await writeJobUnlocked(next);
|
|
741
673
|
return next;
|
|
742
674
|
});
|
|
@@ -755,18 +687,13 @@ export async function markJobNotified(
|
|
|
755
687
|
if (!notificationClaimIsOwnedBy(current, claimedBy)) {
|
|
756
688
|
throw new Error(`Oracle notification claim is not owned by ${claimedBy}: ${jobId}`);
|
|
757
689
|
}
|
|
758
|
-
const next
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
notificationEntryId: options?.notificationEntryId
|
|
762
|
-
notificationSessionKey: options?.notificationSessionKey
|
|
763
|
-
notificationSessionFile: options?.notificationSessionFile
|
|
764
|
-
|
|
765
|
-
wakeupLastRequestedAt: undefined,
|
|
766
|
-
wakeupSettledAt: undefined,
|
|
767
|
-
notifyClaimedAt: undefined,
|
|
768
|
-
notifyClaimedBy: undefined,
|
|
769
|
-
};
|
|
690
|
+
const next = markOracleJobNotified(current, {
|
|
691
|
+
at,
|
|
692
|
+
source: "oracle:poller",
|
|
693
|
+
notificationEntryId: options?.notificationEntryId,
|
|
694
|
+
notificationSessionKey: options?.notificationSessionKey,
|
|
695
|
+
notificationSessionFile: options?.notificationSessionFile,
|
|
696
|
+
});
|
|
770
697
|
await writeJobUnlocked(next);
|
|
771
698
|
return next;
|
|
772
699
|
});
|
|
@@ -777,11 +704,7 @@ export async function releaseNotificationClaim(jobId: string, claimedBy: string)
|
|
|
777
704
|
const current = readJob(jobId);
|
|
778
705
|
if (!current) return undefined;
|
|
779
706
|
if (current.notifyClaimedBy && current.notifyClaimedBy !== claimedBy) return current;
|
|
780
|
-
const next
|
|
781
|
-
...current,
|
|
782
|
-
notifyClaimedAt: undefined,
|
|
783
|
-
notifyClaimedBy: undefined,
|
|
784
|
-
};
|
|
707
|
+
const next = releaseOracleJobNotificationClaim(current);
|
|
785
708
|
await writeJobUnlocked(next);
|
|
786
709
|
return next;
|
|
787
710
|
});
|
|
@@ -789,11 +712,7 @@ export async function releaseNotificationClaim(jobId: string, claimedBy: string)
|
|
|
789
712
|
|
|
790
713
|
export async function noteWakeupRequested(jobId: string, at = new Date().toISOString()): Promise<OracleJob | undefined> {
|
|
791
714
|
try {
|
|
792
|
-
return await updateJob(jobId, (job) => ({
|
|
793
|
-
...job,
|
|
794
|
-
wakeupAttemptCount: (job.wakeupAttemptCount ?? 0) + 1,
|
|
795
|
-
wakeupLastRequestedAt: at,
|
|
796
|
-
}));
|
|
715
|
+
return await updateJob(jobId, (job) => noteOracleJobWakeupRequested(job, { at, source: "oracle:poller" }));
|
|
797
716
|
} catch {
|
|
798
717
|
return readJob(jobId);
|
|
799
718
|
}
|
|
@@ -819,37 +738,13 @@ export async function markWakeupSettled(
|
|
|
819
738
|
const sessionKey = getWakeupSessionKey(options.sessionFile, options.cwd);
|
|
820
739
|
|
|
821
740
|
try {
|
|
822
|
-
return await updateJob(jobId, (job) => {
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
wakeupSettledSessionKey: job.wakeupSettledSessionKey ?? sessionKey,
|
|
830
|
-
wakeupSettledBeforeFirstAttempt: job.wakeupSettledBeforeFirstAttempt ?? beforeFirstAttempt,
|
|
831
|
-
};
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
if (beforeFirstAttempt && !options.allowBeforeFirstAttempt) {
|
|
835
|
-
return {
|
|
836
|
-
...job,
|
|
837
|
-
wakeupObservedAt: job.wakeupObservedAt ?? at,
|
|
838
|
-
wakeupObservedSource: job.wakeupObservedSource ?? options.source,
|
|
839
|
-
wakeupObservedSessionFile: job.wakeupObservedSessionFile ?? options.sessionFile,
|
|
840
|
-
wakeupObservedSessionKey: job.wakeupObservedSessionKey ?? sessionKey,
|
|
841
|
-
};
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
return {
|
|
845
|
-
...job,
|
|
846
|
-
wakeupSettledAt: at,
|
|
847
|
-
wakeupSettledSource: options.source,
|
|
848
|
-
wakeupSettledSessionFile: options.sessionFile,
|
|
849
|
-
wakeupSettledSessionKey: sessionKey,
|
|
850
|
-
wakeupSettledBeforeFirstAttempt: beforeFirstAttempt,
|
|
851
|
-
};
|
|
852
|
-
});
|
|
741
|
+
return await updateJob(jobId, (job) => markOracleJobWakeupSettled(job, {
|
|
742
|
+
source: options.source,
|
|
743
|
+
at,
|
|
744
|
+
sessionFile: options.sessionFile,
|
|
745
|
+
sessionKey,
|
|
746
|
+
allowBeforeFirstAttempt: options.allowBeforeFirstAttempt,
|
|
747
|
+
}));
|
|
853
748
|
} catch {
|
|
854
749
|
return readJob(jobId);
|
|
855
750
|
}
|
|
@@ -863,26 +758,24 @@ export async function cancelOracleJob(id: string, reason = "Cancelled by user"):
|
|
|
863
758
|
|
|
864
759
|
const now = new Date().toISOString();
|
|
865
760
|
if (current.status === "queued") {
|
|
866
|
-
const cancelled = await updateJob(id, (job) => ({
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
761
|
+
const cancelled = await updateJob(id, (job) => transitionOracleJobPhase(job, "cancelled", {
|
|
762
|
+
at: now,
|
|
763
|
+
source: "oracle:cancel",
|
|
764
|
+
message: `Job cancelled: ${reason}`,
|
|
765
|
+
clearNotificationClaim: true,
|
|
766
|
+
patch: {
|
|
871
767
|
heartbeatAt: now,
|
|
872
|
-
notifyClaimedAt: undefined,
|
|
873
|
-
notifyClaimedBy: undefined,
|
|
874
768
|
error: reason,
|
|
875
|
-
},
|
|
769
|
+
},
|
|
876
770
|
}));
|
|
877
771
|
|
|
878
772
|
const cleanupReport = await cleanupJobResources(cancelled);
|
|
879
773
|
if (cleanupReport.warnings.length === 0) return cancelled;
|
|
880
774
|
|
|
881
|
-
return updateJob(id, (job) => ({
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
error: [job.error, ...cleanupReport.warnings].filter(Boolean).join("\n"),
|
|
775
|
+
return updateJob(id, (job) => applyOracleJobCleanupWarnings(job, cleanupReport.warnings, {
|
|
776
|
+
at: now,
|
|
777
|
+
source: "oracle:cancel",
|
|
778
|
+
message: "Queued job cleanup completed with warnings after cancellation.",
|
|
886
779
|
}));
|
|
887
780
|
}
|
|
888
781
|
|
|
@@ -891,18 +784,19 @@ export async function cancelOracleJob(id: string, reason = "Cancelled by user"):
|
|
|
891
784
|
const cancelled = await updateJob(id, (job) => {
|
|
892
785
|
if (isTerminalOracleJob(job)) return job;
|
|
893
786
|
transitioned = true;
|
|
894
|
-
return {
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
787
|
+
return transitionOracleJobPhase(job, terminated ? "cancelled" : "failed", {
|
|
788
|
+
at: now,
|
|
789
|
+
source: "oracle:cancel",
|
|
790
|
+
message: terminated
|
|
791
|
+
? `Job cancelled: ${reason}`
|
|
792
|
+
: `Job cancellation failed because worker PID ${job.workerPid ?? "unknown"} did not exit.`,
|
|
793
|
+
clearNotificationClaim: true,
|
|
794
|
+
patch: {
|
|
899
795
|
heartbeatAt: now,
|
|
900
|
-
notifyClaimedAt: undefined,
|
|
901
|
-
notifyClaimedBy: undefined,
|
|
902
796
|
cleanupPending: terminated,
|
|
903
797
|
error: terminated ? reason : `${reason}; worker PID ${job.workerPid ?? "unknown"} did not exit`,
|
|
904
|
-
},
|
|
905
|
-
};
|
|
798
|
+
},
|
|
799
|
+
});
|
|
906
800
|
});
|
|
907
801
|
if (!transitioned) return cancelled;
|
|
908
802
|
|
|
@@ -910,11 +804,10 @@ export async function cancelOracleJob(id: string, reason = "Cancelled by user"):
|
|
|
910
804
|
const cleanupWarnings = [
|
|
911
805
|
`Oracle runtime cleanup is blocked because worker PID ${current.workerPid ?? "unknown"} could not be terminated safely.`,
|
|
912
806
|
];
|
|
913
|
-
return updateJob(id, (job) => ({
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
error: [job.error, ...cleanupWarnings].filter(Boolean).join("\n"),
|
|
807
|
+
return updateJob(id, (job) => applyOracleJobCleanupWarnings(job, cleanupWarnings, {
|
|
808
|
+
at: now,
|
|
809
|
+
source: "oracle:cancel",
|
|
810
|
+
message: "Runtime cleanup remained blocked after cancellation.",
|
|
918
811
|
}));
|
|
919
812
|
}
|
|
920
813
|
|
|
@@ -924,12 +817,10 @@ export async function cancelOracleJob(id: string, reason = "Cancelled by user"):
|
|
|
924
817
|
return finalized ?? cancelled;
|
|
925
818
|
}
|
|
926
819
|
|
|
927
|
-
return updateJob(id, (job) => ({
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
lastCleanupAt: now,
|
|
932
|
-
error: [job.error, ...cleanupReport.warnings].filter(Boolean).join("\n"),
|
|
820
|
+
return updateJob(id, (job) => applyOracleJobCleanupWarnings(job, cleanupReport.warnings, {
|
|
821
|
+
at: now,
|
|
822
|
+
source: "oracle:cancel",
|
|
823
|
+
message: "Runtime cleanup completed with warnings after cancellation.",
|
|
933
824
|
}));
|
|
934
825
|
});
|
|
935
826
|
}
|
|
@@ -967,7 +858,7 @@ export async function createJob(
|
|
|
967
858
|
|
|
968
859
|
const createdAt = options?.createdAt ?? new Date().toISOString();
|
|
969
860
|
const initialState = options?.initialState ?? "submitted";
|
|
970
|
-
const job
|
|
861
|
+
const job = markOracleJobCreated({
|
|
971
862
|
id,
|
|
972
863
|
status: initialState,
|
|
973
864
|
phase: initialState,
|
|
@@ -999,17 +890,27 @@ export async function createJob(
|
|
|
999
890
|
runtimeProfileDir: runtime.runtimeProfileDir,
|
|
1000
891
|
seedGeneration: runtime.seedGeneration,
|
|
1001
892
|
config,
|
|
1002
|
-
}
|
|
893
|
+
} satisfies OracleJob, {
|
|
894
|
+
at: createdAt,
|
|
895
|
+
source: "oracle:create",
|
|
896
|
+
message: initialState === "queued" ? "Job created and queued for later admission." : "Job created and admitted for worker launch.",
|
|
897
|
+
});
|
|
1003
898
|
|
|
1004
899
|
await writeJob(job);
|
|
1005
900
|
return job;
|
|
1006
901
|
}
|
|
1007
902
|
|
|
903
|
+
function isPathInsideDirectory(rootPath: string, candidatePath: string): boolean {
|
|
904
|
+
const boundary = relativePath(rootPath, candidatePath);
|
|
905
|
+
return boundary === "" || (!boundary.startsWith(`..${sep}`) && boundary !== ".." && !isAbsolute(boundary));
|
|
906
|
+
}
|
|
907
|
+
|
|
1008
908
|
export function resolveArchiveInputs(cwd: string, files: string[]): { absolute: string; relative: string }[] {
|
|
1009
909
|
if (files.length === 0) {
|
|
1010
910
|
throw new Error("oracle_submit requires at least one file or directory to archive");
|
|
1011
911
|
}
|
|
1012
912
|
|
|
913
|
+
const realCwd = realpathSync(cwd);
|
|
1013
914
|
return files.map((file) => {
|
|
1014
915
|
const absolute = resolve(cwd, file);
|
|
1015
916
|
const relative = absolute.startsWith(`${cwd}/`) ? absolute.slice(cwd.length + 1) : absolute === cwd ? "." : "";
|
|
@@ -1019,6 +920,9 @@ export function resolveArchiveInputs(cwd: string, files: string[]): { absolute:
|
|
|
1019
920
|
if (!existsSync(absolute)) {
|
|
1020
921
|
throw new Error(`Archive input does not exist: ${file}`);
|
|
1021
922
|
}
|
|
923
|
+
if (!isPathInsideDirectory(realCwd, realpathSync(absolute))) {
|
|
924
|
+
throw new Error(`Archive input must resolve inside the project cwd without symlink escapes: ${file}`);
|
|
925
|
+
}
|
|
1022
926
|
return { absolute, relative };
|
|
1023
927
|
});
|
|
1024
928
|
}
|
|
@@ -1028,14 +932,10 @@ export async function spawnWorker(
|
|
|
1028
932
|
jobId: string,
|
|
1029
933
|
): Promise<{ pid: number | undefined; nonce: string; startedAt: string | undefined }> {
|
|
1030
934
|
const nonce = randomUUID();
|
|
1031
|
-
const child =
|
|
1032
|
-
detached: true,
|
|
1033
|
-
stdio: "ignore",
|
|
1034
|
-
});
|
|
1035
|
-
child.unref();
|
|
935
|
+
const child = await spawnDetachedNodeProcess(workerPath, [jobId, nonce]);
|
|
1036
936
|
return {
|
|
1037
937
|
pid: child.pid,
|
|
1038
938
|
nonce,
|
|
1039
|
-
startedAt:
|
|
939
|
+
startedAt: child.startedAt,
|
|
1040
940
|
};
|
|
1041
941
|
}
|