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.
- package/CHANGELOG.md +33 -0
- package/README.md +7 -0
- package/docs/ORACLE_DESIGN.md +1 -1
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +249 -0
- package/docs/ORACLE_RECOVERY_DRILL.md +5 -4
- package/extensions/oracle/index.ts +8 -1
- package/extensions/oracle/lib/commands.ts +11 -24
- package/extensions/oracle/lib/config.ts +5 -0
- package/extensions/oracle/lib/jobs.ts +117 -217
- package/extensions/oracle/lib/locks.ts +41 -209
- package/extensions/oracle/lib/poller.ts +14 -51
- package/extensions/oracle/lib/queue.ts +75 -112
- package/extensions/oracle/lib/runtime.ts +60 -14
- package/extensions/oracle/lib/tools.ts +70 -67
- package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
- package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +130 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.mjs +377 -0
- package/extensions/oracle/shared/job-observability-helpers.d.mts +59 -0
- package/extensions/oracle/shared/job-observability-helpers.mjs +143 -0
- package/extensions/oracle/shared/process-helpers.d.mts +20 -0
- package/extensions/oracle/shared/process-helpers.mjs +128 -0
- package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
- package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
- package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +100 -139
- package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
- package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
- package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +33 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +292 -0
- package/extensions/oracle/worker/run-job.mjs +235 -380
- package/extensions/oracle/worker/state-locks.mjs +31 -216
- package/package.json +14 -5
- 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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
411
|
-
|
|
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",
|
|
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",
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
await
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
492
|
-
|
|
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
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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(
|
|
416
|
+
cleanupWarnings = await cleanupRuntime(job);
|
|
525
417
|
} catch (cleanupError) {
|
|
526
|
-
const message = `Cleanup-driven promotion teardown warning for ${
|
|
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(
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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"
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
-
|
|
1402
|
-
let
|
|
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
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) => (
|
|
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) => (
|
|
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) => (
|
|
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) => (
|
|
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
|
-
|
|
1827
|
-
|
|
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) => (
|
|
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) => (
|
|
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
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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
|
-
|
|
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
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
:
|
|
1888
|
-
|
|
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);
|