pi-oracle 0.1.12 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ # Oracle Recovery Drill
2
+
3
+ This document codifies the safe validation drill for expired / missing auth in the isolated oracle seed profile.
4
+
5
+ The goal is to prove:
6
+ 1. a broken seed profile fails cleanly
7
+ 2. the failure is classified as auth/login-required, not as generic UI drift
8
+ 3. `/oracle-auth` repairs the seed profile
9
+ 4. the next normal oracle job succeeds again
10
+
11
+ ## Safety guarantees
12
+
13
+ This drill must **not** touch the user’s real Chrome profile.
14
+ It only mutates the isolated oracle seed profile configured by `browser.authSeedProfileDir`.
15
+
16
+ That directory must remain separate from the real Chrome user-data tree.
17
+
18
+ ## Preconditions
19
+
20
+ - No active oracle jobs
21
+ - `pi` reloaded with the current extension code
22
+ - `/oracle-auth` happy path already known to work in the current environment
23
+
24
+ ## Backup
25
+
26
+ Create a backup of the current seed profile first:
27
+
28
+ ```bash
29
+ SEED="<oracle-auth-seed-profile-dir>"
30
+ BACKUP="/tmp/oracle-auth-seed-backup-$(date +%Y%m%dT%H%M%S)"
31
+ cp -cR "$SEED" "$BACKUP"
32
+ echo "$BACKUP"
33
+ ```
34
+
35
+ ## Expired/missing-auth simulation
36
+
37
+ Replace the seed profile with an empty isolated directory:
38
+
39
+ ```bash
40
+ SEED="<oracle-auth-seed-profile-dir>"
41
+ rm -rf "$SEED"
42
+ mkdir -p "$SEED"
43
+ chmod 700 "$SEED"
44
+ ```
45
+
46
+ This simulates a seed profile with no usable ChatGPT session.
47
+
48
+ ## Validation steps
49
+
50
+ ### 1. Reload `pi`
51
+
52
+ Reload so the extension sees the current seed directory state.
53
+
54
+ ### 2. Run a tiny oracle job
55
+
56
+ Use a tiny prompt with a tiny archive.
57
+ Expected result:
58
+ - job fails quickly
59
+ - failure is clearly auth/login related
60
+ - failure is **not** misclassified as:
61
+ - model configuration failure
62
+ - artifact failure
63
+ - generic timeout
64
+ - vague UI drift
65
+
66
+ ### 3. Repair with `/oracle-auth`
67
+
68
+ Run:
69
+
70
+ ```text
71
+ /oracle-auth
72
+ ```
73
+
74
+ Expected result:
75
+ - ChatGPT cookies are re-synced into the seed profile
76
+ - no real Chrome profile is mutated
77
+ - command reports success
78
+
79
+ ### 4. Reload `pi` again
80
+
81
+ Reload after auth repair.
82
+
83
+ ### 5. Run the same tiny oracle job again
84
+
85
+ Expected result:
86
+ - job succeeds normally
87
+ - response persists under `/tmp/oracle-<job-id>/response.md`
88
+ - wake-up triggers correctly
89
+
90
+ ## Pass criteria
91
+
92
+ The drill passes only if all of the following are true:
93
+
94
+ - Broken seed profile fails as an auth/login-required problem
95
+ - `/oracle-auth` repairs the seed profile cleanly
96
+ - The next normal oracle run succeeds
97
+ - No active worker/session/profile cleanup regressions appear
98
+ - No interaction with the real Chrome profile is required beyond cookie sync during `/oracle-auth`
99
+
100
+ ## Evidence to capture
101
+
102
+ For the failed run:
103
+ - `/tmp/oracle-<job-id>/job.json`
104
+ - `/tmp/oracle-<job-id>/logs/worker.log`
105
+ - any failure diagnostics under that job dir
106
+
107
+ For the repair:
108
+ - `/tmp/oracle-auth.log`
109
+ - `/tmp/oracle-auth.url.txt`
110
+ - `/tmp/oracle-auth.snapshot.txt`
111
+ - `/tmp/oracle-auth.body.txt`
112
+
113
+ For the successful rerun:
114
+ - `/tmp/oracle-<job-id>/job.json`
115
+ - `/tmp/oracle-<job-id>/response.md`
116
+ - `/tmp/oracle-<job-id>/logs/worker.log`
117
+
118
+ ## Maintainer note
119
+
120
+ This is a maintainer/operator validation document, not end-user setup documentation.
121
+ It intentionally includes destructive steps against the isolated oracle seed profile only.
122
+
123
+ ## If the drill fails
124
+
125
+ If the broken-seed run fails with anything other than a clean auth classification, fix that before treating recovery as production-ready.
126
+
127
+ If `/oracle-auth` does not restore a working seed, treat auth recovery as still blocking.
@@ -3,9 +3,11 @@ import { dirname, join } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
4
  import { loadOracleConfig } from "./lib/config.js";
5
5
  import { registerOracleCommands } from "./lib/commands.js";
6
- import { pruneTerminalOracleJobs, reconcileStaleOracleJobs } from "./lib/jobs.js";
6
+ import { getSessionFile, pruneTerminalOracleJobs, reconcileStaleOracleJobs } from "./lib/jobs.js";
7
7
  import { isLockTimeoutError, withGlobalReconcileLock } from "./lib/locks.js";
8
8
  import { refreshOracleStatus, startPoller, stopPoller } from "./lib/poller.js";
9
+ import { promoteQueuedJobs } from "./lib/queue.js";
10
+ import { hasPersistedSessionFile } from "./lib/runtime.js";
9
11
  import { registerOracleTools } from "./lib/tools.js";
10
12
 
11
13
  export default function oracleExtension(pi: ExtensionAPI) {
@@ -13,7 +15,7 @@ export default function oracleExtension(pi: ExtensionAPI) {
13
15
  const workerPath = join(extensionDir, "worker", "run-job.mjs");
14
16
  const authWorkerPath = join(extensionDir, "worker", "auth-bootstrap.mjs");
15
17
 
16
- registerOracleCommands(pi, authWorkerPath);
18
+ registerOracleCommands(pi, authWorkerPath, workerPath);
17
19
  registerOracleTools(pi, workerPath);
18
20
 
19
21
  async function runStartupMaintenance(ctx: ExtensionContext): Promise<void> {
@@ -25,20 +27,29 @@ export default function oracleExtension(pi: ExtensionAPI) {
25
27
  } catch (error) {
26
28
  if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
27
29
  }
30
+
31
+ await promoteQueuedJobs({ workerPath, source: "oracle_session_start" });
28
32
  }
29
33
 
30
34
  function startPollerForContext(ctx: ExtensionContext) {
31
35
  try {
36
+ const sessionFile = getSessionFile(ctx);
37
+ if (!hasPersistedSessionFile(sessionFile)) {
38
+ stopPoller(ctx);
39
+ ctx.ui.setStatus("oracle", ctx.ui.theme.fg("accent", "oracle: unavailable"));
40
+ return;
41
+ }
42
+
32
43
  const config = loadOracleConfig(ctx.cwd);
33
44
  void runStartupMaintenance(ctx).catch((error) => {
34
45
  console.error("Oracle startup maintenance failed:", error);
35
46
  });
36
- startPoller(pi, ctx, config.poller.intervalMs);
47
+ startPoller(pi, ctx, config.poller.intervalMs, workerPath);
37
48
  refreshOracleStatus(ctx);
38
49
  } catch (error) {
39
50
  const message = error instanceof Error ? error.message : String(error);
40
51
  stopPoller(ctx);
41
- ctx.ui.setStatus("oracle", ctx.ui.theme.fg("danger", "oracle: config error"));
52
+ ctx.ui.setStatus("oracle", ctx.ui.theme.fg("error", "oracle: config error"));
42
53
  ctx.ui.notify(message, "warning");
43
54
  }
44
55
  }
@@ -3,13 +3,17 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-cod
3
3
  import { loadOracleConfig } from "./config.js";
4
4
  import {
5
5
  cancelOracleJob,
6
- isActiveOracleJob,
6
+ isOpenOracleJob,
7
+ isTerminalOracleJob,
7
8
  listJobsForCwd,
9
+ markWakeupSettled,
8
10
  pruneTerminalOracleJobs,
9
11
  readJob,
10
12
  reconcileStaleOracleJobs,
11
13
  removeTerminalOracleJob,
14
+ shouldAdvanceQueueAfterCancellation,
12
15
  } from "./jobs.js";
16
+ import { getQueuePosition, promoteQueuedJobs } from "./queue.js";
13
17
  import { refreshOracleStatus } from "./poller.js";
14
18
  import { isLockTimeoutError, withGlobalReconcileLock } from "./locks.js";
15
19
  import { getProjectId } from "./runtime.js";
@@ -18,11 +22,15 @@ function summarizeJob(jobId: string): string {
18
22
  const job = readJob(jobId);
19
23
  if (!job) return `Oracle job ${jobId} not found.`;
20
24
 
25
+ const queuePosition = job.status === "queued" ? getQueuePosition(job.id) : undefined;
21
26
  return [
22
27
  `job: ${job.id}`,
23
28
  `status: ${job.status}`,
24
29
  `phase: ${job.phase}`,
25
30
  `created: ${job.createdAt}`,
31
+ job.queuedAt ? `queued: ${job.queuedAt}` : undefined,
32
+ job.submittedAt ? `submitted: ${job.submittedAt}` : undefined,
33
+ queuePosition ? `queue-position: ${queuePosition.position} of ${queuePosition.depth} global` : undefined,
26
34
  `project: ${job.projectId}`,
27
35
  `session: ${job.sessionId}`,
28
36
  job.completedAt ? `completed: ${job.completedAt}` : undefined,
@@ -84,7 +92,7 @@ async function runAuthBootstrap(authWorkerPath: string, cwd: string): Promise<st
84
92
  });
85
93
  }
86
94
 
87
- export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string): void {
95
+ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string, workerPath: string): void {
88
96
  pi.registerCommand("oracle-auth", {
89
97
  description: "Sync ChatGPT cookies from real Chrome into the oracle auth seed profile",
90
98
  handler: async (_args, ctx) => {
@@ -107,16 +115,24 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string)
107
115
  ctx.ui.notify("No oracle jobs found for this project", "info");
108
116
  return;
109
117
  }
110
- if (explicitJobId && !readScopedJob(jobId, ctx.cwd)) {
118
+ const job = readScopedJob(jobId, ctx.cwd);
119
+ if (!job) {
111
120
  ctx.ui.notify(`Oracle job ${jobId} was not found in this project`, "warning");
112
121
  return;
113
122
  }
114
- ctx.ui.notify(summarizeJob(jobId), "info");
123
+ if (isTerminalOracleJob(job)) {
124
+ await markWakeupSettled(job.id, {
125
+ source: "oracle_status",
126
+ sessionFile: ctx.sessionManager.getSessionFile?.(),
127
+ cwd: ctx.cwd,
128
+ });
129
+ }
130
+ ctx.ui.notify(summarizeJob(job.id), "info");
115
131
  },
116
132
  });
117
133
 
118
134
  pi.registerCommand("oracle-cancel", {
119
- description: "Cancel an active oracle job",
135
+ description: "Cancel a queued or active oracle job",
120
136
  handler: async (args, ctx) => {
121
137
  const explicitJobId = args.trim();
122
138
  const jobId = explicitJobId || getLatestJobId(ctx.cwd);
@@ -130,14 +146,20 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string)
130
146
  ctx.ui.notify(`Oracle job ${jobId} not found in this project`, "warning");
131
147
  return;
132
148
  }
133
- if (!isActiveOracleJob(job)) {
134
- ctx.ui.notify(`Oracle job ${jobId} is not active (${job.status})`, "info");
149
+ if (!isOpenOracleJob(job)) {
150
+ ctx.ui.notify(`Oracle job ${jobId} is not cancellable (${job.status})`, "info");
135
151
  return;
136
152
  }
137
153
 
138
154
  const cancelled = await cancelOracleJob(jobId);
155
+ if (shouldAdvanceQueueAfterCancellation(cancelled)) {
156
+ await promoteQueuedJobs({ workerPath, source: "oracle_cancel_command" });
157
+ }
139
158
  refreshOracleStatus(ctx);
140
- ctx.ui.notify(`Cancelled oracle job ${cancelled.id}`, "info");
159
+ const message = cancelled.status === "cancelled" || cancelled.status === "failed"
160
+ ? `Cancelled oracle job ${cancelled.id}`
161
+ : `Oracle job ${cancelled.id} was already ${cancelled.status}`;
162
+ ctx.ui.notify(message, "info");
141
163
  },
142
164
  });
143
165
 
@@ -156,20 +178,22 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string)
156
178
  return;
157
179
  }
158
180
 
159
- const activeJobs = jobs.filter((job): job is NonNullable<typeof job> => Boolean(job && isActiveOracleJob(job)));
160
- if (activeJobs.length > 0) {
181
+ const nonTerminalJobs = jobs.filter((job): job is NonNullable<typeof job> => Boolean(job && !isTerminalOracleJob(job)));
182
+ if (nonTerminalJobs.length > 0) {
161
183
  ctx.ui.notify(
162
- `Refusing to remove active oracle job${activeJobs.length === 1 ? "" : "s"}: ${activeJobs.map((job) => job.id).join(", ")}`,
184
+ `Refusing to remove non-terminal oracle job${nonTerminalJobs.length === 1 ? "" : "s"}: ${nonTerminalJobs.map((job) => job.id).join(", ")}`,
163
185
  "warning",
164
186
  );
165
187
  return;
166
188
  }
167
189
 
168
190
  const cleanupWarnings: string[] = [];
191
+ let removedCount = 0;
169
192
  const removeJobs = async () => {
170
193
  for (const job of jobs) {
171
194
  if (!job) continue;
172
195
  const result = await removeTerminalOracleJob(job);
196
+ if (result.removed) removedCount += 1;
173
197
  cleanupWarnings.push(...result.cleanupReport.warnings.map((warning) => `${job.id}: ${warning}`));
174
198
  }
175
199
  };
@@ -186,7 +210,10 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string)
186
210
 
187
211
  refreshOracleStatus(ctx);
188
212
  const warningSuffix = cleanupWarnings.length > 0 ? ` Cleanup warnings:\n${cleanupWarnings.join("\n")}` : "";
189
- ctx.ui.notify(`Removed ${jobs.length} oracle job director${jobs.length === 1 ? "y" : "ies"}.${warningSuffix}`, cleanupWarnings.length > 0 ? "warning" : "info");
213
+ const removalSummary = removedCount === jobs.length
214
+ ? `Removed ${removedCount} oracle job director${removedCount === 1 ? "y" : "ies"}.`
215
+ : `Removed ${removedCount} of ${jobs.length} oracle job director${jobs.length === 1 ? "y" : "ies"}; retained ${jobs.length - removedCount} with cleanup warnings.`;
216
+ ctx.ui.notify(`${removalSummary}${warningSuffix}`, cleanupWarnings.length > 0 ? "warning" : "info");
190
217
  },
191
218
  });
192
219
  }
@@ -106,7 +106,7 @@ export const DEFAULT_CONFIG: OracleConfig = {
106
106
  sessionPrefix: "oracle",
107
107
  authSeedProfileDir: join(agentExtensionsDir, "oracle-auth-seed-profile"),
108
108
  runtimeProfilesDir: join(agentExtensionsDir, "oracle-runtime-profiles"),
109
- maxConcurrentJobs: 8,
109
+ maxConcurrentJobs: 2,
110
110
  cloneStrategy: "apfs-clone",
111
111
  chatUrl: "https://chatgpt.com/",
112
112
  authUrl: "https://chatgpt.com/auth/login",
@@ -299,7 +299,7 @@ function validateOracleConfig(value: unknown): OracleConfig {
299
299
  const modelFamily = expectEnum(defaults.modelFamily, "defaults.modelFamily", MODEL_FAMILIES);
300
300
  const effort = expectEnum(defaults.effort, "defaults.effort", EFFORTS);
301
301
  const autoSwitchToThinking = expectBoolean(defaults.autoSwitchToThinking, "defaults.autoSwitchToThinking");
302
- if (modelFamily === "pro" && !PRO_EFFORTS.includes(effort)) {
302
+ if (modelFamily === "pro" && effort !== "standard" && effort !== "extended") {
303
303
  throw new Error(`Invalid oracle config: defaults.effort must be one of ${PRO_EFFORTS.join(", ")} for pro`);
304
304
  }
305
305
  if (modelFamily !== "instant" && autoSwitchToThinking) {