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.
@@ -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
  ),
@@ -60,6 +74,10 @@ const VALID_EFFORTS: Record<OracleModelFamily, readonly OracleEffort[]> = {
60
74
  pro: ["standard", "extended"],
61
75
  };
62
76
 
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;
80
+
63
81
  const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
64
82
  ".git",
65
83
  ".hg",
@@ -89,11 +107,35 @@ const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
89
107
  ".serverless",
90
108
  ".aws-sam",
91
109
  ]);
92
- const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT = new Set(["coverage", "htmlcov", "tmp", "temp", ".tmp", "dist", "build", "out"]);
93
- const DEFAULT_ARCHIVE_EXCLUDED_FILES = new Set([".coverage", ".DS_Store", "Thumbs.db"]);
94
- const DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES = [".pyc", ".pyo", ".pyd", ".tsbuildinfo", ".tfstate"];
110
+ const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT = new Set(["coverage", "htmlcov", "tmp", "temp", ".tmp", "dist", "build", "out", "secrets", ".secrets"]);
111
+ const DEFAULT_ARCHIVE_EXCLUDED_FILES = new Set([
112
+ ".coverage",
113
+ ".DS_Store",
114
+ ".env",
115
+ ".netrc",
116
+ ".npmrc",
117
+ ".pypirc",
118
+ "Thumbs.db",
119
+ "id_dsa",
120
+ "id_ecdsa",
121
+ "id_ed25519",
122
+ "id_rsa",
123
+ ]);
124
+ const DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES = [".db", ".key", ".p12", ".pfx", ".pyc", ".pyd", ".pyo", ".pem", ".sqlite", ".sqlite3", ".tsbuildinfo", ".tfstate"];
95
125
  const DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS = [".tfstate."];
126
+ const DEFAULT_ARCHIVE_EXCLUDED_ENV_ALLOWLIST = new Set([".env.dist", ".env.example", ".env.sample", ".env.template"]);
96
127
  const DEFAULT_ARCHIVE_EXCLUDED_PATH_SEQUENCES = [[".yarn", "cache"]] as const;
128
+ const ADAPTIVE_ARCHIVE_PRUNE_DIR_NAMES_ANYWHERE = new Set(["build", "dist", "out", "coverage", "htmlcov", "tmp", "temp", ".tmp"]);
129
+ const ADAPTIVE_ARCHIVE_PRUNE_PROTECTED_ANCESTOR_DIR_NAMES = new Set(["src", "source", "sources", "lib"]);
130
+
131
+ type ArchiveSizeBreakdownRow = { relativePath: string; bytes: number };
132
+ type ArchiveCreationResult = {
133
+ sha256: string;
134
+ archiveBytes: number;
135
+ initialArchiveBytes?: number;
136
+ autoPrunedPrefixes: ArchiveSizeBreakdownRow[];
137
+ includedEntries: string[];
138
+ };
97
139
 
98
140
  function pathContainsSequence(relativePath: string, sequence: readonly string[]): boolean {
99
141
  const segments = relativePath.split("/").filter(Boolean);
@@ -108,6 +150,62 @@ function getRelativeDepth(relativePath: string): number {
108
150
  return relativePath.split("/").filter(Boolean).length;
109
151
  }
110
152
 
153
+ function formatBytes(bytes: number): string {
154
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
155
+ }
156
+
157
+ function formatDirectoryLabel(relativePath: string): string {
158
+ return relativePath.endsWith("/") ? relativePath : `${relativePath}/`;
159
+ }
160
+
161
+ function summarizeByKey(
162
+ entrySizes: ArchiveSizeBreakdownRow[],
163
+ keyForEntry: (relativePath: string) => string | undefined,
164
+ limit = 7,
165
+ ): ArchiveSizeBreakdownRow[] {
166
+ const totals = new Map<string, number>();
167
+ for (const entry of entrySizes) {
168
+ const key = keyForEntry(entry.relativePath);
169
+ if (!key) continue;
170
+ totals.set(key, (totals.get(key) ?? 0) + entry.bytes);
171
+ }
172
+ return [...totals.entries()]
173
+ .map(([relativePath, bytes]) => ({ relativePath, bytes }))
174
+ .sort((left, right) => right.bytes - left.bytes || left.relativePath.localeCompare(right.relativePath))
175
+ .slice(0, limit);
176
+ }
177
+
178
+ function summarizeTopLevelIncludedPaths(entrySizes: ArchiveSizeBreakdownRow[]): ArchiveSizeBreakdownRow[] {
179
+ return summarizeByKey(entrySizes, (relativePath) => {
180
+ const [topLevel, ...rest] = relativePath.split("/").filter(Boolean);
181
+ if (!topLevel) return undefined;
182
+ return rest.length > 0 ? `${topLevel}/` : topLevel;
183
+ });
184
+ }
185
+
186
+ function getAdaptivePrunePrefix(relativePath: string): string | undefined {
187
+ const segments = relativePath.split("/").filter(Boolean);
188
+ for (let index = 0; index < segments.length - 1; index += 1) {
189
+ const name = segments[index];
190
+ if (!ADAPTIVE_ARCHIVE_PRUNE_DIR_NAMES_ANYWHERE.has(name)) continue;
191
+ const ancestors = segments.slice(0, index);
192
+ if (ancestors.some((segment) => ADAPTIVE_ARCHIVE_PRUNE_PROTECTED_ANCESTOR_DIR_NAMES.has(segment))) continue;
193
+ return segments.slice(0, index + 1).join("/");
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ function summarizeAdaptivePruneCandidates(
199
+ entrySizes: ArchiveSizeBreakdownRow[],
200
+ minimumBytes = 0,
201
+ ): ArchiveSizeBreakdownRow[] {
202
+ return summarizeByKey(entrySizes, getAdaptivePrunePrefix, Number.POSITIVE_INFINITY).filter((entry) => entry.bytes >= minimumBytes);
203
+ }
204
+
205
+ function pruneEntriesByPrefix(entries: string[], prefix: string): string[] {
206
+ return entries.filter((entry) => entry !== prefix && !entry.startsWith(`${prefix}/`));
207
+ }
208
+
111
209
  function shouldExcludeArchivePath(relativePath: string, isDirectory: boolean, options?: { forceInclude?: boolean }): boolean {
112
210
  const normalized = posix.normalize(relativePath).replace(/^\.\//, "");
113
211
  if (!normalized || normalized === ".") return false;
@@ -120,6 +218,7 @@ function shouldExcludeArchivePath(relativePath: string, isDirectory: boolean, op
120
218
  return false;
121
219
  }
122
220
  if (DEFAULT_ARCHIVE_EXCLUDED_FILES.has(name)) return true;
221
+ if (name.startsWith(".env.") && !DEFAULT_ARCHIVE_EXCLUDED_ENV_ALLOWLIST.has(name)) return true;
123
222
  if (DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix))) return true;
124
223
  if (DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS.some((needle) => name.includes(needle))) return true;
125
224
  return false;
@@ -173,87 +272,191 @@ async function expandArchiveEntries(cwd: string, relativePath: string, options?:
173
272
  return results;
174
273
  }
175
274
 
176
- export async function resolveExpandedArchiveEntries(cwd: string, files: string[]): Promise<string[]> {
177
- const entries = resolveArchiveInputs(cwd, files);
275
+ async function resolveExpandedArchiveEntriesFromInputs(
276
+ cwd: string,
277
+ entries: Array<{ absolute: string; relative: string }>,
278
+ ): Promise<string[]> {
178
279
  return Array.from(new Set((await Promise.all(entries.map(async (entry) => {
179
- const absolute = join(cwd, entry.relative);
180
- const statEntry = await lstat(absolute);
280
+ const statEntry = await lstat(entry.absolute);
181
281
  const forceIncludeSubtree = statEntry.isDirectory() && entry.relative !== "." && shouldExcludeArchivePath(entry.relative, true);
182
282
  return expandArchiveEntries(cwd, entry.relative, { forceIncludeSubtree });
183
283
  }))).flat())).sort();
184
284
  }
185
285
 
186
- async function createArchive(cwd: string, files: string[], archivePath: string): Promise<string> {
187
- const expandedEntries = await resolveExpandedArchiveEntries(cwd, files);
286
+ export async function resolveExpandedArchiveEntries(cwd: string, files: string[]): Promise<string[]> {
287
+ return resolveExpandedArchiveEntriesFromInputs(cwd, resolveArchiveInputs(cwd, files));
288
+ }
289
+
290
+ function isWholeRepoArchiveSelection(entries: Array<{ absolute: string; relative: string }>): boolean {
291
+ return entries.length === 1 && entries[0]?.relative === ".";
292
+ }
293
+
294
+ async function measureArchiveEntrySizes(cwd: string, entries: string[]): Promise<ArchiveSizeBreakdownRow[]> {
295
+ return Promise.all(entries.map(async (relativePath) => ({ relativePath, bytes: (await lstat(join(cwd, relativePath))).size })));
296
+ }
297
+
298
+ function formatArchiveOversizeError(args: {
299
+ archiveBytes: number;
300
+ maxBytes: number;
301
+ entrySizes: ArchiveSizeBreakdownRow[];
302
+ autoPrunedPrefixes: ArchiveSizeBreakdownRow[];
303
+ adaptivePruneMinBytes?: number;
304
+ }): string {
305
+ const topLevel = summarizeTopLevelIncludedPaths(args.entrySizes);
306
+ const adaptiveCandidates = summarizeAdaptivePruneCandidates(args.entrySizes, args.adaptivePruneMinBytes).slice(0, 7);
307
+ return [
308
+ `Oracle archive exceeds ChatGPT upload limit after default exclusions${args.autoPrunedPrefixes.length > 0 ? " and automatic generic generated-output-dir pruning" : ""}: ${args.archiveBytes} bytes >= ${args.maxBytes} bytes`,
309
+ args.autoPrunedPrefixes.length > 0 ? "Automatically pruned generic generated-output paths before failing:" : undefined,
310
+ ...args.autoPrunedPrefixes.map((entry) => `- ${formatDirectoryLabel(entry.relativePath)} — ${formatBytes(entry.bytes)}`),
311
+ topLevel.length > 0 ? "Approx top-level included sizes:" : undefined,
312
+ ...topLevel.map((entry) => `- ${entry.relativePath} — ${formatBytes(entry.bytes)}`),
313
+ adaptiveCandidates.length > 0 ? "Largest remaining generic generated-output-dir candidates:" : undefined,
314
+ ...adaptiveCandidates.map((entry) => `- ${formatDirectoryLabel(entry.relativePath)} — ${formatBytes(entry.bytes)}`),
315
+ "Retry with narrower archive inputs, starting with modified files plus adjacent files plus directly relevant subtrees.",
316
+ ]
317
+ .filter(Boolean)
318
+ .join("\n");
319
+ }
320
+
321
+ async function writeArchiveFile(cwd: string, entries: string[], archivePath: string, listPath: string): Promise<number> {
322
+ await writeFile(listPath, Buffer.from(`${entries.join("\0")}\0`), { mode: 0o600 });
323
+ await rm(archivePath, { force: true }).catch(() => undefined);
324
+
325
+ const { spawn } = await import("node:child_process");
326
+ await new Promise<void>((resolvePromise, rejectPromise) => {
327
+ const tar = spawn("tar", ["--null", "-cf", "-", "-T", listPath], {
328
+ cwd,
329
+ stdio: ["ignore", "pipe", "pipe"],
330
+ });
331
+ const zstd = spawn("zstd", ["-19", "-T0", "-f", "-o", archivePath], {
332
+ stdio: ["pipe", "ignore", "pipe"],
333
+ });
334
+
335
+ let stderr = "";
336
+ let settled = false;
337
+ let tarCode: number | null | undefined;
338
+ let zstdCode: number | null | undefined;
339
+
340
+ const finish = (error?: Error) => {
341
+ if (settled) return;
342
+ if (error) {
343
+ settled = true;
344
+ tar.kill("SIGTERM");
345
+ zstd.kill("SIGTERM");
346
+ rejectPromise(error);
347
+ return;
348
+ }
349
+ if (tarCode === undefined || zstdCode === undefined) return;
350
+ settled = true;
351
+ if (tarCode === 0 && zstdCode === 0) resolvePromise();
352
+ else rejectPromise(new Error(stderr || `archive command failed (tar=${tarCode}, zstd=${zstdCode})`));
353
+ };
354
+
355
+ tar.stderr.on("data", (data) => {
356
+ stderr += String(data);
357
+ });
358
+ zstd.stderr.on("data", (data) => {
359
+ stderr += String(data);
360
+ });
361
+ tar.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
362
+ zstd.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
363
+ tar.on("close", (code) => {
364
+ tarCode = code;
365
+ finish();
366
+ });
367
+ zstd.on("close", (code) => {
368
+ zstdCode = code;
369
+ finish();
370
+ });
371
+ tar.stdout.pipe(zstd.stdin);
372
+ });
373
+
374
+ return (await stat(archivePath)).size;
375
+ }
376
+
377
+ export async function createArchiveForTesting(
378
+ cwd: string,
379
+ files: string[],
380
+ archivePath: string,
381
+ options?: { maxBytes?: number; adaptivePruneMinBytes?: number },
382
+ ): Promise<ArchiveCreationResult> {
383
+ const archiveInputs = resolveArchiveInputs(cwd, files);
384
+ const wholeRepoSelection = isWholeRepoArchiveSelection(archiveInputs);
385
+ let expandedEntries = await resolveExpandedArchiveEntriesFromInputs(cwd, archiveInputs);
188
386
  if (expandedEntries.length === 0) {
189
387
  throw new Error("Oracle archive inputs are empty after default exclusions");
190
388
  }
389
+
191
390
  const listDir = await mkdtemp(join(tmpdir(), "oracle-filelist-"));
192
391
  const listPath = join(listDir, "files.list");
193
- await writeFile(listPath, Buffer.from(`${expandedEntries.join("\0")}\0`), { mode: 0o600 });
392
+ const maxBytes = options?.maxBytes ?? MAX_ARCHIVE_BYTES;
393
+ const adaptivePruneMinBytes = options?.adaptivePruneMinBytes ?? 0;
394
+ const autoPrunedPrefixes: ArchiveSizeBreakdownRow[] = [];
395
+ let initialArchiveBytes: number | undefined;
194
396
 
195
397
  try {
196
- const { spawn } = await import("node:child_process");
197
- await new Promise<void>((resolvePromise, rejectPromise) => {
198
- const tar = spawn("tar", ["--null", "-cf", "-", "-T", listPath], {
199
- cwd,
200
- stdio: ["ignore", "pipe", "pipe"],
201
- });
202
- const zstd = spawn("zstd", ["-19", "-T0", "-o", archivePath], {
203
- stdio: ["pipe", "ignore", "pipe"],
204
- });
205
-
206
- let stderr = "";
207
- let settled = false;
208
- let tarCode: number | null | undefined;
209
- let zstdCode: number | null | undefined;
210
-
211
- const finish = (error?: Error) => {
212
- if (settled) return;
213
- if (error) {
214
- settled = true;
215
- tar.kill("SIGTERM");
216
- zstd.kill("SIGTERM");
217
- rejectPromise(error);
218
- return;
219
- }
220
- if (tarCode === undefined || zstdCode === undefined) return;
221
- settled = true;
222
- if (tarCode === 0 && zstdCode === 0) resolvePromise();
223
- else rejectPromise(new Error(stderr || `archive command failed (tar=${tarCode}, zstd=${zstdCode})`));
224
- };
398
+ while (true) {
399
+ if (expandedEntries.length === 0) {
400
+ throw new Error("Oracle archive inputs are empty after default exclusions and automatic size pruning");
401
+ }
225
402
 
226
- tar.stderr.on("data", (data) => {
227
- stderr += String(data);
228
- });
229
- zstd.stderr.on("data", (data) => {
230
- stderr += String(data);
231
- });
232
- tar.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
233
- zstd.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
234
- tar.on("close", (code) => {
235
- tarCode = code;
236
- finish();
237
- });
238
- zstd.on("close", (code) => {
239
- zstdCode = code;
240
- finish();
241
- });
242
- tar.stdout.pipe(zstd.stdin);
243
- });
403
+ const archiveBytes = await writeArchiveFile(cwd, expandedEntries, archivePath, listPath);
404
+ if (archiveBytes < maxBytes) {
405
+ return {
406
+ sha256: await sha256File(archivePath),
407
+ archiveBytes,
408
+ initialArchiveBytes,
409
+ autoPrunedPrefixes,
410
+ includedEntries: [...expandedEntries],
411
+ };
412
+ }
244
413
 
245
- const archiveStat = await stat(archivePath);
246
- const maxBytes = 250 * 1024 * 1024;
247
- if (archiveStat.size >= maxBytes) {
248
- throw new Error(`Oracle archive exceeds ChatGPT upload limit: ${archiveStat.size} bytes`);
249
- }
414
+ if (initialArchiveBytes === undefined) initialArchiveBytes = archiveBytes;
415
+ const entrySizes = await measureArchiveEntrySizes(cwd, expandedEntries);
416
+ if (!wholeRepoSelection) {
417
+ throw new Error(formatArchiveOversizeError({ archiveBytes, maxBytes, entrySizes, autoPrunedPrefixes, adaptivePruneMinBytes }));
418
+ }
419
+
420
+ const nextCandidate = summarizeAdaptivePruneCandidates(entrySizes, adaptivePruneMinBytes).find(
421
+ (entry) => !autoPrunedPrefixes.some((pruned) => pruned.relativePath === entry.relativePath),
422
+ );
423
+ if (!nextCandidate) {
424
+ throw new Error(formatArchiveOversizeError({ archiveBytes, maxBytes, entrySizes, autoPrunedPrefixes, adaptivePruneMinBytes }));
425
+ }
250
426
 
251
- return sha256File(archivePath);
427
+ autoPrunedPrefixes.push(nextCandidate);
428
+ expandedEntries = pruneEntriesByPrefix(expandedEntries, nextCandidate.relativePath);
429
+ }
252
430
  } finally {
253
431
  await rm(listDir, { recursive: true, force: true }).catch(() => undefined);
254
432
  }
255
433
  }
256
434
 
435
+ async function createArchive(cwd: string, files: string[], archivePath: string): Promise<ArchiveCreationResult> {
436
+ return createArchiveForTesting(cwd, files, archivePath);
437
+ }
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
+
257
460
  function validateSubmissionOptions(
258
461
  params: { effort?: OracleEffort; autoSwitchToThinking?: boolean },
259
462
  modelFamily: OracleModelFamily,
@@ -311,6 +514,7 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
311
514
  projectId: job.projectId,
312
515
  sessionId: job.sessionId,
313
516
  createdAt: job.createdAt,
517
+ queuedAt: job.queuedAt,
314
518
  submittedAt: job.submittedAt,
315
519
  completedAt: job.completedAt,
316
520
  followUpToJobId: job.followUpToJobId,
@@ -329,6 +533,35 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
329
533
  };
330
534
  }
331
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
+
332
565
  export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void {
333
566
  pi.registerTool({
334
567
  name: "oracle_submit",
@@ -338,25 +571,32 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
338
571
  promptSnippet: "Dispatch a background ChatGPT web oracle job after gathering repo context.",
339
572
  promptGuidelines: [
340
573
  "Gather context before calling oracle_submit.",
341
- "Always include a narrowly scoped archive of exact relevant files/directories.",
574
+ "By default, archive the whole repo by passing '.'; default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like .env files, key material, credential dotfiles, local database files, and root secrets directories.",
575
+ "Only narrow file selection when the user explicitly asks, the task is clearly scoped smaller, or privacy/sensitivity requires it.",
576
+ "For very targeted asks like a single function or stack trace, a smaller archive is preferable.",
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.",
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.",
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.",
342
581
  "Stop after dispatching oracle_submit; do not continue the task while the oracle job is running.",
343
- "If oracle_submit fails, stop and report the error instead of retrying automatically.",
344
582
  "Only use autoSwitchToThinking with modelFamily=instant.",
345
583
  ],
346
584
  parameters: ORACLE_SUBMIT_PARAMS,
347
585
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
348
586
  const config = loadOracleConfig(ctx.cwd);
349
- const originSessionFile = getSessionFile(ctx);
587
+ const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
350
588
  const projectId = getProjectId(ctx.cwd);
351
589
  const sessionId = getSessionId(originSessionFile, projectId);
352
- const modelFamily = params.modelFamily ?? config.defaults.modelFamily;
353
- const requestedEffort = params.effort ?? config.defaults.effort;
354
- 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;
355
595
  const rawAutoSwitchToThinking = params.autoSwitchToThinking ?? config.defaults.autoSwitchToThinking;
356
596
  const autoSwitchToThinking = modelFamily === "instant" ? rawAutoSwitchToThinking : false;
357
597
  const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
358
598
 
359
- validateSubmissionOptions(params, modelFamily, effort, autoSwitchToThinking);
599
+ validateSubmissionOptions({ effort: submittedEffort, autoSwitchToThinking: params.autoSwitchToThinking }, modelFamily, effort, autoSwitchToThinking);
360
600
  try {
361
601
  await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
362
602
  await reconcileStaleOracleJobs();
@@ -369,29 +609,95 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
369
609
  const jobId = randomUUID();
370
610
  const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
371
611
  const runtime = allocateRuntime(config);
372
- 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;
373
620
 
374
621
  try {
375
- const archiveSha256 = await createArchive(ctx.cwd, params.files, tempArchivePath);
622
+ archive = await createArchive(ctx.cwd, params.files, tempArchivePath);
623
+ const currentArchive = archive;
376
624
  await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
377
- await acquireRuntimeLease(config, {
625
+ await promoteQueuedJobsWithinAdmissionLock({ workerPath, source: "oracle_submit" });
626
+
627
+ const admittedAt = new Date().toISOString();
628
+ const runtimeAttempt = await tryAcquireRuntimeLease(config, {
378
629
  jobId,
379
630
  runtimeId: runtime.runtimeId,
380
631
  runtimeSessionName: runtime.runtimeSessionName,
381
632
  runtimeProfileDir: runtime.runtimeProfileDir,
382
633
  projectId,
383
634
  sessionId,
384
- createdAt: new Date().toISOString(),
635
+ createdAt: admittedAt,
385
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;
386
684
  if (followUp.conversationId) {
387
- await acquireConversationLease({
685
+ const conversationAttempt = await tryAcquireConversationLease({
388
686
  jobId,
389
687
  conversationId: followUp.conversationId,
390
688
  projectId,
391
689
  sessionId,
392
- createdAt: new Date().toISOString(),
690
+ createdAt: admittedAt,
393
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;
394
699
  }
700
+
395
701
  job = await createJob(
396
702
  jobId,
397
703
  {
@@ -408,40 +714,109 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
408
714
  originSessionFile,
409
715
  config,
410
716
  runtime,
717
+ { initialState: "submitted", createdAt: admittedAt },
411
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
+ }));
412
730
  });
413
- await rename(tempArchivePath, job.archivePath);
414
- const worker = await spawnWorker(workerPath, job.id);
415
- await updateJob(job.id, (current) => ({
416
- ...current,
417
- archiveSha256,
418
- workerPid: worker.pid,
419
- workerNonce: worker.nonce,
420
- workerStartedAt: worker.startedAt,
421
- }));
731
+ if (!job || !archive) throw new Error(`Oracle submission ${jobId} did not persist job metadata durably`);
422
732
  if (ctx.hasUI) refreshOracleStatus(ctx);
423
733
 
734
+ const queuePosition = queued ? getQueuePosition(job.id) : undefined;
424
735
  return {
425
736
  content: [
426
737
  {
427
738
  type: "text",
428
- text: [
429
- `Oracle job dispatched: ${job.id}`,
430
- followUp.followUpToJobId ? `Follow-up to: ${followUp.followUpToJobId}` : undefined,
431
- `Prompt: ${job.promptPath}`,
432
- `Archive: ${job.archivePath}`,
433
- `Response will be written to: ${job.responsePath}`,
434
- "Stop now and wait for the oracle completion wake-up.",
435
- ]
436
- .filter(Boolean)
437
- .join("\n"),
739
+ text: formatSubmitResponse(job, {
740
+ autoPrunedPrefixes: currentArchive.autoPrunedPrefixes,
741
+ queued,
742
+ queuePosition: queuePosition?.position,
743
+ queueDepth: queuePosition?.depth,
744
+ }),
438
745
  },
439
746
  ],
440
- details: { jobId: job.id, archiveSha256, runtimeId: job.runtimeId, followUpToJobId: followUp.followUpToJobId },
747
+ details: {
748
+ jobId: job.id,
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,
756
+ runtimeId: job.runtimeId,
757
+ followUpToJobId: followUp.followUpToJobId,
758
+ },
441
759
  };
442
760
  } catch (error) {
443
761
  const message = error instanceof Error ? error.message : String(error);
444
- 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))) {
445
820
  const failedAt = new Date().toISOString();
446
821
  await updateJob(job.id, (current) => ({
447
822
  ...current,
@@ -452,12 +827,15 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
452
827
  }, failedAt),
453
828
  })).catch(() => undefined);
454
829
  }
455
- await cleanupRuntimeArtifacts({
456
- runtimeId: runtime.runtimeId,
457
- runtimeProfileDir: runtime.runtimeProfileDir,
458
- runtimeSessionName: runtime.runtimeSessionName,
459
- conversationId: followUp.conversationId,
460
- }).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
+ }
461
839
  if (ctx.hasUI) refreshOracleStatus(ctx);
462
840
  throw error;
463
841
  } finally {
@@ -476,10 +854,12 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
476
854
  if (!job || job.projectId !== getProjectId(ctx.cwd)) {
477
855
  throw new Error(`Oracle job not found in this project: ${params.jobId}`);
478
856
  }
857
+ const latest = isTerminalOracleJob(job) ? await markWakeupSettled(job.id) : job;
858
+ const current = latest ?? readJob(job.id) ?? job;
479
859
 
480
860
  let responsePreview = "";
481
861
  try {
482
- 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"));
483
863
  responsePreview = response.slice(0, 4000);
484
864
  } catch {
485
865
  responsePreview = "(response not available yet)";
@@ -490,14 +870,22 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
490
870
  {
491
871
  type: "text",
492
872
  text: [
493
- `job: ${job.id}`,
494
- `status: ${job.status}`,
495
- job.followUpToJobId ? `follow-up-to: ${job.followUpToJobId}` : undefined,
496
- job.chatUrl ? `chat: ${job.chatUrl}` : undefined,
497
- job.responsePath ? `response: ${job.responsePath}` : undefined,
498
- job.responseFormat ? `response-format: ${job.responseFormat}` : undefined,
499
- `artifacts: /tmp/oracle-${job.id}/artifacts`,
500
- 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,
501
889
  "",
502
890
  responsePreview,
503
891
  ]
@@ -505,7 +893,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
505
893
  .join("\n"),
506
894
  },
507
895
  ],
508
- details: { job: redactJobDetails(job) },
896
+ details: { job: redactJobDetails(current) },
509
897
  };
510
898
  },
511
899
  });
@@ -513,24 +901,27 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
513
901
  pi.registerTool({
514
902
  name: "oracle_cancel",
515
903
  label: "Oracle Cancel",
516
- description: "Cancel an active oracle job.",
904
+ description: "Cancel a queued or active oracle job.",
517
905
  parameters: ORACLE_CANCEL_PARAMS,
518
906
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
519
907
  const job = readJob(params.jobId);
520
908
  if (!job || job.projectId !== getProjectId(ctx.cwd)) {
521
909
  throw new Error(`Oracle job not found in this project: ${params.jobId}`);
522
910
  }
523
- if (!isActiveOracleJob(job)) {
911
+ if (!isOpenOracleJob(job)) {
524
912
  return {
525
- 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}).` }],
526
914
  details: { job: redactJobDetails(job) },
527
915
  };
528
916
  }
529
917
 
530
918
  const cancelled = await cancelOracleJob(params.jobId);
919
+ if (shouldAdvanceQueueAfterCancellation(cancelled)) {
920
+ await promoteQueuedJobs({ workerPath, source: "oracle_cancel_tool" });
921
+ }
531
922
  if (ctx.hasUI) refreshOracleStatus(ctx);
532
923
  return {
533
- 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}.` }],
534
925
  details: { job: redactJobDetails(cancelled) },
535
926
  };
536
927
  },