pi-async 0.0.0-stage → 0.1.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,416 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { killTree, spawnDetached } from "./spawn.ts";
5
+ import { stripAnsi } from "./output.ts";
6
+ import { deriveName, isTerminal, type KillKind, type RunOptions, type SyncResult, type TaskMode, type TaskSnapshot, type WatchSpec, type WaitOutcome } from "./task.ts";
7
+ import { evalConditions, validateWatchSpec, WatchLoop } from "./watch.ts";
8
+
9
+ export interface RegistryEvents {
10
+ onFinalize(snapshot: TaskSnapshot): void;
11
+ onStateChange(snapshot: TaskSnapshot): void;
12
+ }
13
+ export interface RegistryConfig { dataDir: string; maxTasks?: number; maxOutputBytes?: number; }
14
+ interface LiveTask { outputFile: string; exit?: Promise<{ code: number }>; timer?: ReturnType<typeof setTimeout>; delayTimer?: ReturnType<typeof setTimeout>; watcher?: ReturnType<typeof setInterval>; killKind?: KillKind; processExited?: boolean; }
15
+ interface Waiter { ids: Set<string>; resolve: () => void; }
16
+ const DEFAULT_MAX_OUTPUT = 20 * 1024 * 1024;
17
+
18
+ export class TaskRegistry {
19
+ private readonly tasks = new Map<string, TaskSnapshot>();
20
+ private readonly live = new Map<string, LiveTask>();
21
+ private readonly waiters = new Set<Waiter>();
22
+ private readonly notifyClaims = new Set<string>();
23
+ private readonly watchLoops = new Map<string, WatchLoop>();
24
+ private events: RegistryEvents = { onFinalize: () => undefined, onStateChange: () => undefined };
25
+ private readonly maxTasks: number;
26
+ private readonly maxOutputBytes: number;
27
+ private closed = false;
28
+ private shutdownPromise?: Promise<void>;
29
+
30
+ private readonly config: RegistryConfig;
31
+ constructor(config: RegistryConfig) {
32
+ this.config = config;
33
+ this.maxTasks = config.maxTasks ?? finiteEnvNumber("PI_ASYNC_MAX_TASKS", 32);
34
+ this.maxOutputBytes = config.maxOutputBytes ?? finiteEnvNumber("PI_ASYNC_MAX_OUTPUT_BYTES", DEFAULT_MAX_OUTPUT);
35
+ mkdirSync(config.dataDir, { recursive: true });
36
+ }
37
+
38
+ setEvents(events: RegistryEvents): void { this.events = events; }
39
+
40
+ submit(opts: RunOptions): TaskSnapshot {
41
+ if (opts.mode && opts.mode !== "background") throw new Error("submit only accepts background mode");
42
+ return this.createAndStart(opts, "background");
43
+ }
44
+
45
+ scheduleDelayed(opts: RunOptions, delayMs: number): TaskSnapshot {
46
+ this.assertAdmission(opts);
47
+ if (!Number.isFinite(delayMs) || delayMs < 0) throw new Error("delayMs must be a non-negative finite number");
48
+ this.validateRunWatch(opts, "delayed");
49
+ const createdAt = Date.now();
50
+ const taskId = randomUUID();
51
+ const outputFile = join(this.config.dataDir, `${taskId}.output`);
52
+ const task: TaskSnapshot = {
53
+ taskId, name: opts.name?.slice(0, 48) ?? deriveName(opts.command), command: opts.command, mode: "delayed", status: "pending",
54
+ createdAt, dueAt: createdAt + delayMs, taskTimeoutMs: opts.taskTimeoutMs, notified: false, outputConsumed: false,
55
+ ...(opts.watch ? { watch: opts.watch } : {}),
56
+ metadata: { notifyOnCompletion: opts.notifyOnCompletion ?? true, triggerOnCompletion: opts.triggerOnCompletion ?? true, outputFile, ...(opts.env ? { delayedEnv: opts.env } : {}) },
57
+ };
58
+ this.tasks.set(taskId, task);
59
+ const live: LiveTask = { outputFile };
60
+ this.live.set(taskId, live);
61
+ this.persist(task);
62
+ this.emitState(task);
63
+ if (opts.watch) this.attachWatch(taskId, opts.watch);
64
+ this.scheduleDelayedStart(task, Math.max(0, (task.dueAt ?? createdAt) - Date.now()));
65
+ return task;
66
+ }
67
+
68
+ async runSync(opts: RunOptions, timeoutMs: number, onTimeout: "promote" | "abort", signal?: AbortSignal): Promise<SyncResult> {
69
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error("timeoutMs must be a non-negative finite number");
70
+ this.validateRunWatch(opts, "sync");
71
+ const task = this.createAndStart(opts, "sync");
72
+ let promoted = false;
73
+ try {
74
+ const outcome = await this.wait([task.taskId], timeoutMs, signal);
75
+ if ("notReady" in outcome && onTimeout === "promote" && !isTerminal(task.status)) {
76
+ task.mode = "background";
77
+ promoted = true;
78
+ this.emitState(task);
79
+ }
80
+ } catch (error) {
81
+ if (!signal?.aborted) throw error;
82
+ }
83
+ return this.syncResult(task, promoted);
84
+ }
85
+
86
+ attachWatch(taskId: string, spec: WatchSpec): void {
87
+ const validation = validateWatchSpec(spec);
88
+ if (!validation.ok) throw new Error(`Invalid watch spec: ${validation.error}`);
89
+ const task = this.tasks.get(taskId);
90
+ if (!task) throw new Error(`Unknown task id: ${taskId}`);
91
+ if (isTerminal(task.status)) throw new Error(`Cannot attach watch to terminal task ${taskId}`);
92
+ const existing = this.watchLoops.get(taskId);
93
+ existing?.stop();
94
+ const loop = new WatchLoop({
95
+ taskId, spec,
96
+ readOutput: () => this.readWatchOutput(taskId),
97
+ getExitCode: () => this.tasks.get(taskId)?.exitCode,
98
+ onSuccess: (detail) => this.markWatchSuccess(taskId, detail),
99
+ onFailure: () => { void this.kill(taskId, "watch_failure").catch((error: unknown) => console.error(`pi-async: watch failure kill failed for task ${taskId}`, error)); },
100
+ onBlindExhausted: () => {
101
+ const current = this.tasks.get(taskId);
102
+ if (current && !isTerminal(current.status)) {
103
+ this.finalize(current, "failed");
104
+ void this.kill(taskId, "watch_failure").catch((error: unknown) => console.error(`pi-async: blind-check kill failed for task ${taskId}`, error));
105
+ }
106
+ },
107
+ onWatchTimeout: () => {
108
+ const current = this.tasks.get(taskId);
109
+ if (current && !isTerminal(current.status)) {
110
+ this.finalize(current, "failed");
111
+ void this.kill(taskId, "watch_failure").catch((error: unknown) => console.error(`pi-async: watch-timeout kill failed for task ${taskId}`, error));
112
+ }
113
+ },
114
+ });
115
+ this.watchLoops.set(taskId, loop);
116
+ if (task.status === "running") loop.start();
117
+ }
118
+ private assertAdmission(opts: RunOptions): void {
119
+ if (this.closed) throw new Error("Async task admission is closed");
120
+ const active = this.activeCount();
121
+ if (active.running + active.pending >= this.maxTasks) throw new Error(`Maximum active async tasks reached (${this.maxTasks})`);
122
+ if (!opts.command.trim()) throw new Error("command must not be empty");
123
+ }
124
+
125
+ private validateRunWatch(opts: RunOptions, mode: TaskMode): void {
126
+ if (!opts.watch) return;
127
+ if (mode === "sync") throw new Error("watch is only supported for background and delayed modes");
128
+ const result = validateWatchSpec(opts.watch);
129
+ if (!result.ok) throw new Error(`Invalid watch spec: ${result.error}`);
130
+ }
131
+
132
+ private createAndStart(opts: RunOptions, mode: "background" | "sync"): TaskSnapshot {
133
+ this.assertAdmission(opts);
134
+ this.validateRunWatch(opts, mode);
135
+ const taskId = randomUUID();
136
+ const outputFile = join(this.config.dataDir, `${taskId}.output`);
137
+ const task: TaskSnapshot = {
138
+ taskId, name: opts.name?.slice(0, 48) ?? deriveName(opts.command), command: opts.command, mode, status: "running",
139
+ createdAt: Date.now(), startedAt: Date.now(), taskTimeoutMs: opts.taskTimeoutMs, notified: false, outputConsumed: false,
140
+ ...(opts.watch ? { watch: opts.watch } : {}),
141
+ metadata: { notifyOnCompletion: opts.notifyOnCompletion ?? true, triggerOnCompletion: opts.triggerOnCompletion ?? true, outputFile },
142
+ };
143
+ this.tasks.set(taskId, task);
144
+ this.live.set(taskId, { outputFile });
145
+ try {
146
+ this.startTask(task, opts);
147
+ if (opts.watch) this.attachWatch(taskId, opts.watch);
148
+ } catch (error) {
149
+ this.live.delete(taskId);
150
+ this.tasks.delete(taskId);
151
+ throw error;
152
+ }
153
+ return task;
154
+ }
155
+
156
+ private startTask(task: TaskSnapshot, opts: RunOptions): void {
157
+ const live = this.live.get(task.taskId)!;
158
+ if (live.delayTimer) { clearTimeout(live.delayTimer); live.delayTimer = undefined; }
159
+ task.status = "running";
160
+ task.startedAt = Date.now();
161
+ const handle = spawnDetached(task.command, { outputFile: live.outputFile, env: { ...opts.env, PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES: String(this.maxOutputBytes) } });
162
+ task.pid = handle.pid;
163
+ live.exit = handle.exit.then(({ code }) => {
164
+ live.processExited = true;
165
+ if (live.timer) { clearTimeout(live.timer); live.timer = undefined; }
166
+ task.exitCode = code;
167
+ task.metadata.capped = this.isOutputCapped(task);
168
+ if (task.metadata.capped || task.metadata.watchSuccess !== undefined) this.persist(task);
169
+ if (!isTerminal(task.status) && task.watch) {
170
+ try {
171
+ const output = this.readWatchOutput(task.taskId);
172
+ if (evalConditions(task.watch.failure_when, { output, exitCode: code })) this.finalize(task, "killed", undefined, "watch_failure");
173
+ else if (evalConditions(task.watch.success_when, { output, exitCode: code })) {
174
+ task.metadata.watchSuccess = `success_when matched after process exit: ${JSON.stringify(task.watch.success_when)}`;
175
+ this.finalize(task, "completed", code);
176
+ }
177
+ } catch { /* WatchLoop owns infrastructure-failure accounting while active. */ }
178
+ }
179
+ if (!isTerminal(task.status)) {
180
+ if (live.killKind === "shutdown" || live.killKind === "user" || live.killKind === "timeout" || live.killKind === "watch_failure") this.finalize(task, "killed", undefined, live.killKind);
181
+ else if (task.metadata.capped) this.finalize(task, "failed", code, "output_cap");
182
+ else this.finalize(task, code === 0 ? "completed" : "failed", code);
183
+ }
184
+ return { code };
185
+ }).catch(() => {
186
+ live.processExited = true;
187
+ if (!isTerminal(task.status)) this.finalize(task, "failed", 1);
188
+ return { code: 1 };
189
+ });
190
+ this.persist(task);
191
+ if (opts.taskTimeoutMs !== undefined && opts.taskTimeoutMs > 0) live.timer = setTimeout(() => {
192
+ if (isTerminal(task.status)) {
193
+ 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));
194
+ return;
195
+ }
196
+ void this.kill(task.taskId, "timeout").catch((error: unknown) => console.error(`pi-async: timeout kill failed for task ${task.taskId}`, error));
197
+ }, opts.taskTimeoutMs);
198
+ this.emitState(task);
199
+ const watch = this.watchLoops.get(task.taskId);
200
+ if (watch) watch.start();
201
+ }
202
+
203
+ private scheduleDelayedStart(task: TaskSnapshot, delayMs: number): void {
204
+ const live = this.live.get(task.taskId);
205
+ if (!live || task.status !== "pending") return;
206
+ live.delayTimer = setTimeout(() => {
207
+ live.delayTimer = undefined;
208
+ if (task.status !== "pending") return;
209
+ const rawEnv = task.metadata.delayedEnv;
210
+ const opts: RunOptions = {
211
+ command: task.command, mode: "delayed", name: task.name, taskTimeoutMs: task.taskTimeoutMs,
212
+ notifyOnCompletion: task.metadata.notifyOnCompletion !== false,
213
+ triggerOnCompletion: task.metadata.triggerOnCompletion !== false,
214
+ ...(rawEnv && typeof rawEnv === "object" ? { env: rawEnv as Record<string, string> } : {}),
215
+ ...(task.watch ? { watch: task.watch } : {}),
216
+ };
217
+ try { this.startTask(task, opts); }
218
+ catch { this.finalize(task, "failed", 1); }
219
+ }, delayMs);
220
+ }
221
+
222
+ private readWatchOutput(taskId: string): string {
223
+ const task = this.tasks.get(taskId);
224
+ if (!task) throw new Error(`Unknown task id: ${taskId}`);
225
+ const outputFile = String(task.metadata.outputFile ?? this.live.get(taskId)?.outputFile ?? join(this.config.dataDir, `${taskId}.output`));
226
+ return stripAnsi(readFileSync(outputFile, "utf8"));
227
+ }
228
+
229
+ private syncResult(task: TaskSnapshot, promoted: boolean): SyncResult {
230
+ let outputTail = this.logs(task.taskId, { maxBytes: 8192, tail: true }).text;
231
+ const lines = outputTail.split("\n");
232
+ if (lines.length > 200) outputTail = lines.slice(-200).join("\n");
233
+ const bytes = Buffer.from(outputTail, "utf8");
234
+ if (bytes.byteLength > 8192) outputTail = bytes.subarray(bytes.byteLength - 8192).toString("utf8");
235
+ return {
236
+ taskId: task.taskId, name: task.name, status: task.status, mode: task.mode,
237
+ outputHint: `Use async_logs with taskId ${task.taskId} to inspect early output.`,
238
+ ...(task.exitCode === undefined ? {} : { exitCode: task.exitCode }), outputTail, promoted,
239
+ };
240
+ }
241
+
242
+ 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); }
243
+ get(taskId: string): TaskSnapshot | undefined { return this.tasks.get(taskId); }
244
+ list(opts?: { includeHistory?: boolean }): TaskSnapshot[] { const all = [...this.tasks.values()]; return opts?.includeHistory === false ? all.filter((task) => !isTerminal(task.status)) : all; }
245
+
246
+ wait(taskIds: string[], timeoutMs: number, signal?: AbortSignal): Promise<WaitOutcome> {
247
+ const uniqueIds = [...new Set(taskIds)];
248
+ const unknown = uniqueIds.filter((id) => !this.tasks.has(id));
249
+ if (unknown.length) return Promise.reject(new Error(`Unknown task id(s): ${unknown.join(", ")}`));
250
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Wait aborted"));
251
+ const pending = () => uniqueIds.map((id) => this.tasks.get(id)!).filter((task) => !isTerminal(task.status));
252
+ if (pending().length === 0) return Promise.resolve({ results: uniqueIds.map((id) => this.waitResult(this.tasks.get(id)!)) });
253
+ return new Promise<WaitOutcome>((resolve, reject) => {
254
+ let settled = false;
255
+ const waiter: Waiter = { ids: new Set(uniqueIds), resolve: () => {
256
+ if (settled || pending().length) return;
257
+ settled = true; cleanup();
258
+ resolve({ results: uniqueIds.map((id) => this.waitResult(this.tasks.get(id)!)) });
259
+ } };
260
+ const timeout = setTimeout(() => {
261
+ if (settled) return;
262
+ settled = true; cleanup();
263
+ resolve({ notReady: pending().map((task) => ({ taskId: task.taskId, status: task.status as "running" | "pending", elapsedMs: Date.now() - task.createdAt })) });
264
+ }, Math.max(0, timeoutMs));
265
+ const abort = () => { if (settled) return; settled = true; cleanup(); reject(signal?.reason ?? new Error("Wait aborted")); };
266
+ const cleanup = () => { clearTimeout(timeout); signal?.removeEventListener("abort", abort); this.waiters.delete(waiter); };
267
+ this.waiters.add(waiter);
268
+ signal?.addEventListener("abort", abort, { once: true });
269
+ });
270
+ }
271
+
272
+ logs(taskId: string, opts: { maxBytes?: number; tail?: boolean }): { text: string; truncated: boolean; fullPath: string } {
273
+ const task = this.tasks.get(taskId);
274
+ if (!task) throw new Error(`Unknown task id: ${taskId}`);
275
+ const fullPath = String(task.metadata.outputFile ?? this.live.get(taskId)?.outputFile ?? join(this.config.dataDir, `${taskId}.output`));
276
+ if (!existsSync(fullPath)) return { text: "", truncated: false, fullPath };
277
+ const data = readFileSync(fullPath);
278
+ const maxBytes = Math.min(50 * 1024, Math.max(0, opts.maxBytes ?? 50 * 1024));
279
+ const selected = opts.tail === false ? data.subarray(0, maxBytes) : data.subarray(Math.max(0, data.length - maxBytes));
280
+ return { text: stripAnsi(selected.toString("utf8")), truncated: data.length > selected.length, fullPath };
281
+ }
282
+
283
+ async kill(taskId: string, kind: KillKind): Promise<void> {
284
+ const task = this.tasks.get(taskId);
285
+ if (!task) return;
286
+ const live = this.live.get(taskId) ?? (task.pid !== undefined
287
+ ? { outputFile: String(task.metadata.outputFile ?? join(this.config.dataDir, `${task.taskId}.output`)), processExited: !this.pidAlive(task.pid) }
288
+ : undefined);
289
+ if (!live) return;
290
+ if (task.status === "pending") {
291
+ if (kind === "user") this.cancel(taskId);
292
+ else this.finalize(task, "killed", undefined, kind);
293
+ return;
294
+ }
295
+ if (isTerminal(task.status) && live.processExited) return;
296
+ live.killKind = kind;
297
+ if (task.pid && !live.processExited) {
298
+ const result = await killTree(task.pid);
299
+ if (!result.confirmed) throw new Error("Descendant processes may have leaked");
300
+ }
301
+ if (!isTerminal(task.status)) this.finalize(task, "killed", undefined, kind);
302
+ else if (live.timer) { clearTimeout(live.timer); live.timer = undefined; }
303
+ }
304
+ cancel(taskId: string): boolean {
305
+ const task = this.tasks.get(taskId);
306
+ if (!task || task.status !== "pending") return false;
307
+ this.finalize(task, "killed", undefined, "user");
308
+ return true;
309
+ }
310
+ consume(taskId: string): void { const task = this.tasks.get(taskId); if (!task) return; task.outputConsumed = true; this.persist(task); }
311
+ claimNotification(taskId: string): boolean {
312
+ const task = this.tasks.get(taskId);
313
+ if (!task || task.notified || task.outputConsumed || this.notifyClaims.has(taskId)) return false;
314
+ this.notifyClaims.add(taskId); task.notified = true; this.persist(task); return true;
315
+ }
316
+ rollbackNotification(taskId: string): void { const task = this.tasks.get(taskId); this.notifyClaims.delete(taskId); if (task) { task.notified = false; this.persist(task); } }
317
+ 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 }; }
318
+ hasActiveWork(): boolean { const count = this.activeCount(); return count.running + count.pending > 0; }
319
+ shutdown(): Promise<void> {
320
+ if (this.shutdownPromise) return this.shutdownPromise;
321
+ this.closed = true;
322
+ this.shutdownPromise = (async () => {
323
+ const ids = [...this.tasks.values()].filter((task) => {
324
+ const live = this.live.get(task.taskId);
325
+ return !isTerminal(task.status) || (task.pid !== undefined && (live ? !live.processExited : this.pidAlive(task.pid)));
326
+ }).map((task) => task.taskId);
327
+ await Promise.all(ids.map(async (id) => {
328
+ try {
329
+ await this.kill(id, "shutdown");
330
+ await this.live.get(id)?.exit;
331
+ } catch (error) {
332
+ // A failed bounded kill must be reported, but must not turn shutdown into an unbounded wait.
333
+ console.error(`pi-async: shutdown could not confirm task ${id} process-group exit`, error);
334
+ }
335
+ return undefined;
336
+ }));
337
+ })();
338
+ return this.shutdownPromise;
339
+ }
340
+ async reloadFromDisk(): Promise<void> {
341
+ for (const file of readdirSync(this.config.dataDir).filter((name) => name.endsWith(".json"))) {
342
+ try {
343
+ const snapshot = JSON.parse(readFileSync(join(this.config.dataDir, file), "utf8")) as TaskSnapshot;
344
+ if (!snapshot.taskId || this.tasks.has(snapshot.taskId)) continue;
345
+ this.tasks.set(snapshot.taskId, snapshot);
346
+ if (snapshot.status === "pending" && snapshot.mode === "delayed") {
347
+ const outputFile = String(snapshot.metadata.outputFile ?? join(this.config.dataDir, `${snapshot.taskId}.output`));
348
+ this.live.set(snapshot.taskId, { outputFile });
349
+ if (snapshot.watch) this.attachWatch(snapshot.taskId, snapshot.watch);
350
+ const dueAt = snapshot.dueAt ?? Date.now();
351
+ this.scheduleDelayedStart(snapshot, Math.max(0, dueAt - Date.now()));
352
+ continue;
353
+ }
354
+ if ((snapshot.status === "running" || snapshot.metadata.watchSuccess !== undefined) && snapshot.pid && this.pidAlive(snapshot.pid)) {
355
+ const outputFile = String(snapshot.metadata.outputFile ?? join(this.config.dataDir, `${snapshot.taskId}.output`));
356
+ const live: LiveTask = { outputFile };
357
+ this.live.set(snapshot.taskId, live);
358
+ live.watcher = setInterval(() => {
359
+ if (snapshot.pid && !this.pidAlive(snapshot.pid)) {
360
+ if (live.watcher) clearInterval(live.watcher);
361
+ live.processExited = true;
362
+ if (live.timer) { clearTimeout(live.timer); live.timer = undefined; }
363
+ snapshot.metadata.capped = this.isOutputCapped(snapshot);
364
+ if (!isTerminal(snapshot.status)) {
365
+ if (live.killKind) this.finalize(snapshot, "killed", undefined, live.killKind);
366
+ else if (snapshot.metadata.capped) this.finalize(snapshot, "failed", undefined, "output_cap");
367
+ else { snapshot.failureKind = "lost"; this.finalize(snapshot, "failed"); }
368
+ }
369
+ }
370
+ }, 100);
371
+ live.watcher.unref?.();
372
+ if (snapshot.status === "running" && snapshot.watch) this.attachWatch(snapshot.taskId, snapshot.watch);
373
+ } else if (snapshot.status === "running") {
374
+ snapshot.status = "failed"; snapshot.failureKind = "lost"; snapshot.finishedAt = Date.now(); this.persist(snapshot); this.events.onFinalize(snapshot);
375
+ }
376
+ } catch { /* Ignore incomplete/corrupt metadata files; atomic writer leaves prior version intact. */ }
377
+ }
378
+ }
379
+
380
+ private waitResult(task: TaskSnapshot): { taskId: string; status: TaskSnapshot["status"]; exitCode?: number; outputTail: string } {
381
+ this.consume(task.taskId);
382
+ return { taskId: task.taskId, status: task.status, ...(task.exitCode === undefined ? {} : { exitCode: task.exitCode }), outputTail: this.logs(task.taskId, { maxBytes: 8192, tail: true }).text };
383
+ }
384
+ private finalize(task: TaskSnapshot, status: TaskSnapshot["status"], code?: number, killKind?: KillKind): void {
385
+ if (isTerminal(task.status)) return;
386
+ task.status = status; task.exitCode = code; task.killKind = killKind; task.finishedAt = Date.now();
387
+ const live = this.live.get(task.taskId);
388
+ if (live?.delayTimer) { clearTimeout(live.delayTimer); live.delayTimer = undefined; }
389
+ const watch = this.watchLoops.get(task.taskId); watch?.stop(); this.watchLoops.delete(task.taskId);
390
+ if (live?.watcher) clearInterval(live.watcher);
391
+ // D4: watch success settles the observed task but leaves the process under its task timeout.
392
+ if (live?.timer && !(task.metadata.watchSuccess !== undefined && !live.processExited)) { clearTimeout(live.timer); live.timer = undefined; }
393
+ this.persist(task); this.emitState(task); this.events.onFinalize(task);
394
+ for (const waiter of [...this.waiters]) if (waiter.ids.has(task.taskId)) waiter.resolve();
395
+ }
396
+ private emitState(task: TaskSnapshot): void { this.persist(task); this.events.onStateChange(task); }
397
+ private persist(task: TaskSnapshot): void {
398
+ const file = join(this.config.dataDir, `${task.taskId}.json`); const temp = `${file}.${process.pid}.tmp`;
399
+ writeFileSync(temp, JSON.stringify(task), { mode: 0o600 }); renameSync(temp, file);
400
+ }
401
+ private isOutputCapped(task: TaskSnapshot): boolean {
402
+ try {
403
+ const persisted = JSON.parse(readFileSync(join(this.config.dataDir, `${task.taskId}.json`), "utf8")) as TaskSnapshot;
404
+ return persisted.metadata.capped === true;
405
+ } catch { return task.metadata.capped === true; }
406
+ }
407
+ private pidAlive(pid: number): boolean {
408
+ try { process.kill(-pid, 0); return true; }
409
+ catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
410
+ }
411
+ }
412
+
413
+ function finiteEnvNumber(name: string, fallback: number): number {
414
+ const parsed = Number(process.env[name] ?? fallback);
415
+ return Number.isFinite(parsed) ? parsed : fallback;
416
+ }
@@ -0,0 +1,98 @@
1
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import type { TaskSnapshot } from "./task.ts";
6
+
7
+ export interface SpawnHandle { pid: number; exit: Promise<{ code: number }>; }
8
+
9
+ /** Start a shell command in its own POSIX process group and capture merged output. */
10
+ export function spawnDetached(command: string, opts: { outputFile: string; env?: Record<string, string> }): SpawnHandle {
11
+ mkdirSync(dirname(opts.outputFile), { recursive: true });
12
+ const capBytes = finiteNumber(opts.env?.PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES ?? process.env.PI_ASYNC_MAX_OUTPUT_BYTES, 20 * 1024 * 1024);
13
+ const env = { ...process.env, ...opts.env };
14
+ delete env.PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES;
15
+ const child = spawn("/bin/sh", ["-c", command], { detached: true, stdio: ["ignore", "pipe", "pipe"], env });
16
+ if (!child.pid) throw new Error("Failed to start detached task process");
17
+ const output = createWriteStream(opts.outputFile, { flags: "w", mode: 0o600 });
18
+ let totalBytes = 0;
19
+ let capReached = false;
20
+ const copy = (chunk: Buffer): void => {
21
+ if (capReached) return;
22
+ const remaining = Math.max(0, capBytes - totalBytes);
23
+ const accepted = chunk.subarray(0, remaining);
24
+ if (accepted.length) {
25
+ output.write(accepted);
26
+ totalBytes += accepted.length;
27
+ }
28
+ if (accepted.length < chunk.length) {
29
+ capReached = true;
30
+ persistOutputCap(opts.outputFile);
31
+ void killTree(child.pid ?? 0, { graceMs: 3000 }).then(({ confirmed }) => {
32
+ if (!confirmed) console.error(`pi-async: output cap could not confirm process group ${child.pid} exited`);
33
+ }).catch((error: unknown) => {
34
+ console.error(`pi-async: failed to kill process group ${child.pid} after output cap`, error);
35
+ });
36
+ }
37
+ };
38
+ child.stdout?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk)));
39
+ child.stderr?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk)));
40
+ const exit = new Promise<{ code: number }>((resolve, reject) => {
41
+ child.once("error", reject);
42
+ child.once("close", (code) => {
43
+ output.end(() => resolve({ code: code ?? 1 }));
44
+ });
45
+ });
46
+ return { pid: child.pid, exit };
47
+ }
48
+
49
+ function finiteNumber(value: string | undefined, fallback: number): number {
50
+ const parsed = Number(value);
51
+ return Number.isFinite(parsed) ? parsed : fallback;
52
+ }
53
+
54
+ /** Merge the cap marker into the task snapshot with the same atomic-rename strategy as the registry. */
55
+ function persistOutputCap(outputFile: string): void {
56
+ const metadataFile = outputFile.endsWith(".output") ? `${outputFile.slice(0, -7)}.json` : `${outputFile}.json`;
57
+ try {
58
+ if (!existsSync(metadataFile)) throw new Error(`Task metadata is not available at ${metadataFile}`);
59
+ const snapshot = JSON.parse(readFileSync(metadataFile, "utf8")) as TaskSnapshot;
60
+ snapshot.metadata = { ...snapshot.metadata, capped: true };
61
+ const temp = `${metadataFile}.${process.pid}.tmp`;
62
+ writeFileSync(temp, JSON.stringify(snapshot), { mode: 0o600 });
63
+ renameSync(temp, metadataFile);
64
+ } catch (error) {
65
+ console.error(`pi-async: failed to persist output cap metadata for ${outputFile}`, error);
66
+ }
67
+ }
68
+
69
+ function groupExists(pid: number): boolean {
70
+ try {
71
+ process.kill(-pid, 0);
72
+ return true;
73
+ } catch (error) {
74
+ return (error as NodeJS.ErrnoException).code === "EPERM";
75
+ }
76
+ }
77
+
78
+ async function waitForGroupExit(pid: number, timeoutMs: number): Promise<boolean> {
79
+ const deadline = Date.now() + timeoutMs;
80
+ while (groupExists(pid) && Date.now() < deadline) await delay(Math.min(100, Math.max(1, deadline - Date.now())));
81
+ return !groupExists(pid);
82
+ }
83
+
84
+ /** Terminate the detached process group, escalating from TERM to KILL. */
85
+ export async function killTree(pid: number, opts?: { graceMs?: number }): Promise<{ confirmed: boolean }> {
86
+ const graceMs = opts?.graceMs ?? 3000;
87
+ if (!Number.isInteger(pid) || pid <= 1) throw new RangeError("pid must identify a child process");
88
+ if (!groupExists(pid)) return { confirmed: true };
89
+ try { process.kill(-pid, "SIGTERM"); } catch (error) {
90
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
91
+ }
92
+ if (await waitForGroupExit(pid, graceMs)) return { confirmed: true };
93
+ try { process.kill(-pid, "SIGKILL"); } catch (error) {
94
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
95
+ }
96
+ // Bounded probe: do not hang shutdown forever if the kernel cannot prove death.
97
+ return { confirmed: await waitForGroupExit(pid, 1500) };
98
+ }
@@ -0,0 +1,85 @@
1
+ export type TaskStatus = "pending" | "running" | "completed" | "failed" | "killed";
2
+ export type TaskMode = "background" | "sync" | "delayed";
3
+ export type KillKind = "user" | "timeout" | "output_cap" | "shutdown" | "watch_failure";
4
+
5
+ export const TERMINAL_STATUSES = ["completed", "failed", "killed"] as const satisfies readonly TaskStatus[];
6
+
7
+ export function isTerminal(status: TaskStatus): boolean {
8
+ return TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]);
9
+ }
10
+
11
+ /** Derive a concise, single-line display name from a shell command. */
12
+ export function deriveName(command: string): string {
13
+ const normalized = command.split(/\r?\n/, 1)[0]?.trim().replace(/\s+/g, " ") ?? "";
14
+ if (normalized.length === 0) return "async task";
15
+ const characters = Array.from(normalized);
16
+ return characters.length <= 48 ? normalized : characters.slice(0, 47).join("") + "…";
17
+ }
18
+
19
+ export type Condition =
20
+ | { type: "exit_code"; equals: number }
21
+ | { type: "stdout_contains"; value: string }
22
+ | { type: "stderr_contains"; value: string }
23
+ | { type: "json_path_exists"; path: string }
24
+ | { type: "output_matches"; regex: string };
25
+
26
+ export interface WatchSpec {
27
+ intervalMs: number;
28
+ timeoutMs: number;
29
+ success_when: Condition[];
30
+ failure_when: Condition[];
31
+ blind_checks?: number;
32
+ }
33
+
34
+ export interface RunOptions {
35
+ command: string;
36
+ name?: string;
37
+ mode?: TaskMode;
38
+ delayMs?: number;
39
+ timeoutMs?: number;
40
+ onTimeout?: "promote" | "abort";
41
+ taskTimeoutMs?: number;
42
+ notifyOnCompletion?: boolean;
43
+ triggerOnCompletion?: boolean;
44
+ env?: Record<string, string>;
45
+ watch?: WatchSpec;
46
+ }
47
+
48
+ export interface RunReceipt {
49
+ taskId: string;
50
+ name: string;
51
+ status: TaskStatus;
52
+ mode: TaskMode;
53
+ outputHint: string;
54
+ }
55
+
56
+ export interface SyncResult extends RunReceipt {
57
+ exitCode?: number;
58
+ outputTail: string;
59
+ promoted: boolean;
60
+ }
61
+
62
+ export type WaitOutcome =
63
+ | { results: Array<{ taskId: string; status: TaskStatus; exitCode?: number; outputTail: string }> }
64
+ | { notReady: Array<{ taskId: string; status: "running" | "pending"; elapsedMs: number }> };
65
+
66
+ export interface TaskSnapshot {
67
+ taskId: string;
68
+ name: string;
69
+ command: string;
70
+ mode: TaskMode;
71
+ status: TaskStatus;
72
+ pid?: number;
73
+ exitCode?: number;
74
+ killKind?: KillKind;
75
+ failureKind?: "lost";
76
+ createdAt: number;
77
+ startedAt?: number;
78
+ finishedAt?: number;
79
+ dueAt?: number;
80
+ taskTimeoutMs?: number;
81
+ notified: boolean;
82
+ outputConsumed: boolean;
83
+ watch?: WatchSpec;
84
+ metadata: Record<string, unknown>;
85
+ }