pi-oracle 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,161 @@ 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
+ `Read response: ${responsePath}`,
184
+ `Artifacts: ${artifactsPath}`,
185
+ job.error ? `Error: ${job.error}` : "Continue from the oracle output.",
186
+ ].join("\n");
187
+ }
188
+
189
+
190
+ function requestWakeupTurn(pi: ExtensionAPI, job: OraclePollerJob): void {
58
191
  pi.sendMessage(
59
192
  {
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"),
193
+ customType: ORACLE_WAKEUP_REMINDER_CUSTOM_TYPE,
194
+ display: false,
195
+ content: buildNotificationContent(job),
68
196
  details: { jobId: job.id, status: job.status },
69
197
  },
70
- { triggerTurn: true },
198
+ { triggerTurn: true, deliverAs: "followUp" },
71
199
  );
72
200
  }
73
201
 
74
- async function scan(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
202
+ async function scan(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string, hooks: OraclePollerHooks = {}): Promise<void> {
75
203
  const currentSessionFile = getSessionFile(ctx);
76
204
  const pollerKey = getPollerSessionKey(currentSessionFile, ctx.cwd);
77
205
  const notificationClaimant = `${pollerKey}:${process.pid}`;
78
206
 
207
+ const projectId = getProjectId(ctx.cwd);
208
+ const sessionId = getSessionId(currentSessionFile, projectId);
209
+ const processStartedAt = readProcessStartedAt(process.pid);
210
+ const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(pollerKey, process.pid, processStartedAt || "unknown");
211
+ const resolveLiveWakeupTargets = hooks.collectLiveWakeupTargets ?? collectLiveWakeupTargets;
212
+ await writeLeaseMetadata(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey, {
213
+ leaseKey: wakeupTargetLeaseKey,
214
+ projectId,
215
+ sessionId,
216
+ processPid: process.pid,
217
+ processStartedAt,
218
+ updatedAt: new Date().toISOString(),
219
+ }).catch(() => undefined);
220
+ const liveWakeupTargets = await resolveLiveWakeupTargets();
221
+
79
222
  try {
80
223
  await withGlobalReconcileLock(
81
224
  { processPid: process.pid, cwd: ctx.cwd, sessionFile: currentSessionFile, source: "poller" },
@@ -88,27 +231,60 @@ async function scan(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
88
231
  if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
89
232
  }
90
233
 
91
- const candidateJobIds = listOracleJobDirs()
234
+ await promoteQueuedJobs({ workerPath, source: "poller" });
235
+
236
+ const terminalJobs = listOracleJobDirs()
92
237
  .map((jobDir) => readJob(jobDir))
93
238
  .filter((job): job is NonNullable<typeof job> => Boolean(job))
239
+ .filter((job) => job.status === "complete" || job.status === "failed" || job.status === "cancelled");
240
+
241
+ const now = Date.now();
242
+ const candidateJobIds = terminalJobs
94
243
  .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;
244
+ if (!jobCanNotifyContext(job, currentSessionFile, ctx.cwd, liveWakeupTargets)) return false;
245
+ if (job.notifiedAt) return false;
246
+ if (shouldPruneTerminalJob(job, now)) return false;
247
+ return shouldRequestWakeup(job, now);
98
248
  })
99
249
  .map((job) => job.id);
100
250
 
101
251
  for (const jobId of candidateJobIds) {
252
+ await hooks.beforeNotificationClaim?.(jobId);
102
253
  const claimed = await tryClaimNotification(jobId, notificationClaimant);
103
254
  if (!claimed) continue;
104
- if (!jobMatchesContext(claimed, currentSessionFile, ctx.cwd)) {
255
+
256
+ await hooks.afterNotificationClaim?.(claimed);
257
+ const preNotifyLiveWakeupTargets = await resolveLiveWakeupTargets();
258
+ if (!jobCanNotifyContext(claimed, currentSessionFile, ctx.cwd, preNotifyLiveWakeupTargets)) {
105
259
  await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
106
260
  continue;
107
261
  }
108
262
 
109
263
  try {
110
- notifyForJob(pi, claimed);
111
- await markJobNotified(jobId, notificationClaimant);
264
+ if (currentSessionFile) {
265
+ await recordNotificationTarget(jobId, notificationClaimant, {
266
+ notificationSessionKey: pollerKey,
267
+ notificationSessionFile: currentSessionFile,
268
+ });
269
+ }
270
+ await hooks.beforeNotificationPersist?.(claimed);
271
+ const preWakeupLiveWakeupTargets = await resolveLiveWakeupTargets();
272
+ if (!jobCanNotifyContext(claimed, currentSessionFile, ctx.cwd, preWakeupLiveWakeupTargets)) {
273
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
274
+ continue;
275
+ }
276
+ const deliverable = readJob(jobId);
277
+ if (!deliverable || shouldPruneTerminalJob(deliverable, Date.now())) {
278
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
279
+ continue;
280
+ }
281
+
282
+ requestWakeupTurn(pi, deliverable);
283
+ await noteWakeupRequested(jobId).catch(() => undefined);
284
+ if (ctx.hasUI) {
285
+ ctx.ui.notify(`Oracle job ${claimed.id} is ${claimed.status}.`, "info");
286
+ }
287
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
112
288
  } catch (error) {
113
289
  await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
114
290
  throw error;
@@ -116,7 +292,11 @@ async function scan(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
116
292
  }
117
293
  }
118
294
 
119
- export function startPoller(pi: ExtensionAPI, ctx: ExtensionContext, intervalMs: number): void {
295
+ export async function scanOracleJobsOnce(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string, options: OraclePollerOptions = {}): Promise<void> {
296
+ await scan(pi, ctx, workerPath, options.hooks);
297
+ }
298
+
299
+ export function startPoller(pi: ExtensionAPI, ctx: ExtensionContext, intervalMs: number, workerPath: string, options: OraclePollerOptions = {}): void {
120
300
  const sessionKey = getPollerSessionKey(getSessionFile(ctx), ctx.cwd);
121
301
  const existing = activePollers.get(sessionKey);
122
302
  if (existing) clearInterval(existing);
@@ -125,7 +305,7 @@ export function startPoller(pi: ExtensionAPI, ctx: ExtensionContext, intervalMs:
125
305
  if (scansInFlight.has(sessionKey)) return;
126
306
  scansInFlight.add(sessionKey);
127
307
  try {
128
- await scan(pi, ctx);
308
+ await scanOracleJobsOnce(pi, ctx, workerPath, options);
129
309
  } catch (error) {
130
310
  console.error(`Oracle poller scan failed (${sessionKey}):`, error);
131
311
  } finally {
@@ -145,12 +325,17 @@ export function startPoller(pi: ExtensionAPI, ctx: ExtensionContext, intervalMs:
145
325
  export function stopPollerForSession(sessionFile: string | undefined, cwd: string): void {
146
326
  const sessionKey = getPollerSessionKey(sessionFile, cwd);
147
327
  const timer = activePollers.get(sessionKey);
148
- if (!timer) return;
149
- clearInterval(timer);
150
- activePollers.delete(sessionKey);
151
- scansInFlight.delete(sessionKey);
328
+ if (timer) {
329
+ clearInterval(timer);
330
+ activePollers.delete(sessionKey);
331
+ scansInFlight.delete(sessionKey);
332
+ }
333
+ const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(sessionKey);
334
+ void releaseLease(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey).catch(() => undefined);
152
335
  }
153
336
 
154
337
  export function stopPoller(ctx: ExtensionContext): void {
155
- stopPollerForSession(getSessionFile(ctx), ctx.cwd);
338
+ const sessionFile = getSessionFile(ctx);
339
+ if (!sessionFile) return;
340
+ stopPollerForSession(sessionFile, ctx.cwd);
156
341
  }