pi-oracle 0.4.0 → 0.5.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 +17 -0
- package/README.md +25 -8
- package/docs/ORACLE_DESIGN.md +14 -8
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +28 -1
- package/extensions/oracle/lib/commands.ts +14 -5
- package/extensions/oracle/lib/config.ts +51 -2
- package/extensions/oracle/lib/jobs.ts +17 -2
- package/extensions/oracle/lib/poller.ts +25 -2
- package/extensions/oracle/lib/runtime.ts +42 -5
- package/extensions/oracle/lib/tools.ts +615 -247
- package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +1 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.mjs +13 -0
- package/extensions/oracle/shared/job-observability-helpers.d.mts +2 -1
- package/extensions/oracle/shared/job-observability-helpers.mjs +26 -8
- package/extensions/oracle/worker/auth-bootstrap.mjs +49 -4
- package/package.json +1 -1
- package/prompts/oracle.md +16 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-04-12
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `oracle_preflight`, a lightweight readiness tool that lets `/oracle` fail fast on missing persisted-session or local auth/config blockers before archive/context work begins
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
- `/oracle` now follows a stricter preflight-first flow, biases toward minimal context gathering for explicitly narrow requests, and prefers the configured default model unless a different preset is clearly needed
|
|
10
|
+
- `oracle_read`, `/oracle-status`, and wake-up messaging now keep the true terminal event prominent, separate wake-up bookkeeping from failure state, and stop implying that a missing `response.md` is ready
|
|
11
|
+
- `/oracle-auth` failure guidance now reports the effective agent config path for the active agent dir and explains when a project config was also read but could not override `auth.*`
|
|
12
|
+
- `/oracle-clean` now documents the short post-send retention grace window and returns a retry-after timestamp when a terminal job is still intentionally retained
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- `oracle_submit` now rejects locally knowable auth-seed blockers before archive creation or job persistence while still preserving direct archive-input validation errors like symlink escapes
|
|
16
|
+
- oracle tool results now use consistent structured `details.job` / `details.error` payloads and preserve `isError` for structured failures through the tool-result hook
|
|
17
|
+
- `/oracle` no-session and missing-seed flows now stop before unnecessary repo exploration, and narrow prompt-template runs dispatch more quickly with smaller archives when the user scope is explicit
|
|
18
|
+
- repeated oracle sanity runs now quiesce background pollers before isolated-state teardown so release verification no longer emits a noisy temp-lock ENOENT race
|
|
19
|
+
|
|
3
20
|
## 0.4.0 - 2026-04-12
|
|
4
21
|
|
|
5
22
|
### Added
|
package/README.md
CHANGED
|
@@ -42,12 +42,17 @@ pi install https://github.com/fitchmultz/pi-oracle
|
|
|
42
42
|
|
|
43
43
|
## Quickstart
|
|
44
44
|
|
|
45
|
-
1.
|
|
46
|
-
2. Make sure
|
|
47
|
-
3.
|
|
48
|
-
4.
|
|
49
|
-
5. Run `/oracle
|
|
50
|
-
6.
|
|
45
|
+
1. Start a normal persisted `pi` session. Do not use `pi --no-session` for oracle.
|
|
46
|
+
2. Make sure ChatGPT already works in your local Chrome profile.
|
|
47
|
+
3. Make sure these are installed: Google Chrome, `agent-browser`, `tar`, and `zstd`.
|
|
48
|
+
4. Optional: create `~/.pi/agent/extensions/oracle.json` if you want non-default settings.
|
|
49
|
+
5. Run `/oracle-auth`.
|
|
50
|
+
6. Run `/oracle Review the current pending changes. Include the whole repo unless a narrower archive is clearly better.`
|
|
51
|
+
7. Wait for a best-effort wake-up, or check `/oracle-status`.
|
|
52
|
+
|
|
53
|
+
The `/oracle` prompt now runs an early oracle preflight before it gathers repo context, so missing persisted-session or local auth/config blockers fail before the agent spends time reading files.
|
|
54
|
+
|
|
55
|
+
For explicitly narrow requests, `/oracle` should gather only minimal context and prefer a minimal archive instead of broad repo exploration. It should also omit `preset` and use the configured default model unless the task clearly needs a different one.
|
|
51
56
|
|
|
52
57
|
If you miss the wake-up, the result is still saved durably in the oracle job directory and can be read later.
|
|
53
58
|
|
|
@@ -61,11 +66,15 @@ If you miss the wake-up, the result is still saved durably in the oracle job dir
|
|
|
61
66
|
/oracle Read the codebase and explain the highest-risk auth/session failure modes, including what to test before shipping.
|
|
62
67
|
```
|
|
63
68
|
|
|
69
|
+
```text
|
|
70
|
+
/oracle Explain the README guidance for /oracle-clean retention grace. Only archive README.md unless another file is clearly necessary.
|
|
71
|
+
```
|
|
72
|
+
|
|
64
73
|
## High-level flow
|
|
65
74
|
|
|
66
75
|
```mermaid
|
|
67
76
|
flowchart LR
|
|
68
|
-
A["/oracle request"] --> B["Agent gathers repo context"]
|
|
77
|
+
A["/oracle request"] --> B["Agent preflights, then gathers only the needed repo context"]
|
|
69
78
|
B --> C["oracle_submit builds archive"]
|
|
70
79
|
C --> D["Detached worker starts isolated ChatGPT runtime"]
|
|
71
80
|
D --> E["Archive + prompt sent to ChatGPT.com"]
|
|
@@ -82,9 +91,10 @@ User-facing commands:
|
|
|
82
91
|
- `/oracle-auth` — sync ChatGPT cookies from your real Chrome profile into the isolated oracle auth profile
|
|
83
92
|
- `/oracle-status [job-id]` — inspect job status
|
|
84
93
|
- `/oracle-cancel [job-id]` — cancel queued or active job
|
|
85
|
-
- `/oracle-clean <job-id|all>` — remove temp files for terminal jobs
|
|
94
|
+
- `/oracle-clean <job-id|all>` — remove temp files for terminal jobs; recently woken terminal jobs may stay retained briefly and return a retry-after hint
|
|
86
95
|
|
|
87
96
|
Agent-facing tools:
|
|
97
|
+
- `oracle_preflight`
|
|
88
98
|
- `oracle_submit`
|
|
89
99
|
- `oracle_read`
|
|
90
100
|
- `oracle_cancel`
|
|
@@ -143,6 +153,7 @@ Project config should only override safe, non-privileged settings.
|
|
|
143
153
|
- Jobs can queue automatically if runtime capacity is full.
|
|
144
154
|
- Completion delivery into `pi` is best-effort wake-up based.
|
|
145
155
|
- If you miss the wake-up, use `oracle_read(jobId)` or `/oracle-status`.
|
|
156
|
+
- `/oracle-clean` can still refuse a terminal job briefly after a wake-up send so saved response/artifact paths survive the follow-up turn; when that guard applies, it returns the next eligible cleanup time.
|
|
146
157
|
|
|
147
158
|
## Requirements
|
|
148
159
|
|
|
@@ -177,6 +188,12 @@ Project config should only override safe, non-privileged settings.
|
|
|
177
188
|
- Use `/oracle-status [job-id]` or `oracle_read(jobId)`.
|
|
178
189
|
- Results are still saved on disk even if the reminder turn does not land.
|
|
179
190
|
|
|
191
|
+
### `/oracle-clean` refuses a terminal job right after completion
|
|
192
|
+
|
|
193
|
+
- This can happen during the short post-send retention grace window after a wake-up was sent.
|
|
194
|
+
- The command now returns a `Retry after ...` timestamp when that guard is active.
|
|
195
|
+
- Wait until that time, then rerun `/oracle-clean [job-id|all]`.
|
|
196
|
+
|
|
180
197
|
### `agent-browser`, `tar`, or `zstd` is missing
|
|
181
198
|
|
|
182
199
|
- Install the missing local dependency and rerun the command.
|
package/docs/ORACLE_DESIGN.md
CHANGED
|
@@ -79,6 +79,9 @@ The extension now follows the current `pi` session lifecycle model:
|
|
|
79
79
|
|
|
80
80
|
### Tools
|
|
81
81
|
|
|
82
|
+
- `oracle_preflight`
|
|
83
|
+
- lightweight agent-facing readiness check for persisted-session and local oracle prerequisites
|
|
84
|
+
- intended to run before expensive `/oracle` context gathering
|
|
82
85
|
- `oracle_submit`
|
|
83
86
|
- low-level agent-facing dispatch tool
|
|
84
87
|
- creates archive and launches a detached worker
|
|
@@ -97,12 +100,15 @@ It expands through the prompt-template path so pi can apply its native queueing
|
|
|
97
100
|
|
|
98
101
|
Instead it instructs the agent to:
|
|
99
102
|
|
|
100
|
-
1.
|
|
101
|
-
2.
|
|
102
|
-
3.
|
|
103
|
-
4.
|
|
104
|
-
5.
|
|
105
|
-
6.
|
|
103
|
+
1. call `oracle_preflight` immediately
|
|
104
|
+
2. stop right away if preflight reports the session or local oracle setup is not ready
|
|
105
|
+
3. understand whether the request is explicitly narrow or genuinely broad
|
|
106
|
+
4. gather only the smallest repo context needed to submit well
|
|
107
|
+
5. if the request is narrow, prefer a minimal targeted archive and dispatch as soon as enough context is in hand
|
|
108
|
+
6. if the request is broad/repo-wide, gather broader context and usually archive `.`
|
|
109
|
+
7. craft the oracle prompt
|
|
110
|
+
8. call `oracle_submit`
|
|
111
|
+
9. stop and wait for the completion wake-up (best-effort; durable oracle response/artifact state is already persisted outside session history)
|
|
106
112
|
|
|
107
113
|
### `/oracle-auth`
|
|
108
114
|
|
|
@@ -133,7 +139,7 @@ The authenticated seed profile remains the source of truth for future oracle run
|
|
|
133
139
|
|
|
134
140
|
### `oracle_submit`
|
|
135
141
|
|
|
136
|
-
Agent-facing submissions use **`preset`**; the canonical registry is `ORACLE_SUBMIT_PRESETS` in `extensions/oracle/lib/config.ts`. **`preset` is the only model-selection parameter** on `oracle_submit`. There are no `modelFamily`, `effort`, or `autoSwitchToThinking` fields. Submit-time inputs accept canonical preset ids plus matching human-readable labels/common hyphen-space variants, and the tool normalizes them back to the canonical id before persisting job state.
|
|
142
|
+
Agent-facing submissions use **`preset`**; the canonical registry is `ORACLE_SUBMIT_PRESETS` in `extensions/oracle/lib/config.ts`. **`preset` is the only model-selection parameter** on `oracle_submit`. There are no `modelFamily`, `effort`, or `autoSwitchToThinking` fields. Submit-time inputs accept canonical preset ids plus matching human-readable labels/common hyphen-space variants, and the tool normalizes them back to the canonical id before persisting job state. Prompt-template guidance biases toward omitting `preset` and using the configured default unless the task clearly needs a different model or the user explicitly asked for one.
|
|
137
143
|
|
|
138
144
|
1. resolve the preset (submit-time or config default) into an execution snapshot
|
|
139
145
|
2. resolve optional `followUpJobId` into a prior `chatUrl` and `conversationId`
|
|
@@ -262,7 +268,7 @@ Long-run hygiene is intentionally conservative:
|
|
|
262
268
|
|
|
263
269
|
- runtime profiles, runtime leases, and conversation leases are cleaned immediately as part of worker/command cleanup paths
|
|
264
270
|
- browser close is time-bounded so cleanup can continue even if `agent-browser close` wedges
|
|
265
|
-
- `/oracle-clean` performs runtime cleanup before removing the persisted job directory, but refuses terminal jobs whose worker is still live or whose wake-up was just sent inside a short post-send retention grace window
|
|
271
|
+
- `/oracle-clean` performs runtime cleanup before removing the persisted job directory, but refuses terminal jobs whose worker is still live or whose wake-up was just sent inside a short post-send retention grace window; when blocked by that grace it returns a retry-after timestamp
|
|
266
272
|
- stale lock directories are swept before reconcile maintenance
|
|
267
273
|
- old auth `.staging-*` profiles are swept during `/oracle-auth` startup when the auth browser session is not still active
|
|
268
274
|
- terminal job directories are retained for inspection, then pruned later based on configurable retention windows
|
|
@@ -29,6 +29,17 @@ pi --no-extensions -e "$REPO/extensions/oracle/index.ts"
|
|
|
29
29
|
|
|
30
30
|
That ensures the session is exercising the in-repo code, not a globally installed package.
|
|
31
31
|
|
|
32
|
+
If you also need the in-repo `/oracle` prompt template, load it explicitly instead of installing this repository as a project-local package:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi --no-extensions -e "$REPO/extensions/oracle/index.ts" \
|
|
36
|
+
--no-prompt-templates --prompt-template "$REPO/prompts/oracle.md"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Do not add `https://github.com/fitchmultz/pi-oracle` to this repository's `.pi/settings.json` just to test local oracle changes. If you already keep `npm:pi-oracle` installed globally, mixing the global npm package with a project-local git package creates two distinct package identities and can trigger prompt/tool conflicts. Use the explicit CLI resource flags above instead.
|
|
40
|
+
|
|
41
|
+
`oracle_submit` now preflights a missing or unreadable auth seed profile before it creates an archive or persists a job. For archive-inspection smoke tests that intentionally run without real auth, create an empty isolated seed-profile directory under the temporary agent dir so submission can proceed far enough to write the archive while still staying isolated from your normal Chrome state.
|
|
42
|
+
|
|
32
43
|
## Preset requirement
|
|
33
44
|
|
|
34
45
|
Use either:
|
|
@@ -70,6 +81,8 @@ mkdir -p \
|
|
|
70
81
|
"$TEST2_AGENT" "$TEST2_SESSIONS" "$TEST2_JOBS" \
|
|
71
82
|
"$FIXTURE" "$OUTSIDE"
|
|
72
83
|
|
|
84
|
+
mkdir -p "$TEST1_AGENT/extensions/oracle-auth-seed-profile"
|
|
85
|
+
|
|
73
86
|
echo 'secret' > "$OUTSIDE/secret.txt"
|
|
74
87
|
ln -s "$OUTSIDE" "$FIXTURE/linked-outside"
|
|
75
88
|
|
|
@@ -149,7 +162,8 @@ Expected behavior:
|
|
|
149
162
|
Notes:
|
|
150
163
|
|
|
151
164
|
- this smoke test does not require `/oracle-auth`
|
|
152
|
-
-
|
|
165
|
+
- the snippet creates an empty isolated auth seed profile for `TEST1_AGENT` because `oracle_submit` now rejects a missing seed profile before archiving
|
|
166
|
+
- with that empty seed profile, the worker still fails later due to missing real auth, which is useful because the archive remains on disk for inspection
|
|
153
167
|
|
|
154
168
|
### Test 2: symlink escape rejection
|
|
155
169
|
|
|
@@ -159,6 +173,19 @@ Expected behavior:
|
|
|
159
173
|
- the error should say the archive input must resolve inside the project cwd without symlink escapes
|
|
160
174
|
- no oracle job directory should be created for the rejected submit
|
|
161
175
|
|
|
176
|
+
## Testing local `/oracle` prompt changes too
|
|
177
|
+
|
|
178
|
+
The main smoke test above calls `oracle_submit` directly, so it only needs the local extension entrypoint. If you also changed `prompts/oracle.md`, start the isolated session with the local prompt template explicitly loaded:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
LOCAL_ORACLE_PI_CMD="pi --session-dir '$TEST1_SESSIONS' --no-extensions -e '$REPO/extensions/oracle/index.ts' --no-prompt-templates --prompt-template '$REPO/prompts/oracle.md'"
|
|
182
|
+
TMUX_CMD1="cd '$REPO' && env PI_CODING_AGENT_DIR='$TEST1_AGENT' PI_ORACLE_JOBS_DIR='$TEST1_JOBS' PATH='$PATH' $LOCAL_ORACLE_PI_CMD"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Use the same pattern for additional sessions, swapping the session/job directories as needed. This keeps the test on the in-repo extension and in-repo prompt template without depending on `.pi/settings.json` package entries.
|
|
186
|
+
|
|
187
|
+
`/oracle` now starts by calling `oracle_preflight`. If you want the prompt flow to proceed past that early guard in an isolated test without using your normal auth state, create an empty isolated auth seed profile first (for example `mkdir -p "$TEST1_AGENT/extensions/oracle-auth-seed-profile"`) or run `/oracle-auth` in the isolated agent dir.
|
|
188
|
+
|
|
162
189
|
## Additional failure-mode smoke tests
|
|
163
190
|
|
|
164
191
|
### `/oracle-auth` should fail fast when `agent-browser` hangs
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// Usage: Imported by the oracle extension entrypoint to register /oracle-* commands with pi.
|
|
5
5
|
// Invariants/Assumptions: Commands operate on persisted project-scoped jobs and rely on shared observability formatting for detached-state clarity.
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
7
8
|
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
8
9
|
import { formatOracleJobSummary } from "../shared/job-observability-helpers.mjs";
|
|
9
|
-
import { loadOracleConfig } from "./config.js";
|
|
10
|
+
import { formatOracleAuthConfigRemediation, formatOracleAuthConfigSummary, getOracleConfigLoadDetails, loadOracleConfig } from "./config.js";
|
|
10
11
|
import {
|
|
11
12
|
cancelOracleJob,
|
|
12
13
|
getJobDir,
|
|
@@ -29,9 +30,11 @@ function summarizeJob(jobId: string): string {
|
|
|
29
30
|
const job = readJob(jobId);
|
|
30
31
|
if (!job) return `Oracle job ${jobId} not found.`;
|
|
31
32
|
|
|
33
|
+
const responseAvailable = Boolean(job.responsePath && existsSync(job.responsePath));
|
|
32
34
|
return formatOracleJobSummary(job, {
|
|
33
35
|
queuePosition: job.status === "queued" ? getQueuePosition(job.id) : undefined,
|
|
34
36
|
artifactsPath: `${getJobDir(job.id)}/artifacts`,
|
|
37
|
+
responseAvailable,
|
|
35
38
|
});
|
|
36
39
|
}
|
|
37
40
|
|
|
@@ -47,6 +50,12 @@ function readScopedJob(jobId: string, cwd: string) {
|
|
|
47
50
|
|
|
48
51
|
async function runAuthBootstrap(authWorkerPath: string, cwd: string): Promise<string> {
|
|
49
52
|
const config = loadOracleConfig(cwd);
|
|
53
|
+
const configLoad = getOracleConfigLoadDetails(cwd);
|
|
54
|
+
const authConfigGuidance = {
|
|
55
|
+
...configLoad,
|
|
56
|
+
remediation: formatOracleAuthConfigRemediation(configLoad),
|
|
57
|
+
summary: formatOracleAuthConfigSummary(configLoad),
|
|
58
|
+
};
|
|
50
59
|
try {
|
|
51
60
|
await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_auth", cwd }, async () => {
|
|
52
61
|
await reconcileStaleOracleJobs();
|
|
@@ -57,7 +66,7 @@ async function runAuthBootstrap(authWorkerPath: string, cwd: string): Promise<st
|
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
return await new Promise<string>((resolve, reject) => {
|
|
60
|
-
const child = spawn(process.execPath, [authWorkerPath, JSON.stringify(config)], {
|
|
69
|
+
const child = spawn(process.execPath, [authWorkerPath, JSON.stringify({ config, configLoad: authConfigGuidance })], {
|
|
61
70
|
cwd,
|
|
62
71
|
stdio: ["ignore", "pipe", "pipe"],
|
|
63
72
|
});
|
|
@@ -151,7 +160,7 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string,
|
|
|
151
160
|
});
|
|
152
161
|
|
|
153
162
|
pi.registerCommand("oracle-clean", {
|
|
154
|
-
description: "Remove oracle temp files for
|
|
163
|
+
description: "Remove oracle temp files for terminal jobs; recently woken jobs may stay retained briefly",
|
|
155
164
|
handler: async (args, ctx: ExtensionCommandContext) => {
|
|
156
165
|
const target = args.trim();
|
|
157
166
|
if (!target) {
|
|
@@ -196,10 +205,10 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string,
|
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
refreshOracleStatus(ctx);
|
|
199
|
-
const warningSuffix = cleanupWarnings.length > 0 ? ` Cleanup warnings:\n${cleanupWarnings.join("\n")}` : "";
|
|
208
|
+
const warningSuffix = cleanupWarnings.length > 0 ? ` Cleanup blockers/warnings:\n${cleanupWarnings.join("\n")}` : "";
|
|
200
209
|
const removalSummary = removedCount === jobs.length
|
|
201
210
|
? `Removed ${removedCount} oracle job director${removedCount === 1 ? "y" : "ies"}.`
|
|
202
|
-
: `Removed ${removedCount} of ${jobs.length} oracle job director${jobs.length === 1 ? "y" : "ies"}; retained ${jobs.length - removedCount}
|
|
211
|
+
: `Removed ${removedCount} of ${jobs.length} oracle job director${jobs.length === 1 ? "y" : "ies"}; retained ${jobs.length - removedCount} due to cleanup blockers or warnings.`;
|
|
203
212
|
ctx.ui.notify(`${removalSummary}${warningSuffix}`, cleanupWarnings.length > 0 ? "warning" : "info");
|
|
204
213
|
},
|
|
205
214
|
});
|
|
@@ -258,6 +258,54 @@ const detectedChromeUserAgent = detectDefaultChromeUserAgent(detectedChromeExecu
|
|
|
258
258
|
const agentExtensionsDir = join(getAgentDir(), "extensions");
|
|
259
259
|
const detectedChromeProfileName = detectDefaultChromeProfileName();
|
|
260
260
|
|
|
261
|
+
export interface OracleConfigLoadDetails {
|
|
262
|
+
agentDir: string;
|
|
263
|
+
agentConfigPath: string;
|
|
264
|
+
agentConfigExists: boolean;
|
|
265
|
+
projectConfigPath: string;
|
|
266
|
+
projectConfigExists: boolean;
|
|
267
|
+
effectiveAuthConfigPath: string;
|
|
268
|
+
effectiveAuthScope: "agent";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function getOracleConfigLoadDetails(cwd: string): OracleConfigLoadDetails {
|
|
272
|
+
const agentDir = getAgentDir();
|
|
273
|
+
const agentConfigPath = join(agentDir, "extensions", "oracle.json");
|
|
274
|
+
const projectConfigPath = join(cwd, ".pi", "extensions", "oracle.json");
|
|
275
|
+
return {
|
|
276
|
+
agentDir,
|
|
277
|
+
agentConfigPath,
|
|
278
|
+
agentConfigExists: existsSync(agentConfigPath),
|
|
279
|
+
projectConfigPath,
|
|
280
|
+
projectConfigExists: existsSync(projectConfigPath),
|
|
281
|
+
effectiveAuthConfigPath: agentConfigPath,
|
|
282
|
+
effectiveAuthScope: "agent",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function formatOracleAuthConfigRemediation(details: OracleConfigLoadDetails): string {
|
|
287
|
+
if (!details.projectConfigExists) {
|
|
288
|
+
return `Set auth.chromeProfile / auth.chromeCookiePath in ${details.effectiveAuthConfigPath}.`;
|
|
289
|
+
}
|
|
290
|
+
return (
|
|
291
|
+
`Set auth.chromeProfile / auth.chromeCookiePath in ${details.effectiveAuthConfigPath}. ` +
|
|
292
|
+
`Project overrides are also read from ${details.projectConfigPath}, but auth.* is loaded from ${details.effectiveAuthConfigPath}.`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function formatOracleAuthConfigSummary(details: OracleConfigLoadDetails): string {
|
|
297
|
+
const lines = [
|
|
298
|
+
`Effective oracle auth config: ${details.effectiveAuthConfigPath} (agent dir: ${details.agentDir}${details.agentConfigExists ? "" : "; create this file to override auth.*"})`,
|
|
299
|
+
];
|
|
300
|
+
if (details.projectConfigExists) {
|
|
301
|
+
lines.push(
|
|
302
|
+
`Project oracle config also loaded: ${details.projectConfigPath} ` +
|
|
303
|
+
`(project scope can override ${[...PROJECT_OVERRIDE_KEYS].join("/")} only; auth.* still comes from ${details.effectiveAuthConfigPath}).`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return lines.join("\n");
|
|
307
|
+
}
|
|
308
|
+
|
|
261
309
|
export const DEFAULT_CONFIG: OracleConfig = {
|
|
262
310
|
defaults: {
|
|
263
311
|
preset: "pro_extended",
|
|
@@ -514,7 +562,8 @@ function validateOracleConfig(value: unknown): OracleConfig {
|
|
|
514
562
|
}
|
|
515
563
|
|
|
516
564
|
export function loadOracleConfig(cwd: string): OracleConfig {
|
|
517
|
-
const
|
|
518
|
-
const
|
|
565
|
+
const details = getOracleConfigLoadDetails(cwd);
|
|
566
|
+
const globalConfig = readJson(details.agentConfigPath);
|
|
567
|
+
const projectConfig = filterProjectConfig(readJson(details.projectConfigPath));
|
|
519
568
|
return validateOracleConfig(deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig));
|
|
520
569
|
}
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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 },
|
|
@@ -292,12 +294,33 @@ export function stopPollerForSession(sessionFile: string | undefined, cwd: strin
|
|
|
292
294
|
if (timer) {
|
|
293
295
|
clearInterval(timer);
|
|
294
296
|
activePollers.delete(sessionKey);
|
|
295
|
-
scansInFlight.delete(sessionKey);
|
|
296
297
|
}
|
|
297
298
|
const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(sessionKey);
|
|
298
299
|
void releaseLease(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey).catch(() => undefined);
|
|
299
300
|
}
|
|
300
301
|
|
|
302
|
+
export async function stopAllPollers(): Promise<void> {
|
|
303
|
+
const sessionKeys = [...activePollers.keys()];
|
|
304
|
+
for (const timer of activePollers.values()) {
|
|
305
|
+
clearInterval(timer);
|
|
306
|
+
}
|
|
307
|
+
activePollers.clear();
|
|
308
|
+
await Promise.all(sessionKeys.map(async (sessionKey) => {
|
|
309
|
+
const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(sessionKey);
|
|
310
|
+
await releaseLease(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey).catch(() => undefined);
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function waitForAllPollersToQuiesce(timeoutMs = 2_000): Promise<void> {
|
|
315
|
+
const startedAt = Date.now();
|
|
316
|
+
while (scansInFlight.size > 0) {
|
|
317
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
318
|
+
throw new Error(`Timed out waiting for oracle pollers to quiesce after ${timeoutMs}ms`);
|
|
319
|
+
}
|
|
320
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
301
324
|
export function stopPoller(ctx: ExtensionContext): void {
|
|
302
325
|
const sessionFile = getSessionFile(ctx);
|
|
303
326
|
if (!sessionFile) return;
|
|
@@ -5,8 +5,8 @@
|
|
|
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";
|
|
8
|
+
import { constants as fsConstants, existsSync, realpathSync, readFileSync } from "node:fs";
|
|
9
|
+
import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { jobBlocksAdmission } from "../shared/job-coordination-helpers.mjs";
|
|
12
12
|
import { isTrackedProcessAlive } from "../shared/process-helpers.mjs";
|
|
@@ -98,6 +98,45 @@ export function authSessionName(config: OracleConfig): string {
|
|
|
98
98
|
return `${config.browser.sessionPrefix}-auth`;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
function missingAuthSeedProfileMessage(seedDir: string): string {
|
|
102
|
+
return `Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function invalidAuthSeedProfileTypeMessage(seedDir: string): string {
|
|
106
|
+
return `Oracle auth seed profile is not a directory: ${seedDir}. Remove the invalid path or rerun /oracle-auth.`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unreadableAuthSeedProfileMessage(seedDir: string): string {
|
|
110
|
+
return `Oracle auth seed profile is not readable: ${seedDir}. Fix its permissions or rerun /oracle-auth.`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function assertOracleAuthSeedProfileReady(config: OracleConfig): Promise<void> {
|
|
114
|
+
const seedDir = config.browser.authSeedProfileDir;
|
|
115
|
+
let seedStats;
|
|
116
|
+
try {
|
|
117
|
+
seedStats = await stat(seedDir);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
|
120
|
+
if (code === "ENOENT") throw new Error(missingAuthSeedProfileMessage(seedDir));
|
|
121
|
+
if (code === "EACCES" || code === "EPERM") throw new Error(unreadableAuthSeedProfileMessage(seedDir));
|
|
122
|
+
throw new Error(`Failed to inspect oracle auth seed profile ${seedDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!seedStats.isDirectory()) {
|
|
126
|
+
throw new Error(invalidAuthSeedProfileTypeMessage(seedDir));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await access(seedDir, fsConstants.R_OK | fsConstants.X_OK);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error(unreadableAuthSeedProfileMessage(seedDir));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function assertOracleSubmitPrerequisites(config: OracleConfig): Promise<void> {
|
|
137
|
+
await assertOracleAuthSeedProfileReady(config);
|
|
138
|
+
}
|
|
139
|
+
|
|
101
140
|
export function getSeedGeneration(config: OracleConfig): string | undefined {
|
|
102
141
|
const path = join(config.browser.authSeedProfileDir, SEED_GENERATION_FILE);
|
|
103
142
|
if (!existsSync(path)) return undefined;
|
|
@@ -267,9 +306,7 @@ export async function cloneSeedProfileToRuntime(
|
|
|
267
306
|
options?: { cpTimeoutMs?: number },
|
|
268
307
|
): Promise<string | undefined> {
|
|
269
308
|
const seedDir = config.browser.authSeedProfileDir;
|
|
270
|
-
|
|
271
|
-
throw new Error(`Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`);
|
|
272
|
-
}
|
|
309
|
+
await assertOracleAuthSeedProfileReady(config);
|
|
273
310
|
|
|
274
311
|
await withAuthLock({ runtimeProfileDir, seedDir }, async () => {
|
|
275
312
|
await rm(runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
|