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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import { join } from "node:path";
|
|
3
|
+
import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
6
|
|
|
7
7
|
export const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
|
|
8
8
|
export const ORACLE_STATE_DIR_ENV = "PI_ORACLE_STATE_DIR";
|
|
@@ -11,6 +11,7 @@ const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
|
|
|
11
11
|
const LEASES_DIR = join(ORACLE_STATE_DIR, "leases");
|
|
12
12
|
const DEFAULT_WAIT_MS = 30_000;
|
|
13
13
|
const POLL_MS = 200;
|
|
14
|
+
export const ORACLE_METADATA_WRITE_GRACE_MS = 1_000;
|
|
14
15
|
|
|
15
16
|
export interface OracleLockHandle {
|
|
16
17
|
path: string;
|
|
@@ -52,11 +53,51 @@ async function sleep(ms: number): Promise<void> {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
async function writeMetadata(path: string, metadata: unknown): Promise<void> {
|
|
55
|
-
|
|
56
|
+
const targetPath = join(path, "metadata.json");
|
|
57
|
+
const tempPath = join(path, `metadata.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
58
|
+
await writeFile(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
59
|
+
await rename(tempPath, targetPath);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function createStateDirAtomically(parentDir: string, finalPath: string, metadata: unknown): Promise<void> {
|
|
63
|
+
const tempPath = join(parentDir, `.tmp-${basename(finalPath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`);
|
|
64
|
+
await mkdir(tempPath, { recursive: false, mode: 0o700 });
|
|
65
|
+
try {
|
|
66
|
+
await writeMetadata(tempPath, metadata);
|
|
67
|
+
await rename(tempPath, finalPath);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await rm(tempPath, { recursive: true, force: true }).catch(() => undefined);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getMetadataPath(path: string): string {
|
|
75
|
+
return join(path, "metadata.json");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getMetadataState(path: string): "present" | "missing" | "invalid" {
|
|
79
|
+
const metadataPath = getMetadataPath(path);
|
|
80
|
+
if (!existsSync(metadataPath)) return "missing";
|
|
81
|
+
try {
|
|
82
|
+
JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
83
|
+
return "present";
|
|
84
|
+
} catch {
|
|
85
|
+
return "invalid";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isIncompleteStateDirStale(path: string, now = Date.now()): boolean {
|
|
90
|
+
try {
|
|
91
|
+
const stats = statSync(path);
|
|
92
|
+
const baselineMs = Math.max(stats.mtimeMs, stats.ctimeMs);
|
|
93
|
+
return now - baselineMs >= ORACLE_METADATA_WRITE_GRACE_MS;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
56
97
|
}
|
|
57
98
|
|
|
58
99
|
function readLockProcessPid(path: string): number | undefined {
|
|
59
|
-
const metadataPath =
|
|
100
|
+
const metadataPath = getMetadataPath(path);
|
|
60
101
|
if (!existsSync(metadataPath)) return undefined;
|
|
61
102
|
try {
|
|
62
103
|
const metadata = JSON.parse(readFileSync(metadataPath, "utf8")) as { processPid?: unknown };
|
|
@@ -78,7 +119,19 @@ function isProcessAlive(pid: number): boolean {
|
|
|
78
119
|
}
|
|
79
120
|
}
|
|
80
121
|
|
|
81
|
-
|
|
122
|
+
function isStateDirExistsError(error: unknown): boolean {
|
|
123
|
+
return Boolean(error && typeof error === "object" && "code" in error && (error.code === "EEXIST" || error.code === "ENOTEMPTY"));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function maybeReclaimIncompleteStateDir(path: string, now = Date.now()): Promise<boolean> {
|
|
127
|
+
if (getMetadataState(path) === "present") return false;
|
|
128
|
+
if (!isIncompleteStateDirStale(path, now)) return false;
|
|
129
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function maybeReclaimStaleLock(path: string, now = Date.now()): Promise<boolean> {
|
|
134
|
+
if (await maybeReclaimIncompleteStateDir(path, now)) return true;
|
|
82
135
|
const processPid = readLockProcessPid(path);
|
|
83
136
|
if (!processPid || isProcessAlive(processPid)) return false;
|
|
84
137
|
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
@@ -105,17 +158,17 @@ export async function acquireLock(
|
|
|
105
158
|
metadata: unknown,
|
|
106
159
|
options?: { timeoutMs?: number },
|
|
107
160
|
): Promise<OracleLockHandle> {
|
|
108
|
-
const
|
|
161
|
+
const parentDir = getLocksDir();
|
|
162
|
+
const path = join(parentDir, leaseKey(kind, key));
|
|
109
163
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_WAIT_MS;
|
|
110
164
|
const deadline = Date.now() + timeoutMs;
|
|
111
165
|
|
|
112
166
|
while (Date.now() < deadline) {
|
|
113
167
|
try {
|
|
114
|
-
await
|
|
115
|
-
await writeMetadata(path, metadata);
|
|
168
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
116
169
|
return { path };
|
|
117
170
|
} catch (error) {
|
|
118
|
-
if (!(error
|
|
171
|
+
if (!isStateDirExistsError(error)) throw error;
|
|
119
172
|
if (await maybeReclaimStaleLock(path)) continue;
|
|
120
173
|
}
|
|
121
174
|
await sleep(POLL_MS);
|
|
@@ -176,9 +229,42 @@ export async function withJobLock<T>(jobId: string, metadata: unknown, fn: () =>
|
|
|
176
229
|
}
|
|
177
230
|
|
|
178
231
|
export async function createLease(kind: string, key: string, metadata: unknown): Promise<string> {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
232
|
+
const parentDir = getLeasesDir();
|
|
233
|
+
const path = join(parentDir, leaseKey(kind, key));
|
|
234
|
+
const deadline = Date.now() + DEFAULT_WAIT_MS;
|
|
235
|
+
|
|
236
|
+
while (Date.now() < deadline) {
|
|
237
|
+
try {
|
|
238
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
239
|
+
return path;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (!isStateDirExistsError(error)) throw error;
|
|
242
|
+
if (await maybeReclaimIncompleteStateDir(path)) continue;
|
|
243
|
+
if (getMetadataState(path) === "present") throw error;
|
|
244
|
+
}
|
|
245
|
+
await sleep(POLL_MS);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
throw new Error(`Timed out waiting for oracle ${kind} lease: ${key}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function writeLeaseMetadata(kind: string, key: string, metadata: unknown): Promise<string> {
|
|
252
|
+
const parentDir = getLeasesDir();
|
|
253
|
+
const path = join(parentDir, leaseKey(kind, key));
|
|
254
|
+
if (existsSync(path)) {
|
|
255
|
+
await writeMetadata(path, metadata);
|
|
256
|
+
return path;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (!isStateDirExistsError(error)) throw error;
|
|
262
|
+
if (await maybeReclaimIncompleteStateDir(path)) {
|
|
263
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
264
|
+
} else {
|
|
265
|
+
await writeMetadata(path, metadata);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
182
268
|
return path;
|
|
183
269
|
}
|
|
184
270
|
|
|
@@ -1,21 +1,56 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { isLockTimeoutError, withGlobalReconcileLock } from "./locks.js";
|
|
3
|
+
import { isLockTimeoutError, listLeaseMetadata, releaseLease, withGlobalReconcileLock, writeLeaseMetadata } from "./locks.js";
|
|
3
4
|
import {
|
|
5
|
+
getJobDir,
|
|
4
6
|
getSessionFile,
|
|
5
7
|
getStaleOracleJobReason,
|
|
8
|
+
hasPersistedOriginSession,
|
|
6
9
|
isActiveOracleJob,
|
|
7
10
|
listOracleJobDirs,
|
|
8
|
-
|
|
11
|
+
noteWakeupRequested,
|
|
9
12
|
readJob,
|
|
13
|
+
recordNotificationTarget,
|
|
10
14
|
reconcileStaleOracleJobs,
|
|
11
15
|
releaseNotificationClaim,
|
|
16
|
+
shouldPruneTerminalJob,
|
|
17
|
+
shouldRequestWakeup,
|
|
12
18
|
tryClaimNotification,
|
|
13
19
|
} from "./jobs.js";
|
|
20
|
+
import { promoteQueuedJobs } from "./queue.js";
|
|
14
21
|
import { getProjectId, getSessionId } from "./runtime.js";
|
|
15
22
|
|
|
16
23
|
const activePollers = new Map<string, NodeJS.Timeout>();
|
|
17
24
|
const scansInFlight = new Set<string>();
|
|
18
25
|
const POLLER_LOCK_TIMEOUT_MS = 50;
|
|
26
|
+
const WAKEUP_TARGET_LEASE_KIND = "wakeup-target";
|
|
27
|
+
const WAKEUP_TARGET_STALE_MS = 2 * 60 * 1000;
|
|
28
|
+
const ORACLE_WAKEUP_REMINDER_CUSTOM_TYPE = "oracle-job-wakeup";
|
|
29
|
+
|
|
30
|
+
interface OracleWakeupTargetLeaseMetadata {
|
|
31
|
+
leaseKey: string;
|
|
32
|
+
projectId: string;
|
|
33
|
+
sessionId: string;
|
|
34
|
+
processPid: number;
|
|
35
|
+
processStartedAt?: string;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
type OraclePollerJob = NonNullable<ReturnType<typeof readJob>>;
|
|
41
|
+
|
|
42
|
+
export interface OraclePollerHooks {
|
|
43
|
+
collectLiveWakeupTargets?: (now?: number) => Promise<Set<string>>;
|
|
44
|
+
beforeNotificationClaim?: (jobId: string) => Promise<void> | void;
|
|
45
|
+
afterNotificationClaim?: (job: OraclePollerJob) => Promise<void> | void;
|
|
46
|
+
beforeNotificationPersist?: (job: OraclePollerJob) => Promise<void> | void;
|
|
47
|
+
afterNotificationPersisted?: (job: OraclePollerJob) => Promise<void> | void;
|
|
48
|
+
beforeMarkJobNotified?: (job: OraclePollerJob) => Promise<void> | void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface OraclePollerOptions {
|
|
52
|
+
hooks?: OraclePollerHooks;
|
|
53
|
+
}
|
|
19
54
|
|
|
20
55
|
export function getPollerSessionKey(sessionFile: string | undefined, cwd: string): string {
|
|
21
56
|
const projectId = getProjectId(cwd);
|
|
@@ -29,53 +64,162 @@ function jobMatchesContext(job: { projectId: string; sessionId: string }, sessio
|
|
|
29
64
|
return job.projectId === projectId && job.sessionId === sessionId;
|
|
30
65
|
}
|
|
31
66
|
|
|
32
|
-
function
|
|
67
|
+
function readProcessStartedAt(pid: number | undefined): string | undefined {
|
|
68
|
+
if (!pid || pid <= 0) return undefined;
|
|
69
|
+
try {
|
|
70
|
+
const startedAt = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
|
|
71
|
+
return startedAt || undefined;
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isProcessAlive(pid: number): boolean {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 0);
|
|
80
|
+
return true;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseTimestamp(value: string | undefined): number | undefined {
|
|
88
|
+
if (!value) return undefined;
|
|
89
|
+
const parsed = Date.parse(value);
|
|
90
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getWakeupTargetLeaseKey(sessionKey: string, processPid = process.pid, processStartedAt = readProcessStartedAt(process.pid) || "unknown"): string {
|
|
94
|
+
return `${sessionKey}::${processPid}::${processStartedAt}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function collectLiveWakeupTargetLeases(now = Date.now()): Promise<Array<OracleWakeupTargetLeaseMetadata & { sessionKey: string }>> {
|
|
98
|
+
const liveTargets: Array<OracleWakeupTargetLeaseMetadata & { sessionKey: string }> = [];
|
|
99
|
+
for (const lease of listLeaseMetadata<OracleWakeupTargetLeaseMetadata>(WAKEUP_TARGET_LEASE_KIND)) {
|
|
100
|
+
const sessionKey = `${lease?.projectId ?? ""}::${lease?.sessionId ?? ""}`;
|
|
101
|
+
const leaseKey = lease?.leaseKey;
|
|
102
|
+
const currentStartedAt = readProcessStartedAt(lease?.processPid);
|
|
103
|
+
const updatedAtMs = parseTimestamp(lease?.updatedAt);
|
|
104
|
+
const stale = updatedAtMs !== undefined && now - updatedAtMs > WAKEUP_TARGET_STALE_MS;
|
|
105
|
+
const missingIdentity = !lease?.projectId || !lease?.sessionId || !lease?.processPid || !leaseKey;
|
|
106
|
+
const deadProcess = !missingIdentity && (!isProcessAlive(lease.processPid) || !currentStartedAt || (lease.processStartedAt && currentStartedAt !== lease.processStartedAt));
|
|
107
|
+
if (missingIdentity || deadProcess || stale) {
|
|
108
|
+
if (leaseKey) {
|
|
109
|
+
await releaseLease(WAKEUP_TARGET_LEASE_KIND, leaseKey).catch(() => undefined);
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
liveTargets.push({ ...lease, sessionKey } as OracleWakeupTargetLeaseMetadata & { sessionKey: string });
|
|
114
|
+
}
|
|
115
|
+
return liveTargets;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function collectLiveWakeupTargets(now = Date.now()): Promise<Set<string>> {
|
|
119
|
+
return new Set((await collectLiveWakeupTargetLeases(now)).map((lease) => lease.sessionKey));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function jobHasLiveWakeupTarget(job: { projectId: string; sessionId: string }, liveWakeupTargets: Set<string>): boolean {
|
|
123
|
+
return liveWakeupTargets.has(`${job.projectId}::${job.sessionId}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function jobCanNotifyContext(
|
|
127
|
+
job: { projectId: string; sessionId: string; originSessionFile?: string },
|
|
128
|
+
sessionFile: string | undefined,
|
|
129
|
+
cwd: string,
|
|
130
|
+
liveWakeupTargets: Set<string>,
|
|
131
|
+
): boolean {
|
|
132
|
+
if (!hasPersistedOriginSession(job)) return false;
|
|
133
|
+
if (jobMatchesContext(job, sessionFile, cwd)) return true;
|
|
134
|
+
return job.projectId === getProjectId(cwd) && !jobHasLiveWakeupTarget(job, liveWakeupTargets);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getJobCounts(ctx: ExtensionContext): { active: number; queued: number } {
|
|
33
138
|
const currentSessionFile = getSessionFile(ctx);
|
|
139
|
+
if (!currentSessionFile) return { active: 0, queued: 0 };
|
|
34
140
|
return listOracleJobDirs()
|
|
35
141
|
.map((jobDir) => readJob(jobDir))
|
|
36
142
|
.filter((job): job is NonNullable<typeof job> => Boolean(job))
|
|
37
|
-
.filter((job) =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
143
|
+
.filter((job) => jobMatchesContext(job, currentSessionFile, ctx.cwd))
|
|
144
|
+
.reduce(
|
|
145
|
+
(counts, job) => {
|
|
146
|
+
if (job.status === "queued") counts.queued += 1;
|
|
147
|
+
else if (isActiveOracleJob(job) && !getStaleOracleJobReason(job)) counts.active += 1;
|
|
148
|
+
return counts;
|
|
149
|
+
},
|
|
150
|
+
{ active: 0, queued: 0 },
|
|
151
|
+
);
|
|
42
152
|
}
|
|
43
153
|
|
|
44
154
|
export function refreshOracleStatus(ctx: ExtensionContext): void {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
155
|
+
if (!getSessionFile(ctx)) {
|
|
156
|
+
ctx.ui.setStatus("oracle", ctx.ui.theme.fg("accent", "oracle: unavailable"));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const counts = getJobCounts(ctx);
|
|
160
|
+
if (counts.active > 0 && counts.queued > 0) {
|
|
161
|
+
ctx.ui.setStatus("oracle", ctx.ui.theme.fg("success", `oracle: running (${counts.active}), queued (${counts.queued})`));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (counts.active > 0) {
|
|
165
|
+
const suffix = counts.active > 1 ? ` (${counts.active})` : "";
|
|
48
166
|
ctx.ui.setStatus("oracle", ctx.ui.theme.fg("success", `oracle: running${suffix}`));
|
|
49
167
|
return;
|
|
50
168
|
}
|
|
169
|
+
if (counts.queued > 0) {
|
|
170
|
+
const suffix = counts.queued > 1 ? ` (${counts.queued})` : "";
|
|
171
|
+
ctx.ui.setStatus("oracle", ctx.ui.theme.fg("accent", `oracle: queued${suffix}`));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
51
174
|
|
|
52
175
|
ctx.ui.setStatus("oracle", ctx.ui.theme.fg("accent", "oracle: ready"));
|
|
53
176
|
}
|
|
54
177
|
|
|
55
|
-
function
|
|
56
|
-
const responsePath = job.responsePath || `${job.id}/response.md`;
|
|
57
|
-
const artifactsPath =
|
|
178
|
+
function buildNotificationContent(job: OraclePollerJob): string {
|
|
179
|
+
const responsePath = job.responsePath || `${getJobDir(job.id)}/response.md`;
|
|
180
|
+
const artifactsPath = `${getJobDir(job.id)}/artifacts`;
|
|
181
|
+
return [
|
|
182
|
+
`Oracle job ${job.id} is ${job.status}.`,
|
|
183
|
+
`Use oracle_read with jobId ${job.id} to open the response and settle wake-up retries.`,
|
|
184
|
+
`Response file: ${responsePath}`,
|
|
185
|
+
`Artifacts: ${artifactsPath}`,
|
|
186
|
+
job.error ? `Error: ${job.error}` : "After oracle_read, continue from the oracle output.",
|
|
187
|
+
].join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
function requestWakeupTurn(pi: ExtensionAPI, job: OraclePollerJob): void {
|
|
58
192
|
pi.sendMessage(
|
|
59
193
|
{
|
|
60
|
-
customType:
|
|
61
|
-
display:
|
|
62
|
-
content:
|
|
63
|
-
`Oracle job ${job.id} is ${job.status}.`,
|
|
64
|
-
`Read response: ${responsePath}`,
|
|
65
|
-
`Artifacts: ${artifactsPath}`,
|
|
66
|
-
job.error ? `Error: ${job.error}` : "Continue from the oracle output.",
|
|
67
|
-
].join("\n"),
|
|
194
|
+
customType: ORACLE_WAKEUP_REMINDER_CUSTOM_TYPE,
|
|
195
|
+
display: false,
|
|
196
|
+
content: buildNotificationContent(job),
|
|
68
197
|
details: { jobId: job.id, status: job.status },
|
|
69
198
|
},
|
|
70
|
-
{ triggerTurn: true },
|
|
199
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
71
200
|
);
|
|
72
201
|
}
|
|
73
202
|
|
|
74
|
-
async function scan(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
203
|
+
async function scan(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string, hooks: OraclePollerHooks = {}): Promise<void> {
|
|
75
204
|
const currentSessionFile = getSessionFile(ctx);
|
|
76
205
|
const pollerKey = getPollerSessionKey(currentSessionFile, ctx.cwd);
|
|
77
206
|
const notificationClaimant = `${pollerKey}:${process.pid}`;
|
|
78
207
|
|
|
208
|
+
const projectId = getProjectId(ctx.cwd);
|
|
209
|
+
const sessionId = getSessionId(currentSessionFile, projectId);
|
|
210
|
+
const processStartedAt = readProcessStartedAt(process.pid);
|
|
211
|
+
const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(pollerKey, process.pid, processStartedAt || "unknown");
|
|
212
|
+
const resolveLiveWakeupTargets = hooks.collectLiveWakeupTargets ?? collectLiveWakeupTargets;
|
|
213
|
+
await writeLeaseMetadata(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey, {
|
|
214
|
+
leaseKey: wakeupTargetLeaseKey,
|
|
215
|
+
projectId,
|
|
216
|
+
sessionId,
|
|
217
|
+
processPid: process.pid,
|
|
218
|
+
processStartedAt,
|
|
219
|
+
updatedAt: new Date().toISOString(),
|
|
220
|
+
}).catch(() => undefined);
|
|
221
|
+
const liveWakeupTargets = await resolveLiveWakeupTargets();
|
|
222
|
+
|
|
79
223
|
try {
|
|
80
224
|
await withGlobalReconcileLock(
|
|
81
225
|
{ processPid: process.pid, cwd: ctx.cwd, sessionFile: currentSessionFile, source: "poller" },
|
|
@@ -88,27 +232,60 @@ async function scan(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
|
88
232
|
if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
|
|
89
233
|
}
|
|
90
234
|
|
|
91
|
-
|
|
235
|
+
await promoteQueuedJobs({ workerPath, source: "poller" });
|
|
236
|
+
|
|
237
|
+
const terminalJobs = listOracleJobDirs()
|
|
92
238
|
.map((jobDir) => readJob(jobDir))
|
|
93
239
|
.filter((job): job is NonNullable<typeof job> => Boolean(job))
|
|
240
|
+
.filter((job) => job.status === "complete" || job.status === "failed" || job.status === "cancelled");
|
|
241
|
+
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
const candidateJobIds = terminalJobs
|
|
94
244
|
.filter((job) => {
|
|
95
|
-
if (job
|
|
96
|
-
if (
|
|
97
|
-
return
|
|
245
|
+
if (!jobCanNotifyContext(job, currentSessionFile, ctx.cwd, liveWakeupTargets)) return false;
|
|
246
|
+
if (job.notifiedAt) return false;
|
|
247
|
+
if (shouldPruneTerminalJob(job, now)) return false;
|
|
248
|
+
return shouldRequestWakeup(job, now);
|
|
98
249
|
})
|
|
99
250
|
.map((job) => job.id);
|
|
100
251
|
|
|
101
252
|
for (const jobId of candidateJobIds) {
|
|
253
|
+
await hooks.beforeNotificationClaim?.(jobId);
|
|
102
254
|
const claimed = await tryClaimNotification(jobId, notificationClaimant);
|
|
103
255
|
if (!claimed) continue;
|
|
104
|
-
|
|
256
|
+
|
|
257
|
+
await hooks.afterNotificationClaim?.(claimed);
|
|
258
|
+
const preNotifyLiveWakeupTargets = await resolveLiveWakeupTargets();
|
|
259
|
+
if (!jobCanNotifyContext(claimed, currentSessionFile, ctx.cwd, preNotifyLiveWakeupTargets)) {
|
|
105
260
|
await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
|
|
106
261
|
continue;
|
|
107
262
|
}
|
|
108
263
|
|
|
109
264
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
265
|
+
if (currentSessionFile) {
|
|
266
|
+
await recordNotificationTarget(jobId, notificationClaimant, {
|
|
267
|
+
notificationSessionKey: pollerKey,
|
|
268
|
+
notificationSessionFile: currentSessionFile,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
await hooks.beforeNotificationPersist?.(claimed);
|
|
272
|
+
const preWakeupLiveWakeupTargets = await resolveLiveWakeupTargets();
|
|
273
|
+
if (!jobCanNotifyContext(claimed, currentSessionFile, ctx.cwd, preWakeupLiveWakeupTargets)) {
|
|
274
|
+
await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const deliverable = readJob(jobId);
|
|
278
|
+
if (!deliverable || shouldPruneTerminalJob(deliverable, Date.now())) {
|
|
279
|
+
await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
requestWakeupTurn(pi, deliverable);
|
|
284
|
+
await noteWakeupRequested(jobId).catch(() => undefined);
|
|
285
|
+
if (ctx.hasUI) {
|
|
286
|
+
ctx.ui.notify(`Oracle job ${claimed.id} is ${claimed.status}.`, "info");
|
|
287
|
+
}
|
|
288
|
+
await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
|
|
112
289
|
} catch (error) {
|
|
113
290
|
await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
|
|
114
291
|
throw error;
|
|
@@ -116,7 +293,11 @@ async function scan(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
|
116
293
|
}
|
|
117
294
|
}
|
|
118
295
|
|
|
119
|
-
export function
|
|
296
|
+
export async function scanOracleJobsOnce(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string, options: OraclePollerOptions = {}): Promise<void> {
|
|
297
|
+
await scan(pi, ctx, workerPath, options.hooks);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function startPoller(pi: ExtensionAPI, ctx: ExtensionContext, intervalMs: number, workerPath: string, options: OraclePollerOptions = {}): void {
|
|
120
301
|
const sessionKey = getPollerSessionKey(getSessionFile(ctx), ctx.cwd);
|
|
121
302
|
const existing = activePollers.get(sessionKey);
|
|
122
303
|
if (existing) clearInterval(existing);
|
|
@@ -125,7 +306,7 @@ export function startPoller(pi: ExtensionAPI, ctx: ExtensionContext, intervalMs:
|
|
|
125
306
|
if (scansInFlight.has(sessionKey)) return;
|
|
126
307
|
scansInFlight.add(sessionKey);
|
|
127
308
|
try {
|
|
128
|
-
await
|
|
309
|
+
await scanOracleJobsOnce(pi, ctx, workerPath, options);
|
|
129
310
|
} catch (error) {
|
|
130
311
|
console.error(`Oracle poller scan failed (${sessionKey}):`, error);
|
|
131
312
|
} finally {
|
|
@@ -145,12 +326,17 @@ export function startPoller(pi: ExtensionAPI, ctx: ExtensionContext, intervalMs:
|
|
|
145
326
|
export function stopPollerForSession(sessionFile: string | undefined, cwd: string): void {
|
|
146
327
|
const sessionKey = getPollerSessionKey(sessionFile, cwd);
|
|
147
328
|
const timer = activePollers.get(sessionKey);
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
329
|
+
if (timer) {
|
|
330
|
+
clearInterval(timer);
|
|
331
|
+
activePollers.delete(sessionKey);
|
|
332
|
+
scansInFlight.delete(sessionKey);
|
|
333
|
+
}
|
|
334
|
+
const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(sessionKey);
|
|
335
|
+
void releaseLease(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey).catch(() => undefined);
|
|
152
336
|
}
|
|
153
337
|
|
|
154
338
|
export function stopPoller(ctx: ExtensionContext): void {
|
|
155
|
-
|
|
339
|
+
const sessionFile = getSessionFile(ctx);
|
|
340
|
+
if (!sessionFile) return;
|
|
341
|
+
stopPollerForSession(sessionFile, ctx.cwd);
|
|
156
342
|
}
|