pi-oracle 0.4.0 → 0.6.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.
@@ -34,7 +34,7 @@ import { cleanupRuntimeArtifacts, getProjectId, getSessionId, parseConversationI
34
34
  export type OracleJobStatus = SharedOracleJobStatus;
35
35
  export type OracleJobPhase = SharedOracleJobPhase;
36
36
 
37
- export type OracleWakeupSettlementSource = "oracle_read" | "oracle_status";
37
+ export type OracleWakeupSettlementSource = "oracle_read" | "oracle_status" | "oracle_read_command";
38
38
 
39
39
  export { ACTIVE_ORACLE_JOB_STATUSES, OPEN_ORACLE_JOB_STATUSES, TERMINAL_ORACLE_JOB_STATUSES };
40
40
  export const ORACLE_MISSING_WORKER_GRACE_MS = 30_000;
@@ -285,6 +285,16 @@ function notificationClaimIsLive(job: Pick<OracleJob, "notifyClaimedAt" | "notif
285
285
  return now - claimedAtMs < ORACLE_NOTIFICATION_CLAIM_TTL_MS;
286
286
  }
287
287
 
288
+ function getWakeupRetentionGraceDeadline(job: Pick<OracleJob, "wakeupLastRequestedAt">, now = Date.now()): { retryAt: string; remainingMs: number } | undefined {
289
+ const lastRequestedAtMs = parseTimestamp(job.wakeupLastRequestedAt);
290
+ if (lastRequestedAtMs === undefined) return undefined;
291
+ const retryAtMs = lastRequestedAtMs + ORACLE_WAKEUP_POST_SEND_RETENTION_MS;
292
+ return {
293
+ retryAt: new Date(retryAtMs).toISOString(),
294
+ remainingMs: Math.max(0, retryAtMs - now),
295
+ };
296
+ }
297
+
288
298
  function wakeupRetentionGraceIsActive(job: Pick<OracleJob, "wakeupLastRequestedAt">, now = Date.now()): boolean {
289
299
  const lastRequestedAtMs = parseTimestamp(job.wakeupLastRequestedAt);
290
300
  if (lastRequestedAtMs === undefined) return false;
@@ -464,12 +474,17 @@ export async function removeTerminalOracleJob(job: OracleJob): Promise<{ removed
464
474
  },
465
475
  };
466
476
  }
467
- if (wakeupRetentionGraceIsActive(current)) {
477
+ const nowMs = Date.now();
478
+ if (wakeupRetentionGraceIsActive(current, nowMs)) {
479
+ const graceDeadline = getWakeupRetentionGraceDeadline(current, nowMs);
480
+ const retryHint = graceDeadline
481
+ ? ` Retry after ${graceDeadline.retryAt} (${Math.ceil(graceDeadline.remainingMs / 1000)}s remaining).`
482
+ : "";
468
483
  return {
469
484
  removed: false,
470
485
  cleanupReport: {
471
486
  attempted: [],
472
- warnings: [`Refusing to remove terminal oracle job ${current.id} because its wake-up delivery is still within the post-send retention grace window.`],
487
+ warnings: [`Refusing to remove terminal oracle job ${current.id} because its wake-up delivery is still within the post-send retention grace window.${retryHint}`],
473
488
  },
474
489
  };
475
490
  }
@@ -713,8 +728,10 @@ export async function releaseNotificationClaim(jobId: string, claimedBy: string)
713
728
  export async function noteWakeupRequested(jobId: string, at = new Date().toISOString()): Promise<OracleJob | undefined> {
714
729
  try {
715
730
  return await updateJob(jobId, (job) => noteOracleJobWakeupRequested(job, { at, source: "oracle:poller" }));
716
- } catch {
717
- return readJob(jobId);
731
+ } catch (error) {
732
+ const message = error instanceof Error ? error.message : String(error);
733
+ if (message.startsWith("Oracle job not found:")) return undefined;
734
+ throw error;
718
735
  }
719
736
  }
720
737
 
@@ -912,7 +929,16 @@ export function resolveArchiveInputs(cwd: string, files: string[]): { absolute:
912
929
 
913
930
  const realCwd = realpathSync(cwd);
914
931
  return files.map((file) => {
932
+ if (!file.trim()) {
933
+ throw new Error("Archive input must be a non-empty project-relative path");
934
+ }
935
+ if (file.trim() === "." && file !== ".") {
936
+ throw new Error("Archive input must use '.' exactly for a whole-repo archive");
937
+ }
915
938
  const absolute = resolve(cwd, file);
939
+ if (absolute === cwd && file !== ".") {
940
+ throw new Error("Archive input must use '.' exactly for a whole-repo archive");
941
+ }
916
942
  const relative = absolute.startsWith(`${cwd}/`) ? absolute.slice(cwd.length + 1) : absolute === cwd ? "." : "";
917
943
  if (!relative) {
918
944
  throw new Error(`Archive input must be inside the project cwd: ${file}`);
@@ -3,6 +3,7 @@
3
3
  // Scope: Poller/orchestration only; durable lifecycle mutations live in jobs.ts and shared observability formatting lives in extensions/oracle/shared.
4
4
  // Usage: Imported by the oracle extension entrypoint to start or stop per-session oracle polling.
5
5
  // Invariants/Assumptions: Poller scans are serialized per session key, wake-up delivery is best-effort, and terminal-job notifications always re-read durable job state before send.
6
+ import { existsSync } from "node:fs";
6
7
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
7
8
  import { buildOracleStatusText, buildOracleWakeupNotificationContent } from "../shared/job-observability-helpers.mjs";
8
9
  import { isProcessAlive, readProcessStartedAt } from "../shared/process-helpers.mjs";
@@ -154,7 +155,8 @@ function requestWakeupTurn(pi: ExtensionAPI, job: OraclePollerJob): void {
154
155
  customType: ORACLE_WAKEUP_REMINDER_CUSTOM_TYPE,
155
156
  display: false,
156
157
  content: buildOracleWakeupNotificationContent(job, {
157
- responsePath: job.responsePath || `${getJobDir(job.id)}/response.md`,
158
+ responsePath: job.responsePath,
159
+ responseAvailable: Boolean(job.responsePath && existsSync(job.responsePath)),
158
160
  artifactsPath: `${getJobDir(job.id)}/artifacts`,
159
161
  }),
160
162
  details: { jobId: job.id, status: job.status },
@@ -243,8 +245,14 @@ async function scan(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string,
243
245
  continue;
244
246
  }
245
247
 
246
- requestWakeupTurn(pi, deliverable);
247
- await noteWakeupRequested(jobId).catch(() => undefined);
248
+ const notedWakeup = await noteWakeupRequested(jobId);
249
+ const deliverableAfterNote = notedWakeup ?? readJob(jobId);
250
+ if (!deliverableAfterNote || shouldPruneTerminalJob(deliverableAfterNote, Date.now())) {
251
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
252
+ continue;
253
+ }
254
+
255
+ requestWakeupTurn(pi, deliverableAfterNote);
248
256
  if (ctx.hasUI) {
249
257
  ctx.ui.notify(`Oracle job ${claimed.id} is ${claimed.status}.`, "info");
250
258
  }
@@ -292,12 +300,33 @@ export function stopPollerForSession(sessionFile: string | undefined, cwd: strin
292
300
  if (timer) {
293
301
  clearInterval(timer);
294
302
  activePollers.delete(sessionKey);
295
- scansInFlight.delete(sessionKey);
296
303
  }
297
304
  const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(sessionKey);
298
305
  void releaseLease(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey).catch(() => undefined);
299
306
  }
300
307
 
308
+ export async function stopAllPollers(): Promise<void> {
309
+ const sessionKeys = [...activePollers.keys()];
310
+ for (const timer of activePollers.values()) {
311
+ clearInterval(timer);
312
+ }
313
+ activePollers.clear();
314
+ await Promise.all(sessionKeys.map(async (sessionKey) => {
315
+ const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(sessionKey);
316
+ await releaseLease(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey).catch(() => undefined);
317
+ }));
318
+ }
319
+
320
+ export async function waitForAllPollersToQuiesce(timeoutMs = 2_000): Promise<void> {
321
+ const startedAt = Date.now();
322
+ while (scansInFlight.size > 0) {
323
+ if (Date.now() - startedAt >= timeoutMs) {
324
+ throw new Error(`Timed out waiting for oracle pollers to quiesce after ${timeoutMs}ms`);
325
+ }
326
+ await new Promise((resolve) => setTimeout(resolve, 25));
327
+ }
328
+ }
329
+
301
330
  export function stopPoller(ctx: ExtensionContext): void {
302
331
  const sessionFile = getSessionFile(ctx);
303
332
  if (!sessionFile) return;
@@ -5,9 +5,9 @@
5
5
  // Invariants/Assumptions: Lease metadata is the admission source of truth, tracked worker identity checks defend against PID reuse, and runtime cleanup always attempts lease release.
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { spawn } from "node:child_process";
8
- import { existsSync, realpathSync, readFileSync } from "node:fs";
9
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
10
- import { dirname, join } from "node:path";
8
+ import { constants as fsConstants, existsSync, realpathSync, readFileSync } from "node:fs";
9
+ import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
10
+ import { delimiter, dirname, join } from "node:path";
11
11
  import { jobBlocksAdmission } from "../shared/job-coordination-helpers.mjs";
12
12
  import { isTrackedProcessAlive } from "../shared/process-helpers.mjs";
13
13
  import type { OracleConfig } from "./config.js";
@@ -21,6 +21,14 @@ const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/ag
21
21
  ) || "agent-browser";
22
22
  const PROFILE_CLONE_TIMEOUT_MS = 120_000;
23
23
  const ORACLE_SUBPROCESS_KILL_GRACE_MS = 2_000;
24
+ const WORKSPACE_ROOT_MARKERS = [
25
+ ".pi/extensions/oracle.json",
26
+ ] as const;
27
+ const REQUIRED_ORACLE_DEPENDENCIES = [
28
+ { name: "agent-browser", command: AGENT_BROWSER_BIN },
29
+ { name: "tar", command: "tar" },
30
+ { name: "zstd", command: "zstd" },
31
+ ] as const;
24
32
 
25
33
  export interface OracleRuntimeLeaseMetadata {
26
34
  jobId: string;
@@ -51,7 +59,7 @@ export interface OracleConversationLeaseAttempt {
51
59
  blocker?: OracleConversationLeaseMetadata;
52
60
  }
53
61
 
54
- export function getProjectId(cwd: string): string {
62
+ function resolveRealCwd(cwd: string): string {
55
63
  try {
56
64
  return realpathSync(cwd);
57
65
  } catch {
@@ -59,6 +67,26 @@ export function getProjectId(cwd: string): string {
59
67
  }
60
68
  }
61
69
 
70
+ function hasWorkspaceRootMarker(path: string): boolean {
71
+ return WORKSPACE_ROOT_MARKERS.some((marker) => existsSync(join(path, marker)));
72
+ }
73
+
74
+ function resolveWorkspaceRoot(realCwd: string): string {
75
+ let current = realCwd;
76
+ let nearestMarkerRoot: string | undefined;
77
+ while (true) {
78
+ if (existsSync(join(current, ".git"))) return current;
79
+ if (!nearestMarkerRoot && hasWorkspaceRootMarker(current)) nearestMarkerRoot = current;
80
+ const parent = dirname(current);
81
+ if (parent === current) return nearestMarkerRoot ?? realCwd;
82
+ current = parent;
83
+ }
84
+ }
85
+
86
+ export function getProjectId(cwd: string): string {
87
+ return resolveWorkspaceRoot(resolveRealCwd(cwd));
88
+ }
89
+
62
90
  export function hasPersistedSessionFile(originSessionFile: string | undefined): originSessionFile is string {
63
91
  return Boolean(originSessionFile);
64
92
  }
@@ -98,6 +126,144 @@ export function authSessionName(config: OracleConfig): string {
98
126
  return `${config.browser.sessionPrefix}-auth`;
99
127
  }
100
128
 
129
+ function missingAuthSeedProfileMessage(seedDir: string): string {
130
+ return `Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`;
131
+ }
132
+
133
+ function invalidAuthSeedProfileTypeMessage(seedDir: string): string {
134
+ return `Oracle auth seed profile is not a directory: ${seedDir}. Remove the invalid path or rerun /oracle-auth.`;
135
+ }
136
+
137
+ function unreadableAuthSeedProfileMessage(seedDir: string): string {
138
+ return `Oracle auth seed profile is not readable: ${seedDir}. Fix its permissions or rerun /oracle-auth.`;
139
+ }
140
+
141
+ function missingBrowserExecutableMessage(executablePath: string): string {
142
+ return `Configured oracle browser executable does not exist: ${executablePath}. Fix browser.executablePath or install Chrome there.`;
143
+ }
144
+
145
+ function nonExecutableBrowserMessage(executablePath: string): string {
146
+ return `Configured oracle browser executable is not executable: ${executablePath}. Fix browser.executablePath permissions or point it at a runnable Chrome binary.`;
147
+ }
148
+
149
+ function missingLocalDependencyMessage(name: string): string {
150
+ return `Oracle prerequisite not found on PATH: ${name}. Install ${name} and retry.`;
151
+ }
152
+
153
+ function unwritableOracleDirectoryMessage(label: "runtime profiles" | "jobs", path: string): string {
154
+ return `Oracle ${label} directory is not writable: ${path}. Fix its permissions or configure a writable path, then retry.`;
155
+ }
156
+
157
+ async function resolveExecutableOnPath(command: string): Promise<string | undefined> {
158
+ if (!command) return undefined;
159
+ if (command.includes("/")) {
160
+ try {
161
+ await access(command, fsConstants.X_OK);
162
+ return command;
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ }
167
+
168
+ const pathValue = process.env.PATH ?? "";
169
+ for (const segment of pathValue.split(delimiter)) {
170
+ if (!segment) continue;
171
+ const candidate = join(segment, command);
172
+ try {
173
+ await access(candidate, fsConstants.X_OK);
174
+ return candidate;
175
+ } catch {
176
+ continue;
177
+ }
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ async function assertConfiguredBrowserExecutableReady(executablePath: string | undefined): Promise<void> {
183
+ if (!executablePath) return;
184
+ let executableStats;
185
+ try {
186
+ executableStats = await stat(executablePath);
187
+ } catch (error) {
188
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
189
+ if (code === "ENOENT") throw new Error(missingBrowserExecutableMessage(executablePath));
190
+ if (code === "EACCES" || code === "EPERM") throw new Error(nonExecutableBrowserMessage(executablePath));
191
+ throw new Error(`Failed to inspect configured oracle browser executable ${executablePath}: ${error instanceof Error ? error.message : String(error)}`);
192
+ }
193
+
194
+ if (!executableStats.isFile()) {
195
+ throw new Error(nonExecutableBrowserMessage(executablePath));
196
+ }
197
+
198
+ try {
199
+ await access(executablePath, fsConstants.X_OK);
200
+ } catch {
201
+ throw new Error(nonExecutableBrowserMessage(executablePath));
202
+ }
203
+ }
204
+
205
+ async function assertRequiredLocalDependencyReady(name: string, command: string): Promise<void> {
206
+ const resolved = await resolveExecutableOnPath(command);
207
+ if (!resolved) throw new Error(missingLocalDependencyMessage(name));
208
+ }
209
+
210
+ async function assertWritableDirectory(path: string, label: "runtime profiles" | "jobs"): Promise<void> {
211
+ try {
212
+ await mkdir(path, { recursive: true, mode: 0o700 });
213
+ } catch {
214
+ throw new Error(unwritableOracleDirectoryMessage(label, path));
215
+ }
216
+
217
+ let directoryStats;
218
+ try {
219
+ directoryStats = await stat(path);
220
+ } catch {
221
+ throw new Error(unwritableOracleDirectoryMessage(label, path));
222
+ }
223
+ if (!directoryStats.isDirectory()) {
224
+ throw new Error(unwritableOracleDirectoryMessage(label, path));
225
+ }
226
+
227
+ try {
228
+ await access(path, fsConstants.W_OK | fsConstants.X_OK);
229
+ } catch {
230
+ throw new Error(unwritableOracleDirectoryMessage(label, path));
231
+ }
232
+ }
233
+
234
+ export async function assertOracleAuthSeedProfileReady(config: OracleConfig): Promise<void> {
235
+ const seedDir = config.browser.authSeedProfileDir;
236
+ let seedStats;
237
+ try {
238
+ seedStats = await stat(seedDir);
239
+ } catch (error) {
240
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
241
+ if (code === "ENOENT") throw new Error(missingAuthSeedProfileMessage(seedDir));
242
+ if (code === "EACCES" || code === "EPERM") throw new Error(unreadableAuthSeedProfileMessage(seedDir));
243
+ throw new Error(`Failed to inspect oracle auth seed profile ${seedDir}: ${error instanceof Error ? error.message : String(error)}`);
244
+ }
245
+
246
+ if (!seedStats.isDirectory()) {
247
+ throw new Error(invalidAuthSeedProfileTypeMessage(seedDir));
248
+ }
249
+
250
+ try {
251
+ await access(seedDir, fsConstants.R_OK | fsConstants.X_OK);
252
+ } catch {
253
+ throw new Error(unreadableAuthSeedProfileMessage(seedDir));
254
+ }
255
+ }
256
+
257
+ export async function assertOracleSubmitPrerequisites(config: OracleConfig): Promise<void> {
258
+ await assertOracleAuthSeedProfileReady(config);
259
+ await assertConfiguredBrowserExecutableReady(config.browser.executablePath);
260
+ for (const dependency of REQUIRED_ORACLE_DEPENDENCIES) {
261
+ await assertRequiredLocalDependencyReady(dependency.name, dependency.command);
262
+ }
263
+ await assertWritableDirectory(config.browser.runtimeProfilesDir, "runtime profiles");
264
+ await assertWritableDirectory(ORACLE_JOBS_DIR, "jobs");
265
+ }
266
+
101
267
  export function getSeedGeneration(config: OracleConfig): string | undefined {
102
268
  const path = join(config.browser.authSeedProfileDir, SEED_GENERATION_FILE);
103
269
  if (!existsSync(path)) return undefined;
@@ -267,9 +433,7 @@ export async function cloneSeedProfileToRuntime(
267
433
  options?: { cpTimeoutMs?: number },
268
434
  ): Promise<string | undefined> {
269
435
  const seedDir = config.browser.authSeedProfileDir;
270
- if (!existsSync(seedDir)) {
271
- throw new Error(`Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`);
272
- }
436
+ await assertOracleAuthSeedProfileReady(config);
273
437
 
274
438
  await withAuthLock({ runtimeProfileDir, seedDir }, async () => {
275
439
  await rm(runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);