pi-oracle 0.1.12 → 0.2.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 +37 -0
- package/README.md +24 -10
- package/docs/ORACLE_DESIGN.md +583 -0
- package/docs/ORACLE_RECOVERY_DRILL.md +127 -0
- package/extensions/oracle/index.ts +15 -4
- package/extensions/oracle/lib/commands.ts +35 -12
- package/extensions/oracle/lib/config.ts +2 -2
- package/extensions/oracle/lib/jobs.ts +438 -72
- package/extensions/oracle/lib/locks.ts +99 -13
- package/extensions/oracle/lib/poller.ts +223 -38
- package/extensions/oracle/lib/queue.ts +193 -0
- package/extensions/oracle/lib/runtime.ts +69 -15
- package/extensions/oracle/lib/tools.ts +274 -64
- package/extensions/oracle/worker/artifact-heuristics.d.mts +29 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +2 -72
- package/extensions/oracle/worker/auth-cookie-policy.d.mts +31 -0
- package/extensions/oracle/worker/run-job.mjs +330 -71
- package/extensions/oracle/worker/state-locks.d.mts +45 -0
- package/extensions/oracle/worker/state-locks.mjs +235 -0
- package/package.json +13 -4
- package/prompts/oracle.md +2 -0
|
@@ -2,44 +2,58 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { lstat, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { basename, join, posix } from "node:path";
|
|
5
|
-
import { StringEnum } from "@mariozechner/pi-ai";
|
|
6
5
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
7
6
|
import { Type } from "@sinclair/typebox";
|
|
8
7
|
import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.js";
|
|
9
8
|
import { loadOracleConfig, EFFORTS, MODEL_FAMILIES, type OracleEffort, type OracleModelFamily } from "./config.js";
|
|
10
9
|
import {
|
|
10
|
+
appendCleanupWarnings,
|
|
11
11
|
cancelOracleJob,
|
|
12
12
|
createJob,
|
|
13
|
+
getJobDir,
|
|
13
14
|
getSessionFile,
|
|
14
|
-
|
|
15
|
+
hasDurableWorkerHandoff,
|
|
16
|
+
isOpenOracleJob,
|
|
17
|
+
isTerminalOracleJob,
|
|
18
|
+
listOracleJobDirs,
|
|
19
|
+
markWakeupSettled,
|
|
15
20
|
readJob,
|
|
16
21
|
pruneTerminalOracleJobs,
|
|
17
22
|
reconcileStaleOracleJobs,
|
|
18
23
|
resolveArchiveInputs,
|
|
19
24
|
sha256File,
|
|
25
|
+
shouldAdvanceQueueAfterCancellation,
|
|
20
26
|
spawnWorker,
|
|
27
|
+
terminateWorkerPid,
|
|
21
28
|
updateJob,
|
|
22
29
|
withJobPhase,
|
|
30
|
+
type OracleJob,
|
|
23
31
|
} from "./jobs.js";
|
|
32
|
+
import { getQueuePosition, promoteQueuedJobs, promoteQueuedJobsWithinAdmissionLock } from "./queue.js";
|
|
24
33
|
import { refreshOracleStatus } from "./poller.js";
|
|
25
34
|
import {
|
|
26
|
-
acquireConversationLease,
|
|
27
|
-
acquireRuntimeLease,
|
|
28
35
|
allocateRuntime,
|
|
29
36
|
cleanupRuntimeArtifacts,
|
|
30
37
|
getProjectId,
|
|
31
38
|
getSessionId,
|
|
32
39
|
parseConversationId,
|
|
40
|
+
requirePersistedSessionFile,
|
|
41
|
+
tryAcquireConversationLease,
|
|
42
|
+
tryAcquireRuntimeLease,
|
|
33
43
|
} from "./runtime.js";
|
|
34
44
|
|
|
45
|
+
function stringEnum(values: readonly string[], description: string) {
|
|
46
|
+
return Type.Union(values.map((value) => Type.Literal(value)), { description });
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
const ORACLE_SUBMIT_PARAMS = Type.Object({
|
|
36
50
|
prompt: Type.String({ description: "Prompt text to send to ChatGPT web." }),
|
|
37
51
|
files: Type.Array(Type.String({ description: "Project-relative file or directory path to include in the archive." }), {
|
|
38
52
|
description: "Exact project-relative files/directories to include in the oracle archive.",
|
|
39
53
|
minItems: 1,
|
|
40
54
|
}),
|
|
41
|
-
modelFamily: Type.Optional(
|
|
42
|
-
effort: Type.Optional(
|
|
55
|
+
modelFamily: Type.Optional(stringEnum(MODEL_FAMILIES, "ChatGPT model family: instant, thinking, or pro.")),
|
|
56
|
+
effort: Type.Optional(stringEnum(EFFORTS, "Reasoning effort. Use only values supported by the chosen model family.")),
|
|
43
57
|
autoSwitchToThinking: Type.Optional(
|
|
44
58
|
Type.Boolean({ description: "Only valid when modelFamily is instant. Omit for thinking and pro." }),
|
|
45
59
|
),
|
|
@@ -61,6 +75,8 @@ const VALID_EFFORTS: Record<OracleModelFamily, readonly OracleEffort[]> = {
|
|
|
61
75
|
};
|
|
62
76
|
|
|
63
77
|
const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
|
|
78
|
+
const MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME = 1;
|
|
79
|
+
const MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME = MAX_ARCHIVE_BYTES;
|
|
64
80
|
|
|
65
81
|
const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
|
|
66
82
|
".git",
|
|
@@ -420,6 +436,27 @@ async function createArchive(cwd: string, files: string[], archivePath: string):
|
|
|
420
436
|
return createArchiveForTesting(cwd, files, archivePath);
|
|
421
437
|
}
|
|
422
438
|
|
|
439
|
+
async function getQueuedArchivePressure(): Promise<{ queuedJobs: number; queuedArchiveBytes: number }> {
|
|
440
|
+
const queuedJobs = listOracleJobDirs()
|
|
441
|
+
.map((dir) => readJob(dir))
|
|
442
|
+
.filter((job): job is NonNullable<typeof job> => Boolean(job && job.status === "queued"));
|
|
443
|
+
|
|
444
|
+
const queuedArchiveBytes = (await Promise.all(
|
|
445
|
+
queuedJobs.map(async (job) => {
|
|
446
|
+
try {
|
|
447
|
+
return (await stat(job.archivePath)).size;
|
|
448
|
+
} catch {
|
|
449
|
+
return 0;
|
|
450
|
+
}
|
|
451
|
+
}),
|
|
452
|
+
)).reduce((sum, bytes) => sum + bytes, 0);
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
queuedJobs: queuedJobs.length,
|
|
456
|
+
queuedArchiveBytes,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
423
460
|
function validateSubmissionOptions(
|
|
424
461
|
params: { effort?: OracleEffort; autoSwitchToThinking?: boolean },
|
|
425
462
|
modelFamily: OracleModelFamily,
|
|
@@ -477,6 +514,7 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
|
|
|
477
514
|
projectId: job.projectId,
|
|
478
515
|
sessionId: job.sessionId,
|
|
479
516
|
createdAt: job.createdAt,
|
|
517
|
+
queuedAt: job.queuedAt,
|
|
480
518
|
submittedAt: job.submittedAt,
|
|
481
519
|
completedAt: job.completedAt,
|
|
482
520
|
followUpToJobId: job.followUpToJobId,
|
|
@@ -495,6 +533,35 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
|
|
|
495
533
|
};
|
|
496
534
|
}
|
|
497
535
|
|
|
536
|
+
function formatAutoPrunedArchiveMessage(autoPrunedPrefixes: ArchiveCreationResult["autoPrunedPrefixes"]): string | undefined {
|
|
537
|
+
if (autoPrunedPrefixes.length === 0) return undefined;
|
|
538
|
+
return `Archive auto-pruned generic generated-output-name dirs to fit size limit: ${autoPrunedPrefixes.map((entry) => `${entry.relativePath}/ (${formatBytes(entry.bytes)})`).join(", ")}`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function formatSubmitResponse(
|
|
542
|
+
job: NonNullable<ReturnType<typeof readJob>>,
|
|
543
|
+
options: {
|
|
544
|
+
autoPrunedPrefixes: ArchiveCreationResult["autoPrunedPrefixes"];
|
|
545
|
+
queued: boolean;
|
|
546
|
+
queuePosition?: number;
|
|
547
|
+
queueDepth?: number;
|
|
548
|
+
},
|
|
549
|
+
): string {
|
|
550
|
+
return [
|
|
551
|
+
`${options.queued ? "Oracle job queued" : "Oracle job dispatched"}: ${job.id}`,
|
|
552
|
+
options.queued && options.queuePosition && options.queueDepth ? `Queue position: ${options.queuePosition} of ${options.queueDepth}` : undefined,
|
|
553
|
+
job.followUpToJobId ? `Follow-up to: ${job.followUpToJobId}` : undefined,
|
|
554
|
+
`Prompt: ${job.promptPath}`,
|
|
555
|
+
`Archive: ${job.archivePath}`,
|
|
556
|
+
formatAutoPrunedArchiveMessage(options.autoPrunedPrefixes),
|
|
557
|
+
`Response will be written to: ${job.responsePath}`,
|
|
558
|
+
options.queued ? "The job will start automatically when capacity is available." : undefined,
|
|
559
|
+
"Stop now and wait for the oracle completion wake-up.",
|
|
560
|
+
]
|
|
561
|
+
.filter(Boolean)
|
|
562
|
+
.join("\n");
|
|
563
|
+
}
|
|
564
|
+
|
|
498
565
|
export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void {
|
|
499
566
|
pi.registerTool({
|
|
500
567
|
name: "oracle_submit",
|
|
@@ -510,23 +577,26 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
510
577
|
"When files='.' and the post-exclusion archive is still too large, submit automatically prunes the largest nested directories matching generic generated-output names like build/, dist/, out/, coverage/, and tmp/ outside obvious source roots like src/ and lib/ until the archive fits or no candidate remains; successful submissions report what was pruned.",
|
|
511
578
|
"If a submitted oracle job later fails because upload is rejected, retry smaller: remove the largest obviously irrelevant/generated content first, then narrow to modified files plus adjacent files plus directly relevant subtrees, then explain the cut or ask the user if still needed.",
|
|
512
579
|
"If oracle_submit itself fails because the local archive still exceeds the upload limit after default exclusions and automatic generic generated-output-dir pruning, or for any other submit-time error, stop and report the error instead of retrying automatically.",
|
|
580
|
+
"If oracle_submit returns a queued job instead of an immediately dispatched one, treat that as success and stop exactly the same way.",
|
|
513
581
|
"Stop after dispatching oracle_submit; do not continue the task while the oracle job is running.",
|
|
514
582
|
"Only use autoSwitchToThinking with modelFamily=instant.",
|
|
515
583
|
],
|
|
516
584
|
parameters: ORACLE_SUBMIT_PARAMS,
|
|
517
585
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
518
586
|
const config = loadOracleConfig(ctx.cwd);
|
|
519
|
-
const originSessionFile = getSessionFile(ctx);
|
|
587
|
+
const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
|
|
520
588
|
const projectId = getProjectId(ctx.cwd);
|
|
521
589
|
const sessionId = getSessionId(originSessionFile, projectId);
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
const
|
|
590
|
+
const submittedModelFamily = params.modelFamily as OracleModelFamily | undefined;
|
|
591
|
+
const submittedEffort = params.effort as OracleEffort | undefined;
|
|
592
|
+
const modelFamily: OracleModelFamily = submittedModelFamily ?? config.defaults.modelFamily;
|
|
593
|
+
const requestedEffort: OracleEffort = submittedEffort ?? config.defaults.effort;
|
|
594
|
+
const effort: OracleEffort | undefined = modelFamily === "instant" ? undefined : requestedEffort;
|
|
525
595
|
const rawAutoSwitchToThinking = params.autoSwitchToThinking ?? config.defaults.autoSwitchToThinking;
|
|
526
596
|
const autoSwitchToThinking = modelFamily === "instant" ? rawAutoSwitchToThinking : false;
|
|
527
597
|
const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
|
|
528
598
|
|
|
529
|
-
validateSubmissionOptions(params, modelFamily, effort, autoSwitchToThinking);
|
|
599
|
+
validateSubmissionOptions({ effort: submittedEffort, autoSwitchToThinking: params.autoSwitchToThinking }, modelFamily, effort, autoSwitchToThinking);
|
|
530
600
|
try {
|
|
531
601
|
await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
|
|
532
602
|
await reconcileStaleOracleJobs();
|
|
@@ -539,29 +609,95 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
539
609
|
const jobId = randomUUID();
|
|
540
610
|
const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
|
|
541
611
|
const runtime = allocateRuntime(config);
|
|
542
|
-
let job;
|
|
612
|
+
let job: OracleJob | undefined;
|
|
613
|
+
let archive: ArchiveCreationResult | undefined;
|
|
614
|
+
let queued = false;
|
|
615
|
+
let queuedSubmissionDurable = false;
|
|
616
|
+
let runtimeLeaseAcquired = false;
|
|
617
|
+
let conversationLeaseAcquired = false;
|
|
618
|
+
let workerSpawned = false;
|
|
619
|
+
let spawnedWorker: Awaited<ReturnType<typeof spawnWorker>> | undefined;
|
|
543
620
|
|
|
544
621
|
try {
|
|
545
|
-
|
|
622
|
+
archive = await createArchive(ctx.cwd, params.files, tempArchivePath);
|
|
623
|
+
const currentArchive = archive;
|
|
546
624
|
await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
|
|
547
|
-
await
|
|
625
|
+
await promoteQueuedJobsWithinAdmissionLock({ workerPath, source: "oracle_submit" });
|
|
626
|
+
|
|
627
|
+
const admittedAt = new Date().toISOString();
|
|
628
|
+
const runtimeAttempt = await tryAcquireRuntimeLease(config, {
|
|
548
629
|
jobId,
|
|
549
630
|
runtimeId: runtime.runtimeId,
|
|
550
631
|
runtimeSessionName: runtime.runtimeSessionName,
|
|
551
632
|
runtimeProfileDir: runtime.runtimeProfileDir,
|
|
552
633
|
projectId,
|
|
553
634
|
sessionId,
|
|
554
|
-
createdAt:
|
|
635
|
+
createdAt: admittedAt,
|
|
555
636
|
});
|
|
637
|
+
|
|
638
|
+
if (!runtimeAttempt.acquired) {
|
|
639
|
+
const queuePressure = await getQueuedArchivePressure();
|
|
640
|
+
const maxQueuedJobs = config.browser.maxConcurrentJobs * MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME;
|
|
641
|
+
if (queuePressure.queuedJobs >= maxQueuedJobs) {
|
|
642
|
+
throw new Error(
|
|
643
|
+
`Oracle is busy (${runtimeAttempt.liveLeases.length}/${config.browser.maxConcurrentJobs} active, ${queuePressure.queuedJobs}/${maxQueuedJobs} queued). ` +
|
|
644
|
+
"Retry later instead of enqueuing more archive state.",
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
const maxQueuedArchiveBytes = config.browser.maxConcurrentJobs * MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME;
|
|
648
|
+
if (queuePressure.queuedArchiveBytes + currentArchive.archiveBytes > maxQueuedArchiveBytes) {
|
|
649
|
+
throw new Error(
|
|
650
|
+
`Oracle queued archive storage is full (${queuePressure.queuedArchiveBytes + currentArchive.archiveBytes} bytes > ${maxQueuedArchiveBytes} bytes across queued jobs). ` +
|
|
651
|
+
"Retry later or narrow the archive inputs.",
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
queued = true;
|
|
656
|
+
job = await createJob(
|
|
657
|
+
jobId,
|
|
658
|
+
{
|
|
659
|
+
prompt: params.prompt,
|
|
660
|
+
files: params.files,
|
|
661
|
+
modelFamily,
|
|
662
|
+
effort,
|
|
663
|
+
autoSwitchToThinking,
|
|
664
|
+
followUpToJobId: followUp.followUpToJobId,
|
|
665
|
+
chatUrl: followUp.chatUrl,
|
|
666
|
+
requestSource: "tool",
|
|
667
|
+
},
|
|
668
|
+
ctx.cwd,
|
|
669
|
+
originSessionFile,
|
|
670
|
+
config,
|
|
671
|
+
runtime,
|
|
672
|
+
{ initialState: "queued", createdAt: admittedAt },
|
|
673
|
+
);
|
|
674
|
+
await rename(tempArchivePath, job.archivePath);
|
|
675
|
+
job = await updateJob(job.id, (current) => ({
|
|
676
|
+
...current,
|
|
677
|
+
archiveSha256: currentArchive.sha256,
|
|
678
|
+
}));
|
|
679
|
+
queuedSubmissionDurable = true;
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
runtimeLeaseAcquired = true;
|
|
556
684
|
if (followUp.conversationId) {
|
|
557
|
-
await
|
|
685
|
+
const conversationAttempt = await tryAcquireConversationLease({
|
|
558
686
|
jobId,
|
|
559
687
|
conversationId: followUp.conversationId,
|
|
560
688
|
projectId,
|
|
561
689
|
sessionId,
|
|
562
|
-
createdAt:
|
|
690
|
+
createdAt: admittedAt,
|
|
563
691
|
});
|
|
692
|
+
if (!conversationAttempt.acquired) {
|
|
693
|
+
throw new Error(
|
|
694
|
+
`Oracle conversation ${followUp.conversationId} is already in use by job ${conversationAttempt.blocker?.jobId ?? "unknown"}. ` +
|
|
695
|
+
"Concurrent follow-ups to the same ChatGPT thread are not allowed.",
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
conversationLeaseAcquired = true;
|
|
564
699
|
}
|
|
700
|
+
|
|
565
701
|
job = await createJob(
|
|
566
702
|
jobId,
|
|
567
703
|
{
|
|
@@ -578,51 +714,109 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
578
714
|
originSessionFile,
|
|
579
715
|
config,
|
|
580
716
|
runtime,
|
|
717
|
+
{ initialState: "submitted", createdAt: admittedAt },
|
|
581
718
|
);
|
|
719
|
+
await rename(tempArchivePath, job.archivePath);
|
|
720
|
+
spawnedWorker = await spawnWorker(workerPath, job.id);
|
|
721
|
+
workerSpawned = true;
|
|
722
|
+
const worker = spawnedWorker;
|
|
723
|
+
job = await updateJob(job.id, (current) => ({
|
|
724
|
+
...current,
|
|
725
|
+
archiveSha256: currentArchive.sha256,
|
|
726
|
+
workerPid: worker.pid,
|
|
727
|
+
workerNonce: worker.nonce,
|
|
728
|
+
workerStartedAt: worker.startedAt,
|
|
729
|
+
}));
|
|
582
730
|
});
|
|
583
|
-
|
|
584
|
-
const worker = await spawnWorker(workerPath, job.id);
|
|
585
|
-
await updateJob(job.id, (current) => ({
|
|
586
|
-
...current,
|
|
587
|
-
archiveSha256: archive.sha256,
|
|
588
|
-
workerPid: worker.pid,
|
|
589
|
-
workerNonce: worker.nonce,
|
|
590
|
-
workerStartedAt: worker.startedAt,
|
|
591
|
-
}));
|
|
731
|
+
if (!job || !archive) throw new Error(`Oracle submission ${jobId} did not persist job metadata durably`);
|
|
592
732
|
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
593
733
|
|
|
734
|
+
const queuePosition = queued ? getQueuePosition(job.id) : undefined;
|
|
594
735
|
return {
|
|
595
736
|
content: [
|
|
596
737
|
{
|
|
597
738
|
type: "text",
|
|
598
|
-
text:
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
? `Archive auto-pruned generic generated-output-name dirs to fit size limit: ${archive.autoPrunedPrefixes.map((entry) => `${entry.relativePath}/ (${formatBytes(entry.bytes)})`).join(", ")}`
|
|
605
|
-
: undefined,
|
|
606
|
-
`Response will be written to: ${job.responsePath}`,
|
|
607
|
-
"Stop now and wait for the oracle completion wake-up.",
|
|
608
|
-
]
|
|
609
|
-
.filter(Boolean)
|
|
610
|
-
.join("\n"),
|
|
739
|
+
text: formatSubmitResponse(job, {
|
|
740
|
+
autoPrunedPrefixes: currentArchive.autoPrunedPrefixes,
|
|
741
|
+
queued,
|
|
742
|
+
queuePosition: queuePosition?.position,
|
|
743
|
+
queueDepth: queuePosition?.depth,
|
|
744
|
+
}),
|
|
611
745
|
},
|
|
612
746
|
],
|
|
613
747
|
details: {
|
|
614
748
|
jobId: job.id,
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
749
|
+
queued,
|
|
750
|
+
queuePosition: queuePosition?.position,
|
|
751
|
+
queueDepth: queuePosition?.depth,
|
|
752
|
+
archiveSha256: currentArchive.sha256,
|
|
753
|
+
archiveBytes: currentArchive.archiveBytes,
|
|
754
|
+
initialArchiveBytes: currentArchive.initialArchiveBytes,
|
|
755
|
+
autoPrunedArchivePaths: currentArchive.autoPrunedPrefixes,
|
|
619
756
|
runtimeId: job.runtimeId,
|
|
620
757
|
followUpToJobId: followUp.followUpToJobId,
|
|
621
758
|
},
|
|
622
759
|
};
|
|
623
760
|
} catch (error) {
|
|
624
761
|
const message = error instanceof Error ? error.message : String(error);
|
|
625
|
-
|
|
762
|
+
const latest = job ? readJob(job.id) : undefined;
|
|
763
|
+
if (latest?.status === "queued" && queuedSubmissionDurable) {
|
|
764
|
+
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
765
|
+
const queuePosition = getQueuePosition(latest.id);
|
|
766
|
+
return {
|
|
767
|
+
content: [
|
|
768
|
+
{
|
|
769
|
+
type: "text",
|
|
770
|
+
text: formatSubmitResponse(latest, {
|
|
771
|
+
autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
|
|
772
|
+
queued: true,
|
|
773
|
+
queuePosition: queuePosition?.position,
|
|
774
|
+
queueDepth: queuePosition?.depth,
|
|
775
|
+
}),
|
|
776
|
+
},
|
|
777
|
+
],
|
|
778
|
+
details: {
|
|
779
|
+
jobId: latest.id,
|
|
780
|
+
queued: true,
|
|
781
|
+
queuePosition: queuePosition?.position,
|
|
782
|
+
queueDepth: queuePosition?.depth,
|
|
783
|
+
archiveSha256: latest.archiveSha256,
|
|
784
|
+
archiveBytes: archive?.archiveBytes,
|
|
785
|
+
initialArchiveBytes: archive?.initialArchiveBytes,
|
|
786
|
+
autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
|
|
787
|
+
runtimeId: latest.runtimeId,
|
|
788
|
+
followUpToJobId: latest.followUpToJobId,
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
if (workerSpawned && latest && hasDurableWorkerHandoff(latest)) {
|
|
793
|
+
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
794
|
+
return {
|
|
795
|
+
content: [
|
|
796
|
+
{
|
|
797
|
+
type: "text",
|
|
798
|
+
text: formatSubmitResponse(latest, {
|
|
799
|
+
autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
|
|
800
|
+
queued: false,
|
|
801
|
+
}),
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
details: {
|
|
805
|
+
jobId: latest.id,
|
|
806
|
+
queued: false,
|
|
807
|
+
archiveSha256: latest.archiveSha256,
|
|
808
|
+
archiveBytes: archive?.archiveBytes,
|
|
809
|
+
initialArchiveBytes: archive?.initialArchiveBytes,
|
|
810
|
+
autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
|
|
811
|
+
runtimeId: latest.runtimeId,
|
|
812
|
+
followUpToJobId: latest.followUpToJobId,
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
if (spawnedWorker) {
|
|
817
|
+
await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.startedAt).catch(() => undefined);
|
|
818
|
+
}
|
|
819
|
+
if (job && (!latest || !isTerminalOracleJob(latest))) {
|
|
626
820
|
const failedAt = new Date().toISOString();
|
|
627
821
|
await updateJob(job.id, (current) => ({
|
|
628
822
|
...current,
|
|
@@ -633,12 +827,15 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
633
827
|
}, failedAt),
|
|
634
828
|
})).catch(() => undefined);
|
|
635
829
|
}
|
|
636
|
-
await cleanupRuntimeArtifacts({
|
|
637
|
-
runtimeId: runtime.runtimeId,
|
|
638
|
-
runtimeProfileDir: runtime.runtimeProfileDir,
|
|
639
|
-
runtimeSessionName: runtime.runtimeSessionName,
|
|
640
|
-
conversationId: followUp.conversationId,
|
|
641
|
-
}).catch(() =>
|
|
830
|
+
const cleanupReport = await cleanupRuntimeArtifacts({
|
|
831
|
+
runtimeId: runtimeLeaseAcquired ? runtime.runtimeId : undefined,
|
|
832
|
+
runtimeProfileDir: runtimeLeaseAcquired ? runtime.runtimeProfileDir : undefined,
|
|
833
|
+
runtimeSessionName: workerSpawned ? runtime.runtimeSessionName : undefined,
|
|
834
|
+
conversationId: conversationLeaseAcquired ? followUp.conversationId : undefined,
|
|
835
|
+
}).catch(() => ({ attempted: [], warnings: [] }));
|
|
836
|
+
if (job && cleanupReport.warnings.length > 0) {
|
|
837
|
+
await appendCleanupWarnings(job.id, cleanupReport.warnings).catch(() => undefined);
|
|
838
|
+
}
|
|
642
839
|
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
643
840
|
throw error;
|
|
644
841
|
} finally {
|
|
@@ -657,10 +854,12 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
657
854
|
if (!job || job.projectId !== getProjectId(ctx.cwd)) {
|
|
658
855
|
throw new Error(`Oracle job not found in this project: ${params.jobId}`);
|
|
659
856
|
}
|
|
857
|
+
const latest = isTerminalOracleJob(job) ? await markWakeupSettled(job.id) : job;
|
|
858
|
+
const current = latest ?? readJob(job.id) ?? job;
|
|
660
859
|
|
|
661
860
|
let responsePreview = "";
|
|
662
861
|
try {
|
|
663
|
-
const response = await import("node:fs/promises").then((fs) => fs.readFile(
|
|
862
|
+
const response = await import("node:fs/promises").then((fs) => fs.readFile(current.responsePath || "", "utf8"));
|
|
664
863
|
responsePreview = response.slice(0, 4000);
|
|
665
864
|
} catch {
|
|
666
865
|
responsePreview = "(response not available yet)";
|
|
@@ -671,14 +870,22 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
671
870
|
{
|
|
672
871
|
type: "text",
|
|
673
872
|
text: [
|
|
674
|
-
`job: ${
|
|
675
|
-
`status: ${
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
873
|
+
`job: ${current.id}`,
|
|
874
|
+
`status: ${current.status}`,
|
|
875
|
+
current.queuedAt ? `queued: ${current.queuedAt}` : undefined,
|
|
876
|
+
current.submittedAt ? `submitted: ${current.submittedAt}` : undefined,
|
|
877
|
+
...(current.status === "queued"
|
|
878
|
+
? (() => {
|
|
879
|
+
const queuePosition = getQueuePosition(current.id);
|
|
880
|
+
return queuePosition ? [`queue-position: ${queuePosition.position} of ${queuePosition.depth}`] : [];
|
|
881
|
+
})()
|
|
882
|
+
: []),
|
|
883
|
+
current.followUpToJobId ? `follow-up-to: ${current.followUpToJobId}` : undefined,
|
|
884
|
+
current.chatUrl ? `chat: ${current.chatUrl}` : undefined,
|
|
885
|
+
current.responsePath ? `response: ${current.responsePath}` : undefined,
|
|
886
|
+
current.responseFormat ? `response-format: ${current.responseFormat}` : undefined,
|
|
887
|
+
`artifacts: ${getJobDir(current.id)}/artifacts`,
|
|
888
|
+
current.error ? `error: ${current.error}` : undefined,
|
|
682
889
|
"",
|
|
683
890
|
responsePreview,
|
|
684
891
|
]
|
|
@@ -686,7 +893,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
686
893
|
.join("\n"),
|
|
687
894
|
},
|
|
688
895
|
],
|
|
689
|
-
details: { job: redactJobDetails(
|
|
896
|
+
details: { job: redactJobDetails(current) },
|
|
690
897
|
};
|
|
691
898
|
},
|
|
692
899
|
});
|
|
@@ -694,24 +901,27 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
694
901
|
pi.registerTool({
|
|
695
902
|
name: "oracle_cancel",
|
|
696
903
|
label: "Oracle Cancel",
|
|
697
|
-
description: "Cancel
|
|
904
|
+
description: "Cancel a queued or active oracle job.",
|
|
698
905
|
parameters: ORACLE_CANCEL_PARAMS,
|
|
699
906
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
700
907
|
const job = readJob(params.jobId);
|
|
701
908
|
if (!job || job.projectId !== getProjectId(ctx.cwd)) {
|
|
702
909
|
throw new Error(`Oracle job not found in this project: ${params.jobId}`);
|
|
703
910
|
}
|
|
704
|
-
if (!
|
|
911
|
+
if (!isOpenOracleJob(job)) {
|
|
705
912
|
return {
|
|
706
|
-
content: [{ type: "text", text: `Oracle job ${job.id} is not
|
|
913
|
+
content: [{ type: "text", text: `Oracle job ${job.id} is not cancellable (${job.status}).` }],
|
|
707
914
|
details: { job: redactJobDetails(job) },
|
|
708
915
|
};
|
|
709
916
|
}
|
|
710
917
|
|
|
711
918
|
const cancelled = await cancelOracleJob(params.jobId);
|
|
919
|
+
if (shouldAdvanceQueueAfterCancellation(cancelled)) {
|
|
920
|
+
await promoteQueuedJobs({ workerPath, source: "oracle_cancel_tool" });
|
|
921
|
+
}
|
|
712
922
|
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
713
923
|
return {
|
|
714
|
-
content: [{ type: "text", text: `Cancelled oracle job ${cancelled.id}.` }],
|
|
924
|
+
content: [{ type: "text", text: cancelled.status === "cancelled" || cancelled.status === "failed" ? `Cancelled oracle job ${cancelled.id}.` : `Oracle job ${cancelled.id} was already ${cancelled.status}.` }],
|
|
715
925
|
details: { job: redactJobDetails(cancelled) },
|
|
716
926
|
};
|
|
717
927
|
},
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface SnapshotEntry {
|
|
2
|
+
line: string;
|
|
3
|
+
lineIndex: number;
|
|
4
|
+
ref: string;
|
|
5
|
+
kind?: string;
|
|
6
|
+
label?: string;
|
|
7
|
+
value?: string;
|
|
8
|
+
disabled: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StructuralArtifactCandidateInput {
|
|
12
|
+
label?: string;
|
|
13
|
+
paragraphText?: string;
|
|
14
|
+
listItemText?: string;
|
|
15
|
+
paragraphFileButtonCount?: number;
|
|
16
|
+
paragraphOtherTextLength?: number;
|
|
17
|
+
listItemFileButtonCount?: number;
|
|
18
|
+
focusableFileButtonCount?: number;
|
|
19
|
+
focusableOtherTextLength?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StructuralArtifactCandidate {
|
|
23
|
+
label: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseSnapshotEntries(snapshot: string): SnapshotEntry[];
|
|
27
|
+
export function filterStructuralArtifactCandidates(
|
|
28
|
+
candidates: StructuralArtifactCandidateInput[],
|
|
29
|
+
): StructuralArtifactCandidate[];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { withLock } from "./state-locks.mjs";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { appendFile, chmod, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
@@ -35,7 +35,6 @@ const SCREENSHOT_PATH = "/tmp/oracle-auth.png";
|
|
|
35
35
|
const REAL_CHROME_USER_DATA_DIR = resolve(homedir(), "Library", "Application Support", "Google", "Chrome");
|
|
36
36
|
const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
|
|
37
37
|
const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
|
|
38
|
-
const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
|
|
39
38
|
const STALE_STAGING_PROFILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
40
39
|
const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
|
|
41
40
|
(candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
|
|
@@ -51,75 +50,6 @@ function sleep(ms) {
|
|
|
51
50
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
function leaseKey(kind, key) {
|
|
55
|
-
return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function readLockProcessPid(path) {
|
|
59
|
-
const metadataPath = join(path, "metadata.json");
|
|
60
|
-
if (!existsSync(metadataPath)) return undefined;
|
|
61
|
-
try {
|
|
62
|
-
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
|
63
|
-
return typeof metadata?.processPid === "number" && Number.isInteger(metadata.processPid) && metadata.processPid > 0
|
|
64
|
-
? metadata.processPid
|
|
65
|
-
: undefined;
|
|
66
|
-
} catch {
|
|
67
|
-
return undefined;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
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
|
-
async function maybeReclaimStaleLock(path) {
|
|
82
|
-
const processPid = await readLockProcessPid(path);
|
|
83
|
-
if (!processPid || isProcessAlive(processPid)) return false;
|
|
84
|
-
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
85
|
-
return true;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
|
|
89
|
-
const path = join(LOCKS_DIR, leaseKey(kind, key));
|
|
90
|
-
const deadline = Date.now() + timeoutMs;
|
|
91
|
-
await mkdir(ORACLE_STATE_DIR, { recursive: true, mode: 0o700 });
|
|
92
|
-
await mkdir(LOCKS_DIR, { recursive: true, mode: 0o700 });
|
|
93
|
-
|
|
94
|
-
while (Date.now() < deadline) {
|
|
95
|
-
try {
|
|
96
|
-
await mkdir(path, { recursive: false, mode: 0o700 });
|
|
97
|
-
await writeFile(join(path, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
98
|
-
return path;
|
|
99
|
-
} catch (error) {
|
|
100
|
-
if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error;
|
|
101
|
-
if (await maybeReclaimStaleLock(path)) continue;
|
|
102
|
-
}
|
|
103
|
-
await sleep(200);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
throw new Error(`Timed out waiting for oracle ${kind} lock: ${key}`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function releaseLock(path) {
|
|
110
|
-
if (!path) return;
|
|
111
|
-
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async function withLock(kind, key, metadata, fn, timeoutMs) {
|
|
115
|
-
const handle = await acquireLock(kind, key, metadata, timeoutMs);
|
|
116
|
-
try {
|
|
117
|
-
return await fn();
|
|
118
|
-
} finally {
|
|
119
|
-
await releaseLock(handle);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
53
|
async function initLog() {
|
|
124
54
|
await writeFile(LOG_PATH, "", { mode: 0o600 });
|
|
125
55
|
await chmod(LOG_PATH, 0o600).catch(() => undefined);
|
|
@@ -850,7 +780,7 @@ async function waitForImportedAuthReady() {
|
|
|
850
780
|
|
|
851
781
|
async function run() {
|
|
852
782
|
await initLog();
|
|
853
|
-
await withLock("auth", "global", { processPid: process.pid, action: "oracle-auth" }, async () => {
|
|
783
|
+
await withLock(ORACLE_STATE_DIR, "auth", "global", { processPid: process.pid, action: "oracle-auth" }, async () => {
|
|
854
784
|
let shouldPreserveBrowser = false;
|
|
855
785
|
let committedProfile = false;
|
|
856
786
|
let profilePlan;
|