pi-oracle 0.3.3 → 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 (37) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +7 -0
  3. package/docs/ORACLE_DESIGN.md +1 -1
  4. package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +249 -0
  5. package/docs/ORACLE_RECOVERY_DRILL.md +5 -4
  6. package/extensions/oracle/index.ts +8 -1
  7. package/extensions/oracle/lib/commands.ts +11 -24
  8. package/extensions/oracle/lib/config.ts +5 -0
  9. package/extensions/oracle/lib/jobs.ts +117 -217
  10. package/extensions/oracle/lib/locks.ts +41 -209
  11. package/extensions/oracle/lib/poller.ts +14 -51
  12. package/extensions/oracle/lib/queue.ts +75 -112
  13. package/extensions/oracle/lib/runtime.ts +60 -14
  14. package/extensions/oracle/lib/tools.ts +70 -67
  15. package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
  16. package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
  17. package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +130 -0
  18. package/extensions/oracle/shared/job-lifecycle-helpers.mjs +377 -0
  19. package/extensions/oracle/shared/job-observability-helpers.d.mts +59 -0
  20. package/extensions/oracle/shared/job-observability-helpers.mjs +143 -0
  21. package/extensions/oracle/shared/process-helpers.d.mts +20 -0
  22. package/extensions/oracle/shared/process-helpers.mjs +128 -0
  23. package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
  24. package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
  25. package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
  26. package/extensions/oracle/worker/auth-bootstrap.mjs +100 -139
  27. package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
  28. package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
  29. package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
  30. package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
  31. package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
  32. package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +33 -0
  33. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +292 -0
  34. package/extensions/oracle/worker/run-job.mjs +235 -380
  35. package/extensions/oracle/worker/state-locks.mjs +31 -216
  36. package/package.json +14 -5
  37. package/prompts/oracle.md +1 -1
@@ -1,10 +1,38 @@
1
+ // Purpose: Execute a single oracle worker job from browser launch through response/artifact extraction and cleanup.
2
+ // Responsibilities: Drive the isolated browser session, update durable job state, coordinate cleanup, and autonomously promote queued work after successful teardown.
3
+ // Scope: Worker runtime behavior only; shared concurrency/process helpers live in extensions/oracle/shared and extension-side policy remains in lib modules.
4
+ // Usage: Spawned as a detached Node process with a job id argument by the oracle extension queue/submission flows.
5
+ // Invariants/Assumptions: Job state is persisted under worker-held locks, browser/session artifacts live under the configured oracle directories, and cleanup preserves durable recovery semantics.
1
6
  import { createHash, randomUUID } from "node:crypto";
2
7
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
8
  import { appendFile, chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
9
  import { basename, dirname, join } from "node:path";
5
10
  import { fileURLToPath } from "node:url";
6
- import { spawn, execFileSync } from "node:child_process";
11
+ import { spawn } from "node:child_process";
12
+ import {
13
+ buildConversationLeaseMetadata,
14
+ buildRuntimeLeaseMetadata,
15
+ compareQueuedOracleJobs,
16
+ hasDurableWorkerHandoff,
17
+ jobBlocksAdmission,
18
+ runQueuedJobPromotionPass,
19
+ } from "../shared/job-coordination-helpers.mjs";
20
+ import { applyOracleJobCleanupWarnings, clearOracleJobCleanupState, transitionOracleJobPhase } from "../shared/job-lifecycle-helpers.mjs";
21
+ import { spawnDetachedNodeProcess, terminateTrackedProcess } from "../shared/process-helpers.mjs";
7
22
  import { extractArtifactLabels, FILE_LABEL_PATTERN_SOURCE, GENERIC_ARTIFACT_LABELS, parseSnapshotEntries, partitionStructuralArtifactCandidates } from "./artifact-heuristics.mjs";
23
+ import {
24
+ buildAllowedChatGptOrigins,
25
+ deriveAssistantCompletionSignature,
26
+ matchesModelFamilyLabel,
27
+ requestedEffortLabel,
28
+ effortSelectionVisible,
29
+ snapshotCanSafelySkipModelConfiguration,
30
+ snapshotHasModelConfigurationUi,
31
+ snapshotStronglyMatchesRequestedModel,
32
+ snapshotWeaklyMatchesRequestedModel,
33
+ autoSwitchToThinkingSelectionVisible,
34
+ } from "./chatgpt-ui-helpers.mjs";
35
+ import { assistantSnapshotSlice, nextStableValueState, resolveStableConversationUrlCandidate, stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
8
36
  import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withLock } from "./state-locks.mjs";
9
37
 
10
38
  const jobId = process.argv[2];
@@ -25,12 +53,6 @@ const CHATGPT_LABELS = {
25
53
  autoSwitchToThinking: "Auto-switch to Thinking",
26
54
  configure: "Configure...",
27
55
  };
28
- const MODEL_FAMILY_PREFIX = {
29
- instant: "Instant ",
30
- thinking: "Thinking ",
31
- pro: "Pro ",
32
- };
33
-
34
56
  const WORKER_SCRIPT_PATH = fileURLToPath(import.meta.url);
35
57
  const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
36
58
  const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
@@ -42,6 +64,7 @@ const ARTIFACT_DOWNLOAD_HEARTBEAT_MS = 10_000;
42
64
  const ARTIFACT_DOWNLOAD_TIMEOUT_MS = 90_000;
43
65
  const ARTIFACT_DOWNLOAD_MAX_ATTEMPTS = 2;
44
66
  const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
67
+ const PROFILE_CLONE_TIMEOUT_MS = 120_000;
45
68
  const MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS = 20_000;
46
69
  const MODEL_CONFIGURATION_SETTLE_POLL_MS = 250;
47
70
  const MODEL_CONFIGURATION_CLOSE_RETRY_MS = 1_000;
@@ -62,79 +85,10 @@ async function ensurePrivateDir(path) {
62
85
  await chmod(path, 0o700).catch(() => undefined);
63
86
  }
64
87
 
65
- function isProcessAlive(pid) {
66
- try {
67
- process.kill(pid, 0);
68
- return true;
69
- } catch (error) {
70
- if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
71
- return true;
72
- }
73
- }
74
-
75
- function readProcessStartedAt(pid) {
76
- if (!pid || pid <= 0) return undefined;
77
- try {
78
- const startedAt = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
79
- return startedAt || undefined;
80
- } catch {
81
- return undefined;
82
- }
83
- }
84
-
85
- async function waitForProcessStartedAt(pid, timeoutMs = 2_000) {
86
- const deadline = Date.now() + timeoutMs;
87
- while (Date.now() < deadline) {
88
- const startedAt = readProcessStartedAt(pid);
89
- if (startedAt) return startedAt;
90
- await sleep(100);
91
- }
92
- return readProcessStartedAt(pid);
93
- }
94
-
95
88
  async function terminateWorkerPid(pid, startedAt, options = {}) {
96
- if (!pid || pid <= 0) return true;
97
- const currentStartedAt = readProcessStartedAt(pid);
98
- if (!currentStartedAt) return true;
99
- if (startedAt && currentStartedAt !== startedAt) return false;
100
-
101
- const termGraceMs = options.termGraceMs ?? 5_000;
102
- const killGraceMs = options.killGraceMs ?? 2_000;
103
-
104
- try {
105
- process.kill(pid, "SIGTERM");
106
- } catch {
107
- return !isProcessAlive(pid);
108
- }
109
-
110
- const termDeadline = Date.now() + termGraceMs;
111
- while (Date.now() < termDeadline) {
112
- const liveStartedAt = readProcessStartedAt(pid);
113
- if (!liveStartedAt) return true;
114
- if (startedAt && liveStartedAt !== startedAt) return true;
115
- await sleep(250);
116
- }
117
-
118
- try {
119
- process.kill(pid, "SIGKILL");
120
- } catch {
121
- return !isProcessAlive(pid);
122
- }
123
-
124
- const killDeadline = Date.now() + killGraceMs;
125
- while (Date.now() < killDeadline) {
126
- const liveStartedAt = readProcessStartedAt(pid);
127
- if (!liveStartedAt) return true;
128
- if (startedAt && liveStartedAt !== startedAt) return true;
129
- await sleep(250);
130
- }
131
-
132
- const finalStartedAt = readProcessStartedAt(pid);
133
- if (!finalStartedAt) return true;
134
- return startedAt ? finalStartedAt !== startedAt : false;
89
+ return terminateTrackedProcess(pid, startedAt, options);
135
90
  }
136
91
 
137
-
138
92
  async function secureWriteText(path, content) {
139
93
  const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
140
94
  await writeFile(tmpPath, content, { encoding: "utf8", mode: 0o600 });
@@ -180,25 +134,7 @@ function listQueuedJobs() {
180
134
  .filter((name) => name.startsWith("oracle-"))
181
135
  .map((name) => readAnyJob(name.slice("oracle-".length)))
182
136
  .filter((job) => job?.status === "queued")
183
- .sort((left, right) => {
184
- const leftKey = left?.queuedAt || left?.createdAt || "";
185
- const rightKey = right?.queuedAt || right?.createdAt || "";
186
- return leftKey.localeCompare(rightKey) || String(left?.createdAt || "").localeCompare(String(right?.createdAt || "")) || String(left?.id || "").localeCompare(String(right?.id || ""));
187
- });
188
- }
189
-
190
- function isActiveJobStatus(status) {
191
- return ["preparing", "submitted", "waiting"].includes(String(status || ""));
192
- }
193
-
194
- function jobBlocksAdmission(job) {
195
- return isActiveJobStatus(job?.status) || job?.cleanupPending === true || (Array.isArray(job?.cleanupWarnings) && job.cleanupWarnings.length > 0);
196
- }
197
-
198
- function hasDurableWorkerHandoff(job) {
199
- if (!job || job.status === "queued") return false;
200
- if (job.workerPid) return true;
201
- return false;
137
+ .sort(compareQueuedOracleJobs);
202
138
  }
203
139
 
204
140
  async function mutateAnyJob(targetJobId, mutator) {
@@ -211,12 +147,6 @@ async function mutateAnyJob(targetJobId, mutator) {
211
147
  });
212
148
  }
213
149
 
214
- async function writeAnyJob(targetJobId, job) {
215
- await withLock(ORACLE_STATE_DIR, "job", targetJobId, { processPid: process.pid, action: "writeJob", targetJobId }, async () => {
216
- await secureWriteText(getAnyJobPath(targetJobId), `${JSON.stringify(job, null, 2)}\n`);
217
- });
218
- }
219
-
220
150
  async function writeJobUnlocked(job) {
221
151
  await secureWriteText(jobPath, `${JSON.stringify(job, null, 2)}\n`);
222
152
  }
@@ -237,14 +167,6 @@ async function mutateJob(mutator) {
237
167
  });
238
168
  }
239
169
 
240
- function phasePatch(phase, patch = undefined, at = new Date().toISOString()) {
241
- return {
242
- ...(patch || {}),
243
- phase,
244
- phaseAt: at,
245
- };
246
- }
247
-
248
170
  async function heartbeat(patch = undefined, options = {}) {
249
171
  const now = Date.now();
250
172
  const force = options.force === true;
@@ -336,7 +258,7 @@ async function cloneSeedProfileToRuntime(job) {
336
258
  await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
337
259
  await ensurePrivateDir(dirname(job.runtimeProfileDir));
338
260
  const cloneArgs = job.config.browser.cloneStrategy === "apfs-clone" ? ["-cR", seedDir, job.runtimeProfileDir] : ["-R", seedDir, job.runtimeProfileDir];
339
- await spawnCommand("cp", cloneArgs);
261
+ await spawnCommand("cp", cloneArgs, { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
340
262
  }, 10 * 60 * 1000);
341
263
 
342
264
  return seedGeneration;
@@ -394,102 +316,77 @@ async function tryAcquireRuntimeLeaseForJob(job, createdAt) {
394
316
  if (liveLeases.length >= job.config.browser.maxConcurrentJobs) {
395
317
  return false;
396
318
  }
397
- await createLease(ORACLE_STATE_DIR, "runtime", job.runtimeId, {
398
- jobId: job.id,
399
- runtimeId: job.runtimeId,
400
- runtimeSessionName: job.runtimeSessionName,
401
- runtimeProfileDir: job.runtimeProfileDir,
402
- projectId: job.projectId,
403
- sessionId: job.sessionId,
404
- createdAt,
405
- });
319
+ await createLease(ORACLE_STATE_DIR, "runtime", job.runtimeId, buildRuntimeLeaseMetadata(job, createdAt));
406
320
  return true;
407
321
  }
408
322
 
409
323
  async function tryAcquireConversationLeaseForJob(job, createdAt) {
410
- if (!job.conversationId) return true;
411
- const existing = await readLeaseMetadata(ORACLE_STATE_DIR, "conversation", job.conversationId);
324
+ const metadata = buildConversationLeaseMetadata(job, createdAt);
325
+ if (!metadata) return true;
326
+ const existing = await readLeaseMetadata(ORACLE_STATE_DIR, "conversation", metadata.conversationId);
412
327
  if (existing?.jobId === job.id) return true;
413
328
  if (existing && existing.jobId !== job.id) {
414
329
  if (!jobBlocksAdmission(readAnyJob(existing.jobId))) {
415
- await releaseLease(ORACLE_STATE_DIR, "conversation", job.conversationId).catch(() => undefined);
330
+ await releaseLease(ORACLE_STATE_DIR, "conversation", metadata.conversationId).catch(() => undefined);
416
331
  } else {
417
332
  return false;
418
333
  }
419
334
  }
420
- await createLease(ORACLE_STATE_DIR, "conversation", job.conversationId, {
421
- jobId: job.id,
422
- conversationId: job.conversationId,
423
- projectId: job.projectId,
424
- sessionId: job.sessionId,
425
- createdAt,
426
- });
335
+ await createLease(ORACLE_STATE_DIR, "conversation", metadata.conversationId, metadata);
427
336
  return true;
428
337
  }
429
338
 
430
339
  async function spawnDetachedWorker(targetJobId) {
431
- const child = spawn(process.execPath, [WORKER_SCRIPT_PATH, targetJobId], {
432
- detached: true,
433
- stdio: "ignore",
434
- });
435
- child.unref();
340
+ const child = await spawnDetachedNodeProcess(WORKER_SCRIPT_PATH, [targetJobId]);
436
341
  return {
437
342
  pid: child.pid,
438
343
  workerNonce: randomUUID(),
439
- workerStartedAt: await waitForProcessStartedAt(child.pid),
344
+ workerStartedAt: child.startedAt,
440
345
  };
441
346
  }
442
347
 
443
348
  async function failQueuedPromotion(targetJobId, message, at = new Date().toISOString()) {
444
349
  await mutateAnyJob(targetJobId, (latest) => {
445
350
  if (["complete", "failed", "cancelled"].includes(String(latest.status || ""))) return latest;
446
- return {
447
- ...latest,
448
- ...phasePatch("failed", {
449
- status: "failed",
450
- completedAt: at,
351
+ return transitionOracleJobPhase(latest, "failed", {
352
+ at,
353
+ source: "oracle:worker-cleanup-promotion",
354
+ message: `Queued promotion failed: ${message}`,
355
+ patch: {
451
356
  heartbeatAt: at,
452
357
  error: message,
453
- }, at),
454
- };
358
+ },
359
+ });
455
360
  }).catch(() => undefined);
456
361
  }
457
362
 
458
363
  async function promoteQueuedJobsAfterCleanup() {
459
364
  await withLock(ORACLE_STATE_DIR, "admission", "global", { processPid: process.pid, source: "worker_cleanup_promoter", jobId }, async () => {
460
- for (const queuedJob of listQueuedJobs()) {
461
- const current = readAnyJob(queuedJob.id);
462
- if (!current || current.status !== "queued") continue;
463
-
464
- let spawnedWorker;
465
- const promotedAt = new Date().toISOString();
466
- if (!existsSync(current.archivePath)) {
467
- await failQueuedPromotion(current.id, `Queued oracle archive is missing: ${current.archivePath}`, promotedAt);
468
- continue;
469
- }
470
- const runtimeLeaseAcquired = await tryAcquireRuntimeLeaseForJob(current, promotedAt);
471
- if (!runtimeLeaseAcquired) break;
472
-
473
- const conversationLeaseAcquired = await tryAcquireConversationLeaseForJob(current, promotedAt);
474
- if (!conversationLeaseAcquired) {
475
- await releaseLease(ORACLE_STATE_DIR, "runtime", current.runtimeId).catch(() => undefined);
476
- continue;
477
- }
478
-
479
- try {
480
- await mutateAnyJob(current.id, (latest) => {
365
+ await runQueuedJobPromotionPass({
366
+ listQueuedJobs,
367
+ refreshJob: (targetJobId) => readAnyJob(targetJobId),
368
+ readLatestJob: (targetJobId) => readAnyJob(targetJobId),
369
+ acquireRuntimeLease: async (job, at) => tryAcquireRuntimeLeaseForJob(job, at),
370
+ acquireConversationLease: async (job, at) => tryAcquireConversationLeaseForJob(job, at),
371
+ releaseRuntimeLease: async (job) => {
372
+ await releaseLease(ORACLE_STATE_DIR, "runtime", job.runtimeId);
373
+ },
374
+ markSubmitted: async (job, at) => {
375
+ await mutateAnyJob(job.id, (latest) => {
481
376
  if (latest.status !== "queued") throw new Error(`Queued job ${latest.id} changed state during cleanup promotion (${latest.status})`);
482
- return {
483
- ...latest,
484
- ...phasePatch("submitted", {
485
- status: "submitted",
486
- submittedAt: latest.submittedAt || promotedAt,
487
- }, promotedAt),
488
- };
377
+ return transitionOracleJobPhase(latest, "submitted", {
378
+ at,
379
+ source: "oracle:worker-cleanup-promotion",
380
+ message: "Queued job admitted after runtime cleanup released capacity.",
381
+ patch: {
382
+ submittedAt: latest.submittedAt || at,
383
+ },
384
+ });
489
385
  });
490
-
491
- spawnedWorker = await spawnDetachedWorker(current.id);
492
- await mutateAnyJob(current.id, (latest) => {
386
+ },
387
+ spawnWorker: async (job) => spawnDetachedWorker(job.id),
388
+ persistWorker: async (job, spawnedWorker) => {
389
+ await mutateAnyJob(job.id, (latest) => {
493
390
  if (hasDurableWorkerHandoff(latest)) {
494
391
  return {
495
392
  ...latest,
@@ -505,44 +402,42 @@ async function promoteQueuedJobsAfterCleanup() {
505
402
  workerStartedAt: spawnedWorker.workerStartedAt,
506
403
  };
507
404
  });
508
- } catch (error) {
509
- const latest = readAnyJob(current.id);
510
- if (hasDurableWorkerHandoff(latest)) {
511
- await log(`Queued promotion handoff already durable for ${current.id}; leaving active job intact`).catch(() => undefined);
512
- continue;
513
- }
514
- if (spawnedWorker) {
515
- await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.workerStartedAt).catch(() => undefined);
516
- }
517
- const failedAt = new Date().toISOString();
518
- if (latest && !["complete", "failed", "cancelled"].includes(String(latest.status || ""))) {
519
- await failQueuedPromotion(current.id, error instanceof Error ? error.message : String(error), failedAt);
520
- }
405
+ },
406
+ hasDurableWorkerHandoff,
407
+ isTerminalJob: (job) => ["complete", "failed", "cancelled"].includes(String(job.status || "")),
408
+ failQueuedPromotion: async (job, message, at) => failQueuedPromotion(job.id, message, at),
409
+ terminateSpawnedWorker: async (spawnedWorker) => {
410
+ await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.workerStartedAt);
411
+ },
412
+ cleanupAfterFailure: async ({ job, at, spawnedWorker }) => {
521
413
  if (spawnedWorker) {
522
414
  let cleanupWarnings = [];
523
415
  try {
524
- cleanupWarnings = await cleanupRuntime(current);
416
+ cleanupWarnings = await cleanupRuntime(job);
525
417
  } catch (cleanupError) {
526
- const message = `Cleanup-driven promotion teardown warning for ${current.id}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
418
+ const message = `Cleanup-driven promotion teardown warning for ${job.id}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
527
419
  cleanupWarnings = [message];
528
420
  await log(message).catch(() => undefined);
529
421
  }
530
422
  if (cleanupWarnings.length > 0) {
531
- await mutateAnyJob(current.id, (job) => ({
532
- ...job,
533
- cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupWarnings],
534
- lastCleanupAt: failedAt,
535
- error: [job.error, ...cleanupWarnings].filter(Boolean).join("\n"),
423
+ await mutateAnyJob(job.id, (current) => applyOracleJobCleanupWarnings(current, cleanupWarnings, {
424
+ at,
425
+ source: "oracle:worker-cleanup-promotion",
426
+ message: `Cleanup-driven queued promotion teardown left ${cleanupWarnings.length} warning(s).`,
536
427
  })).catch(() => undefined);
537
- await log(`Stopping queued cleanup promotion after ${current.id} because teardown left ${cleanupWarnings.length} warning(s)`).catch(() => undefined);
538
- break;
428
+ await log(`Stopping queued cleanup promotion after ${job.id} because teardown left ${cleanupWarnings.length} warning(s)`).catch(() => undefined);
429
+ return "break";
539
430
  }
540
- } else {
541
- await releaseLease(ORACLE_STATE_DIR, "conversation", current.conversationId).catch(() => undefined);
542
- await releaseLease(ORACLE_STATE_DIR, "runtime", current.runtimeId).catch(() => undefined);
431
+ return;
543
432
  }
544
- }
545
- }
433
+
434
+ await releaseLease(ORACLE_STATE_DIR, "conversation", job.conversationId).catch(() => undefined);
435
+ await releaseLease(ORACLE_STATE_DIR, "runtime", job.runtimeId).catch(() => undefined);
436
+ },
437
+ onDurableHandoff: async (job) => {
438
+ await log(`Queued promotion handoff already durable for ${job.id}; leaving active job intact`).catch(() => undefined);
439
+ },
440
+ });
546
441
  }).catch(async (error) => {
547
442
  await log(`Queued cleanup promotion warning: ${error instanceof Error ? error.message : String(error)}`).catch(() => undefined);
548
443
  });
@@ -665,14 +560,7 @@ async function currentUrl(job) {
665
560
  }
666
561
 
667
562
  function stripQuery(url) {
668
- try {
669
- const parsed = new URL(url);
670
- parsed.hash = "";
671
- parsed.search = "";
672
- return parsed.toString();
673
- } catch {
674
- return url;
675
- }
563
+ return stripUrlQueryAndHash(url);
676
564
  }
677
565
 
678
566
  async function snapshotText(job) {
@@ -791,83 +679,10 @@ function findLastEntry(snapshot, predicate) {
791
679
  return undefined;
792
680
  }
793
681
 
794
- function titleCase(value) {
795
- return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
796
- }
797
-
798
- function matchesModelFamilyLabel(label, family) {
799
- const normalized = String(label || "");
800
- const prefix = MODEL_FAMILY_PREFIX[family];
801
- const exact = prefix.trim();
802
- return normalized === exact || normalized.startsWith(prefix) || normalized.startsWith(`${exact},`);
803
- }
804
-
805
682
  function matchesModelFamilyButton(candidate, family) {
806
683
  return candidate.kind === "button" && typeof candidate.label === "string" && matchesModelFamilyLabel(candidate.label, family) && !candidate.disabled;
807
684
  }
808
685
 
809
- function requestedEffortLabel(job) {
810
- return job.selection?.effort ? titleCase(job.selection.effort) : undefined;
811
- }
812
-
813
- function effortSelectionVisible(snapshot, effortLabel) {
814
- if (!effortLabel) return true;
815
- const entries = parseSnapshotEntries(snapshot);
816
- return entries.some((entry) => {
817
- if (entry.disabled) return false;
818
- if (entry.kind === "combobox" && entry.value === effortLabel) return true;
819
- if (entry.kind !== "button") return false;
820
- const label = String(entry.label || "").toLowerCase();
821
- const normalizedEffort = effortLabel.toLowerCase();
822
- return (
823
- label === normalizedEffort ||
824
- label === `${normalizedEffort} thinking` ||
825
- label === `${normalizedEffort}, click to remove` ||
826
- label === `${normalizedEffort} thinking, click to remove`
827
- );
828
- });
829
- }
830
-
831
- function thinkingChipVisible(snapshot) {
832
- return /button "(?:Light|Standard|Extended|Heavy)(?: thinking)?(?:, click to remove)?"/i.test(snapshot);
833
- }
834
-
835
- function snapshotHasModelConfigurationUi(snapshot) {
836
- const entries = parseSnapshotEntries(snapshot);
837
- const visibleFamilies = new Set(
838
- entries
839
- .filter((entry) => entry.kind === "button" && typeof entry.label === "string")
840
- .flatMap((entry) =>
841
- Object.keys(MODEL_FAMILY_PREFIX)
842
- .filter((family) => matchesModelFamilyLabel(entry.label, family))
843
- .map((family) => family),
844
- ),
845
- );
846
- const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.close && !entry.disabled);
847
- const hasEffortCombobox = entries.some(
848
- (entry) => entry.kind === "combobox" && ["Light", "Standard", "Extended", "Heavy"].includes(entry.value || "") && !entry.disabled,
849
- );
850
- return visibleFamilies.size >= 2 || hasCloseButton || hasEffortCombobox;
851
- }
852
-
853
- function snapshotStronglyMatchesRequestedModel(snapshot, job) {
854
- const entries = parseSnapshotEntries(snapshot);
855
- const familyMatched = entries.some((entry) => matchesModelFamilyButton(entry, job.selection.modelFamily));
856
- const effortLabel = requestedEffortLabel(job);
857
- if (job.selection.modelFamily === "thinking") {
858
- return familyMatched || effortSelectionVisible(snapshot, effortLabel);
859
- }
860
- if (job.selection.modelFamily === "pro") {
861
- return effortLabel ? familyMatched && effortSelectionVisible(snapshot, effortLabel) : familyMatched;
862
- }
863
- return familyMatched;
864
- }
865
-
866
- function thinkingSelectionVisible(snapshot) {
867
- const entries = parseSnapshotEntries(snapshot);
868
- return entries.some((entry) => !entry.disabled && entry.kind === "button" && matchesModelFamilyLabel(entry.label, "thinking"));
869
- }
870
-
871
686
  function composerControlsVisible(snapshot) {
872
687
  const entries = parseSnapshotEntries(snapshot);
873
688
  const hasComposer = entries.some(
@@ -879,17 +694,15 @@ function composerControlsVisible(snapshot) {
879
694
  return hasComposer && hasAddFiles;
880
695
  }
881
696
 
882
- function snapshotWeaklyMatchesRequestedModel(snapshot, job) {
883
- if (job.selection.modelFamily === "thinking") {
884
- return effortSelectionVisible(snapshot, requestedEffortLabel(job)) || thinkingSelectionVisible(snapshot);
885
- }
886
- if (job.selection.modelFamily === "pro") {
887
- return !thinkingChipVisible(snapshot);
888
- }
889
- if (job.selection.modelFamily === "instant") {
890
- return !thinkingChipVisible(snapshot);
891
- }
892
- return false;
697
+ async function clickAutoSwitchToThinkingControl(job) {
698
+ const snapshot = await snapshotText(job);
699
+ const entry = findEntry(
700
+ snapshot,
701
+ (candidate) => candidate.kind === "button" && typeof candidate.label === "string" && candidate.label.startsWith(CHATGPT_LABELS.autoSwitchToThinking) && !candidate.disabled,
702
+ );
703
+ if (!entry) throw new Error(`Could not find ${CHATGPT_LABELS.autoSwitchToThinking} control`);
704
+ await clickRef(job, entry.ref);
705
+ return entry;
893
706
  }
894
707
 
895
708
  async function clickRef(job, ref) {
@@ -962,7 +775,7 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
962
775
  return { state: "transient_outage_error", message: "ChatGPT is showing a transient outage/error page" };
963
776
  }
964
777
 
965
- const allowedOrigins = [new URL(job.config.browser.chatUrl).origin, "https://auth.openai.com"];
778
+ const allowedOrigins = buildAllowedChatGptOrigins(job.config.browser.chatUrl, job.config.browser.authUrl);
966
779
  const onAllowedOrigin = typeof url === "string" && allowedOrigins.some((origin) => url.startsWith(origin));
967
780
  const onAuthPath = typeof url === "string" && url.includes("/auth/");
968
781
  const hasComposer = snapshot.includes(`textbox "${CHATGPT_LABELS.composer}"`);
@@ -1210,7 +1023,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1210
1023
  const configurationUiVisible = snapshotHasModelConfigurationUi(snapshot);
1211
1024
 
1212
1025
  if (!configurationUiVisible) {
1213
- if (snapshotWeaklyMatchesRequestedModel(snapshot, job)) return;
1026
+ if (snapshotWeaklyMatchesRequestedModel(snapshot, job.selection)) return;
1214
1027
  if (options.stronglyVerified) {
1215
1028
  if (!fallbackLogged) {
1216
1029
  fallbackLogged = true;
@@ -1248,7 +1061,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1248
1061
 
1249
1062
  async function configureModel(job) {
1250
1063
  const initialSnapshot = await snapshotText(job);
1251
- if (snapshotStronglyMatchesRequestedModel(initialSnapshot, job)) {
1064
+ if (snapshotCanSafelySkipModelConfiguration(initialSnapshot, job.selection)) {
1252
1065
  await log(`Model already appears configured for family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}; skipping reconfiguration`);
1253
1066
  return;
1254
1067
  }
@@ -1257,23 +1070,27 @@ async function configureModel(job) {
1257
1070
  let familySnapshot = await openModelConfiguration(job);
1258
1071
  let verificationSnapshot = familySnapshot;
1259
1072
 
1073
+ const alreadyConfiguredInUi = snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection);
1260
1074
  let familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.selection.modelFamily));
1261
- if (!familyEntry && snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
1075
+ if (alreadyConfiguredInUi) {
1262
1076
  await log("Model configuration UI opened with requested settings already selected");
1263
- }
1264
- if (!familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
1077
+ } else if (!familyEntry) {
1265
1078
  throw new Error(`Could not find model family button for ${job.selection.modelFamily}`);
1266
1079
  }
1267
1080
 
1268
- if (familyEntry) {
1081
+ if (!alreadyConfiguredInUi && familyEntry) {
1269
1082
  await clickRef(job, familyEntry.ref);
1270
1083
  await agentBrowser(job, "wait", "800");
1271
1084
  familySnapshot = await snapshotText(job);
1272
1085
  verificationSnapshot = familySnapshot;
1086
+ familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.selection.modelFamily));
1087
+ if (!familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection)) {
1088
+ throw new Error(`Requested model family did not remain selected: ${job.selection.modelFamily}`);
1089
+ }
1273
1090
  }
1274
1091
 
1275
1092
  if (job.selection.modelFamily === "thinking" || job.selection.modelFamily === "pro") {
1276
- const effortLabel = requestedEffortLabel(job);
1093
+ const effortLabel = requestedEffortLabel(job.selection);
1277
1094
  if (effortLabel && !effortSelectionVisible(familySnapshot, effortLabel)) {
1278
1095
  const opened = await openEffortDropdown(job);
1279
1096
  if (!opened) {
@@ -1291,15 +1108,22 @@ async function configureModel(job) {
1291
1108
  if (!selectedEffort && !effortSelectionVisible(effortSnapshot, effortLabel)) {
1292
1109
  throw new Error(`Requested effort did not remain selected: ${effortLabel}`);
1293
1110
  }
1111
+ familySnapshot = effortSnapshot;
1294
1112
  }
1295
1113
  }
1296
1114
 
1297
- if (job.selection.modelFamily === "instant" && job.selection.autoSwitchToThinking) {
1298
- await maybeClickLabeledEntry(job, CHATGPT_LABELS.autoSwitchToThinking);
1299
- verificationSnapshot = await snapshotText(job);
1115
+ if (job.selection.modelFamily === "instant") {
1116
+ const desiredAutoSwitchState = job.selection.autoSwitchToThinking === true;
1117
+ const currentAutoSwitchState = autoSwitchToThinkingSelectionVisible(familySnapshot);
1118
+ if (currentAutoSwitchState !== desiredAutoSwitchState && (desiredAutoSwitchState || currentAutoSwitchState === true)) {
1119
+ await clickAutoSwitchToThinkingControl(job);
1120
+ await agentBrowser(job, "wait", "400");
1121
+ verificationSnapshot = await snapshotText(job);
1122
+ familySnapshot = verificationSnapshot;
1123
+ }
1300
1124
  }
1301
1125
 
1302
- const stronglyVerified = snapshotStronglyMatchesRequestedModel(verificationSnapshot, job);
1126
+ const stronglyVerified = snapshotStronglyMatchesRequestedModel(verificationSnapshot, job.selection);
1303
1127
  if (!stronglyVerified) {
1304
1128
  throw new Error(`Could not verify requested model settings in configuration UI for ${job.selection.modelFamily}`);
1305
1129
  }
@@ -1377,46 +1201,17 @@ async function assistantMessages(job) {
1377
1201
  return result.messages.map((message) => ({ text: typeof message?.text === "string" ? message.text : "" }));
1378
1202
  }
1379
1203
 
1380
- function assistantSnapshotSlice(snapshot, responseIndex) {
1381
- const lines = snapshot.split("\n");
1382
- const assistantHeadingIndices = lines.flatMap((line, index) => (line.includes('heading "ChatGPT said:"') ? [index] : []));
1383
- const startIndex = assistantHeadingIndices[responseIndex];
1384
- if (startIndex === undefined) return undefined;
1385
-
1386
- const endCandidates = [];
1387
- const nextAssistantIndex = assistantHeadingIndices[responseIndex + 1];
1388
- if (nextAssistantIndex !== undefined) endCandidates.push(nextAssistantIndex);
1389
-
1390
- const composerIndex = lines.findIndex(
1391
- (line, index) => index > startIndex && line.includes(`textbox "${CHATGPT_LABELS.composer}"`),
1392
- );
1393
- if (composerIndex !== -1) endCandidates.push(composerIndex);
1394
-
1395
- const endIndex = endCandidates.length > 0 ? Math.min(...endCandidates) : undefined;
1396
- return lines.slice(startIndex, endIndex).join("\n");
1397
- }
1398
-
1399
1204
  async function waitForStableChatUrl(job, previousChatUrl) {
1400
1205
  const timeoutAt = Date.now() + 60_000;
1401
- let lastUrl = "";
1402
- let stableCount = 0;
1206
+ /** @type {import("./chatgpt-flow-helpers.d.mts").OracleStableValueState | undefined} */
1207
+ let stableState;
1403
1208
 
1404
1209
  while (Date.now() < timeoutAt) {
1405
1210
  await heartbeat();
1406
- const url = stripQuery(await currentUrl(job));
1407
- let isConversationUrl = false;
1408
- try {
1409
- isConversationUrl = /\/c\/[A-Za-z0-9-]+$/i.test(new URL(url).pathname);
1410
- } catch {
1411
- isConversationUrl = false;
1412
- }
1413
- const isKnownFollowUpUrl = previousChatUrl ? stripQuery(previousChatUrl) === url : false;
1414
-
1415
- if (isConversationUrl || isKnownFollowUpUrl) {
1416
- if (url === lastUrl) stableCount += 1;
1417
- else stableCount = 1;
1418
- lastUrl = url;
1419
- if (stableCount >= 2) return url;
1211
+ const candidateUrl = resolveStableConversationUrlCandidate(await currentUrl(job), previousChatUrl);
1212
+ if (candidateUrl) {
1213
+ stableState = nextStableValueState(stableState, candidateUrl);
1214
+ if (stableState.stableCount >= 2) return candidateUrl;
1420
1215
  }
1421
1216
 
1422
1217
  await sleep(1000);
@@ -1427,7 +1222,7 @@ async function waitForStableChatUrl(job, previousChatUrl) {
1427
1222
 
1428
1223
  async function waitForChatCompletion(job, baselineAssistantCount) {
1429
1224
  const timeoutAt = Date.now() + job.config.worker.completionTimeoutMs;
1430
- let lastText = "";
1225
+ let lastCompletionSignature = "";
1431
1226
  let stableCount = 0;
1432
1227
  let retriedAfterFailure = false;
1433
1228
 
@@ -1451,7 +1246,7 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1451
1246
  );
1452
1247
  if (retryEntry) {
1453
1248
  retriedAfterFailure = true;
1454
- lastText = "";
1249
+ lastCompletionSignature = "";
1455
1250
  stableCount = 0;
1456
1251
  await log(`Response delivery failed (${responseFailureText}); clicking Retry once`);
1457
1252
  await clickRef(job, retryEntry.ref);
@@ -1462,13 +1257,34 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1462
1257
  throw new Error(`ChatGPT response failed: ${responseFailureText}`);
1463
1258
  }
1464
1259
 
1260
+ let completionSignature;
1465
1261
  if (!hasStopStreaming && hasTargetCopyResponse && targetText) {
1466
- if (targetText === lastText) stableCount += 1;
1262
+ completionSignature = deriveAssistantCompletionSignature({
1263
+ hasStopStreaming,
1264
+ hasTargetCopyResponse,
1265
+ responseText: targetText,
1266
+ });
1267
+ } else if (!hasStopStreaming && !targetText) {
1268
+ const artifactSignals = await collectArtifactCandidates(job, baselineAssistantCount, targetText).catch(() => ({ candidates: [], suspiciousLabels: [] }));
1269
+ completionSignature = deriveAssistantCompletionSignature({
1270
+ hasStopStreaming,
1271
+ hasTargetCopyResponse,
1272
+ responseText: targetText,
1273
+ artifactLabels: artifactSignals.candidates.map((candidate) => candidate.label),
1274
+ suspiciousArtifactLabels: artifactSignals.suspiciousLabels,
1275
+ });
1276
+ }
1277
+
1278
+ if (completionSignature) {
1279
+ if (completionSignature === lastCompletionSignature) stableCount += 1;
1467
1280
  else stableCount = 1;
1468
- lastText = targetText;
1281
+ lastCompletionSignature = completionSignature;
1469
1282
  if (stableCount >= 2) {
1470
1283
  return { responseIndex: baselineAssistantCount, responseText: targetText };
1471
1284
  }
1285
+ } else {
1286
+ lastCompletionSignature = "";
1287
+ stableCount = 0;
1472
1288
  }
1473
1289
 
1474
1290
  await sleep(job.config.worker.pollMs);
@@ -1496,7 +1312,7 @@ function preferredArtifactName(label, index) {
1496
1312
 
1497
1313
  async function collectArtifactCandidates(job, responseIndex, responseText = "") {
1498
1314
  const snapshot = await snapshotText(job);
1499
- const targetSlice = assistantSnapshotSlice(snapshot, responseIndex);
1315
+ const targetSlice = assistantSnapshotSlice(snapshot, CHATGPT_LABELS.composer, responseIndex);
1500
1316
  if (!targetSlice) return { snapshot, targetSlice, candidates: [], suspiciousLabels: [] };
1501
1317
 
1502
1318
  const structural = await evalPage(
@@ -1799,19 +1615,44 @@ async function run() {
1799
1615
 
1800
1616
  try {
1801
1617
  await log(`Starting oracle worker for job ${currentJob.id}`);
1802
- await heartbeat(phasePatch("cloning_runtime", { status: "waiting" }), { force: true });
1618
+ currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "cloning_runtime", {
1619
+ at: new Date().toISOString(),
1620
+ source: "oracle:worker",
1621
+ message: "Cloning the auth seed profile into the isolated runtime.",
1622
+ patch: { heartbeatAt: new Date().toISOString() },
1623
+ }));
1803
1624
  await closeBrowser(currentJob);
1804
1625
 
1805
1626
  const seedGeneration = await cloneSeedProfileToRuntime(currentJob);
1806
- currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("launching_browser", { seedGeneration, heartbeatAt: new Date().toISOString() }) }));
1627
+ currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "launching_browser", {
1628
+ at: new Date().toISOString(),
1629
+ source: "oracle:worker",
1630
+ message: "Launching the isolated oracle browser runtime.",
1631
+ patch: { seedGeneration, heartbeatAt: new Date().toISOString() },
1632
+ }));
1807
1633
 
1808
1634
  const targetUrl = currentJob.chatUrl || currentJob.config.browser.chatUrl;
1809
1635
  await launchBrowser(currentJob, targetUrl);
1810
- currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("verifying_auth", { heartbeatAt: new Date().toISOString() }) }));
1636
+ currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "verifying_auth", {
1637
+ at: new Date().toISOString(),
1638
+ source: "oracle:worker",
1639
+ message: "Verifying the imported ChatGPT auth session.",
1640
+ patch: { heartbeatAt: new Date().toISOString() },
1641
+ }));
1811
1642
  await waitForOracleReady(currentJob);
1812
- currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("configuring_model", { heartbeatAt: new Date().toISOString() }) }));
1643
+ currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "configuring_model", {
1644
+ at: new Date().toISOString(),
1645
+ source: "oracle:worker",
1646
+ message: "Configuring the requested ChatGPT model selection.",
1647
+ patch: { heartbeatAt: new Date().toISOString() },
1648
+ }));
1813
1649
  await configureModel(currentJob);
1814
- currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("uploading_archive", { heartbeatAt: new Date().toISOString() }) }));
1650
+ currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "uploading_archive", {
1651
+ at: new Date().toISOString(),
1652
+ source: "oracle:worker",
1653
+ message: "Uploading the oracle context archive.",
1654
+ patch: { heartbeatAt: new Date().toISOString() },
1655
+ }));
1815
1656
  await uploadArchive(currentJob);
1816
1657
  await setComposerText(currentJob, await readFile(currentJob.promptPath, "utf8"));
1817
1658
  const baselineAssistantCount = (await assistantMessages(currentJob)).length;
@@ -1822,30 +1663,44 @@ async function run() {
1822
1663
 
1823
1664
  const chatUrl = await waitForStableChatUrl(currentJob, currentJob.chatUrl);
1824
1665
  const conversationId = parseConversationId(chatUrl) || currentJob.conversationId;
1825
- currentJob = await mutateJob((job) => ({
1826
- ...job,
1827
- ...phasePatch("awaiting_response", { chatUrl, conversationId, heartbeatAt: new Date().toISOString() }),
1666
+ currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "awaiting_response", {
1667
+ at: new Date().toISOString(),
1668
+ source: "oracle:worker",
1669
+ message: "Waiting for the assistant response to finish streaming.",
1670
+ patch: { chatUrl, conversationId, heartbeatAt: new Date().toISOString() },
1828
1671
  }));
1829
1672
 
1830
1673
  const completion = await waitForChatCompletion(currentJob, baselineAssistantCount);
1831
- currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("extracting_response", { heartbeatAt: new Date().toISOString() }) }));
1674
+ currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "extracting_response", {
1675
+ at: new Date().toISOString(),
1676
+ source: "oracle:worker",
1677
+ message: "Extracting the completed response body.",
1678
+ patch: { heartbeatAt: new Date().toISOString() },
1679
+ }));
1832
1680
  await secureWriteText(currentJob.responsePath, `${completion.responseText.trim()}\n`);
1833
- currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("downloading_artifacts", { heartbeatAt: new Date().toISOString() }) }));
1681
+ currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "downloading_artifacts", {
1682
+ at: new Date().toISOString(),
1683
+ source: "oracle:worker",
1684
+ message: "Downloading any response artifacts.",
1685
+ patch: { heartbeatAt: new Date().toISOString() },
1686
+ }));
1834
1687
  const artifacts = await downloadArtifacts(currentJob, completion.responseIndex, completion.responseText);
1835
1688
  const artifactFailureCount = artifacts.filter((artifact) => artifact.unconfirmed || artifact.error).length;
1836
1689
  const finalPhase = artifactFailureCount > 0 ? "complete_with_artifact_errors" : "complete";
1837
1690
 
1838
- await heartbeat(
1839
- phasePatch(finalPhase, {
1840
- status: "complete",
1841
- completedAt: new Date().toISOString(),
1691
+ currentJob = await mutateJob((job) => transitionOracleJobPhase(job, finalPhase, {
1692
+ at: new Date().toISOString(),
1693
+ source: "oracle:worker",
1694
+ message: artifactFailureCount > 0
1695
+ ? `Job completed with ${artifactFailureCount} artifact issue(s).`
1696
+ : "Job completed successfully.",
1697
+ patch: {
1842
1698
  responsePath: currentJob.responsePath,
1843
1699
  responseFormat: "text/plain",
1844
1700
  artifactFailureCount,
1845
1701
  cleanupPending: true,
1846
- }),
1847
- { force: true },
1848
- );
1702
+ },
1703
+ }));
1849
1704
  const persistedJob = await readJob().catch(() => undefined);
1850
1705
  await log(`Persisted final status after completion write: ${persistedJob?.status || "unknown"}`);
1851
1706
  await log(`Job ${currentJob.id} complete (${finalPhase}, artifact failures=${artifactFailureCount})`);
@@ -1854,15 +1709,15 @@ async function run() {
1854
1709
  const message = error instanceof Error ? error.message : String(error);
1855
1710
  await captureDiagnostics(currentJob, "failure");
1856
1711
  await log(`Job failed: ${message}`);
1857
- await heartbeat(
1858
- phasePatch("failed", {
1859
- status: "failed",
1860
- completedAt: new Date().toISOString(),
1712
+ currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "failed", {
1713
+ at: new Date().toISOString(),
1714
+ source: "oracle:worker",
1715
+ message: `Job failed: ${message}`,
1716
+ patch: {
1861
1717
  error: message,
1862
1718
  cleanupPending: true,
1863
- }),
1864
- { force: true },
1865
- );
1719
+ },
1720
+ }));
1866
1721
  process.exitCode = 1;
1867
1722
  }
1868
1723
  } finally {
@@ -1875,17 +1730,17 @@ async function run() {
1875
1730
  }
1876
1731
  if (currentJob?.id) {
1877
1732
  const cleanupAt = new Date().toISOString();
1878
- await mutateJob((job) => ({
1879
- ...job,
1880
- cleanupPending: false,
1881
- ...(cleanupWarnings.length > 0
1882
- ? {
1883
- cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupWarnings],
1884
- lastCleanupAt: cleanupAt,
1885
- error: [job.error, ...cleanupWarnings].filter(Boolean).join("\n"),
1886
- }
1887
- : { lastCleanupAt: cleanupAt }),
1888
- })).catch(() => undefined);
1733
+ await mutateJob((job) => cleanupWarnings.length > 0
1734
+ ? applyOracleJobCleanupWarnings(job, cleanupWarnings, {
1735
+ at: cleanupAt,
1736
+ source: "oracle:worker",
1737
+ message: `Runtime cleanup completed with ${cleanupWarnings.length} warning(s).`,
1738
+ })
1739
+ : clearOracleJobCleanupState(job, {
1740
+ at: cleanupAt,
1741
+ source: "oracle:worker",
1742
+ message: "Runtime cleanup finished without warnings.",
1743
+ })).catch(() => undefined);
1889
1744
  }
1890
1745
  if (cleanupWarnings.length === 0) {
1891
1746
  await promoteQueuedJobsAfterCleanup().catch(() => undefined);