pi-oracle 0.4.0 → 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.
@@ -7,14 +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
12
  import { formatOracleJobSummary, formatOracleSubmitResponse } from "../shared/job-observability-helpers.mjs";
13
- import { transitionOracleJobPhase } from "../shared/job-lifecycle-helpers.mjs";
13
+ import { getLatestOracleJobLifecycleEvent, getLatestOracleTerminalLifecycleEvent, transitionOracleJobPhase } from "../shared/job-lifecycle-helpers.mjs";
14
14
  import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.js";
15
15
  import {
16
16
  coerceOracleSubmitPresetId,
17
17
  loadOracleConfig,
18
+ ORACLE_SUBMIT_PRESET_IDS,
18
19
  resolveOracleSubmitPreset,
19
20
  } from "./config.js";
20
21
  import {
@@ -44,9 +45,11 @@ import { getQueuePosition, promoteQueuedJobs, promoteQueuedJobsWithinAdmissionLo
44
45
  import { refreshOracleStatus } from "./poller.js";
45
46
  import {
46
47
  allocateRuntime,
48
+ assertOracleSubmitPrerequisites,
47
49
  cleanupRuntimeArtifacts,
48
50
  getProjectId,
49
51
  getSessionId,
52
+ hasPersistedSessionFile,
50
53
  parseConversationId,
51
54
  requirePersistedSessionFile,
52
55
  tryAcquireConversationLease,
@@ -565,7 +568,54 @@ function resolveFollowUp(previousJobId: string | undefined, cwd: string): {
565
568
  };
566
569
  }
567
570
 
568
- function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
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);
569
619
  return {
570
620
  id: job.id,
571
621
  status: job.status,
@@ -576,24 +626,319 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
576
626
  queuedAt: job.queuedAt,
577
627
  submittedAt: job.submittedAt,
578
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),
579
636
  followUpToJobId: job.followUpToJobId,
580
637
  chatUrl: job.chatUrl,
581
638
  conversationId: job.conversationId,
582
639
  responsePath: job.responsePath,
583
640
  responseFormat: job.responseFormat,
641
+ responseAvailable: options.responseAvailable ?? false,
642
+ responsePreview: options.responsePreview,
643
+ artifactsPath: `${getJobDir(job.id)}/artifacts`,
584
644
  artifactPaths: job.artifactPaths,
585
645
  artifactFailureCount: job.artifactFailureCount,
586
646
  artifactsManifestPath: job.artifactsManifestPath,
647
+ workerLogPath: job.workerLogPath,
587
648
  archiveDeletedAfterUpload: job.archiveDeletedAfterUpload,
588
649
  runtimeId: job.runtimeId,
589
650
  cleanupWarnings: job.cleanupWarnings,
590
651
  lastCleanupAt: job.lastCleanupAt,
652
+ terminalEvent: terminalEvent ? { ...terminalEvent } : undefined,
653
+ lastEvent: lastEvent ? { ...lastEvent } : undefined,
591
654
  error: job.error,
592
655
  lifecycleEvents: job.lifecycleEvents,
593
656
  };
594
657
  }
595
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.",
807
+ };
808
+ }
809
+
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
+ };
824
+ }
825
+
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
+
852
+ return [
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
+ };
911
+ }
912
+
596
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
+
597
942
  pi.registerTool({
598
943
  name: "oracle_submit",
599
944
  label: "Oracle Submit",
@@ -615,68 +960,115 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
615
960
  ],
616
961
  parameters: ORACLE_SUBMIT_PARAMS,
617
962
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
618
- const config = loadOracleConfig(ctx.cwd);
619
- const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
620
- const projectId = getProjectId(ctx.cwd);
621
- const sessionId = getSessionId(originSessionFile, projectId);
622
- const presetId = typeof params.preset === "string" ? coerceOracleSubmitPresetId(params.preset) : config.defaults.preset;
623
- const selection = resolveOracleSubmitPreset(presetId);
624
- const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
625
963
  try {
626
- await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
627
- await reconcileStaleOracleJobs();
628
- await pruneTerminalOracleJobs();
629
- });
630
- } catch (error) {
631
- if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
632
- }
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
+ }
633
982
 
634
- const jobId = randomUUID();
635
- const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
636
- const runtime = allocateRuntime(config);
637
- let job: OracleJob | undefined;
638
- let archive: ArchiveCreationResult | undefined;
639
- let queued = false;
640
- let queuedSubmissionDurable = false;
641
- let runtimeLeaseAcquired = false;
642
- let conversationLeaseAcquired = false;
643
- let workerSpawned = false;
644
- let spawnedWorker: Awaited<ReturnType<typeof spawnWorker>> | undefined;
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;
645
994
 
646
- try {
647
- archive = await createArchive(ctx.cwd, params.files, tempArchivePath);
648
- const currentArchive = archive;
649
- await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
650
- await promoteQueuedJobsWithinAdmissionLock({ workerPath, source: "oracle_submit" });
651
-
652
- const admittedAt = new Date().toISOString();
653
- const runtimeAttempt = await tryAcquireRuntimeLease(config, {
654
- jobId,
655
- runtimeId: runtime.runtimeId,
656
- runtimeSessionName: runtime.runtimeSessionName,
657
- runtimeProfileDir: runtime.runtimeProfileDir,
658
- projectId,
659
- sessionId,
660
- createdAt: admittedAt,
661
- });
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" });
662
1000
 
663
- if (!runtimeAttempt.acquired) {
664
- const queuePressure = await getQueuedArchivePressure();
665
- const maxQueuedJobs = config.browser.maxConcurrentJobs * MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME;
666
- const maxQueuedArchiveBytes = config.browser.maxConcurrentJobs * MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME;
667
- const queueAdmissionFailure = getQueueAdmissionFailure({
668
- queuePressure,
669
- archiveBytes: currentArchive.archiveBytes,
670
- activeJobs: runtimeAttempt.liveLeases.length,
671
- maxActiveJobs: config.browser.maxConcurrentJobs,
672
- maxQueuedJobs,
673
- 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,
674
1010
  });
675
- if (queueAdmissionFailure) {
676
- throw new Error(queueAdmissionFailure);
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;
677
1070
  }
678
1071
 
679
- queued = true;
680
1072
  job = await createJob(
681
1073
  jobId,
682
1074
  {
@@ -691,175 +1083,133 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
691
1083
  originSessionFile,
692
1084
  config,
693
1085
  runtime,
694
- { initialState: "queued", createdAt: admittedAt },
1086
+ { initialState: "submitted", createdAt: admittedAt },
695
1087
  );
696
1088
  await rename(tempArchivePath, job.archivePath);
1089
+ spawnedWorker = await spawnWorker(workerPath, job.id);
1090
+ workerSpawned = true;
1091
+ const worker = spawnedWorker;
697
1092
  job = await updateJob(job.id, (current) => ({
698
1093
  ...current,
699
1094
  archiveSha256: currentArchive.sha256,
1095
+ workerPid: worker.pid,
1096
+ workerNonce: worker.nonce,
1097
+ workerStartedAt: worker.startedAt,
700
1098
  }));
701
- queuedSubmissionDurable = true;
702
- return;
703
- }
704
-
705
- runtimeLeaseAcquired = true;
706
- if (followUp.conversationId) {
707
- const conversationAttempt = await tryAcquireConversationLease({
708
- jobId,
709
- conversationId: followUp.conversationId,
710
- projectId,
711
- sessionId,
712
- createdAt: admittedAt,
713
- });
714
- if (!conversationAttempt.acquired) {
715
- throw new Error(
716
- `Oracle conversation ${followUp.conversationId} is already in use by job ${conversationAttempt.blocker?.jobId ?? "unknown"}. ` +
717
- "Concurrent follow-ups to the same ChatGPT thread are not allowed.",
718
- );
719
- }
720
- conversationLeaseAcquired = true;
721
- }
722
-
723
- job = await createJob(
724
- jobId,
725
- {
726
- prompt: params.prompt,
727
- files: params.files,
728
- selection,
729
- followUpToJobId: followUp.followUpToJobId,
730
- chatUrl: followUp.chatUrl,
731
- requestSource: "tool",
732
- },
733
- ctx.cwd,
734
- originSessionFile,
735
- config,
736
- runtime,
737
- { initialState: "submitted", createdAt: admittedAt },
738
- );
739
- await rename(tempArchivePath, job.archivePath);
740
- spawnedWorker = await spawnWorker(workerPath, job.id);
741
- workerSpawned = true;
742
- const worker = spawnedWorker;
743
- job = await updateJob(job.id, (current) => ({
744
- ...current,
745
- archiveSha256: currentArchive.sha256,
746
- workerPid: worker.pid,
747
- workerNonce: worker.nonce,
748
- workerStartedAt: worker.startedAt,
749
- }));
750
- });
751
- if (!job || !archive) throw new Error(`Oracle submission ${jobId} did not persist job metadata durably`);
752
- if (ctx.hasUI) refreshOracleStatus(ctx);
753
-
754
- const queuePosition = queued ? getQueuePosition(job.id) : undefined;
755
- return {
756
- content: [
757
- {
758
- type: "text",
759
- text: formatOracleSubmitResponse(job, {
760
- autoPrunedPrefixes: currentArchive.autoPrunedPrefixes,
761
- queued,
762
- queuePosition: queuePosition?.position,
763
- queueDepth: queuePosition?.depth,
764
- }),
765
- },
766
- ],
767
- details: {
768
- jobId: job.id,
769
- queued,
770
- queuePosition: queuePosition?.position,
771
- queueDepth: queuePosition?.depth,
772
- archiveSha256: currentArchive.sha256,
773
- archiveBytes: currentArchive.archiveBytes,
774
- initialArchiveBytes: currentArchive.initialArchiveBytes,
775
- autoPrunedArchivePaths: currentArchive.autoPrunedPrefixes,
776
- runtimeId: job.runtimeId,
777
- followUpToJobId: followUp.followUpToJobId,
778
- },
779
- };
780
- } catch (error) {
781
- const message = error instanceof Error ? error.message : String(error);
782
- const latest = job ? readJob(job.id) : undefined;
783
- if (latest?.status === "queued" && queuedSubmissionDurable) {
1099
+ });
1100
+ if (!job || !archive) throw new Error(`Oracle submission ${jobId} did not persist job metadata durably`);
784
1101
  if (ctx.hasUI) refreshOracleStatus(ctx);
785
- const queuePosition = getQueuePosition(latest.id);
1102
+
1103
+ const queuePosition = queued ? getQueuePosition(job.id) : undefined;
786
1104
  return {
787
1105
  content: [
788
1106
  {
789
1107
  type: "text",
790
- text: formatOracleSubmitResponse(latest, {
791
- autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
792
- queued: true,
1108
+ text: formatOracleSubmitResponse(job, {
1109
+ autoPrunedPrefixes: archive.autoPrunedPrefixes,
1110
+ queued,
793
1111
  queuePosition: queuePosition?.position,
794
1112
  queueDepth: queuePosition?.depth,
795
1113
  }),
796
1114
  },
797
1115
  ],
798
1116
  details: {
799
- jobId: latest.id,
800
- queued: true,
801
- queuePosition: queuePosition?.position,
802
- queueDepth: queuePosition?.depth,
803
- archiveSha256: latest.archiveSha256,
804
- archiveBytes: archive?.archiveBytes,
805
- initialArchiveBytes: archive?.initialArchiveBytes,
806
- autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
807
- runtimeId: latest.runtimeId,
808
- 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
+ }),
809
1123
  },
810
1124
  };
811
- }
812
- if (workerSpawned && latest && hasDurableWorkerHandoff(latest)) {
813
- if (ctx.hasUI) refreshOracleStatus(ctx);
814
- return {
815
- content: [
816
- {
817
- type: "text",
818
- text: formatOracleSubmitResponse(latest, {
819
- autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
820
- queued: false,
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,
821
1149
  }),
822
1150
  },
823
- ],
824
- details: {
825
- jobId: latest.id,
826
- queued: false,
827
- archiveSha256: latest.archiveSha256,
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,
828
1203
  archiveBytes: archive?.archiveBytes,
829
1204
  initialArchiveBytes: archive?.initialArchiveBytes,
830
1205
  autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
831
- runtimeId: latest.runtimeId,
832
- followUpToJobId: latest.followUpToJobId,
833
1206
  },
834
- };
835
- }
836
- if (spawnedWorker) {
837
- await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.startedAt).catch(() => undefined);
838
- }
839
- if (job && (!latest || !isTerminalOracleJob(latest))) {
840
- const failedAt = new Date().toISOString();
841
- await updateJob(job.id, (current) => transitionOracleJobPhase(current, "failed", {
842
- at: failedAt,
843
- source: "oracle:submit",
844
- message: `Submission failed before durable worker handoff: ${message}`,
845
- patch: {
846
- error: message,
847
- },
848
- })).catch(() => undefined);
849
- }
850
- const cleanupReport = await cleanupRuntimeArtifacts({
851
- runtimeId: runtimeLeaseAcquired ? runtime.runtimeId : undefined,
852
- runtimeProfileDir: runtimeLeaseAcquired ? runtime.runtimeProfileDir : undefined,
853
- runtimeSessionName: workerSpawned ? runtime.runtimeSessionName : undefined,
854
- conversationId: conversationLeaseAcquired ? followUp.conversationId : undefined,
855
- }).catch(() => ({ attempted: [], warnings: [] }));
856
- if (job && cleanupReport.warnings.length > 0) {
857
- await appendCleanupWarnings(job.id, cleanupReport.warnings).catch(() => undefined);
1207
+ });
1208
+ } finally {
1209
+ await rm(tempArchivePath, { force: true }).catch(() => undefined);
858
1210
  }
859
- if (ctx.hasUI) refreshOracleStatus(ctx);
860
- throw error;
861
- } finally {
862
- await rm(tempArchivePath, { force: true }).catch(() => undefined);
1211
+ } catch (error) {
1212
+ return buildOracleToolErrorResult("oracle_submit", error, params as unknown as Record<string, unknown>);
863
1213
  }
864
1214
  },
865
1215
  });
@@ -870,40 +1220,54 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
870
1220
  description: "Read the status and outputs of a previously dispatched oracle job.",
871
1221
  parameters: ORACLE_READ_PARAMS,
872
1222
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
873
- const job = readJob(params.jobId);
874
- if (!job || job.projectId !== getProjectId(ctx.cwd)) {
875
- throw new Error(`Oracle job not found in this project: ${params.jobId}`);
876
- }
877
- const latest = isTerminalOracleJob(job)
878
- ? await markWakeupSettled(job.id, {
879
- source: "oracle_read",
880
- sessionFile: getSessionFile(ctx),
881
- cwd: ctx.cwd,
882
- })
883
- : job;
884
- const current = latest ?? readJob(job.id) ?? job;
885
-
886
- let responsePreview = "";
887
1223
  try {
888
- const response = await import("node:fs/promises").then((fs) => fs.readFile(current.responsePath || "", "utf8"));
889
- responsePreview = response.slice(0, 4000);
890
- } catch {
891
- responsePreview = "(response not available yet)";
892
- }
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
+ }
893
1246
 
894
- return {
895
- content: [
896
- {
897
- type: "text",
898
- text: formatOracleJobSummary(current, {
899
- queuePosition: current.status === "queued" ? getQueuePosition(current.id) : undefined,
900
- artifactsPath: `${getJobDir(current.id)}/artifacts`,
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),
901
1263
  responsePreview,
1264
+ responseAvailable,
902
1265
  }),
903
1266
  },
904
- ],
905
- details: { job: redactJobDetails(current) },
906
- };
1267
+ };
1268
+ } catch (error) {
1269
+ return buildOracleToolErrorResult("oracle_read", error, params as unknown as Record<string, unknown>);
1270
+ }
907
1271
  },
908
1272
  });
909
1273
 
@@ -913,26 +1277,30 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
913
1277
  description: "Cancel a queued or active oracle job.",
914
1278
  parameters: ORACLE_CANCEL_PARAMS,
915
1279
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
916
- const job = readJob(params.jobId);
917
- if (!job || job.projectId !== getProjectId(ctx.cwd)) {
918
- throw new Error(`Oracle job not found in this project: ${params.jobId}`);
919
- }
920
- if (!isOpenOracleJob(job)) {
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);
921
1297
  return {
922
- content: [{ type: "text", text: `Oracle job ${job.id} is not cancellable (${job.status}).` }],
923
- details: { job: redactJobDetails(job) },
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) }) },
924
1300
  };
1301
+ } catch (error) {
1302
+ return buildOracleToolErrorResult("oracle_cancel", error, params as unknown as Record<string, unknown>);
925
1303
  }
926
-
927
- const cancelled = await cancelOracleJob(params.jobId);
928
- if (shouldAdvanceQueueAfterCancellation(cancelled)) {
929
- await promoteQueuedJobs({ workerPath, source: "oracle_cancel_tool" });
930
- }
931
- if (ctx.hasUI) refreshOracleStatus(ctx);
932
- return {
933
- content: [{ type: "text", text: cancelled.status === "cancelled" || cancelled.status === "failed" ? `Cancelled oracle job ${cancelled.id}.` : `Oracle job ${cancelled.id} was already ${cancelled.status}.` }],
934
- details: { job: redactJobDetails(cancelled) },
935
- };
936
1304
  },
937
1305
  });
938
1306
  }