pi-oracle 0.1.12 → 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 +24 -10
- 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 +274 -64
- 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 +330 -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 +2 -0
|
@@ -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("
|
|
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
|
-
|
|
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,20 @@ 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
|
-
|
|
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
|
-
|
|
123
|
+
if (isTerminalOracleJob(job)) {
|
|
124
|
+
await markWakeupSettled(job.id);
|
|
125
|
+
}
|
|
126
|
+
ctx.ui.notify(summarizeJob(job.id), "info");
|
|
115
127
|
},
|
|
116
128
|
});
|
|
117
129
|
|
|
118
130
|
pi.registerCommand("oracle-cancel", {
|
|
119
|
-
description: "Cancel
|
|
131
|
+
description: "Cancel a queued or active oracle job",
|
|
120
132
|
handler: async (args, ctx) => {
|
|
121
133
|
const explicitJobId = args.trim();
|
|
122
134
|
const jobId = explicitJobId || getLatestJobId(ctx.cwd);
|
|
@@ -130,14 +142,20 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string)
|
|
|
130
142
|
ctx.ui.notify(`Oracle job ${jobId} not found in this project`, "warning");
|
|
131
143
|
return;
|
|
132
144
|
}
|
|
133
|
-
if (!
|
|
134
|
-
ctx.ui.notify(`Oracle job ${jobId} is not
|
|
145
|
+
if (!isOpenOracleJob(job)) {
|
|
146
|
+
ctx.ui.notify(`Oracle job ${jobId} is not cancellable (${job.status})`, "info");
|
|
135
147
|
return;
|
|
136
148
|
}
|
|
137
149
|
|
|
138
150
|
const cancelled = await cancelOracleJob(jobId);
|
|
151
|
+
if (shouldAdvanceQueueAfterCancellation(cancelled)) {
|
|
152
|
+
await promoteQueuedJobs({ workerPath, source: "oracle_cancel_command" });
|
|
153
|
+
}
|
|
139
154
|
refreshOracleStatus(ctx);
|
|
140
|
-
|
|
155
|
+
const message = cancelled.status === "cancelled" || cancelled.status === "failed"
|
|
156
|
+
? `Cancelled oracle job ${cancelled.id}`
|
|
157
|
+
: `Oracle job ${cancelled.id} was already ${cancelled.status}`;
|
|
158
|
+
ctx.ui.notify(message, "info");
|
|
141
159
|
},
|
|
142
160
|
});
|
|
143
161
|
|
|
@@ -156,20 +174,22 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string)
|
|
|
156
174
|
return;
|
|
157
175
|
}
|
|
158
176
|
|
|
159
|
-
const
|
|
160
|
-
if (
|
|
177
|
+
const nonTerminalJobs = jobs.filter((job): job is NonNullable<typeof job> => Boolean(job && !isTerminalOracleJob(job)));
|
|
178
|
+
if (nonTerminalJobs.length > 0) {
|
|
161
179
|
ctx.ui.notify(
|
|
162
|
-
`Refusing to remove
|
|
180
|
+
`Refusing to remove non-terminal oracle job${nonTerminalJobs.length === 1 ? "" : "s"}: ${nonTerminalJobs.map((job) => job.id).join(", ")}`,
|
|
163
181
|
"warning",
|
|
164
182
|
);
|
|
165
183
|
return;
|
|
166
184
|
}
|
|
167
185
|
|
|
168
186
|
const cleanupWarnings: string[] = [];
|
|
187
|
+
let removedCount = 0;
|
|
169
188
|
const removeJobs = async () => {
|
|
170
189
|
for (const job of jobs) {
|
|
171
190
|
if (!job) continue;
|
|
172
191
|
const result = await removeTerminalOracleJob(job);
|
|
192
|
+
if (result.removed) removedCount += 1;
|
|
173
193
|
cleanupWarnings.push(...result.cleanupReport.warnings.map((warning) => `${job.id}: ${warning}`));
|
|
174
194
|
}
|
|
175
195
|
};
|
|
@@ -186,7 +206,10 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string)
|
|
|
186
206
|
|
|
187
207
|
refreshOracleStatus(ctx);
|
|
188
208
|
const warningSuffix = cleanupWarnings.length > 0 ? ` Cleanup warnings:\n${cleanupWarnings.join("\n")}` : "";
|
|
189
|
-
|
|
209
|
+
const removalSummary = removedCount === jobs.length
|
|
210
|
+
? `Removed ${removedCount} oracle job director${removedCount === 1 ? "y" : "ies"}.`
|
|
211
|
+
: `Removed ${removedCount} of ${jobs.length} oracle job director${jobs.length === 1 ? "y" : "ies"}; retained ${jobs.length - removedCount} with cleanup warnings.`;
|
|
212
|
+
ctx.ui.notify(`${removalSummary}${warningSuffix}`, cleanupWarnings.length > 0 ? "warning" : "info");
|
|
190
213
|
},
|
|
191
214
|
});
|
|
192
215
|
}
|
|
@@ -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:
|
|
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" &&
|
|
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) {
|