pi-oracle 0.2.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 - 2026-04-07
4
+
5
+ ### Fixed
6
+ - wake-up guidance now tells receivers to use `oracle_read(jobId)` as the canonical way to consume completion results
7
+ - manual inspection before the first wake-up no longer suppresses the initial reminder attempt
8
+ - wake-up settlement now records provenance so suppressed/settled delivery can be explained in postmortems
9
+ - queued archive cleanup retries now retry queued archive deletion and keep warnings until cleanup succeeds
10
+ - queued archive byte-pressure accounting now includes retained pre-submit archives instead of only currently queued jobs
11
+
3
12
  ## 0.2.0 - 2026-04-06
4
13
 
5
14
  ### Added
package/README.md CHANGED
@@ -136,7 +136,9 @@ Detailed design and maintainer docs:
136
136
  Completion notification semantics:
137
137
  - oracle responses and artifacts are always persisted durably in oracle job state under the configured oracle jobs dir (`${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/` by default)
138
138
  - completion delivery into pi sessions is best-effort wake-up based; the extension no longer appends synthetic assistant completion messages into session history
139
- - manual `oracle_read` or `/oracle-status` inspection settles further reminder retries once the terminal job has been opened
139
+ - wake-up content tells the receiving assistant to call `oracle_read(jobId)` as the canonical completion-consumption path, with saved file paths included as secondary context
140
+ - manual `oracle_read` or `/oracle-status` inspection settles further reminder retries once the terminal job has been opened, and now persists settlement provenance for postmortems
141
+ - manual inspection before the first wake-up attempt is recorded as observation only and does not suppress the first reminder send
140
142
  - if a wake-up does not land, the job remains available via its saved response/artifacts and status commands
141
143
 
142
144
  ## Privacy / local data
@@ -336,6 +336,14 @@ Important fields include:
336
336
  - `wakeupAttemptCount`
337
337
  - `wakeupLastRequestedAt`
338
338
  - `wakeupSettledAt`
339
+ - `wakeupSettledSource`
340
+ - `wakeupSettledSessionFile`
341
+ - `wakeupSettledSessionKey`
342
+ - `wakeupSettledBeforeFirstAttempt`
343
+ - `wakeupObservedAt`
344
+ - `wakeupObservedSource`
345
+ - `wakeupObservedSessionFile`
346
+ - `wakeupObservedSessionKey`
339
347
  - `notifyClaimedAt`
340
348
  - `notifyClaimedBy`
341
349
  - `artifactFailureCount`
@@ -461,7 +469,9 @@ The extension still uses the same general `pi`-native background completion patt
461
469
  - poller scans jobs on an interval
462
470
  - completed job durability lives in oracle job state plus saved response/artifact files, not in synthetic session-history assistant messages
463
471
  - when a matching job reaches `complete`, `failed`, or `cancelled`, the poller issues bounded best-effort wake-up reminders to whichever matching session is currently live
464
- - manual `oracle_read` or `/oracle-status` inspection settles further reminder retries once the terminal job has been opened
472
+ - those wake-ups direct the receiver to `oracle_read(jobId)` as the canonical completion-consumption path, while still surfacing saved response/artifact paths as secondary context
473
+ - manual `oracle_read` or `/oracle-status` inspection settles further reminder retries once the terminal job has been opened and persists provenance about which path/session settled the wake-up
474
+ - manual inspection before the first wake-up attempt is recorded separately as observation metadata and does not suppress the first reminder send
465
475
  - if no wake-up lands, the job remains available via `/oracle-status`, `oracle_read`, and the saved `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/` response/artifact files
466
476
  - because completion delivery is best-effort, pruning uses explicit terminal-job age policy instead of pretending a durable session notification happened
467
477
  - recently sent wake-ups keep response/artifact files retained briefly so follow-up turns do not point at deleted paths if cleanup or pruning races with delivery
@@ -121,7 +121,11 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string,
121
121
  return;
122
122
  }
123
123
  if (isTerminalOracleJob(job)) {
124
- await markWakeupSettled(job.id);
124
+ await markWakeupSettled(job.id, {
125
+ source: "oracle_status",
126
+ sessionFile: ctx.sessionManager.getSessionFile?.(),
127
+ cwd: ctx.cwd,
128
+ });
125
129
  }
126
130
  ctx.ui.notify(summarizeJob(job.id), "info");
127
131
  },
@@ -25,6 +25,8 @@ export type OracleJobPhase =
25
25
  | "failed"
26
26
  | "cancelled";
27
27
 
28
+ export type OracleWakeupSettlementSource = "oracle_read" | "oracle_status";
29
+
28
30
  export const ACTIVE_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["preparing", "submitted", "waiting"];
29
31
  export const OPEN_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["queued", ...ACTIVE_ORACLE_JOB_STATUSES];
30
32
  export const TERMINAL_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["complete", "failed", "cancelled"];
@@ -56,6 +58,10 @@ export function shouldAdvanceQueueAfterCancellation(job: Pick<OracleJob, "status
56
58
  return job.status === "cancelled" && !job.cleanupPending && !job.cleanupWarnings?.length;
57
59
  }
58
60
 
61
+ export function hasRetainedPreSubmitArchive(job: Pick<OracleJob, "submittedAt" | "archiveDeletedAfterUpload" | "archivePath">): boolean {
62
+ return !job.submittedAt && !job.archiveDeletedAfterUpload && typeof job.archivePath === "string" && job.archivePath.length > 0;
63
+ }
64
+
59
65
  export function hasDurableWorkerHandoff(
60
66
  job: Pick<OracleJob, "status" | "phase" | "workerPid" | "workerStartedAt" | "heartbeatAt">,
61
67
  ): boolean {
@@ -147,6 +153,14 @@ export interface OracleJob {
147
153
  wakeupAttemptCount?: number;
148
154
  wakeupLastRequestedAt?: string;
149
155
  wakeupSettledAt?: string;
156
+ wakeupSettledSource?: OracleWakeupSettlementSource;
157
+ wakeupSettledSessionFile?: string;
158
+ wakeupSettledSessionKey?: string;
159
+ wakeupSettledBeforeFirstAttempt?: boolean;
160
+ wakeupObservedAt?: string;
161
+ wakeupObservedSource?: OracleWakeupSettlementSource;
162
+ wakeupObservedSessionFile?: string;
163
+ wakeupObservedSessionKey?: string;
150
164
  notifyClaimedAt?: string;
151
165
  notifyClaimedBy?: string;
152
166
  artifactFailureCount?: number;
@@ -443,17 +457,32 @@ function getTerminalCleanupStaleReason(job: Pick<OracleJob, "status" | "cleanupP
443
457
  }
444
458
 
445
459
  export async function cleanupJobResources(
446
- job: Pick<OracleJob, "submittedAt" | "runtimeId" | "runtimeProfileDir" | "runtimeSessionName" | "conversationId">,
460
+ job: Pick<OracleJob, "submittedAt" | "runtimeId" | "runtimeProfileDir" | "runtimeSessionName" | "conversationId" | "archivePath" | "archiveDeletedAfterUpload">,
447
461
  ): Promise<OracleCleanupReport> {
462
+ const report: OracleCleanupReport = { attempted: [], warnings: [] };
463
+
464
+ if (hasRetainedPreSubmitArchive(job)) {
465
+ report.attempted.push("queuedArchive");
466
+ await rm(job.archivePath, { force: true }).catch((error: Error) => {
467
+ report.warnings.push(`Failed to remove queued archive ${job.archivePath}: ${error.message}`);
468
+ });
469
+ }
470
+
448
471
  if (!job.submittedAt) {
449
- return { attempted: [], warnings: [] };
472
+ return report;
450
473
  }
451
- return cleanupRuntimeArtifacts({
474
+
475
+ const runtimeReport = await cleanupRuntimeArtifacts({
452
476
  runtimeId: job.runtimeId,
453
477
  runtimeProfileDir: job.runtimeProfileDir,
454
478
  runtimeSessionName: job.runtimeSessionName,
455
479
  conversationId: job.conversationId,
456
480
  });
481
+
482
+ return {
483
+ attempted: [...report.attempted, ...runtimeReport.attempted],
484
+ warnings: [...report.warnings, ...runtimeReport.warnings],
485
+ };
457
486
  }
458
487
 
459
488
  function getCleanupRetentionMs(job: OracleJob): { complete: number; failed: number } {
@@ -774,12 +803,57 @@ export async function noteWakeupRequested(jobId: string, at = new Date().toISOSt
774
803
  }
775
804
  }
776
805
 
777
- export async function markWakeupSettled(jobId: string, at = new Date().toISOString()): Promise<OracleJob | undefined> {
806
+ function getWakeupSessionKey(sessionFile: string | undefined, cwd: string | undefined): string | undefined {
807
+ if (!sessionFile || !cwd) return undefined;
808
+ const projectId = getProjectId(cwd);
809
+ return `${projectId}::${getSessionId(sessionFile, projectId)}`;
810
+ }
811
+
812
+ export async function markWakeupSettled(
813
+ jobId: string,
814
+ options: {
815
+ source: OracleWakeupSettlementSource;
816
+ sessionFile?: string;
817
+ cwd?: string;
818
+ at?: string;
819
+ allowBeforeFirstAttempt?: boolean;
820
+ },
821
+ ): Promise<OracleJob | undefined> {
822
+ const at = options.at ?? new Date().toISOString();
823
+ const sessionKey = getWakeupSessionKey(options.sessionFile, options.cwd);
824
+
778
825
  try {
779
- return await updateJob(jobId, (job) => ({
780
- ...job,
781
- wakeupSettledAt: job.wakeupSettledAt ?? at,
782
- }));
826
+ return await updateJob(jobId, (job) => {
827
+ const beforeFirstAttempt = !job.wakeupLastRequestedAt && (job.wakeupAttemptCount ?? 0) === 0;
828
+ if (job.wakeupSettledAt) {
829
+ return {
830
+ ...job,
831
+ wakeupSettledSource: job.wakeupSettledSource ?? options.source,
832
+ wakeupSettledSessionFile: job.wakeupSettledSessionFile ?? options.sessionFile,
833
+ wakeupSettledSessionKey: job.wakeupSettledSessionKey ?? sessionKey,
834
+ wakeupSettledBeforeFirstAttempt: job.wakeupSettledBeforeFirstAttempt ?? beforeFirstAttempt,
835
+ };
836
+ }
837
+
838
+ if (beforeFirstAttempt && !options.allowBeforeFirstAttempt) {
839
+ return {
840
+ ...job,
841
+ wakeupObservedAt: job.wakeupObservedAt ?? at,
842
+ wakeupObservedSource: job.wakeupObservedSource ?? options.source,
843
+ wakeupObservedSessionFile: job.wakeupObservedSessionFile ?? options.sessionFile,
844
+ wakeupObservedSessionKey: job.wakeupObservedSessionKey ?? sessionKey,
845
+ };
846
+ }
847
+
848
+ return {
849
+ ...job,
850
+ wakeupSettledAt: at,
851
+ wakeupSettledSource: options.source,
852
+ wakeupSettledSessionFile: options.sessionFile,
853
+ wakeupSettledSessionKey: sessionKey,
854
+ wakeupSettledBeforeFirstAttempt: beforeFirstAttempt,
855
+ };
856
+ });
783
857
  } catch {
784
858
  return readJob(jobId);
785
859
  }
@@ -805,17 +879,14 @@ export async function cancelOracleJob(id: string, reason = "Cancelled by user"):
805
879
  }, now),
806
880
  }));
807
881
 
808
- const cleanupWarnings: string[] = [];
809
- await rm(cancelled.archivePath, { force: true }).catch((error: Error) => {
810
- cleanupWarnings.push(`Failed to remove queued archive ${cancelled.archivePath}: ${error.message}`);
811
- });
812
- if (cleanupWarnings.length === 0) return cancelled;
882
+ const cleanupReport = await cleanupJobResources(cancelled);
883
+ if (cleanupReport.warnings.length === 0) return cancelled;
813
884
 
814
885
  return updateJob(id, (job) => ({
815
886
  ...job,
816
- cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupWarnings],
887
+ cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupReport.warnings],
817
888
  lastCleanupAt: now,
818
- error: [job.error, ...cleanupWarnings].filter(Boolean).join("\n"),
889
+ error: [job.error, ...cleanupReport.warnings].filter(Boolean).join("\n"),
819
890
  }));
820
891
  }
821
892
 
@@ -180,9 +180,10 @@ function buildNotificationContent(job: OraclePollerJob): string {
180
180
  const artifactsPath = `${getJobDir(job.id)}/artifacts`;
181
181
  return [
182
182
  `Oracle job ${job.id} is ${job.status}.`,
183
- `Read response: ${responsePath}`,
183
+ `Use oracle_read with jobId ${job.id} to open the response and settle wake-up retries.`,
184
+ `Response file: ${responsePath}`,
184
185
  `Artifacts: ${artifactsPath}`,
185
- job.error ? `Error: ${job.error}` : "Continue from the oracle output.",
186
+ job.error ? `Error: ${job.error}` : "After oracle_read, continue from the oracle output.",
186
187
  ].join("\n");
187
188
  }
188
189
 
@@ -231,7 +231,7 @@ export async function cloneSeedProfileToRuntime(config: OracleConfig, runtimePro
231
231
  const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
232
232
 
233
233
  export interface OracleCleanupReport {
234
- attempted: Array<"browser" | "runtimeProfileDir" | "conversationLease" | "runtimeLease">;
234
+ attempted: Array<"browser" | "runtimeProfileDir" | "conversationLease" | "runtimeLease" | "queuedArchive">;
235
235
  warnings: string[];
236
236
  }
237
237
 
@@ -13,6 +13,7 @@ import {
13
13
  getJobDir,
14
14
  getSessionFile,
15
15
  hasDurableWorkerHandoff,
16
+ hasRetainedPreSubmitArchive,
16
17
  isOpenOracleJob,
17
18
  isTerminalOracleJob,
18
19
  listOracleJobDirs,
@@ -436,27 +437,60 @@ async function createArchive(cwd: string, files: string[], archivePath: string):
436
437
  return createArchiveForTesting(cwd, files, archivePath);
437
438
  }
438
439
 
439
- async function getQueuedArchivePressure(): Promise<{ queuedJobs: number; queuedArchiveBytes: number }> {
440
- const queuedJobs = listOracleJobDirs()
440
+ export interface QueuedArchivePressure {
441
+ queuedJobs: number;
442
+ queuedArchiveBytes: number;
443
+ }
444
+
445
+ export async function getQueuedArchivePressure(): Promise<QueuedArchivePressure> {
446
+ const jobs = listOracleJobDirs()
441
447
  .map((dir) => readJob(dir))
442
- .filter((job): job is NonNullable<typeof job> => Boolean(job && job.status === "queued"));
448
+ .filter((job): job is NonNullable<typeof job> => Boolean(job));
443
449
 
444
450
  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
- }),
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
+ }),
452
460
  )).reduce((sum, bytes) => sum + bytes, 0);
453
461
 
454
462
  return {
455
- queuedJobs: queuedJobs.length,
463
+ queuedJobs: jobs.filter((job) => job.status === "queued").length,
456
464
  queuedArchiveBytes,
457
465
  };
458
466
  }
459
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
+
460
494
  function validateSubmissionOptions(
461
495
  params: { effort?: OracleEffort; autoSwitchToThinking?: boolean },
462
496
  modelFamily: OracleModelFamily,
@@ -638,18 +672,17 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
638
672
  if (!runtimeAttempt.acquired) {
639
673
  const queuePressure = await getQueuedArchivePressure();
640
674
  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
675
  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
- );
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);
653
686
  }
654
687
 
655
688
  queued = true;
@@ -854,7 +887,13 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
854
887
  if (!job || job.projectId !== getProjectId(ctx.cwd)) {
855
888
  throw new Error(`Oracle job not found in this project: ${params.jobId}`);
856
889
  }
857
- const latest = isTerminalOracleJob(job) ? await markWakeupSettled(job.id) : job;
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;
858
897
  const current = latest ?? readJob(job.id) ?? job;
859
898
 
860
899
  let responsePreview = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",