pi-oracle 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +27 -8
- package/docs/ORACLE_DESIGN.md +14 -8
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +276 -0
- package/extensions/oracle/index.ts +8 -1
- package/extensions/oracle/lib/commands.ts +25 -29
- package/extensions/oracle/lib/config.ts +56 -2
- package/extensions/oracle/lib/jobs.ts +134 -219
- package/extensions/oracle/lib/locks.ts +41 -209
- package/extensions/oracle/lib/poller.ts +38 -52
- package/extensions/oracle/lib/queue.ts +75 -112
- package/extensions/oracle/lib/runtime.ts +102 -19
- package/extensions/oracle/lib/tools.ts +663 -294
- package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
- package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +131 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.mjs +390 -0
- package/extensions/oracle/shared/job-observability-helpers.d.mts +60 -0
- package/extensions/oracle/shared/job-observability-helpers.mjs +161 -0
- package/extensions/oracle/shared/process-helpers.d.mts +20 -0
- package/extensions/oracle/shared/process-helpers.mjs +128 -0
- package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
- package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
- package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +125 -134
- package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
- package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
- package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +93 -9
- package/extensions/oracle/worker/run-job.mjs +166 -274
- package/extensions/oracle/worker/state-locks.mjs +31 -216
- package/package.json +4 -3
- package/prompts/oracle.md +16 -10
|
@@ -7,12 +7,15 @@ import { randomUUID } from "node:crypto";
|
|
|
7
7
|
import { lstat, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { basename, join, posix } from "node:path";
|
|
10
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
11
11
|
import { Type } from "@sinclair/typebox";
|
|
12
|
+
import { formatOracleJobSummary, formatOracleSubmitResponse } from "../shared/job-observability-helpers.mjs";
|
|
13
|
+
import { getLatestOracleJobLifecycleEvent, getLatestOracleTerminalLifecycleEvent, transitionOracleJobPhase } from "../shared/job-lifecycle-helpers.mjs";
|
|
12
14
|
import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.js";
|
|
13
15
|
import {
|
|
14
16
|
coerceOracleSubmitPresetId,
|
|
15
17
|
loadOracleConfig,
|
|
18
|
+
ORACLE_SUBMIT_PRESET_IDS,
|
|
16
19
|
resolveOracleSubmitPreset,
|
|
17
20
|
} from "./config.js";
|
|
18
21
|
import {
|
|
@@ -36,16 +39,17 @@ import {
|
|
|
36
39
|
spawnWorker,
|
|
37
40
|
terminateWorkerPid,
|
|
38
41
|
updateJob,
|
|
39
|
-
withJobPhase,
|
|
40
42
|
type OracleJob,
|
|
41
43
|
} from "./jobs.js";
|
|
42
44
|
import { getQueuePosition, promoteQueuedJobs, promoteQueuedJobsWithinAdmissionLock } from "./queue.js";
|
|
43
45
|
import { refreshOracleStatus } from "./poller.js";
|
|
44
46
|
import {
|
|
45
47
|
allocateRuntime,
|
|
48
|
+
assertOracleSubmitPrerequisites,
|
|
46
49
|
cleanupRuntimeArtifacts,
|
|
47
50
|
getProjectId,
|
|
48
51
|
getSessionId,
|
|
52
|
+
hasPersistedSessionFile,
|
|
49
53
|
parseConversationId,
|
|
50
54
|
requirePersistedSessionFile,
|
|
51
55
|
tryAcquireConversationLease,
|
|
@@ -78,11 +82,16 @@ const ORACLE_CANCEL_PARAMS = Type.Object({
|
|
|
78
82
|
const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
|
|
79
83
|
const MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME = 1;
|
|
80
84
|
const MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME = MAX_ARCHIVE_BYTES;
|
|
85
|
+
const ARCHIVE_COMMAND_TIMEOUT_MS = 120_000;
|
|
86
|
+
const ARCHIVE_COMMAND_KILL_GRACE_MS = 2_000;
|
|
81
87
|
|
|
82
88
|
const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
|
|
83
89
|
".git",
|
|
84
90
|
".hg",
|
|
85
91
|
".svn",
|
|
92
|
+
".pi",
|
|
93
|
+
".oracle-context",
|
|
94
|
+
".cursor",
|
|
86
95
|
"node_modules",
|
|
87
96
|
"target",
|
|
88
97
|
".venv",
|
|
@@ -118,6 +127,7 @@ const DEFAULT_ARCHIVE_EXCLUDED_FILES = new Set([
|
|
|
118
127
|
".netrc",
|
|
119
128
|
".npmrc",
|
|
120
129
|
".pypirc",
|
|
130
|
+
".scratchpad.md",
|
|
121
131
|
"Thumbs.db",
|
|
122
132
|
"id_dsa",
|
|
123
133
|
"id_ecdsa",
|
|
@@ -321,7 +331,13 @@ function formatArchiveOversizeError(args: {
|
|
|
321
331
|
.join("\n");
|
|
322
332
|
}
|
|
323
333
|
|
|
324
|
-
async function writeArchiveFile(
|
|
334
|
+
async function writeArchiveFile(
|
|
335
|
+
cwd: string,
|
|
336
|
+
entries: string[],
|
|
337
|
+
archivePath: string,
|
|
338
|
+
listPath: string,
|
|
339
|
+
options?: { commandTimeoutMs?: number },
|
|
340
|
+
): Promise<number> {
|
|
325
341
|
await writeFile(listPath, Buffer.from(`${entries.join("\0")}\0`), { mode: 0o600 });
|
|
326
342
|
await rm(archivePath, { force: true }).catch(() => undefined);
|
|
327
343
|
|
|
@@ -337,24 +353,57 @@ async function writeArchiveFile(cwd: string, entries: string[], archivePath: str
|
|
|
337
353
|
|
|
338
354
|
let stderr = "";
|
|
339
355
|
let settled = false;
|
|
356
|
+
let timedOut = false;
|
|
357
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
358
|
+
let killGraceTimer: NodeJS.Timeout | undefined;
|
|
340
359
|
let tarCode: number | null | undefined;
|
|
341
360
|
let zstdCode: number | null | undefined;
|
|
342
361
|
|
|
362
|
+
const clearTimers = () => {
|
|
363
|
+
if (timeout) clearTimeout(timeout);
|
|
364
|
+
if (killGraceTimer) clearTimeout(killGraceTimer);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const terminateChildren = () => {
|
|
368
|
+
tar.kill("SIGTERM");
|
|
369
|
+
zstd.kill("SIGTERM");
|
|
370
|
+
killGraceTimer = setTimeout(() => {
|
|
371
|
+
tar.kill("SIGKILL");
|
|
372
|
+
zstd.kill("SIGKILL");
|
|
373
|
+
}, ARCHIVE_COMMAND_KILL_GRACE_MS);
|
|
374
|
+
killGraceTimer.unref?.();
|
|
375
|
+
};
|
|
376
|
+
|
|
343
377
|
const finish = (error?: Error) => {
|
|
344
378
|
if (settled) return;
|
|
345
379
|
if (error) {
|
|
346
380
|
settled = true;
|
|
347
|
-
|
|
348
|
-
|
|
381
|
+
clearTimers();
|
|
382
|
+
terminateChildren();
|
|
349
383
|
rejectPromise(error);
|
|
350
384
|
return;
|
|
351
385
|
}
|
|
352
386
|
if (tarCode === undefined || zstdCode === undefined) return;
|
|
353
387
|
settled = true;
|
|
388
|
+
clearTimers();
|
|
389
|
+
if (timedOut) {
|
|
390
|
+
rejectPromise(new Error(stderr || `Oracle archive subprocess timed out after ${options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS}ms`));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
354
393
|
if (tarCode === 0 && zstdCode === 0) resolvePromise();
|
|
355
394
|
else rejectPromise(new Error(stderr || `archive command failed (tar=${tarCode}, zstd=${zstdCode})`));
|
|
356
395
|
};
|
|
357
396
|
|
|
397
|
+
const commandTimeoutMs = options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS;
|
|
398
|
+
if (commandTimeoutMs > 0) {
|
|
399
|
+
timeout = setTimeout(() => {
|
|
400
|
+
timedOut = true;
|
|
401
|
+
stderr = `${stderr}${stderr ? "\n" : ""}Oracle archive subprocess timed out after ${commandTimeoutMs}ms`;
|
|
402
|
+
terminateChildren();
|
|
403
|
+
}, commandTimeoutMs);
|
|
404
|
+
timeout.unref?.();
|
|
405
|
+
}
|
|
406
|
+
|
|
358
407
|
tar.stderr.on("data", (data) => {
|
|
359
408
|
stderr += String(data);
|
|
360
409
|
});
|
|
@@ -381,7 +430,7 @@ export async function createArchiveForTesting(
|
|
|
381
430
|
cwd: string,
|
|
382
431
|
files: string[],
|
|
383
432
|
archivePath: string,
|
|
384
|
-
options?: { maxBytes?: number; adaptivePruneMinBytes?: number },
|
|
433
|
+
options?: { maxBytes?: number; adaptivePruneMinBytes?: number; commandTimeoutMs?: number },
|
|
385
434
|
): Promise<ArchiveCreationResult> {
|
|
386
435
|
const archiveInputs = resolveArchiveInputs(cwd, files);
|
|
387
436
|
const wholeRepoSelection = isWholeRepoArchiveSelection(archiveInputs);
|
|
@@ -403,7 +452,7 @@ export async function createArchiveForTesting(
|
|
|
403
452
|
throw new Error("Oracle archive inputs are empty after default exclusions and automatic size pruning");
|
|
404
453
|
}
|
|
405
454
|
|
|
406
|
-
const archiveBytes = await writeArchiveFile(cwd, expandedEntries, archivePath, listPath);
|
|
455
|
+
const archiveBytes = await writeArchiveFile(cwd, expandedEntries, archivePath, listPath, { commandTimeoutMs: options?.commandTimeoutMs });
|
|
407
456
|
if (archiveBytes < maxBytes) {
|
|
408
457
|
return {
|
|
409
458
|
sha256: await sha256File(archivePath),
|
|
@@ -519,7 +568,54 @@ function resolveFollowUp(previousJobId: string | undefined, cwd: string): {
|
|
|
519
568
|
};
|
|
520
569
|
}
|
|
521
570
|
|
|
522
|
-
|
|
571
|
+
type OracleToolName = "oracle_submit" | "oracle_read" | "oracle_cancel";
|
|
572
|
+
type OracleToolErrorSource = OracleToolName | "oracle_preflight";
|
|
573
|
+
type OracleQueueSnapshot = { queued: boolean; position?: number; depth?: number };
|
|
574
|
+
type OracleToolErrorDetails = {
|
|
575
|
+
code: string;
|
|
576
|
+
message: string;
|
|
577
|
+
rejectedValue?: string;
|
|
578
|
+
allowedValues?: string[];
|
|
579
|
+
suggestedNextStep?: string;
|
|
580
|
+
};
|
|
581
|
+
type OracleToolJobDetailsOptions = {
|
|
582
|
+
queue?: OracleQueueSnapshot;
|
|
583
|
+
archiveBytes?: number;
|
|
584
|
+
initialArchiveBytes?: number;
|
|
585
|
+
autoPrunedArchivePaths?: ArchiveSizeBreakdownRow[];
|
|
586
|
+
responsePreview?: string;
|
|
587
|
+
responseAvailable?: boolean;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const ORACLE_TOOL_NAMES = new Set<OracleToolName>(["oracle_submit", "oracle_read", "oracle_cancel"]);
|
|
591
|
+
|
|
592
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
593
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
594
|
+
? value as Record<string, unknown>
|
|
595
|
+
: undefined;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function getErrorMessage(error: unknown): string {
|
|
599
|
+
return error instanceof Error ? error.message : String(error);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function buildOracleQueueSnapshot(
|
|
603
|
+
job: NonNullable<ReturnType<typeof readJob>>,
|
|
604
|
+
queuePosition?: { position: number; depth: number },
|
|
605
|
+
): OracleQueueSnapshot {
|
|
606
|
+
return {
|
|
607
|
+
queued: job.status === "queued",
|
|
608
|
+
position: queuePosition?.position,
|
|
609
|
+
depth: queuePosition?.depth,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function redactJobDetails(
|
|
614
|
+
job: NonNullable<ReturnType<typeof readJob>>,
|
|
615
|
+
options: OracleToolJobDetailsOptions = {},
|
|
616
|
+
) {
|
|
617
|
+
const lastEvent = getLatestOracleJobLifecycleEvent(job);
|
|
618
|
+
const terminalEvent = getLatestOracleTerminalLifecycleEvent(job);
|
|
523
619
|
return {
|
|
524
620
|
id: job.id,
|
|
525
621
|
status: job.status,
|
|
@@ -530,52 +626,319 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
|
|
|
530
626
|
queuedAt: job.queuedAt,
|
|
531
627
|
submittedAt: job.submittedAt,
|
|
532
628
|
completedAt: job.completedAt,
|
|
629
|
+
promptPath: job.promptPath,
|
|
630
|
+
archivePath: job.archivePath,
|
|
631
|
+
archiveSha256: job.archiveSha256,
|
|
632
|
+
archiveBytes: options.archiveBytes,
|
|
633
|
+
initialArchiveBytes: options.initialArchiveBytes,
|
|
634
|
+
autoPrunedArchivePaths: options.autoPrunedArchivePaths ?? [],
|
|
635
|
+
queue: options.queue ?? buildOracleQueueSnapshot(job),
|
|
533
636
|
followUpToJobId: job.followUpToJobId,
|
|
534
637
|
chatUrl: job.chatUrl,
|
|
535
638
|
conversationId: job.conversationId,
|
|
536
639
|
responsePath: job.responsePath,
|
|
537
640
|
responseFormat: job.responseFormat,
|
|
641
|
+
responseAvailable: options.responseAvailable ?? false,
|
|
642
|
+
responsePreview: options.responsePreview,
|
|
643
|
+
artifactsPath: `${getJobDir(job.id)}/artifacts`,
|
|
538
644
|
artifactPaths: job.artifactPaths,
|
|
539
645
|
artifactFailureCount: job.artifactFailureCount,
|
|
540
646
|
artifactsManifestPath: job.artifactsManifestPath,
|
|
647
|
+
workerLogPath: job.workerLogPath,
|
|
541
648
|
archiveDeletedAfterUpload: job.archiveDeletedAfterUpload,
|
|
542
649
|
runtimeId: job.runtimeId,
|
|
543
650
|
cleanupWarnings: job.cleanupWarnings,
|
|
544
651
|
lastCleanupAt: job.lastCleanupAt,
|
|
652
|
+
terminalEvent: terminalEvent ? { ...terminalEvent } : undefined,
|
|
653
|
+
lastEvent: lastEvent ? { ...lastEvent } : undefined,
|
|
545
654
|
error: job.error,
|
|
655
|
+
lifecycleEvents: job.lifecycleEvents,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function buildOracleToolErrorDetails(toolName: OracleToolErrorSource, error: unknown, params: Record<string, unknown>): OracleToolErrorDetails {
|
|
660
|
+
const message = getErrorMessage(error);
|
|
661
|
+
|
|
662
|
+
if (toolName === "oracle_submit" && typeof params.preset === "string" && message.startsWith("Unknown oracle_submit preset:")) {
|
|
663
|
+
return {
|
|
664
|
+
code: "invalid_preset",
|
|
665
|
+
message,
|
|
666
|
+
rejectedValue: params.preset,
|
|
667
|
+
allowedValues: [...ORACLE_SUBMIT_PRESET_IDS],
|
|
668
|
+
suggestedNextStep: "Retry with one of the canonical preset ids, or omit preset to use the configured default.",
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (message.startsWith("Oracle requires a persisted pi session")) {
|
|
673
|
+
return {
|
|
674
|
+
code: "persisted_session_required",
|
|
675
|
+
message,
|
|
676
|
+
suggestedNextStep: "Start or save a persisted pi session, then retry oracle_submit.",
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (message.startsWith("Oracle auth seed profile not found: ")) {
|
|
681
|
+
return {
|
|
682
|
+
code: "auth_seed_profile_missing",
|
|
683
|
+
message,
|
|
684
|
+
rejectedValue: message.replace(/^Oracle auth seed profile not found: /, "").replace(/\. Run \/oracle-auth first\.$/, ""),
|
|
685
|
+
suggestedNextStep: "Run /oracle-auth first, then retry the oracle tool call.",
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (message.startsWith("Oracle auth seed profile is not readable: ")) {
|
|
690
|
+
return {
|
|
691
|
+
code: "auth_seed_profile_unreadable",
|
|
692
|
+
message,
|
|
693
|
+
rejectedValue: message.replace(/^Oracle auth seed profile is not readable: /, "").replace(/\. Fix its permissions or rerun \/oracle-auth\.$/, ""),
|
|
694
|
+
suggestedNextStep: "Fix the auth seed profile permissions or rerun /oracle-auth, then retry.",
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (message.startsWith("Oracle auth seed profile is not a directory: ")) {
|
|
699
|
+
return {
|
|
700
|
+
code: "auth_seed_profile_invalid_type",
|
|
701
|
+
message,
|
|
702
|
+
rejectedValue: message.replace(/^Oracle auth seed profile is not a directory: /, "").replace(/\. Remove the invalid path or rerun \/oracle-auth\.$/, ""),
|
|
703
|
+
suggestedNextStep: "Remove the invalid auth seed path or rerun /oracle-auth to recreate it.",
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (message.startsWith("Failed to parse oracle config ") || message.startsWith("Invalid oracle config:") || message.startsWith("Invalid oracle project config:")) {
|
|
708
|
+
return {
|
|
709
|
+
code: "oracle_config_invalid",
|
|
710
|
+
message,
|
|
711
|
+
suggestedNextStep: "Fix the oracle config and retry once the configured paths and values are valid.",
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (toolName === "oracle_submit" && message === "oracle_submit requires at least one file or directory to archive") {
|
|
716
|
+
return {
|
|
717
|
+
code: "archive_input_required",
|
|
718
|
+
message,
|
|
719
|
+
suggestedNextStep: "Pass at least one project-relative file or directory in files.",
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (toolName === "oracle_submit" && message.startsWith("Archive input does not exist: ")) {
|
|
724
|
+
return {
|
|
725
|
+
code: "archive_input_missing",
|
|
726
|
+
message,
|
|
727
|
+
rejectedValue: message.replace(/^Archive input does not exist: /, ""),
|
|
728
|
+
suggestedNextStep: "Retry with an existing project-relative file or directory.",
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (toolName === "oracle_submit" && message.startsWith("Archive input must be inside the project cwd: ")) {
|
|
733
|
+
return {
|
|
734
|
+
code: "archive_input_outside_project",
|
|
735
|
+
message,
|
|
736
|
+
rejectedValue: message.replace(/^Archive input must be inside the project cwd: /, ""),
|
|
737
|
+
suggestedNextStep: "Retry with a path inside the current project cwd.",
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (toolName === "oracle_submit" && message.startsWith("Archive input must resolve inside the project cwd without symlink escapes: ")) {
|
|
742
|
+
return {
|
|
743
|
+
code: "archive_input_symlink_escape",
|
|
744
|
+
message,
|
|
745
|
+
rejectedValue: message.replace(/^Archive input must resolve inside the project cwd without symlink escapes: /, ""),
|
|
746
|
+
suggestedNextStep: "Retry with a real project-relative path that does not escape the repo through symlinks.",
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (toolName === "oracle_submit" && message.startsWith("Follow-up oracle job not found: ")) {
|
|
751
|
+
return {
|
|
752
|
+
code: "follow_up_job_not_found",
|
|
753
|
+
message,
|
|
754
|
+
rejectedValue: typeof params.followUpJobId === "string" ? params.followUpJobId : undefined,
|
|
755
|
+
suggestedNextStep: "Retry with a completed oracle job id from this project that has a persisted chat URL.",
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (toolName === "oracle_submit" && message.includes("belongs to a different project")) {
|
|
760
|
+
return {
|
|
761
|
+
code: "follow_up_job_wrong_project",
|
|
762
|
+
message,
|
|
763
|
+
rejectedValue: typeof params.followUpJobId === "string" ? params.followUpJobId : undefined,
|
|
764
|
+
suggestedNextStep: "Retry with a follow-up job id from the current project.",
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (toolName === "oracle_submit" && message.includes("is not complete")) {
|
|
769
|
+
return {
|
|
770
|
+
code: "follow_up_job_not_complete",
|
|
771
|
+
message,
|
|
772
|
+
rejectedValue: typeof params.followUpJobId === "string" ? params.followUpJobId : undefined,
|
|
773
|
+
suggestedNextStep: "Wait for the earlier oracle job to finish, then retry the follow-up.",
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (toolName === "oracle_submit" && message.includes("has no persisted chat URL")) {
|
|
778
|
+
return {
|
|
779
|
+
code: "follow_up_job_missing_chat_url",
|
|
780
|
+
message,
|
|
781
|
+
rejectedValue: typeof params.followUpJobId === "string" ? params.followUpJobId : undefined,
|
|
782
|
+
suggestedNextStep: "Retry with an earlier completed oracle job that recorded a chat URL.",
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if ((toolName === "oracle_read" || toolName === "oracle_cancel") && typeof params.jobId === "string" && message.startsWith("Oracle job not found in this project:")) {
|
|
787
|
+
return {
|
|
788
|
+
code: "job_not_found",
|
|
789
|
+
message,
|
|
790
|
+
rejectedValue: params.jobId,
|
|
791
|
+
suggestedNextStep: "Use /oracle-status to discover a valid job id for this project, then retry.",
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (toolName === "oracle_submit" && message.startsWith("Oracle archive exceeds ChatGPT upload limit")) {
|
|
796
|
+
return {
|
|
797
|
+
code: "archive_too_large",
|
|
798
|
+
message,
|
|
799
|
+
suggestedNextStep: "Retry with a narrower archive, starting with modified files plus adjacent files plus directly relevant subtrees.",
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return {
|
|
804
|
+
code: `${toolName}_failed`,
|
|
805
|
+
message,
|
|
806
|
+
suggestedNextStep: "Inspect the error message, correct the inputs or environment, and retry.",
|
|
546
807
|
};
|
|
547
808
|
}
|
|
548
809
|
|
|
549
|
-
function
|
|
550
|
-
|
|
551
|
-
|
|
810
|
+
function buildOracleToolErrorResult(
|
|
811
|
+
toolName: OracleToolName,
|
|
812
|
+
error: unknown,
|
|
813
|
+
params: Record<string, unknown>,
|
|
814
|
+
options?: { job?: NonNullable<ReturnType<typeof readJob>>; jobDetails?: OracleToolJobDetailsOptions },
|
|
815
|
+
) {
|
|
816
|
+
const errorDetails = buildOracleToolErrorDetails(toolName, error, params);
|
|
817
|
+
return {
|
|
818
|
+
content: [{ type: "text" as const, text: errorDetails.message }],
|
|
819
|
+
details: {
|
|
820
|
+
job: options?.job ? redactJobDetails(options.job, options.jobDetails) : undefined,
|
|
821
|
+
error: errorDetails,
|
|
822
|
+
},
|
|
823
|
+
};
|
|
552
824
|
}
|
|
553
825
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
826
|
+
type OraclePreflightDetails = {
|
|
827
|
+
ready: boolean;
|
|
828
|
+
session: {
|
|
829
|
+
persisted: boolean;
|
|
830
|
+
sessionFile?: string;
|
|
831
|
+
};
|
|
832
|
+
config: {
|
|
833
|
+
ready: boolean;
|
|
834
|
+
};
|
|
835
|
+
auth: {
|
|
836
|
+
ready: boolean;
|
|
837
|
+
seedProfileDir?: string;
|
|
838
|
+
};
|
|
839
|
+
error?: OracleToolErrorDetails;
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
function formatOraclePreflightResponse(details: OraclePreflightDetails): string {
|
|
843
|
+
if (details.ready) {
|
|
844
|
+
return [
|
|
845
|
+
"Oracle preflight ready.",
|
|
846
|
+
details.session.sessionFile ? `Persisted session: ${details.session.sessionFile}` : undefined,
|
|
847
|
+
details.auth.seedProfileDir ? `Auth seed profile: ${details.auth.seedProfileDir}` : undefined,
|
|
848
|
+
"You can continue with oracle context gathering and submission.",
|
|
849
|
+
].filter(Boolean).join("\n");
|
|
850
|
+
}
|
|
851
|
+
|
|
563
852
|
return [
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
853
|
+
`Oracle preflight blocked: ${details.error?.message ?? "unknown blocker"}`,
|
|
854
|
+
details.error?.suggestedNextStep ? `Suggested next step: ${details.error.suggestedNextStep}` : undefined,
|
|
855
|
+
].filter(Boolean).join("\n");
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function runOraclePreflight(ctx: ExtensionContext): Promise<OraclePreflightDetails> {
|
|
859
|
+
const sessionFile = getSessionFile(ctx);
|
|
860
|
+
if (!hasPersistedSessionFile(sessionFile)) {
|
|
861
|
+
return {
|
|
862
|
+
ready: false,
|
|
863
|
+
session: { persisted: false },
|
|
864
|
+
config: { ready: false },
|
|
865
|
+
auth: { ready: false },
|
|
866
|
+
error: buildOracleToolErrorDetails(
|
|
867
|
+
"oracle_preflight",
|
|
868
|
+
new Error("Oracle requires a persisted pi session to submit oracle jobs. Start or save a real session before using oracle."),
|
|
869
|
+
{},
|
|
870
|
+
),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
let config;
|
|
875
|
+
try {
|
|
876
|
+
config = loadOracleConfig(ctx.cwd);
|
|
877
|
+
} catch (error) {
|
|
878
|
+
return {
|
|
879
|
+
ready: false,
|
|
880
|
+
session: { persisted: true, sessionFile },
|
|
881
|
+
config: { ready: false },
|
|
882
|
+
auth: { ready: false },
|
|
883
|
+
error: buildOracleToolErrorDetails("oracle_preflight", error, {}),
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
await assertOracleSubmitPrerequisites(config);
|
|
889
|
+
} catch (error) {
|
|
890
|
+
return {
|
|
891
|
+
ready: false,
|
|
892
|
+
session: { persisted: true, sessionFile },
|
|
893
|
+
config: { ready: true },
|
|
894
|
+
auth: {
|
|
895
|
+
ready: false,
|
|
896
|
+
seedProfileDir: config.browser.authSeedProfileDir,
|
|
897
|
+
},
|
|
898
|
+
error: buildOracleToolErrorDetails("oracle_preflight", error, {}),
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
ready: true,
|
|
904
|
+
session: { persisted: true, sessionFile },
|
|
905
|
+
config: { ready: true },
|
|
906
|
+
auth: {
|
|
907
|
+
ready: true,
|
|
908
|
+
seedProfileDir: config.browser.authSeedProfileDir,
|
|
909
|
+
},
|
|
910
|
+
};
|
|
576
911
|
}
|
|
577
912
|
|
|
578
913
|
export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void {
|
|
914
|
+
pi.on("tool_result", async (event) => {
|
|
915
|
+
if (!ORACLE_TOOL_NAMES.has(event.toolName as OracleToolName)) return;
|
|
916
|
+
if (event.isError) return;
|
|
917
|
+
const details = asRecord(event.details);
|
|
918
|
+
const errorDetails = asRecord(details?.error);
|
|
919
|
+
if (typeof errorDetails?.code === "string" && typeof errorDetails?.message === "string") {
|
|
920
|
+
return { isError: true };
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
pi.registerTool({
|
|
925
|
+
name: "oracle_preflight",
|
|
926
|
+
label: "Oracle Preflight",
|
|
927
|
+
description: "Check whether oracle is ready in this session before spending time gathering context or preparing a submission.",
|
|
928
|
+
promptSnippet: "Check oracle readiness before expensive /oracle preparation.",
|
|
929
|
+
promptGuidelines: [
|
|
930
|
+
"Call oracle_preflight before doing expensive /oracle preparation. If ready is false, stop immediately and report the suggested next step instead of reading files or crafting archive inputs.",
|
|
931
|
+
],
|
|
932
|
+
parameters: Type.Object({}),
|
|
933
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
934
|
+
const details = await runOraclePreflight(ctx);
|
|
935
|
+
return {
|
|
936
|
+
content: [{ type: "text" as const, text: formatOraclePreflightResponse(details) }],
|
|
937
|
+
details,
|
|
938
|
+
};
|
|
939
|
+
},
|
|
940
|
+
});
|
|
941
|
+
|
|
579
942
|
pi.registerTool({
|
|
580
943
|
name: "oracle_submit",
|
|
581
944
|
label: "Oracle Submit",
|
|
@@ -597,68 +960,115 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
597
960
|
],
|
|
598
961
|
parameters: ORACLE_SUBMIT_PARAMS,
|
|
599
962
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
600
|
-
const config = loadOracleConfig(ctx.cwd);
|
|
601
|
-
const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
|
|
602
|
-
const projectId = getProjectId(ctx.cwd);
|
|
603
|
-
const sessionId = getSessionId(originSessionFile, projectId);
|
|
604
|
-
const presetId = typeof params.preset === "string" ? coerceOracleSubmitPresetId(params.preset) : config.defaults.preset;
|
|
605
|
-
const selection = resolveOracleSubmitPreset(presetId);
|
|
606
|
-
const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
|
|
607
963
|
try {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
964
|
+
const config = loadOracleConfig(ctx.cwd);
|
|
965
|
+
const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
|
|
966
|
+
const projectId = getProjectId(ctx.cwd);
|
|
967
|
+
const sessionId = getSessionId(originSessionFile, projectId);
|
|
968
|
+
const presetId = typeof params.preset === "string" ? coerceOracleSubmitPresetId(params.preset) : config.defaults.preset;
|
|
969
|
+
const selection = resolveOracleSubmitPreset(presetId);
|
|
970
|
+
const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
|
|
971
|
+
// Validate caller-specified archive paths before surfacing unrelated local setup failures such as a missing auth seed profile.
|
|
972
|
+
resolveArchiveInputs(ctx.cwd, params.files);
|
|
973
|
+
await assertOracleSubmitPrerequisites(config);
|
|
974
|
+
try {
|
|
975
|
+
await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
|
|
976
|
+
await reconcileStaleOracleJobs();
|
|
977
|
+
await pruneTerminalOracleJobs();
|
|
978
|
+
});
|
|
979
|
+
} catch (error) {
|
|
980
|
+
if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
|
|
981
|
+
}
|
|
615
982
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
983
|
+
const jobId = randomUUID();
|
|
984
|
+
const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
|
|
985
|
+
const runtime = allocateRuntime(config);
|
|
986
|
+
let job: OracleJob | undefined;
|
|
987
|
+
let archive: ArchiveCreationResult | undefined;
|
|
988
|
+
let queued = false;
|
|
989
|
+
let queuedSubmissionDurable = false;
|
|
990
|
+
let runtimeLeaseAcquired = false;
|
|
991
|
+
let conversationLeaseAcquired = false;
|
|
992
|
+
let workerSpawned = false;
|
|
993
|
+
let spawnedWorker: Awaited<ReturnType<typeof spawnWorker>> | undefined;
|
|
627
994
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const admittedAt = new Date().toISOString();
|
|
635
|
-
const runtimeAttempt = await tryAcquireRuntimeLease(config, {
|
|
636
|
-
jobId,
|
|
637
|
-
runtimeId: runtime.runtimeId,
|
|
638
|
-
runtimeSessionName: runtime.runtimeSessionName,
|
|
639
|
-
runtimeProfileDir: runtime.runtimeProfileDir,
|
|
640
|
-
projectId,
|
|
641
|
-
sessionId,
|
|
642
|
-
createdAt: admittedAt,
|
|
643
|
-
});
|
|
995
|
+
try {
|
|
996
|
+
archive = await createArchive(ctx.cwd, params.files, tempArchivePath);
|
|
997
|
+
const currentArchive = archive;
|
|
998
|
+
await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
|
|
999
|
+
await promoteQueuedJobsWithinAdmissionLock({ workerPath, source: "oracle_submit" });
|
|
644
1000
|
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
maxQueuedJobs,
|
|
655
|
-
maxQueuedArchiveBytes,
|
|
1001
|
+
const admittedAt = new Date().toISOString();
|
|
1002
|
+
const runtimeAttempt = await tryAcquireRuntimeLease(config, {
|
|
1003
|
+
jobId,
|
|
1004
|
+
runtimeId: runtime.runtimeId,
|
|
1005
|
+
runtimeSessionName: runtime.runtimeSessionName,
|
|
1006
|
+
runtimeProfileDir: runtime.runtimeProfileDir,
|
|
1007
|
+
projectId,
|
|
1008
|
+
sessionId,
|
|
1009
|
+
createdAt: admittedAt,
|
|
656
1010
|
});
|
|
657
|
-
|
|
658
|
-
|
|
1011
|
+
|
|
1012
|
+
if (!runtimeAttempt.acquired) {
|
|
1013
|
+
const queuePressure = await getQueuedArchivePressure();
|
|
1014
|
+
const maxQueuedJobs = config.browser.maxConcurrentJobs * MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME;
|
|
1015
|
+
const maxQueuedArchiveBytes = config.browser.maxConcurrentJobs * MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME;
|
|
1016
|
+
const queueAdmissionFailure = getQueueAdmissionFailure({
|
|
1017
|
+
queuePressure,
|
|
1018
|
+
archiveBytes: currentArchive.archiveBytes,
|
|
1019
|
+
activeJobs: runtimeAttempt.liveLeases.length,
|
|
1020
|
+
maxActiveJobs: config.browser.maxConcurrentJobs,
|
|
1021
|
+
maxQueuedJobs,
|
|
1022
|
+
maxQueuedArchiveBytes,
|
|
1023
|
+
});
|
|
1024
|
+
if (queueAdmissionFailure) {
|
|
1025
|
+
throw new Error(queueAdmissionFailure);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
queued = true;
|
|
1029
|
+
job = await createJob(
|
|
1030
|
+
jobId,
|
|
1031
|
+
{
|
|
1032
|
+
prompt: params.prompt,
|
|
1033
|
+
files: params.files,
|
|
1034
|
+
selection,
|
|
1035
|
+
followUpToJobId: followUp.followUpToJobId,
|
|
1036
|
+
chatUrl: followUp.chatUrl,
|
|
1037
|
+
requestSource: "tool",
|
|
1038
|
+
},
|
|
1039
|
+
ctx.cwd,
|
|
1040
|
+
originSessionFile,
|
|
1041
|
+
config,
|
|
1042
|
+
runtime,
|
|
1043
|
+
{ initialState: "queued", createdAt: admittedAt },
|
|
1044
|
+
);
|
|
1045
|
+
await rename(tempArchivePath, job.archivePath);
|
|
1046
|
+
job = await updateJob(job.id, (current) => ({
|
|
1047
|
+
...current,
|
|
1048
|
+
archiveSha256: currentArchive.sha256,
|
|
1049
|
+
}));
|
|
1050
|
+
queuedSubmissionDurable = true;
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
runtimeLeaseAcquired = true;
|
|
1055
|
+
if (followUp.conversationId) {
|
|
1056
|
+
const conversationAttempt = await tryAcquireConversationLease({
|
|
1057
|
+
jobId,
|
|
1058
|
+
conversationId: followUp.conversationId,
|
|
1059
|
+
projectId,
|
|
1060
|
+
sessionId,
|
|
1061
|
+
createdAt: admittedAt,
|
|
1062
|
+
});
|
|
1063
|
+
if (!conversationAttempt.acquired) {
|
|
1064
|
+
throw new Error(
|
|
1065
|
+
`Oracle conversation ${followUp.conversationId} is already in use by job ${conversationAttempt.blocker?.jobId ?? "unknown"}. ` +
|
|
1066
|
+
"Concurrent follow-ups to the same ChatGPT thread are not allowed.",
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
conversationLeaseAcquired = true;
|
|
659
1070
|
}
|
|
660
1071
|
|
|
661
|
-
queued = true;
|
|
662
1072
|
job = await createJob(
|
|
663
1073
|
jobId,
|
|
664
1074
|
{
|
|
@@ -673,175 +1083,133 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
673
1083
|
originSessionFile,
|
|
674
1084
|
config,
|
|
675
1085
|
runtime,
|
|
676
|
-
{ initialState: "
|
|
1086
|
+
{ initialState: "submitted", createdAt: admittedAt },
|
|
677
1087
|
);
|
|
678
1088
|
await rename(tempArchivePath, job.archivePath);
|
|
1089
|
+
spawnedWorker = await spawnWorker(workerPath, job.id);
|
|
1090
|
+
workerSpawned = true;
|
|
1091
|
+
const worker = spawnedWorker;
|
|
679
1092
|
job = await updateJob(job.id, (current) => ({
|
|
680
1093
|
...current,
|
|
681
1094
|
archiveSha256: currentArchive.sha256,
|
|
1095
|
+
workerPid: worker.pid,
|
|
1096
|
+
workerNonce: worker.nonce,
|
|
1097
|
+
workerStartedAt: worker.startedAt,
|
|
682
1098
|
}));
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
runtimeLeaseAcquired = true;
|
|
688
|
-
if (followUp.conversationId) {
|
|
689
|
-
const conversationAttempt = await tryAcquireConversationLease({
|
|
690
|
-
jobId,
|
|
691
|
-
conversationId: followUp.conversationId,
|
|
692
|
-
projectId,
|
|
693
|
-
sessionId,
|
|
694
|
-
createdAt: admittedAt,
|
|
695
|
-
});
|
|
696
|
-
if (!conversationAttempt.acquired) {
|
|
697
|
-
throw new Error(
|
|
698
|
-
`Oracle conversation ${followUp.conversationId} is already in use by job ${conversationAttempt.blocker?.jobId ?? "unknown"}. ` +
|
|
699
|
-
"Concurrent follow-ups to the same ChatGPT thread are not allowed.",
|
|
700
|
-
);
|
|
701
|
-
}
|
|
702
|
-
conversationLeaseAcquired = true;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
job = await createJob(
|
|
706
|
-
jobId,
|
|
707
|
-
{
|
|
708
|
-
prompt: params.prompt,
|
|
709
|
-
files: params.files,
|
|
710
|
-
selection,
|
|
711
|
-
followUpToJobId: followUp.followUpToJobId,
|
|
712
|
-
chatUrl: followUp.chatUrl,
|
|
713
|
-
requestSource: "tool",
|
|
714
|
-
},
|
|
715
|
-
ctx.cwd,
|
|
716
|
-
originSessionFile,
|
|
717
|
-
config,
|
|
718
|
-
runtime,
|
|
719
|
-
{ initialState: "submitted", createdAt: admittedAt },
|
|
720
|
-
);
|
|
721
|
-
await rename(tempArchivePath, job.archivePath);
|
|
722
|
-
spawnedWorker = await spawnWorker(workerPath, job.id);
|
|
723
|
-
workerSpawned = true;
|
|
724
|
-
const worker = spawnedWorker;
|
|
725
|
-
job = await updateJob(job.id, (current) => ({
|
|
726
|
-
...current,
|
|
727
|
-
archiveSha256: currentArchive.sha256,
|
|
728
|
-
workerPid: worker.pid,
|
|
729
|
-
workerNonce: worker.nonce,
|
|
730
|
-
workerStartedAt: worker.startedAt,
|
|
731
|
-
}));
|
|
732
|
-
});
|
|
733
|
-
if (!job || !archive) throw new Error(`Oracle submission ${jobId} did not persist job metadata durably`);
|
|
734
|
-
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
735
|
-
|
|
736
|
-
const queuePosition = queued ? getQueuePosition(job.id) : undefined;
|
|
737
|
-
return {
|
|
738
|
-
content: [
|
|
739
|
-
{
|
|
740
|
-
type: "text",
|
|
741
|
-
text: formatSubmitResponse(job, {
|
|
742
|
-
autoPrunedPrefixes: currentArchive.autoPrunedPrefixes,
|
|
743
|
-
queued,
|
|
744
|
-
queuePosition: queuePosition?.position,
|
|
745
|
-
queueDepth: queuePosition?.depth,
|
|
746
|
-
}),
|
|
747
|
-
},
|
|
748
|
-
],
|
|
749
|
-
details: {
|
|
750
|
-
jobId: job.id,
|
|
751
|
-
queued,
|
|
752
|
-
queuePosition: queuePosition?.position,
|
|
753
|
-
queueDepth: queuePosition?.depth,
|
|
754
|
-
archiveSha256: currentArchive.sha256,
|
|
755
|
-
archiveBytes: currentArchive.archiveBytes,
|
|
756
|
-
initialArchiveBytes: currentArchive.initialArchiveBytes,
|
|
757
|
-
autoPrunedArchivePaths: currentArchive.autoPrunedPrefixes,
|
|
758
|
-
runtimeId: job.runtimeId,
|
|
759
|
-
followUpToJobId: followUp.followUpToJobId,
|
|
760
|
-
},
|
|
761
|
-
};
|
|
762
|
-
} catch (error) {
|
|
763
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
764
|
-
const latest = job ? readJob(job.id) : undefined;
|
|
765
|
-
if (latest?.status === "queued" && queuedSubmissionDurable) {
|
|
1099
|
+
});
|
|
1100
|
+
if (!job || !archive) throw new Error(`Oracle submission ${jobId} did not persist job metadata durably`);
|
|
766
1101
|
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
767
|
-
|
|
1102
|
+
|
|
1103
|
+
const queuePosition = queued ? getQueuePosition(job.id) : undefined;
|
|
768
1104
|
return {
|
|
769
1105
|
content: [
|
|
770
1106
|
{
|
|
771
1107
|
type: "text",
|
|
772
|
-
text:
|
|
773
|
-
autoPrunedPrefixes: archive
|
|
774
|
-
queued
|
|
1108
|
+
text: formatOracleSubmitResponse(job, {
|
|
1109
|
+
autoPrunedPrefixes: archive.autoPrunedPrefixes,
|
|
1110
|
+
queued,
|
|
775
1111
|
queuePosition: queuePosition?.position,
|
|
776
1112
|
queueDepth: queuePosition?.depth,
|
|
777
1113
|
}),
|
|
778
1114
|
},
|
|
779
1115
|
],
|
|
780
1116
|
details: {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
initialArchiveBytes: archive?.initialArchiveBytes,
|
|
788
|
-
autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
|
|
789
|
-
runtimeId: latest.runtimeId,
|
|
790
|
-
followUpToJobId: latest.followUpToJobId,
|
|
1117
|
+
job: redactJobDetails(job, {
|
|
1118
|
+
queue: buildOracleQueueSnapshot(job, queuePosition),
|
|
1119
|
+
archiveBytes: archive.archiveBytes,
|
|
1120
|
+
initialArchiveBytes: archive.initialArchiveBytes,
|
|
1121
|
+
autoPrunedArchivePaths: archive.autoPrunedPrefixes,
|
|
1122
|
+
}),
|
|
791
1123
|
},
|
|
792
1124
|
};
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
const message = getErrorMessage(error);
|
|
1127
|
+
const latest = job ? readJob(job.id) : undefined;
|
|
1128
|
+
if (latest?.status === "queued" && queuedSubmissionDurable) {
|
|
1129
|
+
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
1130
|
+
const queuePosition = getQueuePosition(latest.id);
|
|
1131
|
+
return {
|
|
1132
|
+
content: [
|
|
1133
|
+
{
|
|
1134
|
+
type: "text",
|
|
1135
|
+
text: formatOracleSubmitResponse(latest, {
|
|
1136
|
+
autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
|
|
1137
|
+
queued: true,
|
|
1138
|
+
queuePosition: queuePosition?.position,
|
|
1139
|
+
queueDepth: queuePosition?.depth,
|
|
1140
|
+
}),
|
|
1141
|
+
},
|
|
1142
|
+
],
|
|
1143
|
+
details: {
|
|
1144
|
+
job: redactJobDetails(latest, {
|
|
1145
|
+
queue: buildOracleQueueSnapshot(latest, queuePosition),
|
|
1146
|
+
archiveBytes: archive?.archiveBytes,
|
|
1147
|
+
initialArchiveBytes: archive?.initialArchiveBytes,
|
|
1148
|
+
autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
|
|
803
1149
|
}),
|
|
804
1150
|
},
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
if (workerSpawned && latest && hasDurableWorkerHandoff(latest)) {
|
|
1154
|
+
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
1155
|
+
return {
|
|
1156
|
+
content: [
|
|
1157
|
+
{
|
|
1158
|
+
type: "text",
|
|
1159
|
+
text: formatOracleSubmitResponse(latest, {
|
|
1160
|
+
autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
|
|
1161
|
+
queued: false,
|
|
1162
|
+
}),
|
|
1163
|
+
},
|
|
1164
|
+
],
|
|
1165
|
+
details: {
|
|
1166
|
+
job: redactJobDetails(latest, {
|
|
1167
|
+
queue: buildOracleQueueSnapshot(latest),
|
|
1168
|
+
archiveBytes: archive?.archiveBytes,
|
|
1169
|
+
initialArchiveBytes: archive?.initialArchiveBytes,
|
|
1170
|
+
autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
|
|
1171
|
+
}),
|
|
1172
|
+
},
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
if (spawnedWorker) {
|
|
1176
|
+
await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.startedAt).catch(() => undefined);
|
|
1177
|
+
}
|
|
1178
|
+
if (job && (!latest || !isTerminalOracleJob(latest))) {
|
|
1179
|
+
const failedAt = new Date().toISOString();
|
|
1180
|
+
await updateJob(job.id, (current) => transitionOracleJobPhase(current, "failed", {
|
|
1181
|
+
at: failedAt,
|
|
1182
|
+
source: "oracle:submit",
|
|
1183
|
+
message: `Submission failed before durable worker handoff: ${message}`,
|
|
1184
|
+
patch: {
|
|
1185
|
+
error: message,
|
|
1186
|
+
},
|
|
1187
|
+
})).catch(() => undefined);
|
|
1188
|
+
}
|
|
1189
|
+
const cleanupReport = await cleanupRuntimeArtifacts({
|
|
1190
|
+
runtimeId: runtimeLeaseAcquired ? runtime.runtimeId : undefined,
|
|
1191
|
+
runtimeProfileDir: runtimeLeaseAcquired ? runtime.runtimeProfileDir : undefined,
|
|
1192
|
+
runtimeSessionName: workerSpawned ? runtime.runtimeSessionName : undefined,
|
|
1193
|
+
conversationId: conversationLeaseAcquired ? followUp.conversationId : undefined,
|
|
1194
|
+
}).catch(() => ({ attempted: [], warnings: [] }));
|
|
1195
|
+
if (job && cleanupReport.warnings.length > 0) {
|
|
1196
|
+
await appendCleanupWarnings(job.id, cleanupReport.warnings).catch(() => undefined);
|
|
1197
|
+
}
|
|
1198
|
+
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
1199
|
+
return buildOracleToolErrorResult("oracle_submit", error, params as unknown as Record<string, unknown>, {
|
|
1200
|
+
job: latest ?? job,
|
|
1201
|
+
jobDetails: {
|
|
1202
|
+
queue: latest ? buildOracleQueueSnapshot(latest, latest.status === "queued" ? getQueuePosition(latest.id) : undefined) : undefined,
|
|
810
1203
|
archiveBytes: archive?.archiveBytes,
|
|
811
1204
|
initialArchiveBytes: archive?.initialArchiveBytes,
|
|
812
1205
|
autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
|
|
813
|
-
runtimeId: latest.runtimeId,
|
|
814
|
-
followUpToJobId: latest.followUpToJobId,
|
|
815
1206
|
},
|
|
816
|
-
};
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.startedAt).catch(() => undefined);
|
|
820
|
-
}
|
|
821
|
-
if (job && (!latest || !isTerminalOracleJob(latest))) {
|
|
822
|
-
const failedAt = new Date().toISOString();
|
|
823
|
-
await updateJob(job.id, (current) => ({
|
|
824
|
-
...current,
|
|
825
|
-
...withJobPhase("failed", {
|
|
826
|
-
status: "failed",
|
|
827
|
-
completedAt: failedAt,
|
|
828
|
-
error: message,
|
|
829
|
-
}, failedAt),
|
|
830
|
-
})).catch(() => undefined);
|
|
831
|
-
}
|
|
832
|
-
const cleanupReport = await cleanupRuntimeArtifacts({
|
|
833
|
-
runtimeId: runtimeLeaseAcquired ? runtime.runtimeId : undefined,
|
|
834
|
-
runtimeProfileDir: runtimeLeaseAcquired ? runtime.runtimeProfileDir : undefined,
|
|
835
|
-
runtimeSessionName: workerSpawned ? runtime.runtimeSessionName : undefined,
|
|
836
|
-
conversationId: conversationLeaseAcquired ? followUp.conversationId : undefined,
|
|
837
|
-
}).catch(() => ({ attempted: [], warnings: [] }));
|
|
838
|
-
if (job && cleanupReport.warnings.length > 0) {
|
|
839
|
-
await appendCleanupWarnings(job.id, cleanupReport.warnings).catch(() => undefined);
|
|
1207
|
+
});
|
|
1208
|
+
} finally {
|
|
1209
|
+
await rm(tempArchivePath, { force: true }).catch(() => undefined);
|
|
840
1210
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
} finally {
|
|
844
|
-
await rm(tempArchivePath, { force: true }).catch(() => undefined);
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
return buildOracleToolErrorResult("oracle_submit", error, params as unknown as Record<string, unknown>);
|
|
845
1213
|
}
|
|
846
1214
|
},
|
|
847
1215
|
});
|
|
@@ -852,57 +1220,54 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
852
1220
|
description: "Read the status and outputs of a previously dispatched oracle job.",
|
|
853
1221
|
parameters: ORACLE_READ_PARAMS,
|
|
854
1222
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
855
|
-
const job = readJob(params.jobId);
|
|
856
|
-
if (!job || job.projectId !== getProjectId(ctx.cwd)) {
|
|
857
|
-
throw new Error(`Oracle job not found in this project: ${params.jobId}`);
|
|
858
|
-
}
|
|
859
|
-
const latest = isTerminalOracleJob(job)
|
|
860
|
-
? await markWakeupSettled(job.id, {
|
|
861
|
-
source: "oracle_read",
|
|
862
|
-
sessionFile: getSessionFile(ctx),
|
|
863
|
-
cwd: ctx.cwd,
|
|
864
|
-
})
|
|
865
|
-
: job;
|
|
866
|
-
const current = latest ?? readJob(job.id) ?? job;
|
|
867
|
-
|
|
868
|
-
let responsePreview = "";
|
|
869
1223
|
try {
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1224
|
+
const job = readJob(params.jobId);
|
|
1225
|
+
if (!job || job.projectId !== getProjectId(ctx.cwd)) {
|
|
1226
|
+
throw new Error(`Oracle job not found in this project: ${params.jobId}`);
|
|
1227
|
+
}
|
|
1228
|
+
const latest = isTerminalOracleJob(job)
|
|
1229
|
+
? await markWakeupSettled(job.id, {
|
|
1230
|
+
source: "oracle_read",
|
|
1231
|
+
sessionFile: getSessionFile(ctx),
|
|
1232
|
+
cwd: ctx.cwd,
|
|
1233
|
+
})
|
|
1234
|
+
: job;
|
|
1235
|
+
const current = latest ?? readJob(job.id) ?? job;
|
|
1236
|
+
|
|
1237
|
+
let responsePreview: string | undefined;
|
|
1238
|
+
let responseAvailable = false;
|
|
1239
|
+
try {
|
|
1240
|
+
const response = await import("node:fs/promises").then((fs) => fs.readFile(current.responsePath || "", "utf8"));
|
|
1241
|
+
responsePreview = response.slice(0, 4000);
|
|
1242
|
+
responseAvailable = true;
|
|
1243
|
+
} catch {
|
|
1244
|
+
responsePreview = undefined;
|
|
1245
|
+
}
|
|
875
1246
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
"",
|
|
1247
|
+
const queuePosition = current.status === "queued" ? getQueuePosition(current.id) : undefined;
|
|
1248
|
+
return {
|
|
1249
|
+
content: [
|
|
1250
|
+
{
|
|
1251
|
+
type: "text",
|
|
1252
|
+
text: formatOracleJobSummary(current, {
|
|
1253
|
+
queuePosition,
|
|
1254
|
+
artifactsPath: `${getJobDir(current.id)}/artifacts`,
|
|
1255
|
+
responsePreview,
|
|
1256
|
+
responseAvailable,
|
|
1257
|
+
}),
|
|
1258
|
+
},
|
|
1259
|
+
],
|
|
1260
|
+
details: {
|
|
1261
|
+
job: redactJobDetails(current, {
|
|
1262
|
+
queue: buildOracleQueueSnapshot(current, queuePosition),
|
|
898
1263
|
responsePreview,
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
.join("\n"),
|
|
1264
|
+
responseAvailable,
|
|
1265
|
+
}),
|
|
902
1266
|
},
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1267
|
+
};
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
return buildOracleToolErrorResult("oracle_read", error, params as unknown as Record<string, unknown>);
|
|
1270
|
+
}
|
|
906
1271
|
},
|
|
907
1272
|
});
|
|
908
1273
|
|
|
@@ -912,26 +1277,30 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
912
1277
|
description: "Cancel a queued or active oracle job.",
|
|
913
1278
|
parameters: ORACLE_CANCEL_PARAMS,
|
|
914
1279
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1280
|
+
try {
|
|
1281
|
+
const job = readJob(params.jobId);
|
|
1282
|
+
if (!job || job.projectId !== getProjectId(ctx.cwd)) {
|
|
1283
|
+
throw new Error(`Oracle job not found in this project: ${params.jobId}`);
|
|
1284
|
+
}
|
|
1285
|
+
if (!isOpenOracleJob(job)) {
|
|
1286
|
+
return {
|
|
1287
|
+
content: [{ type: "text", text: `Oracle job ${job.id} is not cancellable (${job.status}).` }],
|
|
1288
|
+
details: { job: redactJobDetails(job, { queue: buildOracleQueueSnapshot(job, job.status === "queued" ? getQueuePosition(job.id) : undefined) }) },
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const cancelled = await cancelOracleJob(params.jobId);
|
|
1293
|
+
if (shouldAdvanceQueueAfterCancellation(cancelled)) {
|
|
1294
|
+
await promoteQueuedJobs({ workerPath, source: "oracle_cancel_tool" });
|
|
1295
|
+
}
|
|
1296
|
+
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
920
1297
|
return {
|
|
921
|
-
content: [{ type: "text", text: `
|
|
922
|
-
details: { job: redactJobDetails(
|
|
1298
|
+
content: [{ type: "text", text: cancelled.status === "cancelled" || cancelled.status === "failed" ? `Cancelled oracle job ${cancelled.id}.` : `Oracle job ${cancelled.id} was already ${cancelled.status}.` }],
|
|
1299
|
+
details: { job: redactJobDetails(cancelled, { queue: buildOracleQueueSnapshot(cancelled, cancelled.status === "queued" ? getQueuePosition(cancelled.id) : undefined) }) },
|
|
923
1300
|
};
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
return buildOracleToolErrorResult("oracle_cancel", error, params as unknown as Record<string, unknown>);
|
|
924
1303
|
}
|
|
925
|
-
|
|
926
|
-
const cancelled = await cancelOracleJob(params.jobId);
|
|
927
|
-
if (shouldAdvanceQueueAfterCancellation(cancelled)) {
|
|
928
|
-
await promoteQueuedJobs({ workerPath, source: "oracle_cancel_tool" });
|
|
929
|
-
}
|
|
930
|
-
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
931
|
-
return {
|
|
932
|
-
content: [{ type: "text", text: cancelled.status === "cancelled" || cancelled.status === "failed" ? `Cancelled oracle job ${cancelled.id}.` : `Oracle job ${cancelled.id} was already ${cancelled.status}.` }],
|
|
933
|
-
details: { job: redactJobDetails(cancelled) },
|
|
934
|
-
};
|
|
935
1304
|
},
|
|
936
1305
|
});
|
|
937
1306
|
}
|