pi-oracle 0.1.12 → 0.2.1
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 +46 -0
- package/README.md +26 -10
- package/docs/ORACLE_DESIGN.md +593 -0
- package/docs/ORACLE_RECOVERY_DRILL.md +127 -0
- package/extensions/oracle/index.ts +15 -4
- package/extensions/oracle/lib/commands.ts +39 -12
- package/extensions/oracle/lib/config.ts +2 -2
- package/extensions/oracle/lib/jobs.ts +510 -73
- package/extensions/oracle/lib/locks.ts +99 -13
- package/extensions/oracle/lib/poller.ts +224 -38
- package/extensions/oracle/lib/queue.ts +193 -0
- package/extensions/oracle/lib/runtime.ts +70 -16
- package/extensions/oracle/lib/tools.ts +313 -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,59 @@ 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
|
+
hasRetainedPreSubmitArchive,
|
|
17
|
+
isOpenOracleJob,
|
|
18
|
+
isTerminalOracleJob,
|
|
19
|
+
listOracleJobDirs,
|
|
20
|
+
markWakeupSettled,
|
|
15
21
|
readJob,
|
|
16
22
|
pruneTerminalOracleJobs,
|
|
17
23
|
reconcileStaleOracleJobs,
|
|
18
24
|
resolveArchiveInputs,
|
|
19
25
|
sha256File,
|
|
26
|
+
shouldAdvanceQueueAfterCancellation,
|
|
20
27
|
spawnWorker,
|
|
28
|
+
terminateWorkerPid,
|
|
21
29
|
updateJob,
|
|
22
30
|
withJobPhase,
|
|
31
|
+
type OracleJob,
|
|
23
32
|
} from "./jobs.js";
|
|
33
|
+
import { getQueuePosition, promoteQueuedJobs, promoteQueuedJobsWithinAdmissionLock } from "./queue.js";
|
|
24
34
|
import { refreshOracleStatus } from "./poller.js";
|
|
25
35
|
import {
|
|
26
|
-
acquireConversationLease,
|
|
27
|
-
acquireRuntimeLease,
|
|
28
36
|
allocateRuntime,
|
|
29
37
|
cleanupRuntimeArtifacts,
|
|
30
38
|
getProjectId,
|
|
31
39
|
getSessionId,
|
|
32
40
|
parseConversationId,
|
|
41
|
+
requirePersistedSessionFile,
|
|
42
|
+
tryAcquireConversationLease,
|
|
43
|
+
tryAcquireRuntimeLease,
|
|
33
44
|
} from "./runtime.js";
|
|
34
45
|
|
|
46
|
+
function stringEnum(values: readonly string[], description: string) {
|
|
47
|
+
return Type.Union(values.map((value) => Type.Literal(value)), { description });
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
const ORACLE_SUBMIT_PARAMS = Type.Object({
|
|
36
51
|
prompt: Type.String({ description: "Prompt text to send to ChatGPT web." }),
|
|
37
52
|
files: Type.Array(Type.String({ description: "Project-relative file or directory path to include in the archive." }), {
|
|
38
53
|
description: "Exact project-relative files/directories to include in the oracle archive.",
|
|
39
54
|
minItems: 1,
|
|
40
55
|
}),
|
|
41
|
-
modelFamily: Type.Optional(
|
|
42
|
-
effort: Type.Optional(
|
|
56
|
+
modelFamily: Type.Optional(stringEnum(MODEL_FAMILIES, "ChatGPT model family: instant, thinking, or pro.")),
|
|
57
|
+
effort: Type.Optional(stringEnum(EFFORTS, "Reasoning effort. Use only values supported by the chosen model family.")),
|
|
43
58
|
autoSwitchToThinking: Type.Optional(
|
|
44
59
|
Type.Boolean({ description: "Only valid when modelFamily is instant. Omit for thinking and pro." }),
|
|
45
60
|
),
|
|
@@ -61,6 +76,8 @@ const VALID_EFFORTS: Record<OracleModelFamily, readonly OracleEffort[]> = {
|
|
|
61
76
|
};
|
|
62
77
|
|
|
63
78
|
const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
|
|
79
|
+
const MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME = 1;
|
|
80
|
+
const MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME = MAX_ARCHIVE_BYTES;
|
|
64
81
|
|
|
65
82
|
const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
|
|
66
83
|
".git",
|
|
@@ -420,6 +437,60 @@ async function createArchive(cwd: string, files: string[], archivePath: string):
|
|
|
420
437
|
return createArchiveForTesting(cwd, files, archivePath);
|
|
421
438
|
}
|
|
422
439
|
|
|
440
|
+
export interface QueuedArchivePressure {
|
|
441
|
+
queuedJobs: number;
|
|
442
|
+
queuedArchiveBytes: number;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function getQueuedArchivePressure(): Promise<QueuedArchivePressure> {
|
|
446
|
+
const jobs = listOracleJobDirs()
|
|
447
|
+
.map((dir) => readJob(dir))
|
|
448
|
+
.filter((job): job is NonNullable<typeof job> => Boolean(job));
|
|
449
|
+
|
|
450
|
+
const queuedArchiveBytes = (await Promise.all(
|
|
451
|
+
jobs
|
|
452
|
+
.filter((job) => hasRetainedPreSubmitArchive(job))
|
|
453
|
+
.map(async (job) => {
|
|
454
|
+
try {
|
|
455
|
+
return (await stat(job.archivePath)).size;
|
|
456
|
+
} catch {
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
}),
|
|
460
|
+
)).reduce((sum, bytes) => sum + bytes, 0);
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
queuedJobs: jobs.filter((job) => job.status === "queued").length,
|
|
464
|
+
queuedArchiveBytes,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function getQueueAdmissionFailure(args: {
|
|
469
|
+
queuePressure: QueuedArchivePressure;
|
|
470
|
+
archiveBytes: number;
|
|
471
|
+
activeJobs: number;
|
|
472
|
+
maxActiveJobs: number;
|
|
473
|
+
maxQueuedJobs: number;
|
|
474
|
+
maxQueuedArchiveBytes: number;
|
|
475
|
+
}): string | undefined {
|
|
476
|
+
if (args.queuePressure.queuedJobs >= args.maxQueuedJobs) {
|
|
477
|
+
return (
|
|
478
|
+
`Oracle is busy (${args.activeJobs}/${args.maxActiveJobs} active, ${args.queuePressure.queuedJobs}/${args.maxQueuedJobs} queued). ` +
|
|
479
|
+
"Retry later instead of enqueuing more archive state."
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const queuedArchiveBytes = args.queuePressure.queuedArchiveBytes + args.archiveBytes;
|
|
484
|
+
if (queuedArchiveBytes > args.maxQueuedArchiveBytes) {
|
|
485
|
+
return (
|
|
486
|
+
`Oracle queued archive storage is full (${queuedArchiveBytes} bytes > ${args.maxQueuedArchiveBytes} bytes across queued jobs and retained pre-submit archives). ` +
|
|
487
|
+
"Retry later or narrow the archive inputs."
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
|
|
423
494
|
function validateSubmissionOptions(
|
|
424
495
|
params: { effort?: OracleEffort; autoSwitchToThinking?: boolean },
|
|
425
496
|
modelFamily: OracleModelFamily,
|
|
@@ -477,6 +548,7 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
|
|
|
477
548
|
projectId: job.projectId,
|
|
478
549
|
sessionId: job.sessionId,
|
|
479
550
|
createdAt: job.createdAt,
|
|
551
|
+
queuedAt: job.queuedAt,
|
|
480
552
|
submittedAt: job.submittedAt,
|
|
481
553
|
completedAt: job.completedAt,
|
|
482
554
|
followUpToJobId: job.followUpToJobId,
|
|
@@ -495,6 +567,35 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
|
|
|
495
567
|
};
|
|
496
568
|
}
|
|
497
569
|
|
|
570
|
+
function formatAutoPrunedArchiveMessage(autoPrunedPrefixes: ArchiveCreationResult["autoPrunedPrefixes"]): string | undefined {
|
|
571
|
+
if (autoPrunedPrefixes.length === 0) return undefined;
|
|
572
|
+
return `Archive auto-pruned generic generated-output-name dirs to fit size limit: ${autoPrunedPrefixes.map((entry) => `${entry.relativePath}/ (${formatBytes(entry.bytes)})`).join(", ")}`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function formatSubmitResponse(
|
|
576
|
+
job: NonNullable<ReturnType<typeof readJob>>,
|
|
577
|
+
options: {
|
|
578
|
+
autoPrunedPrefixes: ArchiveCreationResult["autoPrunedPrefixes"];
|
|
579
|
+
queued: boolean;
|
|
580
|
+
queuePosition?: number;
|
|
581
|
+
queueDepth?: number;
|
|
582
|
+
},
|
|
583
|
+
): string {
|
|
584
|
+
return [
|
|
585
|
+
`${options.queued ? "Oracle job queued" : "Oracle job dispatched"}: ${job.id}`,
|
|
586
|
+
options.queued && options.queuePosition && options.queueDepth ? `Queue position: ${options.queuePosition} of ${options.queueDepth}` : undefined,
|
|
587
|
+
job.followUpToJobId ? `Follow-up to: ${job.followUpToJobId}` : undefined,
|
|
588
|
+
`Prompt: ${job.promptPath}`,
|
|
589
|
+
`Archive: ${job.archivePath}`,
|
|
590
|
+
formatAutoPrunedArchiveMessage(options.autoPrunedPrefixes),
|
|
591
|
+
`Response will be written to: ${job.responsePath}`,
|
|
592
|
+
options.queued ? "The job will start automatically when capacity is available." : undefined,
|
|
593
|
+
"Stop now and wait for the oracle completion wake-up.",
|
|
594
|
+
]
|
|
595
|
+
.filter(Boolean)
|
|
596
|
+
.join("\n");
|
|
597
|
+
}
|
|
598
|
+
|
|
498
599
|
export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void {
|
|
499
600
|
pi.registerTool({
|
|
500
601
|
name: "oracle_submit",
|
|
@@ -510,23 +611,26 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
510
611
|
"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
612
|
"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
613
|
"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.",
|
|
614
|
+
"If oracle_submit returns a queued job instead of an immediately dispatched one, treat that as success and stop exactly the same way.",
|
|
513
615
|
"Stop after dispatching oracle_submit; do not continue the task while the oracle job is running.",
|
|
514
616
|
"Only use autoSwitchToThinking with modelFamily=instant.",
|
|
515
617
|
],
|
|
516
618
|
parameters: ORACLE_SUBMIT_PARAMS,
|
|
517
619
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
518
620
|
const config = loadOracleConfig(ctx.cwd);
|
|
519
|
-
const originSessionFile = getSessionFile(ctx);
|
|
621
|
+
const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
|
|
520
622
|
const projectId = getProjectId(ctx.cwd);
|
|
521
623
|
const sessionId = getSessionId(originSessionFile, projectId);
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
const
|
|
624
|
+
const submittedModelFamily = params.modelFamily as OracleModelFamily | undefined;
|
|
625
|
+
const submittedEffort = params.effort as OracleEffort | undefined;
|
|
626
|
+
const modelFamily: OracleModelFamily = submittedModelFamily ?? config.defaults.modelFamily;
|
|
627
|
+
const requestedEffort: OracleEffort = submittedEffort ?? config.defaults.effort;
|
|
628
|
+
const effort: OracleEffort | undefined = modelFamily === "instant" ? undefined : requestedEffort;
|
|
525
629
|
const rawAutoSwitchToThinking = params.autoSwitchToThinking ?? config.defaults.autoSwitchToThinking;
|
|
526
630
|
const autoSwitchToThinking = modelFamily === "instant" ? rawAutoSwitchToThinking : false;
|
|
527
631
|
const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
|
|
528
632
|
|
|
529
|
-
validateSubmissionOptions(params, modelFamily, effort, autoSwitchToThinking);
|
|
633
|
+
validateSubmissionOptions({ effort: submittedEffort, autoSwitchToThinking: params.autoSwitchToThinking }, modelFamily, effort, autoSwitchToThinking);
|
|
530
634
|
try {
|
|
531
635
|
await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
|
|
532
636
|
await reconcileStaleOracleJobs();
|
|
@@ -539,29 +643,94 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
539
643
|
const jobId = randomUUID();
|
|
540
644
|
const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
|
|
541
645
|
const runtime = allocateRuntime(config);
|
|
542
|
-
let job;
|
|
646
|
+
let job: OracleJob | undefined;
|
|
647
|
+
let archive: ArchiveCreationResult | undefined;
|
|
648
|
+
let queued = false;
|
|
649
|
+
let queuedSubmissionDurable = false;
|
|
650
|
+
let runtimeLeaseAcquired = false;
|
|
651
|
+
let conversationLeaseAcquired = false;
|
|
652
|
+
let workerSpawned = false;
|
|
653
|
+
let spawnedWorker: Awaited<ReturnType<typeof spawnWorker>> | undefined;
|
|
543
654
|
|
|
544
655
|
try {
|
|
545
|
-
|
|
656
|
+
archive = await createArchive(ctx.cwd, params.files, tempArchivePath);
|
|
657
|
+
const currentArchive = archive;
|
|
546
658
|
await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
|
|
547
|
-
await
|
|
659
|
+
await promoteQueuedJobsWithinAdmissionLock({ workerPath, source: "oracle_submit" });
|
|
660
|
+
|
|
661
|
+
const admittedAt = new Date().toISOString();
|
|
662
|
+
const runtimeAttempt = await tryAcquireRuntimeLease(config, {
|
|
548
663
|
jobId,
|
|
549
664
|
runtimeId: runtime.runtimeId,
|
|
550
665
|
runtimeSessionName: runtime.runtimeSessionName,
|
|
551
666
|
runtimeProfileDir: runtime.runtimeProfileDir,
|
|
552
667
|
projectId,
|
|
553
668
|
sessionId,
|
|
554
|
-
createdAt:
|
|
669
|
+
createdAt: admittedAt,
|
|
555
670
|
});
|
|
671
|
+
|
|
672
|
+
if (!runtimeAttempt.acquired) {
|
|
673
|
+
const queuePressure = await getQueuedArchivePressure();
|
|
674
|
+
const maxQueuedJobs = config.browser.maxConcurrentJobs * MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME;
|
|
675
|
+
const maxQueuedArchiveBytes = config.browser.maxConcurrentJobs * MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME;
|
|
676
|
+
const queueAdmissionFailure = getQueueAdmissionFailure({
|
|
677
|
+
queuePressure,
|
|
678
|
+
archiveBytes: currentArchive.archiveBytes,
|
|
679
|
+
activeJobs: runtimeAttempt.liveLeases.length,
|
|
680
|
+
maxActiveJobs: config.browser.maxConcurrentJobs,
|
|
681
|
+
maxQueuedJobs,
|
|
682
|
+
maxQueuedArchiveBytes,
|
|
683
|
+
});
|
|
684
|
+
if (queueAdmissionFailure) {
|
|
685
|
+
throw new Error(queueAdmissionFailure);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
queued = true;
|
|
689
|
+
job = await createJob(
|
|
690
|
+
jobId,
|
|
691
|
+
{
|
|
692
|
+
prompt: params.prompt,
|
|
693
|
+
files: params.files,
|
|
694
|
+
modelFamily,
|
|
695
|
+
effort,
|
|
696
|
+
autoSwitchToThinking,
|
|
697
|
+
followUpToJobId: followUp.followUpToJobId,
|
|
698
|
+
chatUrl: followUp.chatUrl,
|
|
699
|
+
requestSource: "tool",
|
|
700
|
+
},
|
|
701
|
+
ctx.cwd,
|
|
702
|
+
originSessionFile,
|
|
703
|
+
config,
|
|
704
|
+
runtime,
|
|
705
|
+
{ initialState: "queued", createdAt: admittedAt },
|
|
706
|
+
);
|
|
707
|
+
await rename(tempArchivePath, job.archivePath);
|
|
708
|
+
job = await updateJob(job.id, (current) => ({
|
|
709
|
+
...current,
|
|
710
|
+
archiveSha256: currentArchive.sha256,
|
|
711
|
+
}));
|
|
712
|
+
queuedSubmissionDurable = true;
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
runtimeLeaseAcquired = true;
|
|
556
717
|
if (followUp.conversationId) {
|
|
557
|
-
await
|
|
718
|
+
const conversationAttempt = await tryAcquireConversationLease({
|
|
558
719
|
jobId,
|
|
559
720
|
conversationId: followUp.conversationId,
|
|
560
721
|
projectId,
|
|
561
722
|
sessionId,
|
|
562
|
-
createdAt:
|
|
723
|
+
createdAt: admittedAt,
|
|
563
724
|
});
|
|
725
|
+
if (!conversationAttempt.acquired) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
`Oracle conversation ${followUp.conversationId} is already in use by job ${conversationAttempt.blocker?.jobId ?? "unknown"}. ` +
|
|
728
|
+
"Concurrent follow-ups to the same ChatGPT thread are not allowed.",
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
conversationLeaseAcquired = true;
|
|
564
732
|
}
|
|
733
|
+
|
|
565
734
|
job = await createJob(
|
|
566
735
|
jobId,
|
|
567
736
|
{
|
|
@@ -578,51 +747,109 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
578
747
|
originSessionFile,
|
|
579
748
|
config,
|
|
580
749
|
runtime,
|
|
750
|
+
{ initialState: "submitted", createdAt: admittedAt },
|
|
581
751
|
);
|
|
752
|
+
await rename(tempArchivePath, job.archivePath);
|
|
753
|
+
spawnedWorker = await spawnWorker(workerPath, job.id);
|
|
754
|
+
workerSpawned = true;
|
|
755
|
+
const worker = spawnedWorker;
|
|
756
|
+
job = await updateJob(job.id, (current) => ({
|
|
757
|
+
...current,
|
|
758
|
+
archiveSha256: currentArchive.sha256,
|
|
759
|
+
workerPid: worker.pid,
|
|
760
|
+
workerNonce: worker.nonce,
|
|
761
|
+
workerStartedAt: worker.startedAt,
|
|
762
|
+
}));
|
|
582
763
|
});
|
|
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
|
-
}));
|
|
764
|
+
if (!job || !archive) throw new Error(`Oracle submission ${jobId} did not persist job metadata durably`);
|
|
592
765
|
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
593
766
|
|
|
767
|
+
const queuePosition = queued ? getQueuePosition(job.id) : undefined;
|
|
594
768
|
return {
|
|
595
769
|
content: [
|
|
596
770
|
{
|
|
597
771
|
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"),
|
|
772
|
+
text: formatSubmitResponse(job, {
|
|
773
|
+
autoPrunedPrefixes: currentArchive.autoPrunedPrefixes,
|
|
774
|
+
queued,
|
|
775
|
+
queuePosition: queuePosition?.position,
|
|
776
|
+
queueDepth: queuePosition?.depth,
|
|
777
|
+
}),
|
|
611
778
|
},
|
|
612
779
|
],
|
|
613
780
|
details: {
|
|
614
781
|
jobId: job.id,
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
782
|
+
queued,
|
|
783
|
+
queuePosition: queuePosition?.position,
|
|
784
|
+
queueDepth: queuePosition?.depth,
|
|
785
|
+
archiveSha256: currentArchive.sha256,
|
|
786
|
+
archiveBytes: currentArchive.archiveBytes,
|
|
787
|
+
initialArchiveBytes: currentArchive.initialArchiveBytes,
|
|
788
|
+
autoPrunedArchivePaths: currentArchive.autoPrunedPrefixes,
|
|
619
789
|
runtimeId: job.runtimeId,
|
|
620
790
|
followUpToJobId: followUp.followUpToJobId,
|
|
621
791
|
},
|
|
622
792
|
};
|
|
623
793
|
} catch (error) {
|
|
624
794
|
const message = error instanceof Error ? error.message : String(error);
|
|
625
|
-
|
|
795
|
+
const latest = job ? readJob(job.id) : undefined;
|
|
796
|
+
if (latest?.status === "queued" && queuedSubmissionDurable) {
|
|
797
|
+
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
798
|
+
const queuePosition = getQueuePosition(latest.id);
|
|
799
|
+
return {
|
|
800
|
+
content: [
|
|
801
|
+
{
|
|
802
|
+
type: "text",
|
|
803
|
+
text: formatSubmitResponse(latest, {
|
|
804
|
+
autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
|
|
805
|
+
queued: true,
|
|
806
|
+
queuePosition: queuePosition?.position,
|
|
807
|
+
queueDepth: queuePosition?.depth,
|
|
808
|
+
}),
|
|
809
|
+
},
|
|
810
|
+
],
|
|
811
|
+
details: {
|
|
812
|
+
jobId: latest.id,
|
|
813
|
+
queued: true,
|
|
814
|
+
queuePosition: queuePosition?.position,
|
|
815
|
+
queueDepth: queuePosition?.depth,
|
|
816
|
+
archiveSha256: latest.archiveSha256,
|
|
817
|
+
archiveBytes: archive?.archiveBytes,
|
|
818
|
+
initialArchiveBytes: archive?.initialArchiveBytes,
|
|
819
|
+
autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
|
|
820
|
+
runtimeId: latest.runtimeId,
|
|
821
|
+
followUpToJobId: latest.followUpToJobId,
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
if (workerSpawned && latest && hasDurableWorkerHandoff(latest)) {
|
|
826
|
+
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
827
|
+
return {
|
|
828
|
+
content: [
|
|
829
|
+
{
|
|
830
|
+
type: "text",
|
|
831
|
+
text: formatSubmitResponse(latest, {
|
|
832
|
+
autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
|
|
833
|
+
queued: false,
|
|
834
|
+
}),
|
|
835
|
+
},
|
|
836
|
+
],
|
|
837
|
+
details: {
|
|
838
|
+
jobId: latest.id,
|
|
839
|
+
queued: false,
|
|
840
|
+
archiveSha256: latest.archiveSha256,
|
|
841
|
+
archiveBytes: archive?.archiveBytes,
|
|
842
|
+
initialArchiveBytes: archive?.initialArchiveBytes,
|
|
843
|
+
autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
|
|
844
|
+
runtimeId: latest.runtimeId,
|
|
845
|
+
followUpToJobId: latest.followUpToJobId,
|
|
846
|
+
},
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
if (spawnedWorker) {
|
|
850
|
+
await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.startedAt).catch(() => undefined);
|
|
851
|
+
}
|
|
852
|
+
if (job && (!latest || !isTerminalOracleJob(latest))) {
|
|
626
853
|
const failedAt = new Date().toISOString();
|
|
627
854
|
await updateJob(job.id, (current) => ({
|
|
628
855
|
...current,
|
|
@@ -633,12 +860,15 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
633
860
|
}, failedAt),
|
|
634
861
|
})).catch(() => undefined);
|
|
635
862
|
}
|
|
636
|
-
await cleanupRuntimeArtifacts({
|
|
637
|
-
runtimeId: runtime.runtimeId,
|
|
638
|
-
runtimeProfileDir: runtime.runtimeProfileDir,
|
|
639
|
-
runtimeSessionName: runtime.runtimeSessionName,
|
|
640
|
-
conversationId: followUp.conversationId,
|
|
641
|
-
}).catch(() =>
|
|
863
|
+
const cleanupReport = await cleanupRuntimeArtifacts({
|
|
864
|
+
runtimeId: runtimeLeaseAcquired ? runtime.runtimeId : undefined,
|
|
865
|
+
runtimeProfileDir: runtimeLeaseAcquired ? runtime.runtimeProfileDir : undefined,
|
|
866
|
+
runtimeSessionName: workerSpawned ? runtime.runtimeSessionName : undefined,
|
|
867
|
+
conversationId: conversationLeaseAcquired ? followUp.conversationId : undefined,
|
|
868
|
+
}).catch(() => ({ attempted: [], warnings: [] }));
|
|
869
|
+
if (job && cleanupReport.warnings.length > 0) {
|
|
870
|
+
await appendCleanupWarnings(job.id, cleanupReport.warnings).catch(() => undefined);
|
|
871
|
+
}
|
|
642
872
|
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
643
873
|
throw error;
|
|
644
874
|
} finally {
|
|
@@ -657,10 +887,18 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
657
887
|
if (!job || job.projectId !== getProjectId(ctx.cwd)) {
|
|
658
888
|
throw new Error(`Oracle job not found in this project: ${params.jobId}`);
|
|
659
889
|
}
|
|
890
|
+
const latest = isTerminalOracleJob(job)
|
|
891
|
+
? await markWakeupSettled(job.id, {
|
|
892
|
+
source: "oracle_read",
|
|
893
|
+
sessionFile: getSessionFile(ctx),
|
|
894
|
+
cwd: ctx.cwd,
|
|
895
|
+
})
|
|
896
|
+
: job;
|
|
897
|
+
const current = latest ?? readJob(job.id) ?? job;
|
|
660
898
|
|
|
661
899
|
let responsePreview = "";
|
|
662
900
|
try {
|
|
663
|
-
const response = await import("node:fs/promises").then((fs) => fs.readFile(
|
|
901
|
+
const response = await import("node:fs/promises").then((fs) => fs.readFile(current.responsePath || "", "utf8"));
|
|
664
902
|
responsePreview = response.slice(0, 4000);
|
|
665
903
|
} catch {
|
|
666
904
|
responsePreview = "(response not available yet)";
|
|
@@ -671,14 +909,22 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
671
909
|
{
|
|
672
910
|
type: "text",
|
|
673
911
|
text: [
|
|
674
|
-
`job: ${
|
|
675
|
-
`status: ${
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
912
|
+
`job: ${current.id}`,
|
|
913
|
+
`status: ${current.status}`,
|
|
914
|
+
current.queuedAt ? `queued: ${current.queuedAt}` : undefined,
|
|
915
|
+
current.submittedAt ? `submitted: ${current.submittedAt}` : undefined,
|
|
916
|
+
...(current.status === "queued"
|
|
917
|
+
? (() => {
|
|
918
|
+
const queuePosition = getQueuePosition(current.id);
|
|
919
|
+
return queuePosition ? [`queue-position: ${queuePosition.position} of ${queuePosition.depth}`] : [];
|
|
920
|
+
})()
|
|
921
|
+
: []),
|
|
922
|
+
current.followUpToJobId ? `follow-up-to: ${current.followUpToJobId}` : undefined,
|
|
923
|
+
current.chatUrl ? `chat: ${current.chatUrl}` : undefined,
|
|
924
|
+
current.responsePath ? `response: ${current.responsePath}` : undefined,
|
|
925
|
+
current.responseFormat ? `response-format: ${current.responseFormat}` : undefined,
|
|
926
|
+
`artifacts: ${getJobDir(current.id)}/artifacts`,
|
|
927
|
+
current.error ? `error: ${current.error}` : undefined,
|
|
682
928
|
"",
|
|
683
929
|
responsePreview,
|
|
684
930
|
]
|
|
@@ -686,7 +932,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
686
932
|
.join("\n"),
|
|
687
933
|
},
|
|
688
934
|
],
|
|
689
|
-
details: { job: redactJobDetails(
|
|
935
|
+
details: { job: redactJobDetails(current) },
|
|
690
936
|
};
|
|
691
937
|
},
|
|
692
938
|
});
|
|
@@ -694,24 +940,27 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
694
940
|
pi.registerTool({
|
|
695
941
|
name: "oracle_cancel",
|
|
696
942
|
label: "Oracle Cancel",
|
|
697
|
-
description: "Cancel
|
|
943
|
+
description: "Cancel a queued or active oracle job.",
|
|
698
944
|
parameters: ORACLE_CANCEL_PARAMS,
|
|
699
945
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
700
946
|
const job = readJob(params.jobId);
|
|
701
947
|
if (!job || job.projectId !== getProjectId(ctx.cwd)) {
|
|
702
948
|
throw new Error(`Oracle job not found in this project: ${params.jobId}`);
|
|
703
949
|
}
|
|
704
|
-
if (!
|
|
950
|
+
if (!isOpenOracleJob(job)) {
|
|
705
951
|
return {
|
|
706
|
-
content: [{ type: "text", text: `Oracle job ${job.id} is not
|
|
952
|
+
content: [{ type: "text", text: `Oracle job ${job.id} is not cancellable (${job.status}).` }],
|
|
707
953
|
details: { job: redactJobDetails(job) },
|
|
708
954
|
};
|
|
709
955
|
}
|
|
710
956
|
|
|
711
957
|
const cancelled = await cancelOracleJob(params.jobId);
|
|
958
|
+
if (shouldAdvanceQueueAfterCancellation(cancelled)) {
|
|
959
|
+
await promoteQueuedJobs({ workerPath, source: "oracle_cancel_tool" });
|
|
960
|
+
}
|
|
712
961
|
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
713
962
|
return {
|
|
714
|
-
content: [{ type: "text", text: `Cancelled oracle job ${cancelled.id}.` }],
|
|
963
|
+
content: [{ type: "text", text: cancelled.status === "cancelled" || cancelled.status === "failed" ? `Cancelled oracle job ${cancelled.id}.` : `Oracle job ${cancelled.id} was already ${cancelled.status}.` }],
|
|
715
964
|
details: { job: redactJobDetails(cancelled) },
|
|
716
965
|
};
|
|
717
966
|
},
|
|
@@ -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[];
|