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 +9 -0
- package/README.md +3 -1
- package/docs/ORACLE_DESIGN.md +11 -1
- package/extensions/oracle/lib/commands.ts +5 -1
- package/extensions/oracle/lib/jobs.ts +86 -15
- package/extensions/oracle/lib/poller.ts +3 -2
- package/extensions/oracle/lib/runtime.ts +1 -1
- package/extensions/oracle/lib/tools.ts +62 -23
- package/package.json +1 -1
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
|
-
-
|
|
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
|
package/docs/ORACLE_DESIGN.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
472
|
+
return report;
|
|
450
473
|
}
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
|
|
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
|
|
809
|
-
|
|
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 || []), ...
|
|
887
|
+
cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupReport.warnings],
|
|
817
888
|
lastCleanupAt: now,
|
|
818
|
-
error: [job.error, ...
|
|
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
|
-
`
|
|
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}` : "
|
|
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
|
-
|
|
440
|
-
|
|
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
|
|
448
|
+
.filter((job): job is NonNullable<typeof job> => Boolean(job));
|
|
443
449
|
|
|
444
450
|
const queuedArchiveBytes = (await Promise.all(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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:
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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)
|
|
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 = "";
|