pi-oracle 0.1.12 → 0.2.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.
@@ -2,44 +2,58 @@ 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
+ isOpenOracleJob,
17
+ isTerminalOracleJob,
18
+ listOracleJobDirs,
19
+ markWakeupSettled,
15
20
  readJob,
16
21
  pruneTerminalOracleJobs,
17
22
  reconcileStaleOracleJobs,
18
23
  resolveArchiveInputs,
19
24
  sha256File,
25
+ shouldAdvanceQueueAfterCancellation,
20
26
  spawnWorker,
27
+ terminateWorkerPid,
21
28
  updateJob,
22
29
  withJobPhase,
30
+ type OracleJob,
23
31
  } from "./jobs.js";
32
+ import { getQueuePosition, promoteQueuedJobs, promoteQueuedJobsWithinAdmissionLock } from "./queue.js";
24
33
  import { refreshOracleStatus } from "./poller.js";
25
34
  import {
26
- acquireConversationLease,
27
- acquireRuntimeLease,
28
35
  allocateRuntime,
29
36
  cleanupRuntimeArtifacts,
30
37
  getProjectId,
31
38
  getSessionId,
32
39
  parseConversationId,
40
+ requirePersistedSessionFile,
41
+ tryAcquireConversationLease,
42
+ tryAcquireRuntimeLease,
33
43
  } from "./runtime.js";
34
44
 
45
+ function stringEnum(values: readonly string[], description: string) {
46
+ return Type.Union(values.map((value) => Type.Literal(value)), { description });
47
+ }
48
+
35
49
  const ORACLE_SUBMIT_PARAMS = Type.Object({
36
50
  prompt: Type.String({ description: "Prompt text to send to ChatGPT web." }),
37
51
  files: Type.Array(Type.String({ description: "Project-relative file or directory path to include in the archive." }), {
38
52
  description: "Exact project-relative files/directories to include in the oracle archive.",
39
53
  minItems: 1,
40
54
  }),
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." })),
55
+ modelFamily: Type.Optional(stringEnum(MODEL_FAMILIES, "ChatGPT model family: instant, thinking, or pro.")),
56
+ effort: Type.Optional(stringEnum(EFFORTS, "Reasoning effort. Use only values supported by the chosen model family.")),
43
57
  autoSwitchToThinking: Type.Optional(
44
58
  Type.Boolean({ description: "Only valid when modelFamily is instant. Omit for thinking and pro." }),
45
59
  ),
@@ -61,6 +75,8 @@ const VALID_EFFORTS: Record<OracleModelFamily, readonly OracleEffort[]> = {
61
75
  };
62
76
 
63
77
  const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
78
+ const MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME = 1;
79
+ const MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME = MAX_ARCHIVE_BYTES;
64
80
 
65
81
  const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
66
82
  ".git",
@@ -420,6 +436,27 @@ async function createArchive(cwd: string, files: string[], archivePath: string):
420
436
  return createArchiveForTesting(cwd, files, archivePath);
421
437
  }
422
438
 
439
+ async function getQueuedArchivePressure(): Promise<{ queuedJobs: number; queuedArchiveBytes: number }> {
440
+ const queuedJobs = listOracleJobDirs()
441
+ .map((dir) => readJob(dir))
442
+ .filter((job): job is NonNullable<typeof job> => Boolean(job && job.status === "queued"));
443
+
444
+ const queuedArchiveBytes = (await Promise.all(
445
+ queuedJobs.map(async (job) => {
446
+ try {
447
+ return (await stat(job.archivePath)).size;
448
+ } catch {
449
+ return 0;
450
+ }
451
+ }),
452
+ )).reduce((sum, bytes) => sum + bytes, 0);
453
+
454
+ return {
455
+ queuedJobs: queuedJobs.length,
456
+ queuedArchiveBytes,
457
+ };
458
+ }
459
+
423
460
  function validateSubmissionOptions(
424
461
  params: { effort?: OracleEffort; autoSwitchToThinking?: boolean },
425
462
  modelFamily: OracleModelFamily,
@@ -477,6 +514,7 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
477
514
  projectId: job.projectId,
478
515
  sessionId: job.sessionId,
479
516
  createdAt: job.createdAt,
517
+ queuedAt: job.queuedAt,
480
518
  submittedAt: job.submittedAt,
481
519
  completedAt: job.completedAt,
482
520
  followUpToJobId: job.followUpToJobId,
@@ -495,6 +533,35 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
495
533
  };
496
534
  }
497
535
 
536
+ function formatAutoPrunedArchiveMessage(autoPrunedPrefixes: ArchiveCreationResult["autoPrunedPrefixes"]): string | undefined {
537
+ if (autoPrunedPrefixes.length === 0) return undefined;
538
+ return `Archive auto-pruned generic generated-output-name dirs to fit size limit: ${autoPrunedPrefixes.map((entry) => `${entry.relativePath}/ (${formatBytes(entry.bytes)})`).join(", ")}`;
539
+ }
540
+
541
+ function formatSubmitResponse(
542
+ job: NonNullable<ReturnType<typeof readJob>>,
543
+ options: {
544
+ autoPrunedPrefixes: ArchiveCreationResult["autoPrunedPrefixes"];
545
+ queued: boolean;
546
+ queuePosition?: number;
547
+ queueDepth?: number;
548
+ },
549
+ ): string {
550
+ return [
551
+ `${options.queued ? "Oracle job queued" : "Oracle job dispatched"}: ${job.id}`,
552
+ options.queued && options.queuePosition && options.queueDepth ? `Queue position: ${options.queuePosition} of ${options.queueDepth}` : undefined,
553
+ job.followUpToJobId ? `Follow-up to: ${job.followUpToJobId}` : undefined,
554
+ `Prompt: ${job.promptPath}`,
555
+ `Archive: ${job.archivePath}`,
556
+ formatAutoPrunedArchiveMessage(options.autoPrunedPrefixes),
557
+ `Response will be written to: ${job.responsePath}`,
558
+ options.queued ? "The job will start automatically when capacity is available." : undefined,
559
+ "Stop now and wait for the oracle completion wake-up.",
560
+ ]
561
+ .filter(Boolean)
562
+ .join("\n");
563
+ }
564
+
498
565
  export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void {
499
566
  pi.registerTool({
500
567
  name: "oracle_submit",
@@ -510,23 +577,26 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
510
577
  "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
578
  "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
579
  "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.",
580
+ "If oracle_submit returns a queued job instead of an immediately dispatched one, treat that as success and stop exactly the same way.",
513
581
  "Stop after dispatching oracle_submit; do not continue the task while the oracle job is running.",
514
582
  "Only use autoSwitchToThinking with modelFamily=instant.",
515
583
  ],
516
584
  parameters: ORACLE_SUBMIT_PARAMS,
517
585
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
518
586
  const config = loadOracleConfig(ctx.cwd);
519
- const originSessionFile = getSessionFile(ctx);
587
+ const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
520
588
  const projectId = getProjectId(ctx.cwd);
521
589
  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;
590
+ const submittedModelFamily = params.modelFamily as OracleModelFamily | undefined;
591
+ const submittedEffort = params.effort as OracleEffort | undefined;
592
+ const modelFamily: OracleModelFamily = submittedModelFamily ?? config.defaults.modelFamily;
593
+ const requestedEffort: OracleEffort = submittedEffort ?? config.defaults.effort;
594
+ const effort: OracleEffort | undefined = modelFamily === "instant" ? undefined : requestedEffort;
525
595
  const rawAutoSwitchToThinking = params.autoSwitchToThinking ?? config.defaults.autoSwitchToThinking;
526
596
  const autoSwitchToThinking = modelFamily === "instant" ? rawAutoSwitchToThinking : false;
527
597
  const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
528
598
 
529
- validateSubmissionOptions(params, modelFamily, effort, autoSwitchToThinking);
599
+ validateSubmissionOptions({ effort: submittedEffort, autoSwitchToThinking: params.autoSwitchToThinking }, modelFamily, effort, autoSwitchToThinking);
530
600
  try {
531
601
  await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
532
602
  await reconcileStaleOracleJobs();
@@ -539,29 +609,95 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
539
609
  const jobId = randomUUID();
540
610
  const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
541
611
  const runtime = allocateRuntime(config);
542
- let job;
612
+ let job: OracleJob | undefined;
613
+ let archive: ArchiveCreationResult | undefined;
614
+ let queued = false;
615
+ let queuedSubmissionDurable = false;
616
+ let runtimeLeaseAcquired = false;
617
+ let conversationLeaseAcquired = false;
618
+ let workerSpawned = false;
619
+ let spawnedWorker: Awaited<ReturnType<typeof spawnWorker>> | undefined;
543
620
 
544
621
  try {
545
- const archive = await createArchive(ctx.cwd, params.files, tempArchivePath);
622
+ archive = await createArchive(ctx.cwd, params.files, tempArchivePath);
623
+ const currentArchive = archive;
546
624
  await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
547
- await acquireRuntimeLease(config, {
625
+ await promoteQueuedJobsWithinAdmissionLock({ workerPath, source: "oracle_submit" });
626
+
627
+ const admittedAt = new Date().toISOString();
628
+ const runtimeAttempt = await tryAcquireRuntimeLease(config, {
548
629
  jobId,
549
630
  runtimeId: runtime.runtimeId,
550
631
  runtimeSessionName: runtime.runtimeSessionName,
551
632
  runtimeProfileDir: runtime.runtimeProfileDir,
552
633
  projectId,
553
634
  sessionId,
554
- createdAt: new Date().toISOString(),
635
+ createdAt: admittedAt,
555
636
  });
637
+
638
+ if (!runtimeAttempt.acquired) {
639
+ const queuePressure = await getQueuedArchivePressure();
640
+ const maxQueuedJobs = config.browser.maxConcurrentJobs * MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME;
641
+ if (queuePressure.queuedJobs >= maxQueuedJobs) {
642
+ throw new Error(
643
+ `Oracle is busy (${runtimeAttempt.liveLeases.length}/${config.browser.maxConcurrentJobs} active, ${queuePressure.queuedJobs}/${maxQueuedJobs} queued). ` +
644
+ "Retry later instead of enqueuing more archive state.",
645
+ );
646
+ }
647
+ const maxQueuedArchiveBytes = config.browser.maxConcurrentJobs * MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME;
648
+ if (queuePressure.queuedArchiveBytes + currentArchive.archiveBytes > maxQueuedArchiveBytes) {
649
+ throw new Error(
650
+ `Oracle queued archive storage is full (${queuePressure.queuedArchiveBytes + currentArchive.archiveBytes} bytes > ${maxQueuedArchiveBytes} bytes across queued jobs). ` +
651
+ "Retry later or narrow the archive inputs.",
652
+ );
653
+ }
654
+
655
+ queued = true;
656
+ job = await createJob(
657
+ jobId,
658
+ {
659
+ prompt: params.prompt,
660
+ files: params.files,
661
+ modelFamily,
662
+ effort,
663
+ autoSwitchToThinking,
664
+ followUpToJobId: followUp.followUpToJobId,
665
+ chatUrl: followUp.chatUrl,
666
+ requestSource: "tool",
667
+ },
668
+ ctx.cwd,
669
+ originSessionFile,
670
+ config,
671
+ runtime,
672
+ { initialState: "queued", createdAt: admittedAt },
673
+ );
674
+ await rename(tempArchivePath, job.archivePath);
675
+ job = await updateJob(job.id, (current) => ({
676
+ ...current,
677
+ archiveSha256: currentArchive.sha256,
678
+ }));
679
+ queuedSubmissionDurable = true;
680
+ return;
681
+ }
682
+
683
+ runtimeLeaseAcquired = true;
556
684
  if (followUp.conversationId) {
557
- await acquireConversationLease({
685
+ const conversationAttempt = await tryAcquireConversationLease({
558
686
  jobId,
559
687
  conversationId: followUp.conversationId,
560
688
  projectId,
561
689
  sessionId,
562
- createdAt: new Date().toISOString(),
690
+ createdAt: admittedAt,
563
691
  });
692
+ if (!conversationAttempt.acquired) {
693
+ throw new Error(
694
+ `Oracle conversation ${followUp.conversationId} is already in use by job ${conversationAttempt.blocker?.jobId ?? "unknown"}. ` +
695
+ "Concurrent follow-ups to the same ChatGPT thread are not allowed.",
696
+ );
697
+ }
698
+ conversationLeaseAcquired = true;
564
699
  }
700
+
565
701
  job = await createJob(
566
702
  jobId,
567
703
  {
@@ -578,51 +714,109 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
578
714
  originSessionFile,
579
715
  config,
580
716
  runtime,
717
+ { initialState: "submitted", createdAt: admittedAt },
581
718
  );
719
+ await rename(tempArchivePath, job.archivePath);
720
+ spawnedWorker = await spawnWorker(workerPath, job.id);
721
+ workerSpawned = true;
722
+ const worker = spawnedWorker;
723
+ job = await updateJob(job.id, (current) => ({
724
+ ...current,
725
+ archiveSha256: currentArchive.sha256,
726
+ workerPid: worker.pid,
727
+ workerNonce: worker.nonce,
728
+ workerStartedAt: worker.startedAt,
729
+ }));
582
730
  });
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
- }));
731
+ if (!job || !archive) throw new Error(`Oracle submission ${jobId} did not persist job metadata durably`);
592
732
  if (ctx.hasUI) refreshOracleStatus(ctx);
593
733
 
734
+ const queuePosition = queued ? getQueuePosition(job.id) : undefined;
594
735
  return {
595
736
  content: [
596
737
  {
597
738
  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"),
739
+ text: formatSubmitResponse(job, {
740
+ autoPrunedPrefixes: currentArchive.autoPrunedPrefixes,
741
+ queued,
742
+ queuePosition: queuePosition?.position,
743
+ queueDepth: queuePosition?.depth,
744
+ }),
611
745
  },
612
746
  ],
613
747
  details: {
614
748
  jobId: job.id,
615
- archiveSha256: archive.sha256,
616
- archiveBytes: archive.archiveBytes,
617
- initialArchiveBytes: archive.initialArchiveBytes,
618
- autoPrunedArchivePaths: archive.autoPrunedPrefixes,
749
+ queued,
750
+ queuePosition: queuePosition?.position,
751
+ queueDepth: queuePosition?.depth,
752
+ archiveSha256: currentArchive.sha256,
753
+ archiveBytes: currentArchive.archiveBytes,
754
+ initialArchiveBytes: currentArchive.initialArchiveBytes,
755
+ autoPrunedArchivePaths: currentArchive.autoPrunedPrefixes,
619
756
  runtimeId: job.runtimeId,
620
757
  followUpToJobId: followUp.followUpToJobId,
621
758
  },
622
759
  };
623
760
  } catch (error) {
624
761
  const message = error instanceof Error ? error.message : String(error);
625
- if (job) {
762
+ const latest = job ? readJob(job.id) : undefined;
763
+ if (latest?.status === "queued" && queuedSubmissionDurable) {
764
+ if (ctx.hasUI) refreshOracleStatus(ctx);
765
+ const queuePosition = getQueuePosition(latest.id);
766
+ return {
767
+ content: [
768
+ {
769
+ type: "text",
770
+ text: formatSubmitResponse(latest, {
771
+ autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
772
+ queued: true,
773
+ queuePosition: queuePosition?.position,
774
+ queueDepth: queuePosition?.depth,
775
+ }),
776
+ },
777
+ ],
778
+ details: {
779
+ jobId: latest.id,
780
+ queued: true,
781
+ queuePosition: queuePosition?.position,
782
+ queueDepth: queuePosition?.depth,
783
+ archiveSha256: latest.archiveSha256,
784
+ archiveBytes: archive?.archiveBytes,
785
+ initialArchiveBytes: archive?.initialArchiveBytes,
786
+ autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
787
+ runtimeId: latest.runtimeId,
788
+ followUpToJobId: latest.followUpToJobId,
789
+ },
790
+ };
791
+ }
792
+ if (workerSpawned && latest && hasDurableWorkerHandoff(latest)) {
793
+ if (ctx.hasUI) refreshOracleStatus(ctx);
794
+ return {
795
+ content: [
796
+ {
797
+ type: "text",
798
+ text: formatSubmitResponse(latest, {
799
+ autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
800
+ queued: false,
801
+ }),
802
+ },
803
+ ],
804
+ details: {
805
+ jobId: latest.id,
806
+ queued: false,
807
+ archiveSha256: latest.archiveSha256,
808
+ archiveBytes: archive?.archiveBytes,
809
+ initialArchiveBytes: archive?.initialArchiveBytes,
810
+ autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
811
+ runtimeId: latest.runtimeId,
812
+ followUpToJobId: latest.followUpToJobId,
813
+ },
814
+ };
815
+ }
816
+ if (spawnedWorker) {
817
+ await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.startedAt).catch(() => undefined);
818
+ }
819
+ if (job && (!latest || !isTerminalOracleJob(latest))) {
626
820
  const failedAt = new Date().toISOString();
627
821
  await updateJob(job.id, (current) => ({
628
822
  ...current,
@@ -633,12 +827,15 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
633
827
  }, failedAt),
634
828
  })).catch(() => undefined);
635
829
  }
636
- await cleanupRuntimeArtifacts({
637
- runtimeId: runtime.runtimeId,
638
- runtimeProfileDir: runtime.runtimeProfileDir,
639
- runtimeSessionName: runtime.runtimeSessionName,
640
- conversationId: followUp.conversationId,
641
- }).catch(() => undefined);
830
+ const cleanupReport = await cleanupRuntimeArtifacts({
831
+ runtimeId: runtimeLeaseAcquired ? runtime.runtimeId : undefined,
832
+ runtimeProfileDir: runtimeLeaseAcquired ? runtime.runtimeProfileDir : undefined,
833
+ runtimeSessionName: workerSpawned ? runtime.runtimeSessionName : undefined,
834
+ conversationId: conversationLeaseAcquired ? followUp.conversationId : undefined,
835
+ }).catch(() => ({ attempted: [], warnings: [] }));
836
+ if (job && cleanupReport.warnings.length > 0) {
837
+ await appendCleanupWarnings(job.id, cleanupReport.warnings).catch(() => undefined);
838
+ }
642
839
  if (ctx.hasUI) refreshOracleStatus(ctx);
643
840
  throw error;
644
841
  } finally {
@@ -657,10 +854,12 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
657
854
  if (!job || job.projectId !== getProjectId(ctx.cwd)) {
658
855
  throw new Error(`Oracle job not found in this project: ${params.jobId}`);
659
856
  }
857
+ const latest = isTerminalOracleJob(job) ? await markWakeupSettled(job.id) : job;
858
+ const current = latest ?? readJob(job.id) ?? job;
660
859
 
661
860
  let responsePreview = "";
662
861
  try {
663
- const response = await import("node:fs/promises").then((fs) => fs.readFile(job.responsePath || "", "utf8"));
862
+ const response = await import("node:fs/promises").then((fs) => fs.readFile(current.responsePath || "", "utf8"));
664
863
  responsePreview = response.slice(0, 4000);
665
864
  } catch {
666
865
  responsePreview = "(response not available yet)";
@@ -671,14 +870,22 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
671
870
  {
672
871
  type: "text",
673
872
  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,
873
+ `job: ${current.id}`,
874
+ `status: ${current.status}`,
875
+ current.queuedAt ? `queued: ${current.queuedAt}` : undefined,
876
+ current.submittedAt ? `submitted: ${current.submittedAt}` : undefined,
877
+ ...(current.status === "queued"
878
+ ? (() => {
879
+ const queuePosition = getQueuePosition(current.id);
880
+ return queuePosition ? [`queue-position: ${queuePosition.position} of ${queuePosition.depth}`] : [];
881
+ })()
882
+ : []),
883
+ current.followUpToJobId ? `follow-up-to: ${current.followUpToJobId}` : undefined,
884
+ current.chatUrl ? `chat: ${current.chatUrl}` : undefined,
885
+ current.responsePath ? `response: ${current.responsePath}` : undefined,
886
+ current.responseFormat ? `response-format: ${current.responseFormat}` : undefined,
887
+ `artifacts: ${getJobDir(current.id)}/artifacts`,
888
+ current.error ? `error: ${current.error}` : undefined,
682
889
  "",
683
890
  responsePreview,
684
891
  ]
@@ -686,7 +893,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
686
893
  .join("\n"),
687
894
  },
688
895
  ],
689
- details: { job: redactJobDetails(job) },
896
+ details: { job: redactJobDetails(current) },
690
897
  };
691
898
  },
692
899
  });
@@ -694,24 +901,27 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
694
901
  pi.registerTool({
695
902
  name: "oracle_cancel",
696
903
  label: "Oracle Cancel",
697
- description: "Cancel an active oracle job.",
904
+ description: "Cancel a queued or active oracle job.",
698
905
  parameters: ORACLE_CANCEL_PARAMS,
699
906
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
700
907
  const job = readJob(params.jobId);
701
908
  if (!job || job.projectId !== getProjectId(ctx.cwd)) {
702
909
  throw new Error(`Oracle job not found in this project: ${params.jobId}`);
703
910
  }
704
- if (!isActiveOracleJob(job)) {
911
+ if (!isOpenOracleJob(job)) {
705
912
  return {
706
- content: [{ type: "text", text: `Oracle job ${job.id} is not active (${job.status}).` }],
913
+ content: [{ type: "text", text: `Oracle job ${job.id} is not cancellable (${job.status}).` }],
707
914
  details: { job: redactJobDetails(job) },
708
915
  };
709
916
  }
710
917
 
711
918
  const cancelled = await cancelOracleJob(params.jobId);
919
+ if (shouldAdvanceQueueAfterCancellation(cancelled)) {
920
+ await promoteQueuedJobs({ workerPath, source: "oracle_cancel_tool" });
921
+ }
712
922
  if (ctx.hasUI) refreshOracleStatus(ctx);
713
923
  return {
714
- content: [{ type: "text", text: `Cancelled oracle job ${cancelled.id}.` }],
924
+ content: [{ type: "text", text: cancelled.status === "cancelled" || cancelled.status === "failed" ? `Cancelled oracle job ${cancelled.id}.` : `Oracle job ${cancelled.id} was already ${cancelled.status}.` }],
715
925
  details: { job: redactJobDetails(cancelled) },
716
926
  };
717
927
  },
@@ -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[];
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { withLock } from "./state-locks.mjs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { appendFile, chmod, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
@@ -35,7 +35,6 @@ const SCREENSHOT_PATH = "/tmp/oracle-auth.png";
35
35
  const REAL_CHROME_USER_DATA_DIR = resolve(homedir(), "Library", "Application Support", "Google", "Chrome");
36
36
  const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
37
37
  const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
38
- const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
39
38
  const STALE_STAGING_PROFILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
40
39
  const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
41
40
  (candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
@@ -51,75 +50,6 @@ function sleep(ms) {
51
50
  return new Promise((resolve) => setTimeout(resolve, ms));
52
51
  }
53
52
 
54
- function leaseKey(kind, key) {
55
- return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
56
- }
57
-
58
- async function readLockProcessPid(path) {
59
- const metadataPath = join(path, "metadata.json");
60
- if (!existsSync(metadataPath)) return undefined;
61
- try {
62
- const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
63
- return typeof metadata?.processPid === "number" && Number.isInteger(metadata.processPid) && metadata.processPid > 0
64
- ? metadata.processPid
65
- : undefined;
66
- } catch {
67
- return undefined;
68
- }
69
- }
70
-
71
- function isProcessAlive(pid) {
72
- try {
73
- process.kill(pid, 0);
74
- return true;
75
- } catch (error) {
76
- if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
77
- return true;
78
- }
79
- }
80
-
81
- async function maybeReclaimStaleLock(path) {
82
- const processPid = await readLockProcessPid(path);
83
- if (!processPid || isProcessAlive(processPid)) return false;
84
- await rm(path, { recursive: true, force: true }).catch(() => undefined);
85
- return true;
86
- }
87
-
88
- async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
89
- const path = join(LOCKS_DIR, leaseKey(kind, key));
90
- const deadline = Date.now() + timeoutMs;
91
- await mkdir(ORACLE_STATE_DIR, { recursive: true, mode: 0o700 });
92
- await mkdir(LOCKS_DIR, { recursive: true, mode: 0o700 });
93
-
94
- while (Date.now() < deadline) {
95
- try {
96
- await mkdir(path, { recursive: false, mode: 0o700 });
97
- await writeFile(join(path, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
98
- return path;
99
- } catch (error) {
100
- if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error;
101
- if (await maybeReclaimStaleLock(path)) continue;
102
- }
103
- await sleep(200);
104
- }
105
-
106
- throw new Error(`Timed out waiting for oracle ${kind} lock: ${key}`);
107
- }
108
-
109
- async function releaseLock(path) {
110
- if (!path) return;
111
- await rm(path, { recursive: true, force: true }).catch(() => undefined);
112
- }
113
-
114
- async function withLock(kind, key, metadata, fn, timeoutMs) {
115
- const handle = await acquireLock(kind, key, metadata, timeoutMs);
116
- try {
117
- return await fn();
118
- } finally {
119
- await releaseLock(handle);
120
- }
121
- }
122
-
123
53
  async function initLog() {
124
54
  await writeFile(LOG_PATH, "", { mode: 0o600 });
125
55
  await chmod(LOG_PATH, 0o600).catch(() => undefined);
@@ -850,7 +780,7 @@ async function waitForImportedAuthReady() {
850
780
 
851
781
  async function run() {
852
782
  await initLog();
853
- await withLock("auth", "global", { processPid: process.pid, action: "oracle-auth" }, async () => {
783
+ await withLock(ORACLE_STATE_DIR, "auth", "global", { processPid: process.pid, action: "oracle-auth" }, async () => {
854
784
  let shouldPreserveBrowser = false;
855
785
  let committedProfile = false;
856
786
  let profilePlan;