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.
- package/CHANGELOG.md +21 -0
- package/README.md +2 -0
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +249 -0
- 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 +66 -65
- 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 +76 -130
- 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
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
// Purpose: Manage oracle browser runtime allocation, lease admission, seed/runtime profile handling, and runtime cleanup for the extension side.
|
|
2
|
+
// Responsibilities: Allocate runtimes, enforce persisted-session requirements, acquire/release runtime and conversation leases, and clean up runtime artifacts safely.
|
|
3
|
+
// Scope: Extension-side runtime coordination only; shared concurrency/process primitives live in extensions/oracle/shared.
|
|
4
|
+
// Usage: Imported by jobs, tools, and queue logic to provision or tear down isolated oracle browser runtimes.
|
|
5
|
+
// Invariants/Assumptions: Lease metadata is the admission source of truth, tracked worker identity checks defend against PID reuse, and runtime cleanup always attempts lease release.
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
2
7
|
import { spawn } from "node:child_process";
|
|
3
8
|
import { existsSync, realpathSync, readFileSync } from "node:fs";
|
|
4
9
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import {
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { jobBlocksAdmission } from "../shared/job-coordination-helpers.mjs";
|
|
12
|
+
import { isTrackedProcessAlive } from "../shared/process-helpers.mjs";
|
|
6
13
|
import type { OracleConfig } from "./config.js";
|
|
7
14
|
import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withAuthLock } from "./locks.js";
|
|
8
15
|
|
|
@@ -12,6 +19,8 @@ const ORACLE_JOBS_DIR = process.env.PI_ORACLE_JOBS_DIR?.trim() || DEFAULT_ORACLE
|
|
|
12
19
|
const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
|
|
13
20
|
(candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
|
|
14
21
|
) || "agent-browser";
|
|
22
|
+
const PROFILE_CLONE_TIMEOUT_MS = 120_000;
|
|
23
|
+
const ORACLE_SUBPROCESS_KILL_GRACE_MS = 2_000;
|
|
15
24
|
|
|
16
25
|
export interface OracleRuntimeLeaseMetadata {
|
|
17
26
|
jobId: string;
|
|
@@ -110,8 +119,18 @@ function activeJobExists(jobId: string): boolean {
|
|
|
110
119
|
const path = join(ORACLE_JOBS_DIR, `oracle-${jobId}`, "job.json");
|
|
111
120
|
if (!existsSync(path)) return false;
|
|
112
121
|
try {
|
|
113
|
-
const job = JSON.parse(readFileSync(path, "utf8")) as {
|
|
114
|
-
|
|
122
|
+
const job = JSON.parse(readFileSync(path, "utf8")) as {
|
|
123
|
+
status?: string;
|
|
124
|
+
cleanupPending?: unknown;
|
|
125
|
+
workerPid?: unknown;
|
|
126
|
+
workerStartedAt?: unknown;
|
|
127
|
+
};
|
|
128
|
+
return jobBlocksAdmission({
|
|
129
|
+
status: typeof job.status === "string" ? job.status : undefined,
|
|
130
|
+
cleanupPending: job.cleanupPending === true,
|
|
131
|
+
workerPid: typeof job.workerPid === "number" ? job.workerPid : undefined,
|
|
132
|
+
workerStartedAt: typeof job.workerStartedAt === "string" ? job.workerStartedAt : undefined,
|
|
133
|
+
}, isTrackedProcessAlive);
|
|
115
134
|
} catch {
|
|
116
135
|
return false;
|
|
117
136
|
}
|
|
@@ -198,22 +217,55 @@ function profileCloneArgs(config: OracleConfig, sourceDir: string, destinationDi
|
|
|
198
217
|
return ["-R", sourceDir, destinationDir];
|
|
199
218
|
}
|
|
200
219
|
|
|
201
|
-
async function spawnCp(args: string[]): Promise<void> {
|
|
220
|
+
async function spawnCp(args: string[], options?: { timeoutMs?: number }): Promise<void> {
|
|
202
221
|
await new Promise<void>((resolve, reject) => {
|
|
203
222
|
const child = spawn("cp", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
204
223
|
let stderr = "";
|
|
224
|
+
let timedOut = false;
|
|
225
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
226
|
+
let killGraceTimer: NodeJS.Timeout | undefined;
|
|
227
|
+
|
|
228
|
+
const clearTimers = () => {
|
|
229
|
+
if (killTimer) clearTimeout(killTimer);
|
|
230
|
+
if (killGraceTimer) clearTimeout(killGraceTimer);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if ((options?.timeoutMs ?? 0) > 0) {
|
|
234
|
+
killTimer = setTimeout(() => {
|
|
235
|
+
timedOut = true;
|
|
236
|
+
child.kill("SIGTERM");
|
|
237
|
+
killGraceTimer = setTimeout(() => {
|
|
238
|
+
child.kill("SIGKILL");
|
|
239
|
+
}, ORACLE_SUBPROCESS_KILL_GRACE_MS);
|
|
240
|
+
killGraceTimer.unref?.();
|
|
241
|
+
}, options?.timeoutMs);
|
|
242
|
+
killTimer.unref?.();
|
|
243
|
+
}
|
|
244
|
+
|
|
205
245
|
child.stderr.on("data", (data) => {
|
|
206
246
|
stderr += String(data);
|
|
207
247
|
});
|
|
208
|
-
child.on("error",
|
|
248
|
+
child.on("error", (error) => {
|
|
249
|
+
clearTimers();
|
|
250
|
+
reject(error);
|
|
251
|
+
});
|
|
209
252
|
child.on("close", (code) => {
|
|
253
|
+
clearTimers();
|
|
254
|
+
if (timedOut) {
|
|
255
|
+
reject(new Error(stderr || `cp timed out after ${options?.timeoutMs}ms`));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
210
258
|
if (code === 0) resolve();
|
|
211
259
|
else reject(new Error(stderr || `cp exited with code ${code}`));
|
|
212
260
|
});
|
|
213
261
|
});
|
|
214
262
|
}
|
|
215
263
|
|
|
216
|
-
export async function cloneSeedProfileToRuntime(
|
|
264
|
+
export async function cloneSeedProfileToRuntime(
|
|
265
|
+
config: OracleConfig,
|
|
266
|
+
runtimeProfileDir: string,
|
|
267
|
+
options?: { cpTimeoutMs?: number },
|
|
268
|
+
): Promise<string | undefined> {
|
|
217
269
|
const seedDir = config.browser.authSeedProfileDir;
|
|
218
270
|
if (!existsSync(seedDir)) {
|
|
219
271
|
throw new Error(`Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`);
|
|
@@ -222,7 +274,7 @@ export async function cloneSeedProfileToRuntime(config: OracleConfig, runtimePro
|
|
|
222
274
|
await withAuthLock({ runtimeProfileDir, seedDir }, async () => {
|
|
223
275
|
await rm(runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
|
|
224
276
|
await mkdir(dirname(runtimeProfileDir), { recursive: true, mode: 0o700 }).catch(() => undefined);
|
|
225
|
-
await spawnCp(profileCloneArgs(config, seedDir, runtimeProfileDir));
|
|
277
|
+
await spawnCp(profileCloneArgs(config, seedDir, runtimeProfileDir), { timeoutMs: options?.cpTimeoutMs ?? PROFILE_CLONE_TIMEOUT_MS });
|
|
226
278
|
});
|
|
227
279
|
|
|
228
280
|
return getSeedGeneration(config);
|
|
@@ -286,9 +338,6 @@ export async function cleanupRuntimeArtifacts(runtime: {
|
|
|
286
338
|
report.warnings.push(`Failed to remove runtime profile ${runtime.runtimeProfileDir}: ${error.message}`);
|
|
287
339
|
});
|
|
288
340
|
}
|
|
289
|
-
if (report.warnings.length > 0) {
|
|
290
|
-
return report;
|
|
291
|
-
}
|
|
292
341
|
if (runtime.conversationId) {
|
|
293
342
|
report.attempted.push("conversationLease");
|
|
294
343
|
}
|
|
@@ -305,6 +354,3 @@ export async function cleanupRuntimeArtifacts(runtime: {
|
|
|
305
354
|
return report;
|
|
306
355
|
}
|
|
307
356
|
|
|
308
|
-
export function stableProjectLabel(projectId: string): string {
|
|
309
|
-
return basename(projectId) || createHash("sha256").update(projectId).digest("hex").slice(0, 8);
|
|
310
|
-
}
|
|
@@ -9,6 +9,8 @@ import { tmpdir } from "node:os";
|
|
|
9
9
|
import { basename, join, posix } from "node:path";
|
|
10
10
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
11
11
|
import { Type } from "@sinclair/typebox";
|
|
12
|
+
import { formatOracleJobSummary, formatOracleSubmitResponse } from "../shared/job-observability-helpers.mjs";
|
|
13
|
+
import { transitionOracleJobPhase } from "../shared/job-lifecycle-helpers.mjs";
|
|
12
14
|
import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.js";
|
|
13
15
|
import {
|
|
14
16
|
coerceOracleSubmitPresetId,
|
|
@@ -36,7 +38,6 @@ import {
|
|
|
36
38
|
spawnWorker,
|
|
37
39
|
terminateWorkerPid,
|
|
38
40
|
updateJob,
|
|
39
|
-
withJobPhase,
|
|
40
41
|
type OracleJob,
|
|
41
42
|
} from "./jobs.js";
|
|
42
43
|
import { getQueuePosition, promoteQueuedJobs, promoteQueuedJobsWithinAdmissionLock } from "./queue.js";
|
|
@@ -78,11 +79,16 @@ const ORACLE_CANCEL_PARAMS = Type.Object({
|
|
|
78
79
|
const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
|
|
79
80
|
const MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME = 1;
|
|
80
81
|
const MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME = MAX_ARCHIVE_BYTES;
|
|
82
|
+
const ARCHIVE_COMMAND_TIMEOUT_MS = 120_000;
|
|
83
|
+
const ARCHIVE_COMMAND_KILL_GRACE_MS = 2_000;
|
|
81
84
|
|
|
82
85
|
const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
|
|
83
86
|
".git",
|
|
84
87
|
".hg",
|
|
85
88
|
".svn",
|
|
89
|
+
".pi",
|
|
90
|
+
".oracle-context",
|
|
91
|
+
".cursor",
|
|
86
92
|
"node_modules",
|
|
87
93
|
"target",
|
|
88
94
|
".venv",
|
|
@@ -118,6 +124,7 @@ const DEFAULT_ARCHIVE_EXCLUDED_FILES = new Set([
|
|
|
118
124
|
".netrc",
|
|
119
125
|
".npmrc",
|
|
120
126
|
".pypirc",
|
|
127
|
+
".scratchpad.md",
|
|
121
128
|
"Thumbs.db",
|
|
122
129
|
"id_dsa",
|
|
123
130
|
"id_ecdsa",
|
|
@@ -321,7 +328,13 @@ function formatArchiveOversizeError(args: {
|
|
|
321
328
|
.join("\n");
|
|
322
329
|
}
|
|
323
330
|
|
|
324
|
-
async function writeArchiveFile(
|
|
331
|
+
async function writeArchiveFile(
|
|
332
|
+
cwd: string,
|
|
333
|
+
entries: string[],
|
|
334
|
+
archivePath: string,
|
|
335
|
+
listPath: string,
|
|
336
|
+
options?: { commandTimeoutMs?: number },
|
|
337
|
+
): Promise<number> {
|
|
325
338
|
await writeFile(listPath, Buffer.from(`${entries.join("\0")}\0`), { mode: 0o600 });
|
|
326
339
|
await rm(archivePath, { force: true }).catch(() => undefined);
|
|
327
340
|
|
|
@@ -337,24 +350,57 @@ async function writeArchiveFile(cwd: string, entries: string[], archivePath: str
|
|
|
337
350
|
|
|
338
351
|
let stderr = "";
|
|
339
352
|
let settled = false;
|
|
353
|
+
let timedOut = false;
|
|
354
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
355
|
+
let killGraceTimer: NodeJS.Timeout | undefined;
|
|
340
356
|
let tarCode: number | null | undefined;
|
|
341
357
|
let zstdCode: number | null | undefined;
|
|
342
358
|
|
|
359
|
+
const clearTimers = () => {
|
|
360
|
+
if (timeout) clearTimeout(timeout);
|
|
361
|
+
if (killGraceTimer) clearTimeout(killGraceTimer);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const terminateChildren = () => {
|
|
365
|
+
tar.kill("SIGTERM");
|
|
366
|
+
zstd.kill("SIGTERM");
|
|
367
|
+
killGraceTimer = setTimeout(() => {
|
|
368
|
+
tar.kill("SIGKILL");
|
|
369
|
+
zstd.kill("SIGKILL");
|
|
370
|
+
}, ARCHIVE_COMMAND_KILL_GRACE_MS);
|
|
371
|
+
killGraceTimer.unref?.();
|
|
372
|
+
};
|
|
373
|
+
|
|
343
374
|
const finish = (error?: Error) => {
|
|
344
375
|
if (settled) return;
|
|
345
376
|
if (error) {
|
|
346
377
|
settled = true;
|
|
347
|
-
|
|
348
|
-
|
|
378
|
+
clearTimers();
|
|
379
|
+
terminateChildren();
|
|
349
380
|
rejectPromise(error);
|
|
350
381
|
return;
|
|
351
382
|
}
|
|
352
383
|
if (tarCode === undefined || zstdCode === undefined) return;
|
|
353
384
|
settled = true;
|
|
385
|
+
clearTimers();
|
|
386
|
+
if (timedOut) {
|
|
387
|
+
rejectPromise(new Error(stderr || `Oracle archive subprocess timed out after ${options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS}ms`));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
354
390
|
if (tarCode === 0 && zstdCode === 0) resolvePromise();
|
|
355
391
|
else rejectPromise(new Error(stderr || `archive command failed (tar=${tarCode}, zstd=${zstdCode})`));
|
|
356
392
|
};
|
|
357
393
|
|
|
394
|
+
const commandTimeoutMs = options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS;
|
|
395
|
+
if (commandTimeoutMs > 0) {
|
|
396
|
+
timeout = setTimeout(() => {
|
|
397
|
+
timedOut = true;
|
|
398
|
+
stderr = `${stderr}${stderr ? "\n" : ""}Oracle archive subprocess timed out after ${commandTimeoutMs}ms`;
|
|
399
|
+
terminateChildren();
|
|
400
|
+
}, commandTimeoutMs);
|
|
401
|
+
timeout.unref?.();
|
|
402
|
+
}
|
|
403
|
+
|
|
358
404
|
tar.stderr.on("data", (data) => {
|
|
359
405
|
stderr += String(data);
|
|
360
406
|
});
|
|
@@ -381,7 +427,7 @@ export async function createArchiveForTesting(
|
|
|
381
427
|
cwd: string,
|
|
382
428
|
files: string[],
|
|
383
429
|
archivePath: string,
|
|
384
|
-
options?: { maxBytes?: number; adaptivePruneMinBytes?: number },
|
|
430
|
+
options?: { maxBytes?: number; adaptivePruneMinBytes?: number; commandTimeoutMs?: number },
|
|
385
431
|
): Promise<ArchiveCreationResult> {
|
|
386
432
|
const archiveInputs = resolveArchiveInputs(cwd, files);
|
|
387
433
|
const wholeRepoSelection = isWholeRepoArchiveSelection(archiveInputs);
|
|
@@ -403,7 +449,7 @@ export async function createArchiveForTesting(
|
|
|
403
449
|
throw new Error("Oracle archive inputs are empty after default exclusions and automatic size pruning");
|
|
404
450
|
}
|
|
405
451
|
|
|
406
|
-
const archiveBytes = await writeArchiveFile(cwd, expandedEntries, archivePath, listPath);
|
|
452
|
+
const archiveBytes = await writeArchiveFile(cwd, expandedEntries, archivePath, listPath, { commandTimeoutMs: options?.commandTimeoutMs });
|
|
407
453
|
if (archiveBytes < maxBytes) {
|
|
408
454
|
return {
|
|
409
455
|
sha256: await sha256File(archivePath),
|
|
@@ -543,38 +589,10 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
|
|
|
543
589
|
cleanupWarnings: job.cleanupWarnings,
|
|
544
590
|
lastCleanupAt: job.lastCleanupAt,
|
|
545
591
|
error: job.error,
|
|
592
|
+
lifecycleEvents: job.lifecycleEvents,
|
|
546
593
|
};
|
|
547
594
|
}
|
|
548
595
|
|
|
549
|
-
function formatAutoPrunedArchiveMessage(autoPrunedPrefixes: ArchiveCreationResult["autoPrunedPrefixes"]): string | undefined {
|
|
550
|
-
if (autoPrunedPrefixes.length === 0) return undefined;
|
|
551
|
-
return `Archive auto-pruned generic generated-output-name dirs to fit size limit: ${autoPrunedPrefixes.map((entry) => `${entry.relativePath}/ (${formatBytes(entry.bytes)})`).join(", ")}`;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function formatSubmitResponse(
|
|
555
|
-
job: NonNullable<ReturnType<typeof readJob>>,
|
|
556
|
-
options: {
|
|
557
|
-
autoPrunedPrefixes: ArchiveCreationResult["autoPrunedPrefixes"];
|
|
558
|
-
queued: boolean;
|
|
559
|
-
queuePosition?: number;
|
|
560
|
-
queueDepth?: number;
|
|
561
|
-
},
|
|
562
|
-
): string {
|
|
563
|
-
return [
|
|
564
|
-
`${options.queued ? "Oracle job queued" : "Oracle job dispatched"}: ${job.id}`,
|
|
565
|
-
options.queued && options.queuePosition && options.queueDepth ? `Queue position: ${options.queuePosition} of ${options.queueDepth}` : undefined,
|
|
566
|
-
job.followUpToJobId ? `Follow-up to: ${job.followUpToJobId}` : undefined,
|
|
567
|
-
`Prompt: ${job.promptPath}`,
|
|
568
|
-
`Archive: ${job.archivePath}`,
|
|
569
|
-
formatAutoPrunedArchiveMessage(options.autoPrunedPrefixes),
|
|
570
|
-
`Response will be written to: ${job.responsePath}`,
|
|
571
|
-
options.queued ? "The job will start automatically when capacity is available." : undefined,
|
|
572
|
-
"Stop now and wait for the oracle completion wake-up.",
|
|
573
|
-
]
|
|
574
|
-
.filter(Boolean)
|
|
575
|
-
.join("\n");
|
|
576
|
-
}
|
|
577
|
-
|
|
578
596
|
export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void {
|
|
579
597
|
pi.registerTool({
|
|
580
598
|
name: "oracle_submit",
|
|
@@ -738,7 +756,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
738
756
|
content: [
|
|
739
757
|
{
|
|
740
758
|
type: "text",
|
|
741
|
-
text:
|
|
759
|
+
text: formatOracleSubmitResponse(job, {
|
|
742
760
|
autoPrunedPrefixes: currentArchive.autoPrunedPrefixes,
|
|
743
761
|
queued,
|
|
744
762
|
queuePosition: queuePosition?.position,
|
|
@@ -769,7 +787,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
769
787
|
content: [
|
|
770
788
|
{
|
|
771
789
|
type: "text",
|
|
772
|
-
text:
|
|
790
|
+
text: formatOracleSubmitResponse(latest, {
|
|
773
791
|
autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
|
|
774
792
|
queued: true,
|
|
775
793
|
queuePosition: queuePosition?.position,
|
|
@@ -797,7 +815,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
797
815
|
content: [
|
|
798
816
|
{
|
|
799
817
|
type: "text",
|
|
800
|
-
text:
|
|
818
|
+
text: formatOracleSubmitResponse(latest, {
|
|
801
819
|
autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
|
|
802
820
|
queued: false,
|
|
803
821
|
}),
|
|
@@ -820,13 +838,13 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
820
838
|
}
|
|
821
839
|
if (job && (!latest || !isTerminalOracleJob(latest))) {
|
|
822
840
|
const failedAt = new Date().toISOString();
|
|
823
|
-
await updateJob(job.id, (current) => ({
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
841
|
+
await updateJob(job.id, (current) => transitionOracleJobPhase(current, "failed", {
|
|
842
|
+
at: failedAt,
|
|
843
|
+
source: "oracle:submit",
|
|
844
|
+
message: `Submission failed before durable worker handoff: ${message}`,
|
|
845
|
+
patch: {
|
|
828
846
|
error: message,
|
|
829
|
-
},
|
|
847
|
+
},
|
|
830
848
|
})).catch(() => undefined);
|
|
831
849
|
}
|
|
832
850
|
const cleanupReport = await cleanupRuntimeArtifacts({
|
|
@@ -877,28 +895,11 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
877
895
|
content: [
|
|
878
896
|
{
|
|
879
897
|
type: "text",
|
|
880
|
-
text:
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
current.queuedAt ? `queued: ${current.queuedAt}` : undefined,
|
|
884
|
-
current.submittedAt ? `submitted: ${current.submittedAt}` : undefined,
|
|
885
|
-
...(current.status === "queued"
|
|
886
|
-
? (() => {
|
|
887
|
-
const queuePosition = getQueuePosition(current.id);
|
|
888
|
-
return queuePosition ? [`queue-position: ${queuePosition.position} of ${queuePosition.depth}`] : [];
|
|
889
|
-
})()
|
|
890
|
-
: []),
|
|
891
|
-
current.followUpToJobId ? `follow-up-to: ${current.followUpToJobId}` : undefined,
|
|
892
|
-
current.chatUrl ? `chat: ${current.chatUrl}` : undefined,
|
|
893
|
-
current.responsePath ? `response: ${current.responsePath}` : undefined,
|
|
894
|
-
current.responseFormat ? `response-format: ${current.responseFormat}` : undefined,
|
|
895
|
-
`artifacts: ${getJobDir(current.id)}/artifacts`,
|
|
896
|
-
current.error ? `error: ${current.error}` : undefined,
|
|
897
|
-
"",
|
|
898
|
+
text: formatOracleJobSummary(current, {
|
|
899
|
+
queuePosition: current.status === "queued" ? getQueuePosition(current.id) : undefined,
|
|
900
|
+
artifactsPath: `${getJobDir(current.id)}/artifacts`,
|
|
898
901
|
responsePreview,
|
|
899
|
-
|
|
900
|
-
.filter(Boolean)
|
|
901
|
-
.join("\n"),
|
|
902
|
+
}),
|
|
902
903
|
},
|
|
903
904
|
],
|
|
904
905
|
details: { job: redactJobDetails(current) },
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export interface OracleDurableWorkerHandoffJobLike {
|
|
2
|
+
status?: string;
|
|
3
|
+
workerPid?: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface OracleAdmissionBlockingJobLike extends OracleDurableWorkerHandoffJobLike {
|
|
7
|
+
cleanupPending?: boolean;
|
|
8
|
+
workerStartedAt?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OracleRuntimeLeaseMetadataLike {
|
|
12
|
+
jobId: string;
|
|
13
|
+
runtimeId: string;
|
|
14
|
+
runtimeSessionName: string;
|
|
15
|
+
runtimeProfileDir: string;
|
|
16
|
+
projectId: string;
|
|
17
|
+
sessionId: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OracleConversationLeaseMetadataLike {
|
|
22
|
+
jobId: string;
|
|
23
|
+
conversationId: string;
|
|
24
|
+
projectId: string;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OracleQueuedPromotionFailureContext<TJob, TWorker> {
|
|
30
|
+
job: TJob;
|
|
31
|
+
latest?: TJob;
|
|
32
|
+
error: unknown;
|
|
33
|
+
at: string;
|
|
34
|
+
spawnedWorker?: TWorker;
|
|
35
|
+
runtimeLeaseAcquired: boolean;
|
|
36
|
+
conversationLeaseAcquired: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type OracleQueuedPromotionFailureOutcome = void | "break";
|
|
40
|
+
|
|
41
|
+
export interface OracleQueuedPromotionOptions<TJob extends { id: string; archivePath: string }, TWorker> {
|
|
42
|
+
listQueuedJobs: () => TJob[];
|
|
43
|
+
refreshJob: (jobId: string) => TJob | undefined;
|
|
44
|
+
readLatestJob: (jobId: string) => TJob | undefined;
|
|
45
|
+
isQueuedJob?: (job: TJob | undefined) => boolean;
|
|
46
|
+
acquireRuntimeLease: (job: TJob, at: string) => Promise<boolean>;
|
|
47
|
+
acquireConversationLease: (job: TJob, at: string) => Promise<boolean>;
|
|
48
|
+
releaseRuntimeLease: (job: TJob) => Promise<void>;
|
|
49
|
+
markSubmitted: (job: TJob, at: string) => Promise<void>;
|
|
50
|
+
spawnWorker: (job: TJob) => Promise<TWorker>;
|
|
51
|
+
persistWorker: (job: TJob, worker: TWorker) => Promise<void>;
|
|
52
|
+
hasDurableWorkerHandoff?: (job: TJob | undefined) => boolean;
|
|
53
|
+
isTerminalJob: (job: TJob) => boolean;
|
|
54
|
+
failQueuedPromotion: (job: TJob, message: string, at: string) => Promise<void>;
|
|
55
|
+
terminateSpawnedWorker: (worker: TWorker) => Promise<void>;
|
|
56
|
+
cleanupAfterFailure: (context: OracleQueuedPromotionFailureContext<TJob, TWorker>) => Promise<OracleQueuedPromotionFailureOutcome>;
|
|
57
|
+
onDurableHandoff?: (job: TJob, latest?: TJob) => Promise<void> | void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export declare function isQueuedOracleJob(job: OracleDurableWorkerHandoffJobLike | undefined): boolean;
|
|
61
|
+
export declare function compareQueuedOracleJobs(
|
|
62
|
+
left: { createdAt: string; queuedAt?: string; id: string },
|
|
63
|
+
right: { createdAt: string; queuedAt?: string; id: string },
|
|
64
|
+
): number;
|
|
65
|
+
export declare function hasDurableWorkerHandoff(job: OracleDurableWorkerHandoffJobLike | undefined): boolean;
|
|
66
|
+
export declare function hasAdmissionBlockingWorker(
|
|
67
|
+
job: OracleAdmissionBlockingJobLike | undefined,
|
|
68
|
+
isTrackedProcessAliveFn?: (pid: number | undefined, startedAt?: string) => boolean,
|
|
69
|
+
): boolean;
|
|
70
|
+
export declare function jobBlocksAdmission(
|
|
71
|
+
job: OracleAdmissionBlockingJobLike | undefined,
|
|
72
|
+
isTrackedProcessAliveFn?: (pid: number | undefined, startedAt?: string) => boolean,
|
|
73
|
+
): boolean;
|
|
74
|
+
export declare function buildRuntimeLeaseMetadata(
|
|
75
|
+
job: { id: string; runtimeId: string; runtimeSessionName: string; runtimeProfileDir: string; projectId: string; sessionId: string },
|
|
76
|
+
createdAt: string,
|
|
77
|
+
): OracleRuntimeLeaseMetadataLike;
|
|
78
|
+
export declare function buildConversationLeaseMetadata(
|
|
79
|
+
job: { id: string; conversationId?: string; projectId: string; sessionId: string },
|
|
80
|
+
createdAt: string,
|
|
81
|
+
): OracleConversationLeaseMetadataLike | undefined;
|
|
82
|
+
export declare function runQueuedJobPromotionPass<TJob extends { id: string; archivePath: string }, TWorker>(
|
|
83
|
+
options: OracleQueuedPromotionOptions<TJob, TWorker>,
|
|
84
|
+
): Promise<{ promotedJobIds: string[] }>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Purpose: Provide shared oracle job coordination helpers for admission control, lease metadata, and queued promotion orchestration.
|
|
2
|
+
// Responsibilities: Normalize queue ordering, derive lease metadata, detect durable handoff/admission blockers, and run a single queued-promotion pass.
|
|
3
|
+
// Scope: Pure coordination/state-machine logic only; filesystem I/O and job persistence remain in injected callbacks.
|
|
4
|
+
// Usage: Imported by lib/queue.ts, lib/runtime.ts, lib/jobs.ts, and worker/run-job.mjs to keep concurrency semantics aligned.
|
|
5
|
+
// Invariants/Assumptions: Queued jobs have durable ids/archive paths, and callers provide side-effect callbacks that preserve atomic job updates.
|
|
6
|
+
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { isTrackedProcessAlive } from "./process-helpers.mjs";
|
|
9
|
+
|
|
10
|
+
/** @typedef {import("./job-coordination-helpers.d.mts").OracleAdmissionBlockingJobLike} OracleAdmissionBlockingJobLike */
|
|
11
|
+
/** @typedef {import("./job-coordination-helpers.d.mts").OracleConversationLeaseMetadataLike} OracleConversationLeaseMetadataLike */
|
|
12
|
+
/** @typedef {import("./job-coordination-helpers.d.mts").OracleDurableWorkerHandoffJobLike} OracleDurableWorkerHandoffJobLike */
|
|
13
|
+
/** @typedef {import("./job-coordination-helpers.d.mts").OracleRuntimeLeaseMetadataLike} OracleRuntimeLeaseMetadataLike */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {OracleDurableWorkerHandoffJobLike | undefined} job
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export function isQueuedOracleJob(job) {
|
|
20
|
+
return job?.status === "queued";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{ createdAt: string; queuedAt?: string; id: string }} left
|
|
25
|
+
* @param {{ createdAt: string; queuedAt?: string; id: string }} right
|
|
26
|
+
* @returns {number}
|
|
27
|
+
*/
|
|
28
|
+
export function compareQueuedOracleJobs(left, right) {
|
|
29
|
+
const leftKey = left.queuedAt ?? left.createdAt;
|
|
30
|
+
const rightKey = right.queuedAt ?? right.createdAt;
|
|
31
|
+
return leftKey.localeCompare(rightKey) || left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {OracleDurableWorkerHandoffJobLike | undefined} job
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function hasDurableWorkerHandoff(job) {
|
|
39
|
+
if (!job || job.status === "queued") return false;
|
|
40
|
+
if (job.workerPid) return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {OracleAdmissionBlockingJobLike | undefined} job
|
|
46
|
+
* @param {(pid: number | undefined, startedAt?: string) => boolean} [isTrackedProcessAliveFn]
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function hasAdmissionBlockingWorker(job, isTrackedProcessAliveFn = isTrackedProcessAlive) {
|
|
50
|
+
if (!job?.workerPid) return false;
|
|
51
|
+
return isTrackedProcessAliveFn(job.workerPid, job.workerStartedAt);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {OracleAdmissionBlockingJobLike | undefined} job
|
|
56
|
+
* @param {(pid: number | undefined, startedAt?: string) => boolean} [isTrackedProcessAliveFn]
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function jobBlocksAdmission(job, isTrackedProcessAliveFn = isTrackedProcessAlive) {
|
|
60
|
+
return ["preparing", "submitted", "waiting"].includes(String(job?.status || "")) ||
|
|
61
|
+
job?.cleanupPending === true ||
|
|
62
|
+
hasAdmissionBlockingWorker(job, isTrackedProcessAliveFn);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{ id: string; runtimeId: string; runtimeSessionName: string; runtimeProfileDir: string; projectId: string; sessionId: string }} job
|
|
67
|
+
* @param {string} createdAt
|
|
68
|
+
* @returns {OracleRuntimeLeaseMetadataLike}
|
|
69
|
+
*/
|
|
70
|
+
export function buildRuntimeLeaseMetadata(job, createdAt) {
|
|
71
|
+
return {
|
|
72
|
+
jobId: job.id,
|
|
73
|
+
runtimeId: job.runtimeId,
|
|
74
|
+
runtimeSessionName: job.runtimeSessionName,
|
|
75
|
+
runtimeProfileDir: job.runtimeProfileDir,
|
|
76
|
+
projectId: job.projectId,
|
|
77
|
+
sessionId: job.sessionId,
|
|
78
|
+
createdAt,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {{ id: string; conversationId?: string; projectId: string; sessionId: string }} job
|
|
84
|
+
* @param {string} createdAt
|
|
85
|
+
* @returns {OracleConversationLeaseMetadataLike | undefined}
|
|
86
|
+
*/
|
|
87
|
+
export function buildConversationLeaseMetadata(job, createdAt) {
|
|
88
|
+
if (!job.conversationId) return undefined;
|
|
89
|
+
return {
|
|
90
|
+
jobId: job.id,
|
|
91
|
+
conversationId: job.conversationId,
|
|
92
|
+
projectId: job.projectId,
|
|
93
|
+
sessionId: job.sessionId,
|
|
94
|
+
createdAt,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @template {{ id: string; archivePath: string }} TJob
|
|
100
|
+
* @template TWorker
|
|
101
|
+
* @param {import("./job-coordination-helpers.d.mts").OracleQueuedPromotionOptions<TJob, TWorker>} options
|
|
102
|
+
* @returns {Promise<{ promotedJobIds: string[] }>}
|
|
103
|
+
*/
|
|
104
|
+
export async function runQueuedJobPromotionPass(options) {
|
|
105
|
+
const promotedJobIds = [];
|
|
106
|
+
const isQueuedJob = options.isQueuedJob ?? isQueuedOracleJob;
|
|
107
|
+
const durableHandoff = options.hasDurableWorkerHandoff ?? hasDurableWorkerHandoff;
|
|
108
|
+
|
|
109
|
+
for (const queuedJob of options.listQueuedJobs()) {
|
|
110
|
+
const promotedAt = new Date().toISOString();
|
|
111
|
+
let runtimeLeaseAcquired = false;
|
|
112
|
+
let conversationLeaseAcquired = false;
|
|
113
|
+
/** @type {TWorker | undefined} */
|
|
114
|
+
let spawnedWorker;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const current = options.refreshJob(queuedJob.id);
|
|
118
|
+
if (!isQueuedJob(current)) continue;
|
|
119
|
+
if (!existsSync(current.archivePath)) {
|
|
120
|
+
await options.failQueuedPromotion(current, `Queued oracle archive is missing: ${current.archivePath}`, promotedAt);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const runtimeAttempt = await options.acquireRuntimeLease(current, promotedAt);
|
|
125
|
+
if (!runtimeAttempt) break;
|
|
126
|
+
runtimeLeaseAcquired = true;
|
|
127
|
+
|
|
128
|
+
const conversationAttempt = await options.acquireConversationLease(current, promotedAt);
|
|
129
|
+
if (!conversationAttempt) {
|
|
130
|
+
await options.releaseRuntimeLease(current).catch(() => undefined);
|
|
131
|
+
runtimeLeaseAcquired = false;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
conversationLeaseAcquired = true;
|
|
135
|
+
|
|
136
|
+
await options.markSubmitted(current, promotedAt);
|
|
137
|
+
spawnedWorker = await options.spawnWorker(current);
|
|
138
|
+
await options.persistWorker(current, spawnedWorker);
|
|
139
|
+
promotedJobIds.push(current.id);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
142
|
+
const latest = options.readLatestJob(queuedJob.id);
|
|
143
|
+
if (spawnedWorker && durableHandoff(latest)) {
|
|
144
|
+
promotedJobIds.push(queuedJob.id);
|
|
145
|
+
await options.onDurableHandoff?.(queuedJob, latest);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (spawnedWorker) {
|
|
149
|
+
await options.terminateSpawnedWorker(spawnedWorker).catch(() => undefined);
|
|
150
|
+
}
|
|
151
|
+
if (latest && !options.isTerminalJob(latest)) {
|
|
152
|
+
await options.failQueuedPromotion(latest, message, promotedAt);
|
|
153
|
+
}
|
|
154
|
+
const failureOutcome = await options.cleanupAfterFailure({
|
|
155
|
+
job: queuedJob,
|
|
156
|
+
latest,
|
|
157
|
+
error,
|
|
158
|
+
at: promotedAt,
|
|
159
|
+
spawnedWorker,
|
|
160
|
+
runtimeLeaseAcquired,
|
|
161
|
+
conversationLeaseAcquired,
|
|
162
|
+
});
|
|
163
|
+
if (failureOutcome === "break") break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { promotedJobIds };
|
|
168
|
+
}
|