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
|
@@ -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
|
-
|
|
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(
|
|
42
|
-
effort: Type.Optional(
|
|
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([
|
|
94
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
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
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
353
|
-
const
|
|
354
|
-
const
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
685
|
+
const conversationAttempt = await tryAcquireConversationLease({
|
|
388
686
|
jobId,
|
|
389
687
|
conversationId: followUp.conversationId,
|
|
390
688
|
projectId,
|
|
391
689
|
sessionId,
|
|
392
|
-
createdAt:
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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: {
|
|
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
|
-
|
|
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(() =>
|
|
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(
|
|
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: ${
|
|
494
|
-
`status: ${
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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(
|
|
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
|
|
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 (!
|
|
911
|
+
if (!isOpenOracleJob(job)) {
|
|
524
912
|
return {
|
|
525
|
-
content: [{ type: "text", text: `Oracle job ${job.id} is not
|
|
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
|
},
|