pi-async 0.1.0 → 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,19 +1,22 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, existsSync } from "node:fs";
2
+ import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, existsSync } from "node:fs";
3
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";
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";
8
9
 
9
10
  export interface RegistryEvents {
10
11
  onFinalize(snapshot: TaskSnapshot): void;
11
12
  onStateChange(snapshot: TaskSnapshot): void;
12
13
  }
13
- export interface RegistryConfig { dataDir: string; maxTasks?: number; maxOutputBytes?: number; }
14
+ export interface RegistryConfig { dataDir: string; maxTasks?: number; maxOutputBytes?: number; retentionMs?: number; retentionCount?: number; }
14
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; }
15
16
  interface Waiter { ids: Set<string>; resolve: () => void; }
16
17
  const DEFAULT_MAX_OUTPUT = 20 * 1024 * 1024;
18
+ const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
19
+ const DEFAULT_RETENTION_COUNT = 100;
17
20
 
18
21
  export class TaskRegistry {
19
22
  private readonly tasks = new Map<string, TaskSnapshot>();
@@ -24,14 +27,19 @@ export class TaskRegistry {
24
27
  private events: RegistryEvents = { onFinalize: () => undefined, onStateChange: () => undefined };
25
28
  private readonly maxTasks: number;
26
29
  private readonly maxOutputBytes: number;
30
+ private readonly retentionMs: number;
31
+ private readonly retentionCount: number;
27
32
  private closed = false;
33
+ private reloading = false;
28
34
  private shutdownPromise?: Promise<void>;
29
35
 
30
36
  private readonly config: RegistryConfig;
31
37
  constructor(config: RegistryConfig) {
32
38
  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);
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 });
35
43
  mkdirSync(config.dataDir, { recursive: true });
36
44
  }
37
45
 
@@ -50,7 +58,7 @@ export class TaskRegistry {
50
58
  const taskId = randomUUID();
51
59
  const outputFile = join(this.config.dataDir, `${taskId}.output`);
52
60
  const task: TaskSnapshot = {
53
- taskId, name: opts.name?.slice(0, 48) ?? deriveName(opts.command), command: opts.command, mode: "delayed", status: "pending",
61
+ taskId, name: opts.name ? capName(opts.name) : deriveName(opts.command), command: opts.command, mode: "delayed", status: "pending",
54
62
  createdAt, dueAt: createdAt + delayMs, taskTimeoutMs: opts.taskTimeoutMs, notified: false, outputConsumed: false,
55
63
  ...(opts.watch ? { watch: opts.watch } : {}),
56
64
  metadata: { notifyOnCompletion: opts.notifyOnCompletion ?? true, triggerOnCompletion: opts.triggerOnCompletion ?? true, outputFile, ...(opts.env ? { delayedEnv: opts.env } : {}) },
@@ -72,13 +80,19 @@ export class TaskRegistry {
72
80
  let promoted = false;
73
81
  try {
74
82
  const outcome = await this.wait([task.taskId], timeoutMs, signal);
75
- if ("notReady" in outcome && onTimeout === "promote" && !isTerminal(task.status)) {
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)) {
76
86
  task.mode = "background";
77
- promoted = true;
87
+ if (onTimeout === "promote") promoted = true;
78
88
  this.emitState(task);
79
89
  }
80
90
  } catch (error) {
81
91
  if (!signal?.aborted) throw error;
92
+ if (!isTerminal(task.status) && task.mode === "sync") {
93
+ task.mode = "background";
94
+ this.emitState(task);
95
+ }
82
96
  }
83
97
  return this.syncResult(task, promoted);
84
98
  }
@@ -100,14 +114,16 @@ export class TaskRegistry {
100
114
  onBlindExhausted: () => {
101
115
  const current = this.tasks.get(taskId);
102
116
  if (current && !isTerminal(current.status)) {
103
- this.finalize(current, "failed");
117
+ current.failureKind = "blind_exhausted";
118
+ this.finalize(current, "failed", undefined, "watch_failure");
104
119
  void this.kill(taskId, "watch_failure").catch((error: unknown) => console.error(`pi-async: blind-check kill failed for task ${taskId}`, error));
105
120
  }
106
121
  },
107
122
  onWatchTimeout: () => {
108
123
  const current = this.tasks.get(taskId);
109
124
  if (current && !isTerminal(current.status)) {
110
- this.finalize(current, "failed");
125
+ current.failureKind = "watch_timeout";
126
+ this.finalize(current, "failed", undefined, "watch_failure");
111
127
  void this.kill(taskId, "watch_failure").catch((error: unknown) => console.error(`pi-async: watch-timeout kill failed for task ${taskId}`, error));
112
128
  }
113
129
  },
@@ -135,7 +151,7 @@ export class TaskRegistry {
135
151
  const taskId = randomUUID();
136
152
  const outputFile = join(this.config.dataDir, `${taskId}.output`);
137
153
  const task: TaskSnapshot = {
138
- taskId, name: opts.name?.slice(0, 48) ?? deriveName(opts.command), command: opts.command, mode, status: "running",
154
+ taskId, name: opts.name ? capName(opts.name) : deriveName(opts.command), command: opts.command, mode, status: "running",
139
155
  createdAt: Date.now(), startedAt: Date.now(), taskTimeoutMs: opts.taskTimeoutMs, notified: false, outputConsumed: false,
140
156
  ...(opts.watch ? { watch: opts.watch } : {}),
141
157
  metadata: { notifyOnCompletion: opts.notifyOnCompletion ?? true, triggerOnCompletion: opts.triggerOnCompletion ?? true, outputFile },
@@ -168,9 +184,9 @@ export class TaskRegistry {
168
184
  if (task.metadata.capped || task.metadata.watchSuccess !== undefined) this.persist(task);
169
185
  if (!isTerminal(task.status) && task.watch) {
170
186
  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 })) {
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 })) {
174
190
  task.metadata.watchSuccess = `success_when matched after process exit: ${JSON.stringify(task.watch.success_when)}`;
175
191
  this.finalize(task, "completed", code);
176
192
  }
@@ -219,11 +235,16 @@ export class TaskRegistry {
219
235
  }, delayMs);
220
236
  }
221
237
 
222
- private readWatchOutput(taskId: string): string {
238
+ private readWatchOutput(taskId: string): WatchOutput {
223
239
  const task = this.tasks.get(taskId);
224
240
  if (!task) throw new Error(`Unknown task id: ${taskId}`);
225
241
  const outputFile = String(task.metadata.outputFile ?? this.live.get(taskId)?.outputFile ?? join(this.config.dataDir, `${taskId}.output`));
226
- return stripAnsi(readFileSync(outputFile, "utf8"));
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) };
227
248
  }
228
249
 
229
250
  private syncResult(task: TaskSnapshot, promoted: boolean): SyncResult {
@@ -231,7 +252,7 @@ export class TaskRegistry {
231
252
  const lines = outputTail.split("\n");
232
253
  if (lines.length > 200) outputTail = lines.slice(-200).join("\n");
233
254
  const bytes = Buffer.from(outputTail, "utf8");
234
- if (bytes.byteLength > 8192) outputTail = bytes.subarray(bytes.byteLength - 8192).toString("utf8");
255
+ if (bytes.byteLength > 8192) outputTail = truncateUtf8Bytes(bytes, 8192, true);
235
256
  return {
236
257
  taskId: task.taskId, name: task.name, status: task.status, mode: task.mode,
237
258
  outputHint: `Use async_logs with taskId ${task.taskId} to inspect early output.`,
@@ -243,8 +264,13 @@ export class TaskRegistry {
243
264
  get(taskId: string): TaskSnapshot | undefined { return this.tasks.get(taskId); }
244
265
  list(opts?: { includeHistory?: boolean }): TaskSnapshot[] { const all = [...this.tasks.values()]; return opts?.includeHistory === false ? all.filter((task) => !isTerminal(task.status)) : all; }
245
266
 
246
- wait(taskIds: string[], timeoutMs: number, signal?: AbortSignal): Promise<WaitOutcome> {
247
- const uniqueIds = [...new Set(taskIds)];
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)];
248
274
  const unknown = uniqueIds.filter((id) => !this.tasks.has(id));
249
275
  if (unknown.length) return Promise.reject(new Error(`Unknown task id(s): ${unknown.join(", ")}`));
250
276
  if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Wait aborted"));
@@ -260,7 +286,12 @@ export class TaskRegistry {
260
286
  const timeout = setTimeout(() => {
261
287
  if (settled) return;
262
288
  settled = true; cleanup();
263
- resolve({ notReady: pending().map((task) => ({ taskId: task.taskId, status: task.status as "running" | "pending", elapsedMs: Date.now() - task.createdAt })) });
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 });
264
295
  }, Math.max(0, timeoutMs));
265
296
  const abort = () => { if (settled) return; settled = true; cleanup(); reject(signal?.reason ?? new Error("Wait aborted")); };
266
297
  const cleanup = () => { clearTimeout(timeout); signal?.removeEventListener("abort", abort); this.waiters.delete(waiter); };
@@ -276,8 +307,9 @@ export class TaskRegistry {
276
307
  if (!existsSync(fullPath)) return { text: "", truncated: false, fullPath };
277
308
  const data = readFileSync(fullPath);
278
309
  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 };
310
+ const truncated = data.length > maxBytes;
311
+ const text = stripLogControls(truncateUtf8Bytes(data, maxBytes, opts.tail !== false));
312
+ return { text, truncated, fullPath };
281
313
  }
282
314
 
283
315
  async kill(taskId: string, kind: KillKind): Promise<void> {
@@ -294,12 +326,22 @@ export class TaskRegistry {
294
326
  }
295
327
  if (isTerminal(task.status) && live.processExited) return;
296
328
  live.killKind = kind;
329
+ let confirmed = true;
297
330
  if (task.pid && !live.processExited) {
298
331
  const result = await killTree(task.pid);
299
- if (!result.confirmed) throw new Error("Descendant processes may have leaked");
332
+ confirmed = result.confirmed;
300
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).
301
336
  if (!isTerminal(task.status)) this.finalize(task, "killed", undefined, kind);
302
- else if (live.timer) { clearTimeout(live.timer); live.timer = undefined; }
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");
303
345
  }
304
346
  cancel(taskId: string): boolean {
305
347
  const task = this.tasks.get(taskId);
@@ -320,7 +362,18 @@ export class TaskRegistry {
320
362
  if (this.shutdownPromise) return this.shutdownPromise;
321
363
  this.closed = true;
322
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
+ }
323
375
  const ids = [...this.tasks.values()].filter((task) => {
376
+ if (task.status === "pending") return false;
324
377
  const live = this.live.get(task.taskId);
325
378
  return !isTerminal(task.status) || (task.pid !== undefined && (live ? !live.processExited : this.pidAlive(task.pid)));
326
379
  }).map((task) => task.taskId);
@@ -338,48 +391,103 @@ export class TaskRegistry {
338
391
  return this.shutdownPromise;
339
392
  }
340
393
  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"); }
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
+ }
368
425
  }
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. */ }
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();
377
437
  }
378
438
  }
379
439
 
380
- private waitResult(task: TaskSnapshot): { taskId: string; status: TaskSnapshot["status"]; exitCode?: number; outputTail: string } {
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 } {
381
482
  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 };
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
+ };
383
491
  }
384
492
  private finalize(task: TaskSnapshot, status: TaskSnapshot["status"], code?: number, killKind?: KillKind): void {
385
493
  if (isTerminal(task.status)) return;
@@ -390,8 +498,14 @@ export class TaskRegistry {
390
498
  if (live?.watcher) clearInterval(live.watcher);
391
499
  // D4: watch success settles the observed task but leaves the process under its task timeout.
392
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
+ }
393
506
  this.persist(task); this.emitState(task); this.events.onFinalize(task);
394
507
  for (const waiter of [...this.waiters]) if (waiter.ids.has(task.taskId)) waiter.resolve();
508
+ if (!this.reloading) this.prune();
395
509
  }
396
510
  private emitState(task: TaskSnapshot): void { this.persist(task); this.events.onStateChange(task); }
397
511
  private persist(task: TaskSnapshot): void {
@@ -405,12 +519,25 @@ export class TaskRegistry {
405
519
  } catch { return task.metadata.capped === true; }
406
520
  }
407
521
  private pidAlive(pid: number): boolean {
408
- try { process.kill(-pid, 0); return true; }
409
- catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
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
+ }
410
541
  }
411
542
  }
412
543
 
413
- function finiteEnvNumber(name: string, fallback: number): number {
414
- const parsed = Number(process.env[name] ?? fallback);
415
- return Number.isFinite(parsed) ? parsed : fallback;
416
- }
package/src/core/spawn.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { setTimeout as delay } from "node:timers/promises";
@@ -6,23 +6,38 @@ import type { TaskSnapshot } from "./task.ts";
6
6
 
7
7
  export interface SpawnHandle { pid: number; exit: Promise<{ code: number }>; }
8
8
 
9
- /** Start a shell command in its own POSIX process group and capture merged output. */
9
+ /** Derive per-stream sidecar paths next to the merged `.output` file. */
10
+ export function streamPaths(outputFile: string): { stdoutFile: string; stderrFile: string } {
11
+ if (outputFile.endsWith(".output")) {
12
+ const base = outputFile.slice(0, -".output".length);
13
+ return { stdoutFile: `${base}.stdout`, stderrFile: `${base}.stderr` };
14
+ }
15
+ return { stdoutFile: `${outputFile}.stdout`, stderrFile: `${outputFile}.stderr` };
16
+ }
17
+
18
+ /** Start a shell command in its own POSIX process group; capture merged + separate streams. */
10
19
  export function spawnDetached(command: string, opts: { outputFile: string; env?: Record<string, string> }): SpawnHandle {
11
20
  mkdirSync(dirname(opts.outputFile), { recursive: true });
21
+ const { stdoutFile, stderrFile } = streamPaths(opts.outputFile);
12
22
  const capBytes = finiteNumber(opts.env?.PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES ?? process.env.PI_ASYNC_MAX_OUTPUT_BYTES, 20 * 1024 * 1024);
13
23
  const env = { ...process.env, ...opts.env };
14
24
  delete env.PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES;
15
25
  const child = spawn("/bin/sh", ["-c", command], { detached: true, stdio: ["ignore", "pipe", "pipe"], env });
16
26
  if (!child.pid) throw new Error("Failed to start detached task process");
17
- const output = createWriteStream(opts.outputFile, { flags: "w", mode: 0o600 });
27
+ const merged = createWriteStream(opts.outputFile, { flags: "w", mode: 0o600 });
28
+ const stdoutOut = createWriteStream(stdoutFile, { flags: "w", mode: 0o600 });
29
+ const stderrOut = createWriteStream(stderrFile, { flags: "w", mode: 0o600 });
30
+ // Cleanup may unlink the data dir while pipes still flush; ignore late write errors.
31
+ for (const stream of [merged, stdoutOut, stderrOut]) stream.on("error", () => undefined);
18
32
  let totalBytes = 0;
19
33
  let capReached = false;
20
- const copy = (chunk: Buffer): void => {
34
+ const copy = (chunk: Buffer, stream: "stdout" | "stderr"): void => {
21
35
  if (capReached) return;
22
36
  const remaining = Math.max(0, capBytes - totalBytes);
23
37
  const accepted = chunk.subarray(0, remaining);
24
38
  if (accepted.length) {
25
- output.write(accepted);
39
+ merged.write(accepted);
40
+ (stream === "stdout" ? stdoutOut : stderrOut).write(accepted);
26
41
  totalBytes += accepted.length;
27
42
  }
28
43
  if (accepted.length < chunk.length) {
@@ -35,12 +50,19 @@ export function spawnDetached(command: string, opts: { outputFile: string; env?:
35
50
  });
36
51
  }
37
52
  };
38
- child.stdout?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk)));
39
- child.stderr?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk)));
53
+ child.stdout?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk), "stdout"));
54
+ child.stderr?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk), "stderr"));
40
55
  const exit = new Promise<{ code: number }>((resolve, reject) => {
41
56
  child.once("error", reject);
42
57
  child.once("close", (code) => {
43
- output.end(() => resolve({ code: code ?? 1 }));
58
+ let pending = 3;
59
+ const done = (): void => {
60
+ pending--;
61
+ if (pending === 0) resolve({ code: code ?? 1 });
62
+ };
63
+ merged.end(done);
64
+ stdoutOut.end(done);
65
+ stderrOut.end(done);
44
66
  });
45
67
  });
46
68
  return { pid: child.pid, exit };
@@ -51,7 +73,6 @@ function finiteNumber(value: string | undefined, fallback: number): number {
51
73
  return Number.isFinite(parsed) ? parsed : fallback;
52
74
  }
53
75
 
54
- /** Merge the cap marker into the task snapshot with the same atomic-rename strategy as the registry. */
55
76
  function persistOutputCap(outputFile: string): void {
56
77
  const metadataFile = outputFile.endsWith(".output") ? `${outputFile.slice(0, -7)}.json` : `${outputFile}.json`;
57
78
  try {
@@ -75,17 +96,37 @@ function groupExists(pid: number): boolean {
75
96
  }
76
97
  }
77
98
 
99
+ /** True only if the process group has a non-zombie member (#6). */
100
+ function groupHasLiveMember(pgid: number): boolean {
101
+ try {
102
+ for (const entry of readdirSync("/proc")) {
103
+ if (!/^\d+$/.test(entry)) continue;
104
+ try {
105
+ const stat = readFileSync(`/proc/${entry}/stat`, "utf8");
106
+ const closeParen = stat.lastIndexOf(") ");
107
+ if (closeParen < 0) continue;
108
+ const rest = stat.slice(closeParen + 2).split(" ");
109
+ const state = rest[0];
110
+ const pgrp = Number(rest[2]);
111
+ if (pgrp === pgid && state !== "Z") return true;
112
+ } catch { /* raced */ }
113
+ }
114
+ return false;
115
+ } catch {
116
+ return groupExists(pgid);
117
+ }
118
+ }
119
+
78
120
  async function waitForGroupExit(pid: number, timeoutMs: number): Promise<boolean> {
79
121
  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);
122
+ while (groupHasLiveMember(pid) && Date.now() < deadline) await delay(Math.min(100, Math.max(1, deadline - Date.now())));
123
+ return !groupHasLiveMember(pid);
82
124
  }
83
125
 
84
- /** Terminate the detached process group, escalating from TERM to KILL. */
85
126
  export async function killTree(pid: number, opts?: { graceMs?: number }): Promise<{ confirmed: boolean }> {
86
127
  const graceMs = opts?.graceMs ?? 3000;
87
128
  if (!Number.isInteger(pid) || pid <= 1) throw new RangeError("pid must identify a child process");
88
- if (!groupExists(pid)) return { confirmed: true };
129
+ if (!groupHasLiveMember(pid)) return { confirmed: true };
89
130
  try { process.kill(-pid, "SIGTERM"); } catch (error) {
90
131
  if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
91
132
  }
@@ -93,6 +134,5 @@ export async function killTree(pid: number, opts?: { graceMs?: number }): Promis
93
134
  try { process.kill(-pid, "SIGKILL"); } catch (error) {
94
135
  if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
95
136
  }
96
- // Bounded probe: do not hang shutdown forever if the kernel cannot prove death.
97
137
  return { confirmed: await waitForGroupExit(pid, 1500) };
98
138
  }
package/src/core/task.ts CHANGED
@@ -1,19 +1,32 @@
1
- export type TaskStatus = "pending" | "running" | "completed" | "failed" | "killed";
1
+ export const TASK_STATUSES = ["pending", "running", "completed", "failed", "killed"] as const;
2
+ export type TaskStatus = (typeof TASK_STATUSES)[number];
2
3
  export type TaskMode = "background" | "sync" | "delayed";
3
4
  export type KillKind = "user" | "timeout" | "output_cap" | "shutdown" | "watch_failure";
5
+ export type FailureKind = "lost" | "blind_exhausted" | "watch_timeout";
4
6
 
5
7
  export const TERMINAL_STATUSES = ["completed", "failed", "killed"] as const satisfies readonly TaskStatus[];
6
8
 
9
+ export function isTaskStatus(value: unknown): value is TaskStatus {
10
+ return typeof value === "string" && (TASK_STATUSES as readonly string[]).includes(value);
11
+ }
12
+
7
13
  export function isTerminal(status: TaskStatus): boolean {
8
14
  return TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]);
9
15
  }
10
16
 
11
- /** Derive a concise, single-line display name from a shell command. */
17
+ /** Cap a task name at `limit` Unicode code points without splitting surrogate pairs (#28). */
18
+ export function capName(name: string, limit = 48): string {
19
+ const characters = Array.from(name);
20
+ return characters.length <= limit ? name : characters.slice(0, limit - 1).join("") + "…";
21
+ }
22
+
23
+ /** Derive a concise, single-line display name from a shell command.
24
+ * Uses the first non-blank line so a leading newline does not collapse to the fallback (#23). */
12
25
  export function deriveName(command: string): string {
13
- const normalized = command.split(/\r?\n/, 1)[0]?.trim().replace(/\s+/g, " ") ?? "";
26
+ const firstContentLine = command.split(/\r?\n/).find((line) => line.trim().length > 0);
27
+ const normalized = firstContentLine?.trim().replace(/\s+/g, " ") ?? "";
14
28
  if (normalized.length === 0) return "async task";
15
- const characters = Array.from(normalized);
16
- return characters.length <= 48 ? normalized : characters.slice(0, 47).join("") + "…";
29
+ return capName(normalized);
17
30
  }
18
31
 
19
32
  export type Condition =
@@ -59,9 +72,13 @@ export interface SyncResult extends RunReceipt {
59
72
  promoted: boolean;
60
73
  }
61
74
 
75
+ export type WaitResultItem = { taskId: string; status: TaskStatus; exitCode?: number; outputTail: string; killKind?: KillKind; failureKind?: FailureKind };
76
+ export type WaitNotReadyItem = { taskId: string; status: "running" | "pending"; elapsedMs: number };
77
+ /** All-terminal → { results }; timeout-only → { notReady }; mixed timeout → { results, notReady } (REV-7 / #43 + #37 kinds). */
62
78
  export type WaitOutcome =
63
- | { results: Array<{ taskId: string; status: TaskStatus; exitCode?: number; outputTail: string }> }
64
- | { notReady: Array<{ taskId: string; status: "running" | "pending"; elapsedMs: number }> };
79
+ | { results: WaitResultItem[] }
80
+ | { notReady: WaitNotReadyItem[] }
81
+ | { results: WaitResultItem[]; notReady: WaitNotReadyItem[] };
65
82
 
66
83
  export interface TaskSnapshot {
67
84
  taskId: string;
@@ -72,7 +89,7 @@ export interface TaskSnapshot {
72
89
  pid?: number;
73
90
  exitCode?: number;
74
91
  killKind?: KillKind;
75
- failureKind?: "lost";
92
+ failureKind?: FailureKind;
76
93
  createdAt: number;
77
94
  startedAt?: number;
78
95
  finishedAt?: number;