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.
Files changed (35) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +27 -8
  3. package/docs/ORACLE_DESIGN.md +14 -8
  4. package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +276 -0
  5. package/extensions/oracle/index.ts +8 -1
  6. package/extensions/oracle/lib/commands.ts +25 -29
  7. package/extensions/oracle/lib/config.ts +56 -2
  8. package/extensions/oracle/lib/jobs.ts +134 -219
  9. package/extensions/oracle/lib/locks.ts +41 -209
  10. package/extensions/oracle/lib/poller.ts +38 -52
  11. package/extensions/oracle/lib/queue.ts +75 -112
  12. package/extensions/oracle/lib/runtime.ts +102 -19
  13. package/extensions/oracle/lib/tools.ts +663 -294
  14. package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
  15. package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
  16. package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +131 -0
  17. package/extensions/oracle/shared/job-lifecycle-helpers.mjs +390 -0
  18. package/extensions/oracle/shared/job-observability-helpers.d.mts +60 -0
  19. package/extensions/oracle/shared/job-observability-helpers.mjs +161 -0
  20. package/extensions/oracle/shared/process-helpers.d.mts +20 -0
  21. package/extensions/oracle/shared/process-helpers.mjs +128 -0
  22. package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
  23. package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
  24. package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
  25. package/extensions/oracle/worker/auth-bootstrap.mjs +125 -134
  26. package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
  27. package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
  28. package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
  29. package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
  30. package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
  31. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +93 -9
  32. package/extensions/oracle/worker/run-job.mjs +166 -274
  33. package/extensions/oracle/worker/state-locks.mjs +31 -216
  34. package/package.json +4 -3
  35. 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(cwd: string, entries: string[], archivePath: string, listPath: string): Promise<number> {
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
- tar.kill("SIGTERM");
348
- zstd.kill("SIGTERM");
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
- 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);
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 formatAutoPrunedArchiveMessage(autoPrunedPrefixes: ArchiveCreationResult["autoPrunedPrefixes"]): string | undefined {
550
- if (autoPrunedPrefixes.length === 0) return undefined;
551
- return `Archive auto-pruned generic generated-output-name dirs to fit size limit: ${autoPrunedPrefixes.map((entry) => `${entry.relativePath}/ (${formatBytes(entry.bytes)})`).join(", ")}`;
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
- function formatSubmitResponse(
555
- job: NonNullable<ReturnType<typeof readJob>>,
556
- options: {
557
- autoPrunedPrefixes: ArchiveCreationResult["autoPrunedPrefixes"];
558
- queued: boolean;
559
- queuePosition?: number;
560
- queueDepth?: number;
561
- },
562
- ): string {
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
- `${options.queued ? "Oracle job queued" : "Oracle job dispatched"}: ${job.id}`,
565
- options.queued && options.queuePosition && options.queueDepth ? `Queue position: ${options.queuePosition} of ${options.queueDepth}` : undefined,
566
- job.followUpToJobId ? `Follow-up to: ${job.followUpToJobId}` : undefined,
567
- `Prompt: ${job.promptPath}`,
568
- `Archive: ${job.archivePath}`,
569
- formatAutoPrunedArchiveMessage(options.autoPrunedPrefixes),
570
- `Response will be written to: ${job.responsePath}`,
571
- options.queued ? "The job will start automatically when capacity is available." : undefined,
572
- "Stop now and wait for the oracle completion wake-up.",
573
- ]
574
- .filter(Boolean)
575
- .join("\n");
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
- await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
609
- await reconcileStaleOracleJobs();
610
- await pruneTerminalOracleJobs();
611
- });
612
- } catch (error) {
613
- if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
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
- const jobId = randomUUID();
617
- const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
618
- const runtime = allocateRuntime(config);
619
- let job: OracleJob | undefined;
620
- let archive: ArchiveCreationResult | undefined;
621
- let queued = false;
622
- let queuedSubmissionDurable = false;
623
- let runtimeLeaseAcquired = false;
624
- let conversationLeaseAcquired = false;
625
- let workerSpawned = false;
626
- 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;
627
994
 
628
- try {
629
- archive = await createArchive(ctx.cwd, params.files, tempArchivePath);
630
- const currentArchive = archive;
631
- await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
632
- await promoteQueuedJobsWithinAdmissionLock({ workerPath, source: "oracle_submit" });
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
- if (!runtimeAttempt.acquired) {
646
- const queuePressure = await getQueuedArchivePressure();
647
- const maxQueuedJobs = config.browser.maxConcurrentJobs * MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME;
648
- const maxQueuedArchiveBytes = config.browser.maxConcurrentJobs * MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME;
649
- const queueAdmissionFailure = getQueueAdmissionFailure({
650
- queuePressure,
651
- archiveBytes: currentArchive.archiveBytes,
652
- activeJobs: runtimeAttempt.liveLeases.length,
653
- maxActiveJobs: config.browser.maxConcurrentJobs,
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
- if (queueAdmissionFailure) {
658
- 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;
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: "queued", createdAt: admittedAt },
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
- queuedSubmissionDurable = true;
684
- return;
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
- const queuePosition = getQueuePosition(latest.id);
1102
+
1103
+ const queuePosition = queued ? getQueuePosition(job.id) : undefined;
768
1104
  return {
769
1105
  content: [
770
1106
  {
771
1107
  type: "text",
772
- text: formatSubmitResponse(latest, {
773
- autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
774
- queued: true,
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
- jobId: latest.id,
782
- queued: true,
783
- queuePosition: queuePosition?.position,
784
- queueDepth: queuePosition?.depth,
785
- archiveSha256: latest.archiveSha256,
786
- archiveBytes: archive?.archiveBytes,
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
- if (workerSpawned && latest && hasDurableWorkerHandoff(latest)) {
795
- if (ctx.hasUI) refreshOracleStatus(ctx);
796
- return {
797
- content: [
798
- {
799
- type: "text",
800
- text: formatSubmitResponse(latest, {
801
- autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
802
- 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,
803
1149
  }),
804
1150
  },
805
- ],
806
- details: {
807
- jobId: latest.id,
808
- queued: false,
809
- 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,
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
- if (spawnedWorker) {
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
- if (ctx.hasUI) refreshOracleStatus(ctx);
842
- throw error;
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 response = await import("node:fs/promises").then((fs) => fs.readFile(current.responsePath || "", "utf8"));
871
- responsePreview = response.slice(0, 4000);
872
- } catch {
873
- responsePreview = "(response not available yet)";
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
- return {
877
- content: [
878
- {
879
- type: "text",
880
- text: [
881
- `job: ${current.id}`,
882
- `status: ${current.status}`,
883
- current.queuedAt ? `queued: ${current.queuedAt}` : undefined,
884
- current.submittedAt ? `submitted: ${current.submittedAt}` : undefined,
885
- ...(current.status === "queued"
886
- ? (() => {
887
- const queuePosition = getQueuePosition(current.id);
888
- return queuePosition ? [`queue-position: ${queuePosition.position} of ${queuePosition.depth}`] : [];
889
- })()
890
- : []),
891
- current.followUpToJobId ? `follow-up-to: ${current.followUpToJobId}` : undefined,
892
- current.chatUrl ? `chat: ${current.chatUrl}` : undefined,
893
- current.responsePath ? `response: ${current.responsePath}` : undefined,
894
- current.responseFormat ? `response-format: ${current.responseFormat}` : undefined,
895
- `artifacts: ${getJobDir(current.id)}/artifacts`,
896
- current.error ? `error: ${current.error}` : undefined,
897
- "",
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
- .filter(Boolean)
901
- .join("\n"),
1264
+ responseAvailable,
1265
+ }),
902
1266
  },
903
- ],
904
- details: { job: redactJobDetails(current) },
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
- const job = readJob(params.jobId);
916
- if (!job || job.projectId !== getProjectId(ctx.cwd)) {
917
- throw new Error(`Oracle job not found in this project: ${params.jobId}`);
918
- }
919
- 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);
920
1297
  return {
921
- content: [{ type: "text", text: `Oracle job ${job.id} is not cancellable (${job.status}).` }],
922
- 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) }) },
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
  }