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.
- package/CHANGELOG.md +46 -0
- package/README.md +26 -10
- package/docs/ORACLE_DESIGN.md +593 -0
- package/docs/ORACLE_RECOVERY_DRILL.md +127 -0
- package/extensions/oracle/index.ts +15 -4
- package/extensions/oracle/lib/commands.ts +39 -12
- package/extensions/oracle/lib/config.ts +2 -2
- package/extensions/oracle/lib/jobs.ts +510 -73
- package/extensions/oracle/lib/locks.ts +99 -13
- package/extensions/oracle/lib/poller.ts +224 -38
- package/extensions/oracle/lib/queue.ts +193 -0
- package/extensions/oracle/lib/runtime.ts +70 -16
- package/extensions/oracle/lib/tools.ts +313 -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,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
|
-
|
|
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
|
+
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
|
|
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 (!
|
|
134
|
-
ctx.ui.notify(`Oracle job ${jobId} is not
|
|
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
|
-
|
|
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
|
|
160
|
-
if (
|
|
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
|
|
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
|
-
|
|
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:
|
|
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) {
|