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,9 +1,24 @@
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";
8
23
  import {
9
24
  buildAllowedChatGptOrigins,
@@ -17,6 +32,7 @@ import {
17
32
  snapshotWeaklyMatchesRequestedModel,
18
33
  autoSwitchToThinkingSelectionVisible,
19
34
  } from "./chatgpt-ui-helpers.mjs";
35
+ import { assistantSnapshotSlice, nextStableValueState, resolveStableConversationUrlCandidate, stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
20
36
  import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withLock } from "./state-locks.mjs";
21
37
 
22
38
  const jobId = process.argv[2];
@@ -48,6 +64,7 @@ const ARTIFACT_DOWNLOAD_HEARTBEAT_MS = 10_000;
48
64
  const ARTIFACT_DOWNLOAD_TIMEOUT_MS = 90_000;
49
65
  const ARTIFACT_DOWNLOAD_MAX_ATTEMPTS = 2;
50
66
  const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
67
+ const PROFILE_CLONE_TIMEOUT_MS = 120_000;
51
68
  const MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS = 20_000;
52
69
  const MODEL_CONFIGURATION_SETTLE_POLL_MS = 250;
53
70
  const MODEL_CONFIGURATION_CLOSE_RETRY_MS = 1_000;
@@ -68,79 +85,10 @@ async function ensurePrivateDir(path) {
68
85
  await chmod(path, 0o700).catch(() => undefined);
69
86
  }
70
87
 
71
- function isProcessAlive(pid) {
72
- try {
73
- process.kill(pid, 0);
74
- return true;
75
- } catch (error) {
76
- if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
77
- return true;
78
- }
79
- }
80
-
81
- function readProcessStartedAt(pid) {
82
- if (!pid || pid <= 0) return undefined;
83
- try {
84
- const startedAt = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
85
- return startedAt || undefined;
86
- } catch {
87
- return undefined;
88
- }
89
- }
90
-
91
- async function waitForProcessStartedAt(pid, timeoutMs = 2_000) {
92
- const deadline = Date.now() + timeoutMs;
93
- while (Date.now() < deadline) {
94
- const startedAt = readProcessStartedAt(pid);
95
- if (startedAt) return startedAt;
96
- await sleep(100);
97
- }
98
- return readProcessStartedAt(pid);
99
- }
100
-
101
88
  async function terminateWorkerPid(pid, startedAt, options = {}) {
102
- if (!pid || pid <= 0) return true;
103
- const currentStartedAt = readProcessStartedAt(pid);
104
- if (!currentStartedAt) return true;
105
- if (startedAt && currentStartedAt !== startedAt) return false;
106
-
107
- const termGraceMs = options.termGraceMs ?? 5_000;
108
- const killGraceMs = options.killGraceMs ?? 2_000;
109
-
110
- try {
111
- process.kill(pid, "SIGTERM");
112
- } catch {
113
- return !isProcessAlive(pid);
114
- }
115
-
116
- const termDeadline = Date.now() + termGraceMs;
117
- while (Date.now() < termDeadline) {
118
- const liveStartedAt = readProcessStartedAt(pid);
119
- if (!liveStartedAt) return true;
120
- if (startedAt && liveStartedAt !== startedAt) return true;
121
- await sleep(250);
122
- }
123
-
124
- try {
125
- process.kill(pid, "SIGKILL");
126
- } catch {
127
- return !isProcessAlive(pid);
128
- }
129
-
130
- const killDeadline = Date.now() + killGraceMs;
131
- while (Date.now() < killDeadline) {
132
- const liveStartedAt = readProcessStartedAt(pid);
133
- if (!liveStartedAt) return true;
134
- if (startedAt && liveStartedAt !== startedAt) return true;
135
- await sleep(250);
136
- }
137
-
138
- const finalStartedAt = readProcessStartedAt(pid);
139
- if (!finalStartedAt) return true;
140
- return startedAt ? finalStartedAt !== startedAt : false;
89
+ return terminateTrackedProcess(pid, startedAt, options);
141
90
  }
142
91
 
143
-
144
92
  async function secureWriteText(path, content) {
145
93
  const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
146
94
  await writeFile(tmpPath, content, { encoding: "utf8", mode: 0o600 });
@@ -186,25 +134,7 @@ function listQueuedJobs() {
186
134
  .filter((name) => name.startsWith("oracle-"))
187
135
  .map((name) => readAnyJob(name.slice("oracle-".length)))
188
136
  .filter((job) => job?.status === "queued")
189
- .sort((left, right) => {
190
- const leftKey = left?.queuedAt || left?.createdAt || "";
191
- const rightKey = right?.queuedAt || right?.createdAt || "";
192
- return leftKey.localeCompare(rightKey) || String(left?.createdAt || "").localeCompare(String(right?.createdAt || "")) || String(left?.id || "").localeCompare(String(right?.id || ""));
193
- });
194
- }
195
-
196
- function isActiveJobStatus(status) {
197
- return ["preparing", "submitted", "waiting"].includes(String(status || ""));
198
- }
199
-
200
- function jobBlocksAdmission(job) {
201
- return isActiveJobStatus(job?.status) || job?.cleanupPending === true || (Array.isArray(job?.cleanupWarnings) && job.cleanupWarnings.length > 0);
202
- }
203
-
204
- function hasDurableWorkerHandoff(job) {
205
- if (!job || job.status === "queued") return false;
206
- if (job.workerPid) return true;
207
- return false;
137
+ .sort(compareQueuedOracleJobs);
208
138
  }
209
139
 
210
140
  async function mutateAnyJob(targetJobId, mutator) {
@@ -217,12 +147,6 @@ async function mutateAnyJob(targetJobId, mutator) {
217
147
  });
218
148
  }
219
149
 
220
- async function writeAnyJob(targetJobId, job) {
221
- await withLock(ORACLE_STATE_DIR, "job", targetJobId, { processPid: process.pid, action: "writeJob", targetJobId }, async () => {
222
- await secureWriteText(getAnyJobPath(targetJobId), `${JSON.stringify(job, null, 2)}\n`);
223
- });
224
- }
225
-
226
150
  async function writeJobUnlocked(job) {
227
151
  await secureWriteText(jobPath, `${JSON.stringify(job, null, 2)}\n`);
228
152
  }
@@ -243,14 +167,6 @@ async function mutateJob(mutator) {
243
167
  });
244
168
  }
245
169
 
246
- function phasePatch(phase, patch = undefined, at = new Date().toISOString()) {
247
- return {
248
- ...(patch || {}),
249
- phase,
250
- phaseAt: at,
251
- };
252
- }
253
-
254
170
  async function heartbeat(patch = undefined, options = {}) {
255
171
  const now = Date.now();
256
172
  const force = options.force === true;
@@ -342,7 +258,7 @@ async function cloneSeedProfileToRuntime(job) {
342
258
  await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
343
259
  await ensurePrivateDir(dirname(job.runtimeProfileDir));
344
260
  const cloneArgs = job.config.browser.cloneStrategy === "apfs-clone" ? ["-cR", seedDir, job.runtimeProfileDir] : ["-R", seedDir, job.runtimeProfileDir];
345
- await spawnCommand("cp", cloneArgs);
261
+ await spawnCommand("cp", cloneArgs, { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
346
262
  }, 10 * 60 * 1000);
347
263
 
348
264
  return seedGeneration;
@@ -400,102 +316,77 @@ async function tryAcquireRuntimeLeaseForJob(job, createdAt) {
400
316
  if (liveLeases.length >= job.config.browser.maxConcurrentJobs) {
401
317
  return false;
402
318
  }
403
- await createLease(ORACLE_STATE_DIR, "runtime", job.runtimeId, {
404
- jobId: job.id,
405
- runtimeId: job.runtimeId,
406
- runtimeSessionName: job.runtimeSessionName,
407
- runtimeProfileDir: job.runtimeProfileDir,
408
- projectId: job.projectId,
409
- sessionId: job.sessionId,
410
- createdAt,
411
- });
319
+ await createLease(ORACLE_STATE_DIR, "runtime", job.runtimeId, buildRuntimeLeaseMetadata(job, createdAt));
412
320
  return true;
413
321
  }
414
322
 
415
323
  async function tryAcquireConversationLeaseForJob(job, createdAt) {
416
- if (!job.conversationId) return true;
417
- 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);
418
327
  if (existing?.jobId === job.id) return true;
419
328
  if (existing && existing.jobId !== job.id) {
420
329
  if (!jobBlocksAdmission(readAnyJob(existing.jobId))) {
421
- await releaseLease(ORACLE_STATE_DIR, "conversation", job.conversationId).catch(() => undefined);
330
+ await releaseLease(ORACLE_STATE_DIR, "conversation", metadata.conversationId).catch(() => undefined);
422
331
  } else {
423
332
  return false;
424
333
  }
425
334
  }
426
- await createLease(ORACLE_STATE_DIR, "conversation", job.conversationId, {
427
- jobId: job.id,
428
- conversationId: job.conversationId,
429
- projectId: job.projectId,
430
- sessionId: job.sessionId,
431
- createdAt,
432
- });
335
+ await createLease(ORACLE_STATE_DIR, "conversation", metadata.conversationId, metadata);
433
336
  return true;
434
337
  }
435
338
 
436
339
  async function spawnDetachedWorker(targetJobId) {
437
- const child = spawn(process.execPath, [WORKER_SCRIPT_PATH, targetJobId], {
438
- detached: true,
439
- stdio: "ignore",
440
- });
441
- child.unref();
340
+ const child = await spawnDetachedNodeProcess(WORKER_SCRIPT_PATH, [targetJobId]);
442
341
  return {
443
342
  pid: child.pid,
444
343
  workerNonce: randomUUID(),
445
- workerStartedAt: await waitForProcessStartedAt(child.pid),
344
+ workerStartedAt: child.startedAt,
446
345
  };
447
346
  }
448
347
 
449
348
  async function failQueuedPromotion(targetJobId, message, at = new Date().toISOString()) {
450
349
  await mutateAnyJob(targetJobId, (latest) => {
451
350
  if (["complete", "failed", "cancelled"].includes(String(latest.status || ""))) return latest;
452
- return {
453
- ...latest,
454
- ...phasePatch("failed", {
455
- status: "failed",
456
- completedAt: at,
351
+ return transitionOracleJobPhase(latest, "failed", {
352
+ at,
353
+ source: "oracle:worker-cleanup-promotion",
354
+ message: `Queued promotion failed: ${message}`,
355
+ patch: {
457
356
  heartbeatAt: at,
458
357
  error: message,
459
- }, at),
460
- };
358
+ },
359
+ });
461
360
  }).catch(() => undefined);
462
361
  }
463
362
 
464
363
  async function promoteQueuedJobsAfterCleanup() {
465
364
  await withLock(ORACLE_STATE_DIR, "admission", "global", { processPid: process.pid, source: "worker_cleanup_promoter", jobId }, async () => {
466
- for (const queuedJob of listQueuedJobs()) {
467
- const current = readAnyJob(queuedJob.id);
468
- if (!current || current.status !== "queued") continue;
469
-
470
- let spawnedWorker;
471
- const promotedAt = new Date().toISOString();
472
- if (!existsSync(current.archivePath)) {
473
- await failQueuedPromotion(current.id, `Queued oracle archive is missing: ${current.archivePath}`, promotedAt);
474
- continue;
475
- }
476
- const runtimeLeaseAcquired = await tryAcquireRuntimeLeaseForJob(current, promotedAt);
477
- if (!runtimeLeaseAcquired) break;
478
-
479
- const conversationLeaseAcquired = await tryAcquireConversationLeaseForJob(current, promotedAt);
480
- if (!conversationLeaseAcquired) {
481
- await releaseLease(ORACLE_STATE_DIR, "runtime", current.runtimeId).catch(() => undefined);
482
- continue;
483
- }
484
-
485
- try {
486
- 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) => {
487
376
  if (latest.status !== "queued") throw new Error(`Queued job ${latest.id} changed state during cleanup promotion (${latest.status})`);
488
- return {
489
- ...latest,
490
- ...phasePatch("submitted", {
491
- status: "submitted",
492
- submittedAt: latest.submittedAt || promotedAt,
493
- }, promotedAt),
494
- };
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
+ });
495
385
  });
496
-
497
- spawnedWorker = await spawnDetachedWorker(current.id);
498
- await mutateAnyJob(current.id, (latest) => {
386
+ },
387
+ spawnWorker: async (job) => spawnDetachedWorker(job.id),
388
+ persistWorker: async (job, spawnedWorker) => {
389
+ await mutateAnyJob(job.id, (latest) => {
499
390
  if (hasDurableWorkerHandoff(latest)) {
500
391
  return {
501
392
  ...latest,
@@ -511,44 +402,42 @@ async function promoteQueuedJobsAfterCleanup() {
511
402
  workerStartedAt: spawnedWorker.workerStartedAt,
512
403
  };
513
404
  });
514
- } catch (error) {
515
- const latest = readAnyJob(current.id);
516
- if (hasDurableWorkerHandoff(latest)) {
517
- await log(`Queued promotion handoff already durable for ${current.id}; leaving active job intact`).catch(() => undefined);
518
- continue;
519
- }
520
- if (spawnedWorker) {
521
- await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.workerStartedAt).catch(() => undefined);
522
- }
523
- const failedAt = new Date().toISOString();
524
- if (latest && !["complete", "failed", "cancelled"].includes(String(latest.status || ""))) {
525
- await failQueuedPromotion(current.id, error instanceof Error ? error.message : String(error), failedAt);
526
- }
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 }) => {
527
413
  if (spawnedWorker) {
528
414
  let cleanupWarnings = [];
529
415
  try {
530
- cleanupWarnings = await cleanupRuntime(current);
416
+ cleanupWarnings = await cleanupRuntime(job);
531
417
  } catch (cleanupError) {
532
- 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)}`;
533
419
  cleanupWarnings = [message];
534
420
  await log(message).catch(() => undefined);
535
421
  }
536
422
  if (cleanupWarnings.length > 0) {
537
- await mutateAnyJob(current.id, (job) => ({
538
- ...job,
539
- cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupWarnings],
540
- lastCleanupAt: failedAt,
541
- 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).`,
542
427
  })).catch(() => undefined);
543
- await log(`Stopping queued cleanup promotion after ${current.id} because teardown left ${cleanupWarnings.length} warning(s)`).catch(() => undefined);
544
- break;
428
+ await log(`Stopping queued cleanup promotion after ${job.id} because teardown left ${cleanupWarnings.length} warning(s)`).catch(() => undefined);
429
+ return "break";
545
430
  }
546
- } else {
547
- await releaseLease(ORACLE_STATE_DIR, "conversation", current.conversationId).catch(() => undefined);
548
- await releaseLease(ORACLE_STATE_DIR, "runtime", current.runtimeId).catch(() => undefined);
431
+ return;
549
432
  }
550
- }
551
- }
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
+ });
552
441
  }).catch(async (error) => {
553
442
  await log(`Queued cleanup promotion warning: ${error instanceof Error ? error.message : String(error)}`).catch(() => undefined);
554
443
  });
@@ -671,14 +560,7 @@ async function currentUrl(job) {
671
560
  }
672
561
 
673
562
  function stripQuery(url) {
674
- try {
675
- const parsed = new URL(url);
676
- parsed.hash = "";
677
- parsed.search = "";
678
- return parsed.toString();
679
- } catch {
680
- return url;
681
- }
563
+ return stripUrlQueryAndHash(url);
682
564
  }
683
565
 
684
566
  async function snapshotText(job) {
@@ -1319,46 +1201,17 @@ async function assistantMessages(job) {
1319
1201
  return result.messages.map((message) => ({ text: typeof message?.text === "string" ? message.text : "" }));
1320
1202
  }
1321
1203
 
1322
- function assistantSnapshotSlice(snapshot, responseIndex) {
1323
- const lines = snapshot.split("\n");
1324
- const assistantHeadingIndices = lines.flatMap((line, index) => (line.includes('heading "ChatGPT said:"') ? [index] : []));
1325
- const startIndex = assistantHeadingIndices[responseIndex];
1326
- if (startIndex === undefined) return undefined;
1327
-
1328
- const endCandidates = [];
1329
- const nextAssistantIndex = assistantHeadingIndices[responseIndex + 1];
1330
- if (nextAssistantIndex !== undefined) endCandidates.push(nextAssistantIndex);
1331
-
1332
- const composerIndex = lines.findIndex(
1333
- (line, index) => index > startIndex && line.includes(`textbox "${CHATGPT_LABELS.composer}"`),
1334
- );
1335
- if (composerIndex !== -1) endCandidates.push(composerIndex);
1336
-
1337
- const endIndex = endCandidates.length > 0 ? Math.min(...endCandidates) : undefined;
1338
- return lines.slice(startIndex, endIndex).join("\n");
1339
- }
1340
-
1341
1204
  async function waitForStableChatUrl(job, previousChatUrl) {
1342
1205
  const timeoutAt = Date.now() + 60_000;
1343
- let lastUrl = "";
1344
- let stableCount = 0;
1206
+ /** @type {import("./chatgpt-flow-helpers.d.mts").OracleStableValueState | undefined} */
1207
+ let stableState;
1345
1208
 
1346
1209
  while (Date.now() < timeoutAt) {
1347
1210
  await heartbeat();
1348
- const url = stripQuery(await currentUrl(job));
1349
- let isConversationUrl = false;
1350
- try {
1351
- isConversationUrl = /\/c\/[A-Za-z0-9-]+$/i.test(new URL(url).pathname);
1352
- } catch {
1353
- isConversationUrl = false;
1354
- }
1355
- const isKnownFollowUpUrl = previousChatUrl ? stripQuery(previousChatUrl) === url : false;
1356
-
1357
- if (isConversationUrl || isKnownFollowUpUrl) {
1358
- if (url === lastUrl) stableCount += 1;
1359
- else stableCount = 1;
1360
- lastUrl = url;
1361
- 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;
1362
1215
  }
1363
1216
 
1364
1217
  await sleep(1000);
@@ -1459,7 +1312,7 @@ function preferredArtifactName(label, index) {
1459
1312
 
1460
1313
  async function collectArtifactCandidates(job, responseIndex, responseText = "") {
1461
1314
  const snapshot = await snapshotText(job);
1462
- const targetSlice = assistantSnapshotSlice(snapshot, responseIndex);
1315
+ const targetSlice = assistantSnapshotSlice(snapshot, CHATGPT_LABELS.composer, responseIndex);
1463
1316
  if (!targetSlice) return { snapshot, targetSlice, candidates: [], suspiciousLabels: [] };
1464
1317
 
1465
1318
  const structural = await evalPage(
@@ -1762,19 +1615,44 @@ async function run() {
1762
1615
 
1763
1616
  try {
1764
1617
  await log(`Starting oracle worker for job ${currentJob.id}`);
1765
- 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
+ }));
1766
1624
  await closeBrowser(currentJob);
1767
1625
 
1768
1626
  const seedGeneration = await cloneSeedProfileToRuntime(currentJob);
1769
- 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
+ }));
1770
1633
 
1771
1634
  const targetUrl = currentJob.chatUrl || currentJob.config.browser.chatUrl;
1772
1635
  await launchBrowser(currentJob, targetUrl);
1773
- 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
+ }));
1774
1642
  await waitForOracleReady(currentJob);
1775
- 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
+ }));
1776
1649
  await configureModel(currentJob);
1777
- 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
+ }));
1778
1656
  await uploadArchive(currentJob);
1779
1657
  await setComposerText(currentJob, await readFile(currentJob.promptPath, "utf8"));
1780
1658
  const baselineAssistantCount = (await assistantMessages(currentJob)).length;
@@ -1785,30 +1663,44 @@ async function run() {
1785
1663
 
1786
1664
  const chatUrl = await waitForStableChatUrl(currentJob, currentJob.chatUrl);
1787
1665
  const conversationId = parseConversationId(chatUrl) || currentJob.conversationId;
1788
- currentJob = await mutateJob((job) => ({
1789
- ...job,
1790
- ...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() },
1791
1671
  }));
1792
1672
 
1793
1673
  const completion = await waitForChatCompletion(currentJob, baselineAssistantCount);
1794
- 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
+ }));
1795
1680
  await secureWriteText(currentJob.responsePath, `${completion.responseText.trim()}\n`);
1796
- 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
+ }));
1797
1687
  const artifacts = await downloadArtifacts(currentJob, completion.responseIndex, completion.responseText);
1798
1688
  const artifactFailureCount = artifacts.filter((artifact) => artifact.unconfirmed || artifact.error).length;
1799
1689
  const finalPhase = artifactFailureCount > 0 ? "complete_with_artifact_errors" : "complete";
1800
1690
 
1801
- await heartbeat(
1802
- phasePatch(finalPhase, {
1803
- status: "complete",
1804
- 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: {
1805
1698
  responsePath: currentJob.responsePath,
1806
1699
  responseFormat: "text/plain",
1807
1700
  artifactFailureCount,
1808
1701
  cleanupPending: true,
1809
- }),
1810
- { force: true },
1811
- );
1702
+ },
1703
+ }));
1812
1704
  const persistedJob = await readJob().catch(() => undefined);
1813
1705
  await log(`Persisted final status after completion write: ${persistedJob?.status || "unknown"}`);
1814
1706
  await log(`Job ${currentJob.id} complete (${finalPhase}, artifact failures=${artifactFailureCount})`);
@@ -1817,15 +1709,15 @@ async function run() {
1817
1709
  const message = error instanceof Error ? error.message : String(error);
1818
1710
  await captureDiagnostics(currentJob, "failure");
1819
1711
  await log(`Job failed: ${message}`);
1820
- await heartbeat(
1821
- phasePatch("failed", {
1822
- status: "failed",
1823
- 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: {
1824
1717
  error: message,
1825
1718
  cleanupPending: true,
1826
- }),
1827
- { force: true },
1828
- );
1719
+ },
1720
+ }));
1829
1721
  process.exitCode = 1;
1830
1722
  }
1831
1723
  } finally {
@@ -1838,17 +1730,17 @@ async function run() {
1838
1730
  }
1839
1731
  if (currentJob?.id) {
1840
1732
  const cleanupAt = new Date().toISOString();
1841
- await mutateJob((job) => ({
1842
- ...job,
1843
- cleanupPending: false,
1844
- ...(cleanupWarnings.length > 0
1845
- ? {
1846
- cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupWarnings],
1847
- lastCleanupAt: cleanupAt,
1848
- error: [job.error, ...cleanupWarnings].filter(Boolean).join("\n"),
1849
- }
1850
- : { lastCleanupAt: cleanupAt }),
1851
- })).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);
1852
1744
  }
1853
1745
  if (cleanupWarnings.length === 0) {
1854
1746
  await promoteQueuedJobsAfterCleanup().catch(() => undefined);