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.
@@ -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 getSessionId(originSessionFile: string | undefined, projectId: string): string {
43
- return originSessionFile || `ephemeral:${projectId}`;
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
- export async function acquireRuntimeLease(config: OracleConfig, metadata: OracleRuntimeLeaseMetadata): Promise<void> {
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
- const blocker = liveLeases[0];
110
- throw new Error(
111
- `Oracle is busy (${liveLeases.length}/${config.browser.maxConcurrentJobs} active). ` +
112
- `Blocking job ${blocker?.jobId ?? "unknown"} in project ${blocker?.projectId ?? "unknown"}.`,
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 acquireConversationLease(metadata: OracleConversationLeaseMetadata): Promise<void> {
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
- throw new Error(
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> {
@@ -180,7 +231,7 @@ export async function cloneSeedProfileToRuntime(config: OracleConfig, runtimePro
180
231
  const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
181
232
 
182
233
  export interface OracleCleanupReport {
183
- attempted: Array<"browser" | "runtimeProfileDir" | "conversationLease" | "runtimeLease">;
234
+ attempted: Array<"browser" | "runtimeProfileDir" | "conversationLease" | "runtimeLease" | "queuedArchive">;
184
235
  warnings: string[];
185
236
  }
186
237
 
@@ -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
  }