pi-async 0.0.0-stage → 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.
@@ -0,0 +1,543 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { killTree, spawnDetached, streamPaths } from "./spawn.ts";
5
+ import { stripLogControls, truncateUtf8Bytes } from "./output.ts";
6
+ import { envNumber } from "./env.ts";
7
+ import { capName, deriveName, isTaskStatus, isTerminal, type FailureKind, type KillKind, type RunOptions, type SyncResult, type TaskMode, type TaskSnapshot, type WatchSpec, type WaitOutcome } from "./task.ts";
8
+ import { evalConditions, validateWatchSpec, WatchLoop, type WatchOutput } from "./watch.ts";
9
+
10
+ export interface RegistryEvents {
11
+ onFinalize(snapshot: TaskSnapshot): void;
12
+ onStateChange(snapshot: TaskSnapshot): void;
13
+ }
14
+ export interface RegistryConfig { dataDir: string; maxTasks?: number; maxOutputBytes?: number; retentionMs?: number; retentionCount?: number; }
15
+ interface LiveTask { outputFile: string; exit?: Promise<{ code: number }>; timer?: ReturnType<typeof setTimeout>; delayTimer?: ReturnType<typeof setTimeout>; watcher?: ReturnType<typeof setInterval>; killKind?: KillKind; processExited?: boolean; }
16
+ interface Waiter { ids: Set<string>; resolve: () => void; }
17
+ const DEFAULT_MAX_OUTPUT = 20 * 1024 * 1024;
18
+ const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
19
+ const DEFAULT_RETENTION_COUNT = 100;
20
+
21
+ export class TaskRegistry {
22
+ private readonly tasks = new Map<string, TaskSnapshot>();
23
+ private readonly live = new Map<string, LiveTask>();
24
+ private readonly waiters = new Set<Waiter>();
25
+ private readonly notifyClaims = new Set<string>();
26
+ private readonly watchLoops = new Map<string, WatchLoop>();
27
+ private events: RegistryEvents = { onFinalize: () => undefined, onStateChange: () => undefined };
28
+ private readonly maxTasks: number;
29
+ private readonly maxOutputBytes: number;
30
+ private readonly retentionMs: number;
31
+ private readonly retentionCount: number;
32
+ private closed = false;
33
+ private reloading = false;
34
+ private shutdownPromise?: Promise<void>;
35
+
36
+ private readonly config: RegistryConfig;
37
+ constructor(config: RegistryConfig) {
38
+ this.config = config;
39
+ this.maxTasks = config.maxTasks ?? envNumber("PI_ASYNC_MAX_TASKS", 32, { integer: true, min: 1 });
40
+ this.maxOutputBytes = config.maxOutputBytes ?? envNumber("PI_ASYNC_MAX_OUTPUT_BYTES", DEFAULT_MAX_OUTPUT, { integer: true, min: 0 });
41
+ this.retentionMs = config.retentionMs ?? envNumber("PI_ASYNC_RETENTION_MS", DEFAULT_RETENTION_MS, { integer: true, min: 0 });
42
+ this.retentionCount = config.retentionCount ?? envNumber("PI_ASYNC_RETENTION_COUNT", DEFAULT_RETENTION_COUNT, { integer: true, min: 0 });
43
+ mkdirSync(config.dataDir, { recursive: true });
44
+ }
45
+
46
+ setEvents(events: RegistryEvents): void { this.events = events; }
47
+
48
+ submit(opts: RunOptions): TaskSnapshot {
49
+ if (opts.mode && opts.mode !== "background") throw new Error("submit only accepts background mode");
50
+ return this.createAndStart(opts, "background");
51
+ }
52
+
53
+ scheduleDelayed(opts: RunOptions, delayMs: number): TaskSnapshot {
54
+ this.assertAdmission(opts);
55
+ if (!Number.isFinite(delayMs) || delayMs < 0) throw new Error("delayMs must be a non-negative finite number");
56
+ this.validateRunWatch(opts, "delayed");
57
+ const createdAt = Date.now();
58
+ const taskId = randomUUID();
59
+ const outputFile = join(this.config.dataDir, `${taskId}.output`);
60
+ const task: TaskSnapshot = {
61
+ taskId, name: opts.name ? capName(opts.name) : deriveName(opts.command), command: opts.command, mode: "delayed", status: "pending",
62
+ createdAt, dueAt: createdAt + delayMs, taskTimeoutMs: opts.taskTimeoutMs, notified: false, outputConsumed: false,
63
+ ...(opts.watch ? { watch: opts.watch } : {}),
64
+ metadata: { notifyOnCompletion: opts.notifyOnCompletion ?? true, triggerOnCompletion: opts.triggerOnCompletion ?? true, outputFile, ...(opts.env ? { delayedEnv: opts.env } : {}) },
65
+ };
66
+ this.tasks.set(taskId, task);
67
+ const live: LiveTask = { outputFile };
68
+ this.live.set(taskId, live);
69
+ this.persist(task);
70
+ this.emitState(task);
71
+ if (opts.watch) this.attachWatch(taskId, opts.watch);
72
+ this.scheduleDelayedStart(task, Math.max(0, (task.dueAt ?? createdAt) - Date.now()));
73
+ return task;
74
+ }
75
+
76
+ async runSync(opts: RunOptions, timeoutMs: number, onTimeout: "promote" | "abort", signal?: AbortSignal): Promise<SyncResult> {
77
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error("timeoutMs must be a non-negative finite number");
78
+ this.validateRunWatch(opts, "sync");
79
+ const task = this.createAndStart(opts, "sync");
80
+ let promoted = false;
81
+ try {
82
+ const outcome = await this.wait([task.taskId], timeoutMs, signal);
83
+ // Timeout / Esc: stop waiting. promote → background + promoted flag; abort → Esc semantics
84
+ // (keep process, bookkeeping mode becomes background so the receipt is not "sync+running").
85
+ if ("notReady" in outcome && !isTerminal(task.status)) {
86
+ task.mode = "background";
87
+ if (onTimeout === "promote") promoted = true;
88
+ this.emitState(task);
89
+ }
90
+ } catch (error) {
91
+ if (!signal?.aborted) throw error;
92
+ if (!isTerminal(task.status) && task.mode === "sync") {
93
+ task.mode = "background";
94
+ this.emitState(task);
95
+ }
96
+ }
97
+ return this.syncResult(task, promoted);
98
+ }
99
+
100
+ attachWatch(taskId: string, spec: WatchSpec): void {
101
+ const validation = validateWatchSpec(spec);
102
+ if (!validation.ok) throw new Error(`Invalid watch spec: ${validation.error}`);
103
+ const task = this.tasks.get(taskId);
104
+ if (!task) throw new Error(`Unknown task id: ${taskId}`);
105
+ if (isTerminal(task.status)) throw new Error(`Cannot attach watch to terminal task ${taskId}`);
106
+ const existing = this.watchLoops.get(taskId);
107
+ existing?.stop();
108
+ const loop = new WatchLoop({
109
+ taskId, spec,
110
+ readOutput: () => this.readWatchOutput(taskId),
111
+ getExitCode: () => this.tasks.get(taskId)?.exitCode,
112
+ onSuccess: (detail) => this.markWatchSuccess(taskId, detail),
113
+ onFailure: () => { void this.kill(taskId, "watch_failure").catch((error: unknown) => console.error(`pi-async: watch failure kill failed for task ${taskId}`, error)); },
114
+ onBlindExhausted: () => {
115
+ const current = this.tasks.get(taskId);
116
+ if (current && !isTerminal(current.status)) {
117
+ current.failureKind = "blind_exhausted";
118
+ this.finalize(current, "failed", undefined, "watch_failure");
119
+ void this.kill(taskId, "watch_failure").catch((error: unknown) => console.error(`pi-async: blind-check kill failed for task ${taskId}`, error));
120
+ }
121
+ },
122
+ onWatchTimeout: () => {
123
+ const current = this.tasks.get(taskId);
124
+ if (current && !isTerminal(current.status)) {
125
+ current.failureKind = "watch_timeout";
126
+ this.finalize(current, "failed", undefined, "watch_failure");
127
+ void this.kill(taskId, "watch_failure").catch((error: unknown) => console.error(`pi-async: watch-timeout kill failed for task ${taskId}`, error));
128
+ }
129
+ },
130
+ });
131
+ this.watchLoops.set(taskId, loop);
132
+ if (task.status === "running") loop.start();
133
+ }
134
+ private assertAdmission(opts: RunOptions): void {
135
+ if (this.closed) throw new Error("Async task admission is closed");
136
+ const active = this.activeCount();
137
+ if (active.running + active.pending >= this.maxTasks) throw new Error(`Maximum active async tasks reached (${this.maxTasks})`);
138
+ if (!opts.command.trim()) throw new Error("command must not be empty");
139
+ }
140
+
141
+ private validateRunWatch(opts: RunOptions, mode: TaskMode): void {
142
+ if (!opts.watch) return;
143
+ if (mode === "sync") throw new Error("watch is only supported for background and delayed modes");
144
+ const result = validateWatchSpec(opts.watch);
145
+ if (!result.ok) throw new Error(`Invalid watch spec: ${result.error}`);
146
+ }
147
+
148
+ private createAndStart(opts: RunOptions, mode: "background" | "sync"): TaskSnapshot {
149
+ this.assertAdmission(opts);
150
+ this.validateRunWatch(opts, mode);
151
+ const taskId = randomUUID();
152
+ const outputFile = join(this.config.dataDir, `${taskId}.output`);
153
+ const task: TaskSnapshot = {
154
+ taskId, name: opts.name ? capName(opts.name) : deriveName(opts.command), command: opts.command, mode, status: "running",
155
+ createdAt: Date.now(), startedAt: Date.now(), taskTimeoutMs: opts.taskTimeoutMs, notified: false, outputConsumed: false,
156
+ ...(opts.watch ? { watch: opts.watch } : {}),
157
+ metadata: { notifyOnCompletion: opts.notifyOnCompletion ?? true, triggerOnCompletion: opts.triggerOnCompletion ?? true, outputFile },
158
+ };
159
+ this.tasks.set(taskId, task);
160
+ this.live.set(taskId, { outputFile });
161
+ try {
162
+ this.startTask(task, opts);
163
+ if (opts.watch) this.attachWatch(taskId, opts.watch);
164
+ } catch (error) {
165
+ this.live.delete(taskId);
166
+ this.tasks.delete(taskId);
167
+ throw error;
168
+ }
169
+ return task;
170
+ }
171
+
172
+ private startTask(task: TaskSnapshot, opts: RunOptions): void {
173
+ const live = this.live.get(task.taskId)!;
174
+ if (live.delayTimer) { clearTimeout(live.delayTimer); live.delayTimer = undefined; }
175
+ task.status = "running";
176
+ task.startedAt = Date.now();
177
+ const handle = spawnDetached(task.command, { outputFile: live.outputFile, env: { ...opts.env, PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES: String(this.maxOutputBytes) } });
178
+ task.pid = handle.pid;
179
+ live.exit = handle.exit.then(({ code }) => {
180
+ live.processExited = true;
181
+ if (live.timer) { clearTimeout(live.timer); live.timer = undefined; }
182
+ task.exitCode = code;
183
+ task.metadata.capped = this.isOutputCapped(task);
184
+ if (task.metadata.capped || task.metadata.watchSuccess !== undefined) this.persist(task);
185
+ if (!isTerminal(task.status) && task.watch) {
186
+ try {
187
+ const streams = this.readWatchOutput(task.taskId);
188
+ if (evalConditions(task.watch.failure_when, { ...streams, exitCode: code })) this.finalize(task, "killed", undefined, "watch_failure");
189
+ else if (evalConditions(task.watch.success_when, { ...streams, exitCode: code })) {
190
+ task.metadata.watchSuccess = `success_when matched after process exit: ${JSON.stringify(task.watch.success_when)}`;
191
+ this.finalize(task, "completed", code);
192
+ }
193
+ } catch { /* WatchLoop owns infrastructure-failure accounting while active. */ }
194
+ }
195
+ if (!isTerminal(task.status)) {
196
+ if (live.killKind === "shutdown" || live.killKind === "user" || live.killKind === "timeout" || live.killKind === "watch_failure") this.finalize(task, "killed", undefined, live.killKind);
197
+ else if (task.metadata.capped) this.finalize(task, "failed", code, "output_cap");
198
+ else this.finalize(task, code === 0 ? "completed" : "failed", code);
199
+ }
200
+ return { code };
201
+ }).catch(() => {
202
+ live.processExited = true;
203
+ if (!isTerminal(task.status)) this.finalize(task, "failed", 1);
204
+ return { code: 1 };
205
+ });
206
+ this.persist(task);
207
+ if (opts.taskTimeoutMs !== undefined && opts.taskTimeoutMs > 0) live.timer = setTimeout(() => {
208
+ if (isTerminal(task.status)) {
209
+ if (!live.processExited && task.pid) void killTree(task.pid).catch((error: unknown) => console.error(`pi-async: timeout kill failed for completed task ${task.taskId}`, error));
210
+ return;
211
+ }
212
+ void this.kill(task.taskId, "timeout").catch((error: unknown) => console.error(`pi-async: timeout kill failed for task ${task.taskId}`, error));
213
+ }, opts.taskTimeoutMs);
214
+ this.emitState(task);
215
+ const watch = this.watchLoops.get(task.taskId);
216
+ if (watch) watch.start();
217
+ }
218
+
219
+ private scheduleDelayedStart(task: TaskSnapshot, delayMs: number): void {
220
+ const live = this.live.get(task.taskId);
221
+ if (!live || task.status !== "pending") return;
222
+ live.delayTimer = setTimeout(() => {
223
+ live.delayTimer = undefined;
224
+ if (task.status !== "pending") return;
225
+ const rawEnv = task.metadata.delayedEnv;
226
+ const opts: RunOptions = {
227
+ command: task.command, mode: "delayed", name: task.name, taskTimeoutMs: task.taskTimeoutMs,
228
+ notifyOnCompletion: task.metadata.notifyOnCompletion !== false,
229
+ triggerOnCompletion: task.metadata.triggerOnCompletion !== false,
230
+ ...(rawEnv && typeof rawEnv === "object" ? { env: rawEnv as Record<string, string> } : {}),
231
+ ...(task.watch ? { watch: task.watch } : {}),
232
+ };
233
+ try { this.startTask(task, opts); }
234
+ catch { this.finalize(task, "failed", 1); }
235
+ }, delayMs);
236
+ }
237
+
238
+ private readWatchOutput(taskId: string): WatchOutput {
239
+ const task = this.tasks.get(taskId);
240
+ if (!task) throw new Error(`Unknown task id: ${taskId}`);
241
+ const outputFile = String(task.metadata.outputFile ?? this.live.get(taskId)?.outputFile ?? join(this.config.dataDir, `${taskId}.output`));
242
+ // Missing primary output must throw so WatchLoop blind_checks can fire (#37/#68 + CONTRACT).
243
+ // stripLogControls: ANSI + bare C0 for model/watch paths (#18); streams stay distinct (#4).
244
+ const output = stripLogControls(readFileSync(outputFile, "utf8"));
245
+ const { stdoutFile, stderrFile } = streamPaths(outputFile);
246
+ const readSide = (file: string): string => existsSync(file) ? stripLogControls(readFileSync(file, "utf8")) : "";
247
+ return { output, stdout: readSide(stdoutFile), stderr: readSide(stderrFile) };
248
+ }
249
+
250
+ private syncResult(task: TaskSnapshot, promoted: boolean): SyncResult {
251
+ let outputTail = this.logs(task.taskId, { maxBytes: 8192, tail: true }).text;
252
+ const lines = outputTail.split("\n");
253
+ if (lines.length > 200) outputTail = lines.slice(-200).join("\n");
254
+ const bytes = Buffer.from(outputTail, "utf8");
255
+ if (bytes.byteLength > 8192) outputTail = truncateUtf8Bytes(bytes, 8192, true);
256
+ return {
257
+ taskId: task.taskId, name: task.name, status: task.status, mode: task.mode,
258
+ outputHint: `Use async_logs with taskId ${task.taskId} to inspect early output.`,
259
+ ...(task.exitCode === undefined ? {} : { exitCode: task.exitCode }), outputTail, promoted,
260
+ };
261
+ }
262
+
263
+ markWatchSuccess(taskId: string, detail: string): void { const task = this.tasks.get(taskId); if (!task || isTerminal(task.status)) return; task.metadata.watchSuccess = detail; this.finalize(task, "completed", task.exitCode); }
264
+ get(taskId: string): TaskSnapshot | undefined { return this.tasks.get(taskId); }
265
+ list(opts?: { includeHistory?: boolean }): TaskSnapshot[] { const all = [...this.tasks.values()]; return opts?.includeHistory === false ? all.filter((task) => !isTerminal(task.status)) : all; }
266
+
267
+ wait(taskIds: string[] | string, timeoutMs: number, signal?: AbortSignal): Promise<WaitOutcome> {
268
+ // A bare string is one id; String is iterable so `[...new Set("uuid")]` would split into characters (#40).
269
+ const idList = typeof taskIds === "string" ? [taskIds] : taskIds;
270
+ if (!Array.isArray(idList) || idList.some((id) => typeof id !== "string")) {
271
+ return Promise.reject(new TypeError(`taskIds must be an array of strings (got ${typeof taskIds})`));
272
+ }
273
+ const uniqueIds = [...new Set(idList)];
274
+ const unknown = uniqueIds.filter((id) => !this.tasks.has(id));
275
+ if (unknown.length) return Promise.reject(new Error(`Unknown task id(s): ${unknown.join(", ")}`));
276
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Wait aborted"));
277
+ const pending = () => uniqueIds.map((id) => this.tasks.get(id)!).filter((task) => !isTerminal(task.status));
278
+ if (pending().length === 0) return Promise.resolve({ results: uniqueIds.map((id) => this.waitResult(this.tasks.get(id)!)) });
279
+ return new Promise<WaitOutcome>((resolve, reject) => {
280
+ let settled = false;
281
+ const waiter: Waiter = { ids: new Set(uniqueIds), resolve: () => {
282
+ if (settled || pending().length) return;
283
+ settled = true; cleanup();
284
+ resolve({ results: uniqueIds.map((id) => this.waitResult(this.tasks.get(id)!)) });
285
+ } };
286
+ const timeout = setTimeout(() => {
287
+ if (settled) return;
288
+ settled = true; cleanup();
289
+ const still = pending();
290
+ const finishedIds = uniqueIds.filter((id) => !still.some((task) => task.taskId === id));
291
+ const notReady = still.map((task) => ({ taskId: task.taskId, status: task.status as "running" | "pending", elapsedMs: Date.now() - task.createdAt }));
292
+ // REV-7: no finished → pure { notReady }; mixed → { results, notReady }.
293
+ if (finishedIds.length === 0) resolve({ notReady });
294
+ else resolve({ results: finishedIds.map((id) => this.waitResult(this.tasks.get(id)!)), notReady });
295
+ }, Math.max(0, timeoutMs));
296
+ const abort = () => { if (settled) return; settled = true; cleanup(); reject(signal?.reason ?? new Error("Wait aborted")); };
297
+ const cleanup = () => { clearTimeout(timeout); signal?.removeEventListener("abort", abort); this.waiters.delete(waiter); };
298
+ this.waiters.add(waiter);
299
+ signal?.addEventListener("abort", abort, { once: true });
300
+ });
301
+ }
302
+
303
+ logs(taskId: string, opts: { maxBytes?: number; tail?: boolean }): { text: string; truncated: boolean; fullPath: string } {
304
+ const task = this.tasks.get(taskId);
305
+ if (!task) throw new Error(`Unknown task id: ${taskId}`);
306
+ const fullPath = String(task.metadata.outputFile ?? this.live.get(taskId)?.outputFile ?? join(this.config.dataDir, `${taskId}.output`));
307
+ if (!existsSync(fullPath)) return { text: "", truncated: false, fullPath };
308
+ const data = readFileSync(fullPath);
309
+ const maxBytes = Math.min(50 * 1024, Math.max(0, opts.maxBytes ?? 50 * 1024));
310
+ const truncated = data.length > maxBytes;
311
+ const text = stripLogControls(truncateUtf8Bytes(data, maxBytes, opts.tail !== false));
312
+ return { text, truncated, fullPath };
313
+ }
314
+
315
+ async kill(taskId: string, kind: KillKind): Promise<void> {
316
+ const task = this.tasks.get(taskId);
317
+ if (!task) return;
318
+ const live = this.live.get(taskId) ?? (task.pid !== undefined
319
+ ? { outputFile: String(task.metadata.outputFile ?? join(this.config.dataDir, `${task.taskId}.output`)), processExited: !this.pidAlive(task.pid) }
320
+ : undefined);
321
+ if (!live) return;
322
+ if (task.status === "pending") {
323
+ if (kind === "user") this.cancel(taskId);
324
+ else this.finalize(task, "killed", undefined, kind);
325
+ return;
326
+ }
327
+ if (isTerminal(task.status) && live.processExited) return;
328
+ live.killKind = kind;
329
+ let confirmed = true;
330
+ if (task.pid && !live.processExited) {
331
+ const result = await killTree(task.pid);
332
+ confirmed = result.confirmed;
333
+ }
334
+ // Finalize first so async_kill can always return { taskId, status } even when the
335
+ // confirmation probe cannot prove the group is gone (#6).
336
+ if (!isTerminal(task.status)) this.finalize(task, "killed", undefined, kind);
337
+ else {
338
+ // D4 / leftover process: record kill without flipping the settled observation status (#33).
339
+ task.killKind = kind;
340
+ try { this.persist(task); } catch { /* dataDir may already be gone during teardown */ }
341
+ if (live.timer) { clearTimeout(live.timer); live.timer = undefined; }
342
+ }
343
+ // Finalize-first (#6): still surface confirmation failure after status is settled.
344
+ if (!confirmed) throw new Error("Descendant processes may have leaked");
345
+ }
346
+ cancel(taskId: string): boolean {
347
+ const task = this.tasks.get(taskId);
348
+ if (!task || task.status !== "pending") return false;
349
+ this.finalize(task, "killed", undefined, "user");
350
+ return true;
351
+ }
352
+ consume(taskId: string): void { const task = this.tasks.get(taskId); if (!task) return; task.outputConsumed = true; this.persist(task); }
353
+ claimNotification(taskId: string): boolean {
354
+ const task = this.tasks.get(taskId);
355
+ if (!task || task.notified || task.outputConsumed || this.notifyClaims.has(taskId)) return false;
356
+ this.notifyClaims.add(taskId); task.notified = true; this.persist(task); return true;
357
+ }
358
+ rollbackNotification(taskId: string): void { const task = this.tasks.get(taskId); this.notifyClaims.delete(taskId); if (task) { task.notified = false; this.persist(task); } }
359
+ activeCount(): { running: number; pending: number } { let running = 0; let pending = 0; for (const task of this.tasks.values()) { if (task.status === "running") running++; else if (task.status === "pending") pending++; } return { running, pending }; }
360
+ hasActiveWork(): boolean { const count = this.activeCount(); return count.running + count.pending > 0; }
361
+ shutdown(): Promise<void> {
362
+ if (this.shutdownPromise) return this.shutdownPromise;
363
+ this.closed = true;
364
+ this.shutdownPromise = (async () => {
365
+ // Pending delayed tasks hold no OS resources. Clear their timers but leave snapshots
366
+ // as pending so same-process /reload can restore them (CONTRACT reload / #20).
367
+ for (const task of this.tasks.values()) {
368
+ if (task.status !== "pending") continue;
369
+ const live = this.live.get(task.taskId);
370
+ if (live?.delayTimer) { clearTimeout(live.delayTimer); live.delayTimer = undefined; }
371
+ const watch = this.watchLoops.get(task.taskId);
372
+ watch?.stop();
373
+ this.watchLoops.delete(task.taskId);
374
+ }
375
+ const ids = [...this.tasks.values()].filter((task) => {
376
+ if (task.status === "pending") return false;
377
+ const live = this.live.get(task.taskId);
378
+ return !isTerminal(task.status) || (task.pid !== undefined && (live ? !live.processExited : this.pidAlive(task.pid)));
379
+ }).map((task) => task.taskId);
380
+ await Promise.all(ids.map(async (id) => {
381
+ try {
382
+ await this.kill(id, "shutdown");
383
+ await this.live.get(id)?.exit;
384
+ } catch (error) {
385
+ // A failed bounded kill must be reported, but must not turn shutdown into an unbounded wait.
386
+ console.error(`pi-async: shutdown could not confirm task ${id} process-group exit`, error);
387
+ }
388
+ return undefined;
389
+ }));
390
+ })();
391
+ return this.shutdownPromise;
392
+ }
393
+ async reloadFromDisk(): Promise<void> {
394
+ this.reloading = true;
395
+ try {
396
+ for (const file of readdirSync(this.config.dataDir).filter((name) => name.endsWith(".json"))) {
397
+ try {
398
+ const snapshot = JSON.parse(readFileSync(join(this.config.dataDir, file), "utf8")) as TaskSnapshot;
399
+ // #34: skip unknown status values from disk.
400
+ if (!snapshot.taskId || !isTaskStatus(snapshot.status) || this.tasks.has(snapshot.taskId)) continue;
401
+ this.tasks.set(snapshot.taskId, snapshot);
402
+ if (snapshot.status === "pending" && snapshot.mode === "delayed") {
403
+ const outputFile = String(snapshot.metadata.outputFile ?? join(this.config.dataDir, `${snapshot.taskId}.output`));
404
+ this.live.set(snapshot.taskId, { outputFile });
405
+ if (snapshot.watch) this.attachWatch(snapshot.taskId, snapshot.watch);
406
+ const dueAt = snapshot.dueAt ?? Date.now();
407
+ this.scheduleDelayedStart(snapshot, Math.max(0, dueAt - Date.now()));
408
+ continue;
409
+ }
410
+ if ((snapshot.status === "running" || snapshot.metadata.watchSuccess !== undefined) && snapshot.pid && this.pidAlive(snapshot.pid)) {
411
+ const outputFile = String(snapshot.metadata.outputFile ?? join(this.config.dataDir, `${snapshot.taskId}.output`));
412
+ const live: LiveTask = { outputFile };
413
+ this.live.set(snapshot.taskId, live);
414
+ live.watcher = setInterval(() => {
415
+ if (snapshot.pid && !this.pidAlive(snapshot.pid)) {
416
+ if (live.watcher) clearInterval(live.watcher);
417
+ live.processExited = true;
418
+ if (live.timer) { clearTimeout(live.timer); live.timer = undefined; }
419
+ snapshot.metadata.capped = this.isOutputCapped(snapshot);
420
+ if (!isTerminal(snapshot.status)) {
421
+ if (live.killKind) this.finalize(snapshot, "killed", undefined, live.killKind);
422
+ else if (snapshot.metadata.capped) this.finalize(snapshot, "failed", undefined, "output_cap");
423
+ else { snapshot.failureKind = "lost"; this.finalize(snapshot, "failed"); }
424
+ }
425
+ }
426
+ }, 100);
427
+ live.watcher.unref?.();
428
+ if (snapshot.status === "running" && snapshot.watch) this.attachWatch(snapshot.taskId, snapshot.watch);
429
+ } else if (snapshot.status === "running") {
430
+ snapshot.status = "failed"; snapshot.failureKind = "lost"; snapshot.finishedAt = Date.now(); this.persist(snapshot); this.events.onFinalize(snapshot);
431
+ }
432
+ } catch { /* Ignore incomplete/corrupt metadata files; atomic writer leaves prior version intact. */ }
433
+ }
434
+ } finally {
435
+ this.reloading = false;
436
+ this.prune();
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Drop terminal-task artifacts that fall outside the retention window.
442
+ *
443
+ * Policy (per dataDir): keep the union of (1) the newest `retentionCount`
444
+ * terminal tasks by finishedAt/createdAt and (2) any terminal task still
445
+ * younger than `retentionMs`. Everything else terminal is deleted
446
+ * (.json + .output via metadata.outputFile) and removed from memory.
447
+ * Non-terminal tasks are never touched. Missing files are ignored.
448
+ */
449
+ prune(now = Date.now()): { pruned: number } {
450
+ const terminal = this.list({ includeHistory: true })
451
+ .filter((task) => isTerminal(task.status))
452
+ .sort((a, b) => (b.finishedAt ?? b.createdAt) - (a.finishedAt ?? a.createdAt));
453
+ let pruned = 0;
454
+ for (let i = 0; i < terminal.length; i++) {
455
+ const task = terminal[i]!;
456
+ const ageAnchor = task.finishedAt ?? task.createdAt;
457
+ const withinCount = i < this.retentionCount;
458
+ const withinAge = now - ageAnchor < this.retentionMs;
459
+ if (withinCount || withinAge) continue;
460
+ this.removeArtifacts(task);
461
+ pruned++;
462
+ }
463
+ return { pruned };
464
+ }
465
+
466
+ private removeArtifacts(task: TaskSnapshot): void {
467
+ const outputFile = String(task.metadata.outputFile ?? join(this.config.dataDir, `${task.taskId}.output`));
468
+ try { rmSync(outputFile, { force: true }); } catch { /* force already ignores ENOENT; swallow other rare races */ }
469
+ try { rmSync(join(this.config.dataDir, `${task.taskId}.json`), { force: true }); } catch { /* same */ }
470
+ const live = this.live.get(task.taskId);
471
+ if (live?.timer) clearTimeout(live.timer);
472
+ if (live?.delayTimer) clearTimeout(live.delayTimer);
473
+ if (live?.watcher) clearInterval(live.watcher);
474
+ this.live.delete(task.taskId);
475
+ this.watchLoops.get(task.taskId)?.stop();
476
+ this.watchLoops.delete(task.taskId);
477
+ this.tasks.delete(task.taskId);
478
+ this.notifyClaims.delete(task.taskId);
479
+ }
480
+
481
+ private waitResult(task: TaskSnapshot): { taskId: string; status: TaskSnapshot["status"]; exitCode?: number; outputTail: string; killKind?: KillKind; failureKind?: FailureKind } {
482
+ this.consume(task.taskId);
483
+ return {
484
+ taskId: task.taskId,
485
+ status: task.status,
486
+ ...(task.exitCode === undefined ? {} : { exitCode: task.exitCode }),
487
+ outputTail: this.logs(task.taskId, { maxBytes: 8192, tail: true }).text,
488
+ ...(task.killKind ? { killKind: task.killKind } : {}),
489
+ ...(task.failureKind ? { failureKind: task.failureKind } : {}),
490
+ };
491
+ }
492
+ private finalize(task: TaskSnapshot, status: TaskSnapshot["status"], code?: number, killKind?: KillKind): void {
493
+ if (isTerminal(task.status)) return;
494
+ task.status = status; task.exitCode = code; task.killKind = killKind; task.finishedAt = Date.now();
495
+ const live = this.live.get(task.taskId);
496
+ if (live?.delayTimer) { clearTimeout(live.delayTimer); live.delayTimer = undefined; }
497
+ const watch = this.watchLoops.get(task.taskId); watch?.stop(); this.watchLoops.delete(task.taskId);
498
+ if (live?.watcher) clearInterval(live.watcher);
499
+ // D4: watch success settles the observed task but leaves the process under its task timeout.
500
+ if (live?.timer && !(task.metadata.watchSuccess !== undefined && !live.processExited)) { clearTimeout(live.timer); live.timer = undefined; }
501
+ // #15: a waiter already owns this result — set the suppression latch before onFinalize
502
+ // can claimNotification / sendCompletionNotification.
503
+ if ([...this.waiters].some((waiter) => waiter.ids.has(task.taskId))) {
504
+ task.outputConsumed = true;
505
+ }
506
+ this.persist(task); this.emitState(task); this.events.onFinalize(task);
507
+ for (const waiter of [...this.waiters]) if (waiter.ids.has(task.taskId)) waiter.resolve();
508
+ if (!this.reloading) this.prune();
509
+ }
510
+ private emitState(task: TaskSnapshot): void { this.persist(task); this.events.onStateChange(task); }
511
+ private persist(task: TaskSnapshot): void {
512
+ const file = join(this.config.dataDir, `${task.taskId}.json`); const temp = `${file}.${process.pid}.tmp`;
513
+ writeFileSync(temp, JSON.stringify(task), { mode: 0o600 }); renameSync(temp, file);
514
+ }
515
+ private isOutputCapped(task: TaskSnapshot): boolean {
516
+ try {
517
+ const persisted = JSON.parse(readFileSync(join(this.config.dataDir, `${task.taskId}.json`), "utf8")) as TaskSnapshot;
518
+ return persisted.metadata.capped === true;
519
+ } catch { return task.metadata.capped === true; }
520
+ }
521
+ private pidAlive(pid: number): boolean {
522
+ // Prefer /proc so a zombie-only group is not treated as alive (#6 / reload lost detection).
523
+ try {
524
+ for (const entry of readdirSync("/proc")) {
525
+ if (!/^\d+$/.test(entry)) continue;
526
+ try {
527
+ const stat = readFileSync(join("/proc", entry, "stat"), "utf8");
528
+ const closeParen = stat.lastIndexOf(") ");
529
+ if (closeParen < 0) continue;
530
+ const rest = stat.slice(closeParen + 2).split(" ");
531
+ const state = rest[0];
532
+ const pgrp = Number(rest[2]);
533
+ if (pgrp === pid && state !== "Z") return true;
534
+ } catch { /* raced with exit */ }
535
+ }
536
+ return false;
537
+ } catch {
538
+ try { process.kill(-pid, 0); return true; }
539
+ catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
540
+ }
541
+ }
542
+ }
543
+