pi-oracle 0.1.11 → 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.
- package/CHANGELOG.md +37 -0
- package/README.md +27 -11
- package/docs/ORACLE_DESIGN.md +583 -0
- package/docs/ORACLE_RECOVERY_DRILL.md +127 -0
- package/extensions/oracle/index.ts +15 -4
- package/extensions/oracle/lib/commands.ts +35 -12
- package/extensions/oracle/lib/config.ts +2 -2
- package/extensions/oracle/lib/jobs.ts +438 -72
- package/extensions/oracle/lib/locks.ts +99 -13
- package/extensions/oracle/lib/poller.ts +223 -38
- package/extensions/oracle/lib/queue.ts +193 -0
- package/extensions/oracle/lib/runtime.ts +69 -15
- package/extensions/oracle/lib/tools.ts +514 -123
- package/extensions/oracle/worker/artifact-heuristics.d.mts +29 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +2 -72
- package/extensions/oracle/worker/auth-cookie-policy.d.mts +31 -0
- package/extensions/oracle/worker/run-job.mjs +333 -71
- package/extensions/oracle/worker/state-locks.d.mts +45 -0
- package/extensions/oracle/worker/state-locks.mjs +235 -0
- package/package.json +13 -4
- package/prompts/oracle.md +9 -3
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { loadOracleConfig } from "./config.js";
|
|
3
|
+
import { withLock } from "./locks.js";
|
|
4
|
+
import { appendCleanupWarnings, createJob, hasDurableWorkerHandoff, isTerminalOracleJob, listOracleJobDirs, readJob, spawnWorker, terminateWorkerPid, updateJob, withJobPhase, type OracleJob } from "./jobs.js";
|
|
5
|
+
import {
|
|
6
|
+
cleanupRuntimeArtifacts,
|
|
7
|
+
releaseRuntimeLease,
|
|
8
|
+
tryAcquireConversationLease,
|
|
9
|
+
tryAcquireRuntimeLease,
|
|
10
|
+
type OracleConversationLeaseMetadata,
|
|
11
|
+
type OracleRuntimeLeaseMetadata,
|
|
12
|
+
} from "./runtime.js";
|
|
13
|
+
|
|
14
|
+
export interface OracleQueuePosition {
|
|
15
|
+
position: number;
|
|
16
|
+
depth: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PromoteQueuedJobsOptions {
|
|
20
|
+
workerPath: string;
|
|
21
|
+
source: string;
|
|
22
|
+
spawnWorkerFn?: typeof spawnWorker;
|
|
23
|
+
loadConfigFn?: typeof loadOracleConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isQueuedJob(job: OracleJob | undefined): job is OracleJob {
|
|
27
|
+
return Boolean(job && job.status === "queued");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function compareQueuedJobs(left: OracleJob, right: OracleJob): number {
|
|
31
|
+
const leftKey = left.queuedAt ?? left.createdAt;
|
|
32
|
+
const rightKey = right.queuedAt ?? right.createdAt;
|
|
33
|
+
return leftKey.localeCompare(rightKey) || left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function listQueuedJobs(): OracleJob[] {
|
|
37
|
+
return listOracleJobDirs()
|
|
38
|
+
.map((jobDir) => readJob(jobDir))
|
|
39
|
+
.filter(isQueuedJob)
|
|
40
|
+
.sort(compareQueuedJobs);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getQueuePosition(jobId: string): OracleQueuePosition | undefined {
|
|
44
|
+
const queuedJobs = listQueuedJobs();
|
|
45
|
+
const index = queuedJobs.findIndex((job) => job.id === jobId);
|
|
46
|
+
if (index === -1) return undefined;
|
|
47
|
+
return {
|
|
48
|
+
position: index + 1,
|
|
49
|
+
depth: queuedJobs.length,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runtimeLeaseMetadata(job: OracleJob, createdAt: string): OracleRuntimeLeaseMetadata {
|
|
54
|
+
return {
|
|
55
|
+
jobId: job.id,
|
|
56
|
+
runtimeId: job.runtimeId,
|
|
57
|
+
runtimeSessionName: job.runtimeSessionName,
|
|
58
|
+
runtimeProfileDir: job.runtimeProfileDir,
|
|
59
|
+
projectId: job.projectId,
|
|
60
|
+
sessionId: job.sessionId,
|
|
61
|
+
createdAt,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function conversationLeaseMetadata(job: OracleJob, createdAt: string): OracleConversationLeaseMetadata | undefined {
|
|
66
|
+
if (!job.conversationId) return undefined;
|
|
67
|
+
return {
|
|
68
|
+
jobId: job.id,
|
|
69
|
+
conversationId: job.conversationId,
|
|
70
|
+
projectId: job.projectId,
|
|
71
|
+
sessionId: job.sessionId,
|
|
72
|
+
createdAt,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function failQueuedPromotion(job: OracleJob, message: string, at: string): Promise<void> {
|
|
77
|
+
await updateJob(job.id, (current) => ({
|
|
78
|
+
...current,
|
|
79
|
+
...withJobPhase("failed", {
|
|
80
|
+
status: "failed",
|
|
81
|
+
completedAt: at,
|
|
82
|
+
heartbeatAt: at,
|
|
83
|
+
notifyClaimedAt: undefined,
|
|
84
|
+
notifyClaimedBy: undefined,
|
|
85
|
+
error: message,
|
|
86
|
+
}, at),
|
|
87
|
+
})).catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function promoteQueuedJobsWithinAdmissionLock(options: PromoteQueuedJobsOptions): Promise<{ promotedJobIds: string[] }> {
|
|
91
|
+
const spawnWorkerFn = options.spawnWorkerFn ?? spawnWorker;
|
|
92
|
+
const loadConfigFn = options.loadConfigFn ?? loadOracleConfig;
|
|
93
|
+
const promotedJobIds: string[] = [];
|
|
94
|
+
|
|
95
|
+
for (const queuedJob of listQueuedJobs()) {
|
|
96
|
+
const now = new Date().toISOString();
|
|
97
|
+
let runtimeLeaseAcquired = false;
|
|
98
|
+
let conversationLeaseAcquired = false;
|
|
99
|
+
let workerSpawned = false;
|
|
100
|
+
let spawnedWorker: Awaited<ReturnType<typeof spawnWorker>> | undefined;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const current = readJob(queuedJob.id);
|
|
104
|
+
if (!isQueuedJob(current)) continue;
|
|
105
|
+
if (!existsSync(current.archivePath)) {
|
|
106
|
+
await failQueuedPromotion(current, `Queued oracle archive is missing: ${current.archivePath}`, now);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const config = current.config ?? loadConfigFn(current.cwd);
|
|
111
|
+
const runtimeAttempt = await tryAcquireRuntimeLease(config, runtimeLeaseMetadata(current, now));
|
|
112
|
+
if (!runtimeAttempt.acquired) break;
|
|
113
|
+
runtimeLeaseAcquired = true;
|
|
114
|
+
|
|
115
|
+
const conversationMetadata = conversationLeaseMetadata(current, now);
|
|
116
|
+
if (conversationMetadata) {
|
|
117
|
+
const conversationAttempt = await tryAcquireConversationLease(conversationMetadata);
|
|
118
|
+
if (!conversationAttempt.acquired) {
|
|
119
|
+
await releaseRuntimeLease(current.runtimeId).catch(() => undefined);
|
|
120
|
+
runtimeLeaseAcquired = false;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
conversationLeaseAcquired = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await updateJob(current.id, (latest) => {
|
|
127
|
+
if (latest.status !== "queued") {
|
|
128
|
+
throw new Error(`Queued job ${latest.id} changed state during promotion (${latest.status})`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
...latest,
|
|
132
|
+
config,
|
|
133
|
+
...withJobPhase("submitted", {
|
|
134
|
+
status: "submitted",
|
|
135
|
+
submittedAt: latest.submittedAt || now,
|
|
136
|
+
}, now),
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
spawnedWorker = await spawnWorkerFn(options.workerPath, current.id);
|
|
141
|
+
workerSpawned = true;
|
|
142
|
+
const worker = spawnedWorker;
|
|
143
|
+
await updateJob(current.id, (latest) => ({
|
|
144
|
+
...latest,
|
|
145
|
+
workerPid: worker.pid,
|
|
146
|
+
workerNonce: worker.nonce,
|
|
147
|
+
workerStartedAt: worker.startedAt,
|
|
148
|
+
}));
|
|
149
|
+
promotedJobIds.push(current.id);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
152
|
+
const latest = readJob(queuedJob.id);
|
|
153
|
+
if (workerSpawned && latest && hasDurableWorkerHandoff(latest)) {
|
|
154
|
+
promotedJobIds.push(queuedJob.id);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (spawnedWorker) {
|
|
158
|
+
await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.startedAt).catch(() => undefined);
|
|
159
|
+
}
|
|
160
|
+
if (latest && !isTerminalOracleJob(latest)) {
|
|
161
|
+
await failQueuedPromotion(latest, message, now);
|
|
162
|
+
}
|
|
163
|
+
const cleanupReport = await cleanupRuntimeArtifacts({
|
|
164
|
+
runtimeId: runtimeLeaseAcquired ? queuedJob.runtimeId : undefined,
|
|
165
|
+
runtimeProfileDir: runtimeLeaseAcquired ? queuedJob.runtimeProfileDir : undefined,
|
|
166
|
+
runtimeSessionName: workerSpawned ? queuedJob.runtimeSessionName : undefined,
|
|
167
|
+
conversationId: conversationLeaseAcquired ? queuedJob.conversationId : undefined,
|
|
168
|
+
}).catch(() => ({ attempted: [], warnings: [] }));
|
|
169
|
+
if (cleanupReport.warnings.length > 0) {
|
|
170
|
+
await appendCleanupWarnings(queuedJob.id, cleanupReport.warnings, now).catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { promotedJobIds };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function promoteQueuedJobs(options: PromoteQueuedJobsOptions): Promise<{ promotedJobIds: string[] }> {
|
|
179
|
+
return withLock("admission", "global", { processPid: process.pid, source: options.source }, async () => {
|
|
180
|
+
return promoteQueuedJobsWithinAdmissionLock(options);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function createQueuedJob(
|
|
185
|
+
id: string,
|
|
186
|
+
input: Parameters<typeof createJob>[1],
|
|
187
|
+
cwd: string,
|
|
188
|
+
originSessionFile: string | undefined,
|
|
189
|
+
config: Parameters<typeof createJob>[4],
|
|
190
|
+
runtime: Parameters<typeof createJob>[5],
|
|
191
|
+
): Promise<OracleJob> {
|
|
192
|
+
return createJob(id, input, cwd, originSessionFile, config, runtime, { initialState: "queued" });
|
|
193
|
+
}
|
|
@@ -31,6 +31,17 @@ export interface OracleConversationLeaseMetadata {
|
|
|
31
31
|
createdAt: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export interface OracleRuntimeLeaseAttempt {
|
|
35
|
+
acquired: boolean;
|
|
36
|
+
liveLeases: OracleRuntimeLeaseMetadata[];
|
|
37
|
+
blocker?: OracleRuntimeLeaseMetadata;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface OracleConversationLeaseAttempt {
|
|
41
|
+
acquired: boolean;
|
|
42
|
+
blocker?: OracleConversationLeaseMetadata;
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
export function getProjectId(cwd: string): string {
|
|
35
46
|
try {
|
|
36
47
|
return realpathSync(cwd);
|
|
@@ -39,8 +50,19 @@ export function getProjectId(cwd: string): string {
|
|
|
39
50
|
}
|
|
40
51
|
}
|
|
41
52
|
|
|
42
|
-
export function
|
|
43
|
-
return originSessionFile
|
|
53
|
+
export function hasPersistedSessionFile(originSessionFile: string | undefined): originSessionFile is string {
|
|
54
|
+
return Boolean(originSessionFile);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function requirePersistedSessionFile(originSessionFile: string | undefined, action = "use oracle"): string {
|
|
58
|
+
if (!originSessionFile) {
|
|
59
|
+
throw new Error(`Oracle requires a persisted pi session to ${action}. Start or save a real session before using oracle.`);
|
|
60
|
+
}
|
|
61
|
+
return originSessionFile;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getSessionId(originSessionFile: string | undefined, _projectId: string): string {
|
|
65
|
+
return requirePersistedSessionFile(originSessionFile, "derive oracle session identity");
|
|
44
66
|
}
|
|
45
67
|
|
|
46
68
|
export function parseConversationId(chatUrl: string | undefined): string | undefined {
|
|
@@ -88,14 +110,14 @@ function activeJobExists(jobId: string): boolean {
|
|
|
88
110
|
const path = join(ORACLE_JOBS_DIR, `oracle-${jobId}`, "job.json");
|
|
89
111
|
if (!existsSync(path)) return false;
|
|
90
112
|
try {
|
|
91
|
-
const job = JSON.parse(readFileSync(path, "utf8")) as { status?: string };
|
|
92
|
-
return ["preparing", "submitted", "waiting"].includes(job.status || "");
|
|
113
|
+
const job = JSON.parse(readFileSync(path, "utf8")) as { status?: string; cleanupWarnings?: unknown; cleanupPending?: unknown };
|
|
114
|
+
return ["preparing", "submitted", "waiting"].includes(job.status || "") || job.cleanupPending === true || (Array.isArray(job.cleanupWarnings) && job.cleanupWarnings.length > 0);
|
|
93
115
|
} catch {
|
|
94
116
|
return false;
|
|
95
117
|
}
|
|
96
118
|
}
|
|
97
119
|
|
|
98
|
-
|
|
120
|
+
async function collectLiveRuntimeLeases(): Promise<OracleRuntimeLeaseMetadata[]> {
|
|
99
121
|
const existing = listLeaseMetadata<OracleRuntimeLeaseMetadata>("runtime");
|
|
100
122
|
const liveLeases: OracleRuntimeLeaseMetadata[] = [];
|
|
101
123
|
for (const lease of existing) {
|
|
@@ -105,14 +127,33 @@ export async function acquireRuntimeLease(config: OracleConfig, metadata: Oracle
|
|
|
105
127
|
}
|
|
106
128
|
liveLeases.push(lease);
|
|
107
129
|
}
|
|
130
|
+
return liveLeases;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function tryAcquireRuntimeLease(config: OracleConfig, metadata: OracleRuntimeLeaseMetadata): Promise<OracleRuntimeLeaseAttempt> {
|
|
134
|
+
const liveLeases = await collectLiveRuntimeLeases();
|
|
108
135
|
if (liveLeases.length >= config.browser.maxConcurrentJobs) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
136
|
+
return {
|
|
137
|
+
acquired: false,
|
|
138
|
+
liveLeases,
|
|
139
|
+
blocker: liveLeases[0],
|
|
140
|
+
};
|
|
114
141
|
}
|
|
115
142
|
await createLease("runtime", metadata.runtimeId, metadata);
|
|
143
|
+
return {
|
|
144
|
+
acquired: true,
|
|
145
|
+
liveLeases,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function acquireRuntimeLease(config: OracleConfig, metadata: OracleRuntimeLeaseMetadata): Promise<void> {
|
|
150
|
+
const attempt = await tryAcquireRuntimeLease(config, metadata);
|
|
151
|
+
if (attempt.acquired) return;
|
|
152
|
+
const blocker = attempt.blocker;
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Oracle is busy (${attempt.liveLeases.length}/${config.browser.maxConcurrentJobs} active). ` +
|
|
155
|
+
`Blocking job ${blocker?.jobId ?? "unknown"} in project ${blocker?.projectId ?? "unknown"}.`,
|
|
156
|
+
);
|
|
116
157
|
}
|
|
117
158
|
|
|
118
159
|
export async function releaseRuntimeLease(runtimeId: string | undefined): Promise<void> {
|
|
@@ -120,19 +161,29 @@ export async function releaseRuntimeLease(runtimeId: string | undefined): Promis
|
|
|
120
161
|
await releaseLease("runtime", runtimeId);
|
|
121
162
|
}
|
|
122
163
|
|
|
123
|
-
export async function
|
|
164
|
+
export async function tryAcquireConversationLease(metadata: OracleConversationLeaseMetadata): Promise<OracleConversationLeaseAttempt> {
|
|
124
165
|
const existing = await readLeaseMetadata<OracleConversationLeaseMetadata>("conversation", metadata.conversationId);
|
|
166
|
+
if (existing?.jobId === metadata.jobId) {
|
|
167
|
+
return { acquired: true };
|
|
168
|
+
}
|
|
125
169
|
if (existing && existing.jobId !== metadata.jobId) {
|
|
126
170
|
if (!activeJobExists(existing.jobId)) {
|
|
127
171
|
await releaseLease("conversation", metadata.conversationId).catch(() => undefined);
|
|
128
172
|
} else {
|
|
129
|
-
|
|
130
|
-
`Oracle conversation ${metadata.conversationId} is already in use by job ${existing.jobId}. ` +
|
|
131
|
-
`Concurrent follow-ups to the same ChatGPT thread are not allowed.`,
|
|
132
|
-
);
|
|
173
|
+
return { acquired: false, blocker: existing };
|
|
133
174
|
}
|
|
134
175
|
}
|
|
135
176
|
await createLease("conversation", metadata.conversationId, metadata);
|
|
177
|
+
return { acquired: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function acquireConversationLease(metadata: OracleConversationLeaseMetadata): Promise<void> {
|
|
181
|
+
const attempt = await tryAcquireConversationLease(metadata);
|
|
182
|
+
if (attempt.acquired) return;
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Oracle conversation ${metadata.conversationId} is already in use by job ${attempt.blocker?.jobId ?? "unknown"}. ` +
|
|
185
|
+
`Concurrent follow-ups to the same ChatGPT thread are not allowed.`,
|
|
186
|
+
);
|
|
136
187
|
}
|
|
137
188
|
|
|
138
189
|
export async function releaseConversationLease(conversationId: string | undefined): Promise<void> {
|
|
@@ -235,6 +286,9 @@ export async function cleanupRuntimeArtifacts(runtime: {
|
|
|
235
286
|
report.warnings.push(`Failed to remove runtime profile ${runtime.runtimeProfileDir}: ${error.message}`);
|
|
236
287
|
});
|
|
237
288
|
}
|
|
289
|
+
if (report.warnings.length > 0) {
|
|
290
|
+
return report;
|
|
291
|
+
}
|
|
238
292
|
if (runtime.conversationId) {
|
|
239
293
|
report.attempted.push("conversationLease");
|
|
240
294
|
}
|