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.
Files changed (33) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +2 -0
  3. package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +249 -0
  4. package/extensions/oracle/index.ts +8 -1
  5. package/extensions/oracle/lib/commands.ts +11 -24
  6. package/extensions/oracle/lib/config.ts +5 -0
  7. package/extensions/oracle/lib/jobs.ts +117 -217
  8. package/extensions/oracle/lib/locks.ts +41 -209
  9. package/extensions/oracle/lib/poller.ts +14 -51
  10. package/extensions/oracle/lib/queue.ts +75 -112
  11. package/extensions/oracle/lib/runtime.ts +60 -14
  12. package/extensions/oracle/lib/tools.ts +66 -65
  13. package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
  14. package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
  15. package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +130 -0
  16. package/extensions/oracle/shared/job-lifecycle-helpers.mjs +377 -0
  17. package/extensions/oracle/shared/job-observability-helpers.d.mts +59 -0
  18. package/extensions/oracle/shared/job-observability-helpers.mjs +143 -0
  19. package/extensions/oracle/shared/process-helpers.d.mts +20 -0
  20. package/extensions/oracle/shared/process-helpers.mjs +128 -0
  21. package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
  22. package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
  23. package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
  24. package/extensions/oracle/worker/auth-bootstrap.mjs +76 -130
  25. package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
  26. package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
  27. package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
  28. package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
  29. package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
  30. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +93 -9
  31. package/extensions/oracle/worker/run-job.mjs +166 -274
  32. package/extensions/oracle/worker/state-locks.mjs +31 -216
  33. 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 { spawn, execFileSync } from "node:child_process";
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 = "queued" | "preparing" | "submitted" | "waiting" | "complete" | "failed" | "cancelled";
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 const ACTIVE_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["preparing", "submitted", "waiting"];
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
- if (job.status === "queued") return false;
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
- const currentStartedAt = readProcessStartedAt(pid);
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
- if (!pid || pid <= 0) return true;
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
- ...current,
552
- cleanupPending: false,
553
- cleanupWarnings: [...(current.cleanupWarnings || []), ...cleanupReport.warnings],
554
- lastCleanupAt: new Date().toISOString(),
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
- ...current,
649
- ...withJobPhase("failed", {
650
- status: "failed",
651
- completedAt: recoveredAt,
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
- }, recoveredAt),
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: OracleJob = {
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: OracleJob = {
736
- ...current,
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: OracleJob = {
759
- ...current,
760
- notifiedAt: at,
761
- notificationEntryId: options?.notificationEntryId ?? current.notificationEntryId,
762
- notificationSessionKey: options?.notificationSessionKey ?? current.notificationSessionKey,
763
- notificationSessionFile: options?.notificationSessionFile ?? current.notificationSessionFile,
764
- wakeupAttemptCount: 0,
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: OracleJob = {
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
- const beforeFirstAttempt = !job.wakeupLastRequestedAt && (job.wakeupAttemptCount ?? 0) === 0;
824
- if (job.wakeupSettledAt) {
825
- return {
826
- ...job,
827
- wakeupSettledSource: job.wakeupSettledSource ?? options.source,
828
- wakeupSettledSessionFile: job.wakeupSettledSessionFile ?? options.sessionFile,
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
- ...job,
868
- ...withJobPhase("cancelled", {
869
- status: "cancelled",
870
- completedAt: now,
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
- }, now),
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
- ...job,
883
- cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupReport.warnings],
884
- lastCleanupAt: now,
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
- ...job,
896
- ...withJobPhase(terminated ? "cancelled" : "failed", {
897
- status: terminated ? "cancelled" : "failed",
898
- completedAt: now,
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
- }, now),
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
- ...job,
915
- cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupWarnings],
916
- lastCleanupAt: now,
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
- ...job,
929
- cleanupPending: false,
930
- cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupReport.warnings],
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: OracleJob = {
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 = spawn(process.execPath, [workerPath, jobId, nonce], {
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: await waitForProcessStartedAt(child.pid),
939
+ startedAt: child.startedAt,
1040
940
  };
1041
941
  }