pi-oracle 0.3.4 → 0.5.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 +38 -0
- package/README.md +27 -8
- package/docs/ORACLE_DESIGN.md +14 -8
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +276 -0
- package/extensions/oracle/index.ts +8 -1
- package/extensions/oracle/lib/commands.ts +25 -29
- package/extensions/oracle/lib/config.ts +56 -2
- package/extensions/oracle/lib/jobs.ts +134 -219
- package/extensions/oracle/lib/locks.ts +41 -209
- package/extensions/oracle/lib/poller.ts +38 -52
- package/extensions/oracle/lib/queue.ts +75 -112
- package/extensions/oracle/lib/runtime.ts +102 -19
- package/extensions/oracle/lib/tools.ts +663 -294
- 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 +131 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.mjs +390 -0
- package/extensions/oracle/shared/job-observability-helpers.d.mts +60 -0
- package/extensions/oracle/shared/job-observability-helpers.mjs +161 -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 +125 -134
- package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
- package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
- package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +93 -9
- package/extensions/oracle/worker/run-job.mjs +166 -274
- package/extensions/oracle/worker/state-locks.mjs +31 -216
- package/package.json +4 -3
- package/prompts/oracle.md +16 -10
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
417
|
-
|
|
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",
|
|
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",
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
await
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
498
|
-
|
|
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
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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(
|
|
416
|
+
cleanupWarnings = await cleanupRuntime(job);
|
|
531
417
|
} catch (cleanupError) {
|
|
532
|
-
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)}`;
|
|
533
419
|
cleanupWarnings = [message];
|
|
534
420
|
await log(message).catch(() => undefined);
|
|
535
421
|
}
|
|
536
422
|
if (cleanupWarnings.length > 0) {
|
|
537
|
-
await mutateAnyJob(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1344
|
-
let
|
|
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
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
|
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) => (
|
|
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) => (
|
|
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) => (
|
|
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) => (
|
|
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
|
-
|
|
1790
|
-
|
|
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) => (
|
|
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) => (
|
|
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
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
-
|
|
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
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
:
|
|
1851
|
-
|
|
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);
|