pi-oracle 0.1.12 → 0.2.1

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