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.
@@ -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
- await writeFile(join(path, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
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 = join(path, "metadata.json");
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
- async function maybeReclaimStaleLock(path: string): Promise<boolean> {
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 path = lockPath(kind, key);
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 mkdir(path, { recursive: false, mode: 0o700 });
115
- await writeMetadata(path, metadata);
168
+ await createStateDirAtomically(parentDir, path, metadata);
116
169
  return { path };
117
170
  } catch (error) {
118
- if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw 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 path = leasePath(kind, key);
180
- await mkdir(path, { recursive: false, mode: 0o700 });
181
- await writeMetadata(path, metadata);
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
- markJobNotified,
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 getActiveJobCount(ctx: ExtensionContext): number {
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
- if (!isActiveOracleJob(job)) return false;
39
- if (getStaleOracleJobReason(job)) return false;
40
- return jobMatchesContext(job, currentSessionFile, ctx.cwd);
41
- }).length;
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
- const activeJobCount = getActiveJobCount(ctx);
46
- if (activeJobCount > 0) {
47
- const suffix = activeJobCount > 1 ? ` (${activeJobCount})` : "";
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 notifyForJob(pi: ExtensionAPI, job: NonNullable<ReturnType<typeof readJob>>): void {
56
- const responsePath = job.responsePath || `${job.id}/response.md`;
57
- const artifactsPath = `/tmp/oracle-${job.id}/artifacts`;
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: "oracle-job-complete",
61
- display: true,
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
- const candidateJobIds = listOracleJobDirs()
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.status !== "complete" && job.status !== "failed" && job.status !== "cancelled") return false;
96
- if (!jobMatchesContext(job, currentSessionFile, ctx.cwd)) return false;
97
- return !job.notifiedAt;
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
- if (!jobMatchesContext(claimed, currentSessionFile, ctx.cwd)) {
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
- notifyForJob(pi, claimed);
111
- await markJobNotified(jobId, notificationClaimant);
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 startPoller(pi: ExtensionAPI, ctx: ExtensionContext, intervalMs: number): void {
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 scan(pi, ctx);
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 (!timer) return;
149
- clearInterval(timer);
150
- activePollers.delete(sessionKey);
151
- scansInFlight.delete(sessionKey);
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
- stopPollerForSession(getSessionFile(ctx), ctx.cwd);
339
+ const sessionFile = getSessionFile(ctx);
340
+ if (!sessionFile) return;
341
+ stopPollerForSession(sessionFile, ctx.cwd);
156
342
  }