pi-async 0.2.0 → 0.3.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,22 +1,65 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, existsSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join, resolve, sep } from "node:path";
4
4
  import { killTree, spawnDetached, streamPaths } from "./spawn.ts";
5
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";
6
+ import { assertSafeEnvKeys, envNumber } from "./env.ts";
7
+ import { capName, deriveName, isTaskStatus, isTerminal, type FailureKind, type KillKind, type RunOptions, type SyncResult, type TaskMode, type TaskSnapshot, type TaskStatus, type WatchSpec, type WaitOutcome } from "./task.ts";
8
8
  import { evalConditions, validateWatchSpec, WatchLoop, type WatchOutput } from "./watch.ts";
9
9
 
10
10
  export interface RegistryEvents {
11
11
  onFinalize(snapshot: TaskSnapshot): void;
12
12
  onStateChange(snapshot: TaskSnapshot): void;
13
13
  }
14
- export interface RegistryConfig { dataDir: string; maxTasks?: number; maxOutputBytes?: number; retentionMs?: number; retentionCount?: number; }
14
+ export interface RegistryConfig { dataDir: string; cwd?: string; maxTasks?: number; maxOutputBytes?: number; retentionMs?: number; retentionCount?: number; }
15
+ export interface ListOptions { includeHistory?: boolean; status?: TaskStatus | "active"; name?: string; limit?: number; }
16
+ export type LogStream = "merged" | "stdout" | "stderr";
17
+ export interface LogSlice { text: string; truncated: boolean; fullPath: string; missing?: boolean; }
18
+
19
+ /** Registry fan-out event (REV-14/TUI refresh): `state` for every mutation, `finalize` once per terminal transition. */
20
+ export type RegistryEvent =
21
+ | { type: "state"; snapshot: TaskSnapshot }
22
+ | { type: "finalize"; snapshot: TaskSnapshot };
23
+
15
24
  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
25
  interface Waiter { ids: Set<string>; resolve: () => void; }
17
26
  const DEFAULT_MAX_OUTPUT = 20 * 1024 * 1024;
18
27
  const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
19
28
  const DEFAULT_RETENTION_COUNT = 100;
29
+ const MAX_LIST_LIMIT = 50;
30
+ const DEFAULT_RESEND_MAX_AGE_MS = 24 * 60 * 60 * 1000;
31
+ const DEFAULT_RESEND_MAX_ATTEMPTS = 2;
32
+ /** Default read window for `logs`; callers (tools/TUI) own the policy above this. */
33
+ const DEFAULT_LOG_BYTES = 50 * 1024;
34
+ /** Defensive ceiling for a single `logs` read: an accidental huge window is clamped, not honoured. */
35
+ const MAX_LOG_WINDOW = 8 * 1024 * 1024;
36
+ /** Artifacts owned by one task; migrated wholesale between data dirs. */
37
+ const ARTIFACT_SUFFIXES = [".json", ".output", ".stdout", ".stderr"] as const;
38
+
39
+ /** Read `length` bytes at `start`, tolerating short reads from a partially written file. */
40
+ function readExactly(handle: number, start: number, length: number): Buffer {
41
+ const buffer = Buffer.alloc(length);
42
+ let read = 0;
43
+ while (read < length) {
44
+ const got = readSync(handle, buffer, read, length - read, start + read);
45
+ if (got <= 0) break;
46
+ read += got;
47
+ }
48
+ return read === length ? buffer : buffer.subarray(0, read);
49
+ }
50
+
51
+ /** UTF-8-safe decode of an already bounded window; mirrors `truncateUtf8Bytes` without the whole file. */
52
+ function truncateUtf8Window(buffer: Buffer, maxBytes: number, tail: boolean, truncated: boolean): string {
53
+ if (!truncated) return buffer.toString("utf8");
54
+ if (tail) {
55
+ let start = 0;
56
+ while (start < buffer.length && (buffer[start]! & 0xc0) === 0x80) start++;
57
+ return buffer.subarray(start).toString("utf8");
58
+ }
59
+ let end = Math.min(maxBytes, buffer.length);
60
+ while (end > 0 && (buffer[end]! & 0xc0) === 0x80) end--;
61
+ return buffer.subarray(0, end).toString("utf8");
62
+ }
20
63
 
21
64
  export class TaskRegistry {
22
65
  private readonly tasks = new Map<string, TaskSnapshot>();
@@ -24,33 +67,86 @@ export class TaskRegistry {
24
67
  private readonly waiters = new Set<Waiter>();
25
68
  private readonly notifyClaims = new Set<string>();
26
69
  private readonly watchLoops = new Map<string, WatchLoop>();
70
+ private readonly listeners = new Set<(event: RegistryEvent) => void>();
27
71
  private events: RegistryEvents = { onFinalize: () => undefined, onStateChange: () => undefined };
28
72
  private readonly maxTasks: number;
29
73
  private readonly maxOutputBytes: number;
30
74
  private readonly retentionMs: number;
31
75
  private readonly retentionCount: number;
76
+ private config: RegistryConfig;
32
77
  private closed = false;
78
+ /** Set by `suspend()`: this generation is retired and must not settle adopted work (REV-17). */
79
+ private suspended = false;
33
80
  private reloading = false;
34
81
  private shutdownPromise?: Promise<void>;
82
+ private configured = false;
83
+ private dataDirReady = false;
35
84
 
36
- private readonly config: RegistryConfig;
37
85
  constructor(config: RegistryConfig) {
38
- this.config = config;
86
+ // NOTE (D-04): the constructor no longer creates the data dir — the factory must stay
87
+ // side-effect free. `persist()` and `configure()` create it on demand.
88
+ this.config = { ...config };
39
89
  this.maxTasks = config.maxTasks ?? envNumber("PI_ASYNC_MAX_TASKS", 32, { integer: true, min: 1 });
40
90
  this.maxOutputBytes = config.maxOutputBytes ?? envNumber("PI_ASYNC_MAX_OUTPUT_BYTES", DEFAULT_MAX_OUTPUT, { integer: true, min: 0 });
41
91
  this.retentionMs = config.retentionMs ?? envNumber("PI_ASYNC_RETENTION_MS", DEFAULT_RETENTION_MS, { integer: true, min: 0 });
42
92
  this.retentionCount = config.retentionCount ?? envNumber("PI_ASYNC_RETENTION_COUNT", DEFAULT_RETENTION_COUNT, { integer: true, min: 0 });
43
- mkdirSync(config.dataDir, { recursive: true });
44
93
  }
45
94
 
46
95
  setEvents(events: RegistryEvents): void { this.events = events; }
47
96
 
97
+ /** Subscribe to registry state/finalize events; returns an unsubscribe function. */
98
+ subscribe(listener: (event: RegistryEvent) => void): () => void {
99
+ this.listeners.add(listener);
100
+ return () => { this.listeners.delete(listener); };
101
+ }
102
+
103
+ /**
104
+ * Session-scoped configuration (REV-13/REV-17). Idempotent.
105
+ *
106
+ * First call switches this registry to the session data dir + cwd and loads persisted
107
+ * tasks. Later calls only react to changes; a data-dir change migrates task artifacts
108
+ * instead of dropping them. Migration failures keep the old dir and never throw (D-05):
109
+ * this runs inside a `session_start` handler where a throw becomes extension noise.
110
+ */
111
+ configure(opts: { dataDir?: string; cwd?: string }): void {
112
+ const first = !this.configured;
113
+ this.configured = true;
114
+ if (opts.cwd !== undefined && opts.cwd.length > 0) this.config = { ...this.config, cwd: opts.cwd };
115
+ const nextDataDir = opts.dataDir;
116
+ const changed = nextDataDir !== undefined && nextDataDir !== this.config.dataDir;
117
+ if (changed) {
118
+ if (this.tasks.size > 0) {
119
+ if (!this.migrateDataDir(nextDataDir)) {
120
+ this.ensureDataDirQuietly();
121
+ if (first) {
122
+ void this.reloadFromDisk();
123
+ this.reapOrphans();
124
+ }
125
+ return;
126
+ }
127
+ } else {
128
+ this.switchDataDir(nextDataDir);
129
+ }
130
+ }
131
+ // D-04: the session data dir is created here or on first persist — never in the constructor.
132
+ this.ensureDataDirQuietly();
133
+ if (first || changed) {
134
+ // reloadFromDisk() has no await points, so it is fully applied when it returns; reaping
135
+ // keeps this generation from adopting snapshots whose processes are already gone (#44).
136
+ void this.reloadFromDisk();
137
+ this.reapOrphans();
138
+ }
139
+ }
140
+
48
141
  submit(opts: RunOptions): TaskSnapshot {
49
142
  if (opts.mode && opts.mode !== "background") throw new Error("submit only accepts background mode");
50
143
  return this.createAndStart(opts, "background");
51
144
  }
52
145
 
53
146
  scheduleDelayed(opts: RunOptions, delayMs: number): TaskSnapshot {
147
+ // Fail fast: delayed tasks spawn later, so dangerous env overrides must be rejected at submit
148
+ // time exactly like background/sync (spawnDetached would otherwise only fail at due time).
149
+ assertSafeEnvKeys(opts.env, "safe");
54
150
  this.assertAdmission(opts);
55
151
  if (!Number.isFinite(delayMs) || delayMs < 0) throw new Error("delayMs must be a non-negative finite number");
56
152
  this.validateRunWatch(opts, "delayed");
@@ -60,6 +156,7 @@ export class TaskRegistry {
60
156
  const task: TaskSnapshot = {
61
157
  taskId, name: opts.name ? capName(opts.name) : deriveName(opts.command), command: opts.command, mode: "delayed", status: "pending",
62
158
  createdAt, dueAt: createdAt + delayMs, taskTimeoutMs: opts.taskTimeoutMs, notified: false, outputConsumed: false,
159
+ cwd: opts.cwd ?? this.config.cwd ?? process.cwd(),
63
160
  ...(opts.watch ? { watch: opts.watch } : {}),
64
161
  metadata: { notifyOnCompletion: opts.notifyOnCompletion ?? true, triggerOnCompletion: opts.triggerOnCompletion ?? true, outputFile, ...(opts.env ? { delayedEnv: opts.env } : {}) },
65
162
  };
@@ -97,7 +194,24 @@ export class TaskRegistry {
97
194
  return this.syncResult(task, promoted);
98
195
  }
99
196
 
100
- attachWatch(taskId: string, spec: WatchSpec): void {
197
+ /**
198
+ * Resolve a user-supplied task reference. Ids win; otherwise an exact (case-insensitive)
199
+ * name is required, and duplicate names are an error listing candidate ids (REV-15).
200
+ */
201
+ resolveTaskId(idOrName: string): TaskSnapshot {
202
+ const direct = this.tasks.get(idOrName);
203
+ if (direct) return direct;
204
+ const needle = idOrName.trim().toLowerCase();
205
+ const matches = needle.length === 0 ? [] : [...this.tasks.values()].filter((task) => task.name.toLowerCase() === needle);
206
+ const [match] = matches;
207
+ if (match && matches.length === 1) return match;
208
+ if (matches.length > 1) {
209
+ throw new Error(`Ambiguous task name "${idOrName}": matches ${matches.map((task) => task.taskId).join(", ")}`);
210
+ }
211
+ throw new Error(`Unknown task or name: ${idOrName}`);
212
+ }
213
+
214
+ attachWatch(taskId: string, spec: WatchSpec, deadline?: number): void {
101
215
  const validation = validateWatchSpec(spec);
102
216
  if (!validation.ok) throw new Error(`Invalid watch spec: ${validation.error}`);
103
217
  const task = this.tasks.get(taskId);
@@ -105,8 +219,17 @@ export class TaskRegistry {
105
219
  if (isTerminal(task.status)) throw new Error(`Cannot attach watch to terminal task ${taskId}`);
106
220
  const existing = this.watchLoops.get(taskId);
107
221
  existing?.stop();
222
+ const now = Date.now();
223
+ // Persist the absolute watch deadline so a reload can restore the remaining budget (D-03).
224
+ // Pending delayed tasks get their deadline when the task actually starts (startTask).
225
+ if (deadline !== undefined) task.watchDeadline = deadline;
226
+ else if (task.status === "running") task.watchDeadline = now + spec.timeoutMs;
227
+ else delete task.watchDeadline;
228
+ // The loop only understands relative timeouts; feed it the remaining budget without
229
+ // rewriting the user-visible spec (`task.watch.timeoutMs` stays the original value).
230
+ const loopSpec: WatchSpec = task.watchDeadline === undefined ? spec : { ...spec, timeoutMs: Math.max(1, task.watchDeadline - now) };
108
231
  const loop = new WatchLoop({
109
- taskId, spec,
232
+ taskId, spec: loopSpec,
110
233
  readOutput: () => this.readWatchOutput(taskId),
111
234
  getExitCode: () => this.tasks.get(taskId)?.exitCode,
112
235
  onSuccess: (detail) => this.markWatchSuccess(taskId, detail),
@@ -129,10 +252,13 @@ export class TaskRegistry {
129
252
  },
130
253
  });
131
254
  this.watchLoops.set(taskId, loop);
255
+ this.persist(task);
132
256
  if (task.status === "running") loop.start();
133
257
  }
134
258
  private assertAdmission(opts: RunOptions): void {
135
259
  if (this.closed) throw new Error("Async task admission is closed");
260
+ // A retired generation must not start work: the next generation owns the session data dir.
261
+ if (this.suspended) throw new Error("Async task admission is closed (registry suspended)");
136
262
  const active = this.activeCount();
137
263
  if (active.running + active.pending >= this.maxTasks) throw new Error(`Maximum active async tasks reached (${this.maxTasks})`);
138
264
  if (!opts.command.trim()) throw new Error("command must not be empty");
@@ -174,9 +300,19 @@ export class TaskRegistry {
174
300
  if (live.delayTimer) { clearTimeout(live.delayTimer); live.delayTimer = undefined; }
175
301
  task.status = "running";
176
302
  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) } });
303
+ // REV-13: always run in the resolved session cwd, never the host process cwd.
304
+ task.cwd = opts.cwd ?? this.config.cwd ?? process.cwd();
305
+ if (task.watch) task.watchDeadline = Date.now() + task.watch.timeoutMs;
306
+ const handle = spawnDetached(task.command, {
307
+ outputFile: live.outputFile,
308
+ env: { ...opts.env, PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES: String(this.maxOutputBytes) },
309
+ cwd: task.cwd,
310
+ });
178
311
  task.pid = handle.pid;
179
312
  live.exit = handle.exit.then(({ code }) => {
313
+ // A retired generation no longer owns this process: the next generation adopted it via
314
+ // reloadFromDisk and its snapshot wins (no finalize, no persist, no emit).
315
+ if (this.suspended) return { code };
180
316
  live.processExited = true;
181
317
  if (live.timer) { clearTimeout(live.timer); live.timer = undefined; }
182
318
  task.exitCode = code;
@@ -199,21 +335,35 @@ export class TaskRegistry {
199
335
  }
200
336
  return { code };
201
337
  }).catch(() => {
338
+ if (this.suspended) return { code: 1 };
202
339
  live.processExited = true;
203
340
  if (!isTerminal(task.status)) this.finalize(task, "failed", 1);
204
341
  return { code: 1 };
205
342
  });
343
+ if (opts.taskTimeoutMs !== undefined && opts.taskTimeoutMs > 0) {
344
+ // Absolute deadline so a reload can re-arm only the remaining time (D-03).
345
+ task.timeoutAt = task.timeoutAt ?? Date.now() + opts.taskTimeoutMs;
346
+ live.timer = this.armTaskTimeout(task, Math.max(1, task.timeoutAt - Date.now()));
347
+ }
206
348
  this.persist(task);
207
- if (opts.taskTimeoutMs !== undefined && opts.taskTimeoutMs > 0) live.timer = setTimeout(() => {
349
+ this.emitState(task);
350
+ const watch = this.watchLoops.get(task.taskId);
351
+ if (watch) watch.start();
352
+ }
353
+
354
+ /** Arm (or re-arm) the task-timeout kill for a running task. */
355
+ private armTaskTimeout(task: TaskSnapshot, remainingMs: number, opts: { unref?: boolean } = {}): ReturnType<typeof setTimeout> {
356
+ const live = this.live.get(task.taskId);
357
+ const timer = setTimeout(() => {
208
358
  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));
359
+ if (live && !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
360
  return;
211
361
  }
212
362
  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();
363
+ }, remainingMs);
364
+ // A timeout restored from disk must not be the only thing keeping the host process alive.
365
+ if (opts.unref) timer.unref?.();
366
+ return timer;
217
367
  }
218
368
 
219
369
  private scheduleDelayedStart(task: TaskSnapshot, delayMs: number): void {
@@ -229,22 +379,27 @@ export class TaskRegistry {
229
379
  triggerOnCompletion: task.metadata.triggerOnCompletion !== false,
230
380
  ...(rawEnv && typeof rawEnv === "object" ? { env: rawEnv as Record<string, string> } : {}),
231
381
  ...(task.watch ? { watch: task.watch } : {}),
382
+ ...(task.cwd !== undefined ? { cwd: task.cwd } : {}),
232
383
  };
233
384
  try { this.startTask(task, opts); }
234
- catch { this.finalize(task, "failed", 1); }
385
+ catch (error) {
386
+ console.error(`pi-async: delayed task ${task.taskId} failed to start`, error);
387
+ task.metadata = { ...task.metadata, startError: error instanceof Error ? error.message : String(error) };
388
+ this.finalize(task, "failed", 1);
389
+ }
235
390
  }, delayMs);
236
391
  }
237
392
 
238
393
  private readWatchOutput(taskId: string): WatchOutput {
239
394
  const task = this.tasks.get(taskId);
240
395
  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`));
396
+ const outputFile = this.assertInsideDataDir(String(task.metadata.outputFile ?? this.live.get(taskId)?.outputFile ?? join(this.config.dataDir, `${taskId}.output`)));
242
397
  // Missing primary output must throw so WatchLoop blind_checks can fire (#37/#68 + CONTRACT).
243
398
  // stripLogControls: ANSI + bare C0 for model/watch paths (#18); streams stay distinct (#4).
244
399
  const output = stripLogControls(readFileSync(outputFile, "utf8"));
245
- const { stdoutFile, stderrFile } = streamPaths(outputFile);
400
+ const { stdout, stderr } = streamPaths(outputFile);
246
401
  const readSide = (file: string): string => existsSync(file) ? stripLogControls(readFileSync(file, "utf8")) : "";
247
- return { output, stdout: readSide(stdoutFile), stderr: readSide(stderrFile) };
402
+ return { output, stdout: readSide(stdout), stderr: readSide(stderr) };
248
403
  }
249
404
 
250
405
  private syncResult(task: TaskSnapshot, promoted: boolean): SyncResult {
@@ -262,7 +417,32 @@ export class TaskRegistry {
262
417
 
263
418
  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
419
  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; }
420
+
421
+ /**
422
+ * List snapshots. `includeHistory`/`status`/`name` filter; `limit` clamps to 1–50 when given
423
+ * (the tool layer supplies the default so internal prune/reload callers stay unbounded).
424
+ * History-wide queries sort newest-first (D-08).
425
+ */
426
+ list(opts: ListOptions = {}): TaskSnapshot[] {
427
+ let items = [...this.tasks.values()];
428
+ if (opts.includeHistory === false) items = items.filter((task) => !isTerminal(task.status));
429
+ if (opts.status !== undefined) {
430
+ const status = opts.status;
431
+ items = status === "active" ? items.filter((task) => !isTerminal(task.status)) : items.filter((task) => task.status === status);
432
+ }
433
+ if (opts.name !== undefined) {
434
+ const needle = opts.name.trim().toLowerCase();
435
+ items = items.filter((task) => task.name.toLowerCase().includes(needle));
436
+ }
437
+ if (opts.includeHistory === true || opts.status !== undefined) {
438
+ items = [...items].sort((a, b) => (b.finishedAt ?? b.createdAt) - (a.finishedAt ?? a.createdAt));
439
+ }
440
+ if (opts.limit !== undefined && Number.isFinite(opts.limit)) {
441
+ const limit = Math.min(MAX_LIST_LIMIT, Math.max(1, Math.trunc(opts.limit)));
442
+ items = items.slice(0, limit);
443
+ }
444
+ return items;
445
+ }
266
446
 
267
447
  wait(taskIds: string[] | string, timeoutMs: number, signal?: AbortSignal): Promise<WaitOutcome> {
268
448
  // A bare string is one id; String is iterable so `[...new Set("uuid")]` would split into characters (#40).
@@ -300,16 +480,44 @@ export class TaskRegistry {
300
480
  });
301
481
  }
302
482
 
303
- logs(taskId: string, opts: { maxBytes?: number; tail?: boolean }): { text: string; truncated: boolean; fullPath: string } {
483
+ /**
484
+ * Read bounded task output. `stream` selects merged/stdout/stderr; a missing file returns
485
+ * `missing: true` instead of throwing, while a path outside the data dir throws (D-11).
486
+ *
487
+ * Only the requested window is read (open/fstat/read, tail by default), so a multi-GB output
488
+ * file costs one bounded read instead of a full-file load. The size policy belongs to the
489
+ * caller (`maxBytes` defaults to 50 KiB; tools cap `async_logs`, the TUI may ask for a larger
490
+ * window), while `MAX_LOG_WINDOW` keeps a mistaken huge request from allocating unboundedly.
491
+ */
492
+ logs(taskId: string, opts: { maxBytes?: number; tail?: boolean; stream?: LogStream } = {}): LogSlice {
304
493
  const task = this.tasks.get(taskId);
305
494
  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 };
495
+ const mergedPath = String(task.metadata.outputFile ?? this.live.get(taskId)?.outputFile ?? join(this.config.dataDir, `${taskId}.output`));
496
+ const stream = opts.stream ?? "merged";
497
+ const { stdout, stderr } = streamPaths(mergedPath);
498
+ const requestPath = stream === "stdout" ? stdout : stream === "stderr" ? stderr : mergedPath;
499
+ const fullPath = this.assertInsideDataDir(requestPath);
500
+ const requestedBytes = opts.maxBytes ?? DEFAULT_LOG_BYTES;
501
+ const boundedBytes = Number.isFinite(requestedBytes) ? Math.max(0, Math.trunc(requestedBytes)) : DEFAULT_LOG_BYTES;
502
+ const maxBytes = Math.min(boundedBytes, MAX_LOG_WINDOW);
503
+ const tail = opts.tail !== false;
504
+ let handle: number | undefined;
505
+ try {
506
+ handle = openSync(fullPath, "r");
507
+ const { size } = fstatSync(handle);
508
+ const truncated = size > maxBytes;
509
+ if (maxBytes === 0) return { text: "", truncated, fullPath };
510
+ // Non-tail reads take one extra byte so the UTF-8 cut can inspect the excluded byte.
511
+ const start = tail ? Math.max(0, size - maxBytes) : 0;
512
+ const length = Math.min(size, tail ? maxBytes : maxBytes + (truncated ? 1 : 0));
513
+ const buffer = readExactly(handle, start, length);
514
+ return { text: stripLogControls(truncateUtf8Window(buffer, maxBytes, tail, truncated)), truncated, fullPath };
515
+ } catch (error) {
516
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { text: "", truncated: false, fullPath, missing: true };
517
+ throw error;
518
+ } finally {
519
+ if (handle !== undefined) closeSync(handle);
520
+ }
313
521
  }
314
522
 
315
523
  async kill(taskId: string, kind: KillKind): Promise<void> {
@@ -350,14 +558,120 @@ export class TaskRegistry {
350
558
  return true;
351
559
  }
352
560
  consume(taskId: string): void { const task = this.tasks.get(taskId); if (!task) return; task.outputConsumed = true; this.persist(task); }
561
+
562
+ /** In-memory send claim only (D-01): `notified` is owned by `markNotified`. */
353
563
  claimNotification(taskId: string): boolean {
354
564
  const task = this.tasks.get(taskId);
355
565
  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;
566
+ this.notifyClaims.add(taskId);
567
+ return true;
357
568
  }
358
- rollbackNotification(taskId: string): void { const task = this.tasks.get(taskId); this.notifyClaims.delete(taskId); if (task) { task.notified = false; this.persist(task); } }
569
+ /** Record a delivery attempt before calling the host (no retries: attempts stay honest). */
570
+ noteNotifyAttempt(taskId: string): void {
571
+ const task = this.tasks.get(taskId);
572
+ if (!task) return;
573
+ task.notifyAttempts = (task.notifyAttempts ?? 0) + 1;
574
+ this.persist(task);
575
+ }
576
+ /** Host accepted the message; the claim is kept for the rest of the generation. */
577
+ markNotified(taskId: string): void {
578
+ const task = this.tasks.get(taskId);
579
+ if (!task) return;
580
+ task.notified = true;
581
+ this.persist(task);
582
+ }
583
+ /**
584
+ * Release the in-memory claim after a failed delivery. `notified` is untouched: it is only
585
+ * ever set by `markNotified`, so a failed send leaves it false and `resendUnnotified` can retry.
586
+ */
587
+ releaseNotification(taskId: string): void {
588
+ this.notifyClaims.delete(taskId);
589
+ }
590
+
359
591
  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
592
  hasActiveWork(): boolean { const count = this.activeCount(); return count.running + count.pending > 0; }
593
+
594
+ /** Liveness probe for `async_kill` (D-07): no pid (or an exited child) → false. */
595
+ isProcessAlive(taskId: string): boolean {
596
+ const task = this.tasks.get(taskId);
597
+ if (!task || task.pid === undefined) return false;
598
+ const live = this.live.get(taskId);
599
+ if (live?.processExited) return false;
600
+ return this.pidAlive(task.pid);
601
+ }
602
+
603
+ /**
604
+ * Stop every timer/watcher and persist, but never kill (REV-17 `/reload`):
605
+ * the next generation adopts the surviving processes via `reloadFromDisk`.
606
+ */
607
+ async suspend(): Promise<void> {
608
+ // Retire first, so a child exit landing during teardown is already ignored.
609
+ this.suspended = true;
610
+ for (const task of this.tasks.values()) {
611
+ const live = this.live.get(task.taskId);
612
+ if (live?.delayTimer) { clearTimeout(live.delayTimer); live.delayTimer = undefined; }
613
+ if (live?.timer) { clearTimeout(live.timer); live.timer = undefined; }
614
+ if (live?.watcher) { clearInterval(live.watcher); live.watcher = undefined; }
615
+ this.watchLoops.get(task.taskId)?.stop();
616
+ this.watchLoops.delete(task.taskId);
617
+ try { this.persist(task); } catch (error) { console.error(`pi-async: suspend could not persist task ${task.taskId}`, error); }
618
+ }
619
+ // Pending waiters belong to the retired generation; never wake them with stale results.
620
+ this.waiters.clear();
621
+ }
622
+
623
+ /**
624
+ * Re-send completion notifications the host never accepted (REV-14 `session_start` backfill).
625
+ * Only the existing `onFinalize` hook is replayed (D-02) — the registry owns no notifier.
626
+ */
627
+ async resendUnnotified(opts: { maxAgeMs?: number; maxAttempts?: number } = {}): Promise<number> {
628
+ const maxAgeMs = opts.maxAgeMs ?? DEFAULT_RESEND_MAX_AGE_MS;
629
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_RESEND_MAX_ATTEMPTS;
630
+ const now = Date.now();
631
+ let resent = 0;
632
+ for (const task of [...this.tasks.values()]) {
633
+ if (!isTerminal(task.status)) continue;
634
+ if (task.notified === true || task.outputConsumed === true) continue;
635
+ if (task.metadata.notifyOnCompletion === false) continue;
636
+ if ((task.notifyAttempts ?? 0) >= maxAttempts) continue;
637
+ if (now - (task.finishedAt ?? task.createdAt) > maxAgeMs) continue;
638
+ try {
639
+ this.events.onFinalize(task);
640
+ this.emit({ type: "finalize", snapshot: task });
641
+ resent++;
642
+ } catch (error) {
643
+ console.error(`pi-async: resend of completion notification failed for ${task.taskId}`, error);
644
+ }
645
+ }
646
+ return resent;
647
+ }
648
+
649
+ /**
650
+ * Mark orphaned non-terminal tasks `failed/lost` (REV-16 startup hygiene):
651
+ * a running task whose process group is gone is dead; pending delayed tasks are kept.
652
+ */
653
+ reapOrphans(now: number = Date.now()): number {
654
+ let reaped = 0;
655
+ for (const task of [...this.tasks.values()]) {
656
+ if (isTerminal(task.status)) continue;
657
+ if (task.status === "pending") {
658
+ // Pending delayed tasks hold no OS resources — their due timer is the source of truth…
659
+ // …unless the due time itself fell out of the retention window (PLAN §2.4).
660
+ if (task.dueAt === undefined || now - task.dueAt <= this.retentionMs) continue;
661
+ task.failureKind = "lost";
662
+ this.finalize(task, "failed");
663
+ reaped++;
664
+ continue;
665
+ }
666
+ const pid = task.pid;
667
+ if (pid !== undefined && this.pidAlive(pid)) continue;
668
+ task.failureKind = "lost";
669
+ this.finalize(task, "failed");
670
+ reaped++;
671
+ }
672
+ return reaped;
673
+ }
674
+
361
675
  shutdown(): Promise<void> {
362
676
  if (this.shutdownPromise) return this.shutdownPromise;
363
677
  this.closed = true;
@@ -390,10 +704,13 @@ export class TaskRegistry {
390
704
  })();
391
705
  return this.shutdownPromise;
392
706
  }
707
+
393
708
  async reloadFromDisk(): Promise<void> {
394
709
  this.reloading = true;
395
710
  try {
396
- for (const file of readdirSync(this.config.dataDir).filter((name) => name.endsWith(".json"))) {
711
+ let files: string[] = [];
712
+ try { files = readdirSync(this.config.dataDir); } catch { /* data dir not created yet */ }
713
+ for (const file of files.filter((name) => name.endsWith(".json"))) {
397
714
  try {
398
715
  const snapshot = JSON.parse(readFileSync(join(this.config.dataDir, file), "utf8")) as TaskSnapshot;
399
716
  // #34: skip unknown status values from disk.
@@ -425,9 +742,18 @@ export class TaskRegistry {
425
742
  }
426
743
  }, 100);
427
744
  live.watcher.unref?.();
428
- if (snapshot.status === "running" && snapshot.watch) this.attachWatch(snapshot.taskId, snapshot.watch);
745
+ // D-03: re-arm only the remaining task-timeout budget instead of a fresh full window.
746
+ if (snapshot.status === "running" && snapshot.taskTimeoutMs !== undefined && snapshot.taskTimeoutMs > 0) {
747
+ const anchor = snapshot.timeoutAt ?? (snapshot.startedAt ?? snapshot.createdAt) + snapshot.taskTimeoutMs;
748
+ live.timer = this.armTaskTimeout(snapshot, Math.max(1, anchor - Date.now()), { unref: true });
749
+ }
750
+ // D-03: restore the watch window from the persisted absolute deadline.
751
+ if (snapshot.status === "running" && snapshot.watch) this.attachWatch(snapshot.taskId, snapshot.watch, snapshot.watchDeadline);
429
752
  } else if (snapshot.status === "running") {
430
- snapshot.status = "failed"; snapshot.failureKind = "lost"; snapshot.finishedAt = Date.now(); this.persist(snapshot); this.events.onFinalize(snapshot);
753
+ // Orphaned on disk: settle it through the same path as `reapOrphans()` so the
754
+ // snapshot also records `processAlive = false` (D-07) and emits one finalize.
755
+ snapshot.failureKind = "lost";
756
+ this.finalize(snapshot, "failed");
431
757
  }
432
758
  } catch { /* Ignore incomplete/corrupt metadata files; atomic writer leaves prior version intact. */ }
433
759
  }
@@ -443,7 +769,7 @@ export class TaskRegistry {
443
769
  * Policy (per dataDir): keep the union of (1) the newest `retentionCount`
444
770
  * terminal tasks by finishedAt/createdAt and (2) any terminal task still
445
771
  * younger than `retentionMs`. Everything else terminal is deleted
446
- * (.json + .output via metadata.outputFile) and removed from memory.
772
+ * (.json + .output/.stdout/.stderr via metadata.outputFile) and removed from memory.
447
773
  * Non-terminal tasks are never touched. Missing files are ignored.
448
774
  */
449
775
  prune(now = Date.now()): { pruned: number } {
@@ -465,8 +791,12 @@ export class TaskRegistry {
465
791
 
466
792
  private removeArtifacts(task: TaskSnapshot): void {
467
793
  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 */ }
794
+ const { stdout, stderr } = streamPaths(outputFile);
795
+ // D-11: never delete outside the data dir (skip silently so prune cannot crash).
796
+ for (const file of [outputFile, stdout, stderr, join(this.config.dataDir, `${task.taskId}.json`)]) {
797
+ if (!this.isInsideDataDir(file)) continue;
798
+ try { rmSync(file, { force: true }); } catch { /* force already ignores ENOENT; swallow other rare races */ }
799
+ }
470
800
  const live = this.live.get(task.taskId);
471
801
  if (live?.timer) clearTimeout(live.timer);
472
802
  if (live?.delayTimer) clearTimeout(live.delayTimer);
@@ -492,6 +822,8 @@ export class TaskRegistry {
492
822
  private finalize(task: TaskSnapshot, status: TaskSnapshot["status"], code?: number, killKind?: KillKind): void {
493
823
  if (isTerminal(task.status)) return;
494
824
  task.status = status; task.exitCode = code; task.killKind = killKind; task.finishedAt = Date.now();
825
+ // D-07: a terminal snapshot never claims a live process.
826
+ task.processAlive = false;
495
827
  const live = this.live.get(task.taskId);
496
828
  if (live?.delayTimer) { clearTimeout(live.delayTimer); live.delayTimer = undefined; }
497
829
  const watch = this.watchLoops.get(task.taskId); watch?.stop(); this.watchLoops.delete(task.taskId);
@@ -503,12 +835,37 @@ export class TaskRegistry {
503
835
  if ([...this.waiters].some((waiter) => waiter.ids.has(task.taskId))) {
504
836
  task.outputConsumed = true;
505
837
  }
506
- this.persist(task); this.emitState(task); this.events.onFinalize(task);
838
+ this.emitState(task);
839
+ this.finalizeEvent(task);
507
840
  for (const waiter of [...this.waiters]) if (waiter.ids.has(task.taskId)) waiter.resolve();
508
841
  if (!this.reloading) this.prune();
509
842
  }
510
- private emitState(task: TaskSnapshot): void { this.persist(task); this.events.onStateChange(task); }
843
+ private finalizeEvent(task: TaskSnapshot): void {
844
+ this.events.onFinalize(task);
845
+ this.emit({ type: "finalize", snapshot: task });
846
+ }
847
+ private emitState(task: TaskSnapshot): void {
848
+ this.persist(task);
849
+ this.events.onStateChange(task);
850
+ this.emit({ type: "state", snapshot: task });
851
+ }
852
+ private emit(event: RegistryEvent): void {
853
+ for (const listener of [...this.listeners]) {
854
+ try { listener(event); } catch (error) { console.error("pi-async: registry event listener failed", error); }
855
+ }
856
+ }
857
+
858
+ private ensureDataDir(): void {
859
+ if (this.dataDirReady) return;
860
+ mkdirSync(this.config.dataDir, { recursive: true });
861
+ this.dataDirReady = true;
862
+ }
863
+ /** `configure()` is contractually non-throwing (D-05); a bad data dir surfaces on persist. */
864
+ private ensureDataDirQuietly(): void {
865
+ try { this.ensureDataDir(); } catch (error) { console.error(`pi-async: cannot create data dir ${this.config.dataDir}`, error); }
866
+ }
511
867
  private persist(task: TaskSnapshot): void {
868
+ this.ensureDataDir();
512
869
  const file = join(this.config.dataDir, `${task.taskId}.json`); const temp = `${file}.${process.pid}.tmp`;
513
870
  writeFileSync(temp, JSON.stringify(task), { mode: 0o600 }); renameSync(temp, file);
514
871
  }
@@ -518,6 +875,74 @@ export class TaskRegistry {
518
875
  return persisted.metadata.capped === true;
519
876
  } catch { return task.metadata.capped === true; }
520
877
  }
878
+
879
+ private switchDataDir(nextDataDir: string): void {
880
+ this.config = { ...this.config, dataDir: nextDataDir };
881
+ this.dataDirReady = false;
882
+ }
883
+
884
+ /**
885
+ * Move every task artifact from the current data dir to `nextDataDir` (copy + rename),
886
+ * then repoint snapshots. On failure nothing is lost: the old dir stays authoritative
887
+ * and the error is reported, never thrown (D-05).
888
+ */
889
+ private migrateDataDir(nextDataDir: string): boolean {
890
+ const oldDir = this.config.dataDir;
891
+ let names: string[] = [];
892
+ try {
893
+ names = readdirSync(oldDir).filter((name) => ARTIFACT_SUFFIXES.some((suffix) => name.endsWith(suffix)));
894
+ } catch { /* Old dir missing: nothing to migrate. */ }
895
+ const copied: string[] = [];
896
+ try {
897
+ mkdirSync(nextDataDir, { recursive: true });
898
+ for (const name of names) {
899
+ const target = join(nextDataDir, name);
900
+ const temp = `${target}.${process.pid}.tmp`;
901
+ copyFileSync(join(oldDir, name), temp);
902
+ renameSync(temp, target);
903
+ copied.push(target);
904
+ }
905
+ } catch (error) {
906
+ for (const file of copied) { try { rmSync(file, { force: true }); } catch { /* best-effort rollback */ } }
907
+ console.error(`pi-async: data dir migration failed; keeping ${oldDir}`, error);
908
+ return false;
909
+ }
910
+ this.switchDataDir(nextDataDir);
911
+ this.ensureDataDir();
912
+ for (const task of this.tasks.values()) {
913
+ const outputFile = join(nextDataDir, `${task.taskId}.output`);
914
+ task.metadata = { ...task.metadata, outputFile };
915
+ const live = this.live.get(task.taskId);
916
+ if (live) live.outputFile = outputFile;
917
+ try { this.persist(task); } catch (error) { console.error(`pi-async: could not rewrite task ${task.taskId} after data dir migration`, error); }
918
+ }
919
+ for (const name of names) { try { rmSync(join(oldDir, name), { force: true }); } catch { /* best-effort cleanup */ } }
920
+ return true;
921
+ }
922
+
923
+ /** Absolute data dir (realpath when it exists) for boundary checks. */
924
+ private resolvedDataDir(): string {
925
+ try { return realpathSync(this.config.dataDir); } catch { return resolve(this.config.dataDir); }
926
+ }
927
+
928
+ private isInsideDataDir(target: string): boolean {
929
+ const root = this.resolvedDataDir();
930
+ let resolvedTarget: string;
931
+ try { resolvedTarget = realpathSync(target); } catch { resolvedTarget = resolve(target); }
932
+ return resolvedTarget === root || resolvedTarget.startsWith(root + sep);
933
+ }
934
+
935
+ /** D-11: reject reads of `metadata.outputFile` outside the data dir. */
936
+ private assertInsideDataDir(target: string): string {
937
+ const root = this.resolvedDataDir();
938
+ let resolvedTarget: string;
939
+ try { resolvedTarget = realpathSync(target); } catch { resolvedTarget = resolve(target); }
940
+ if (resolvedTarget !== root && !resolvedTarget.startsWith(root + sep)) {
941
+ throw new Error(`Refusing to read task output outside the async data directory: ${target}`);
942
+ }
943
+ return resolvedTarget;
944
+ }
945
+
521
946
  private pidAlive(pid: number): boolean {
522
947
  // Prefer /proc so a zombie-only group is not treated as alive (#6 / reload lost detection).
523
948
  try {
@@ -540,4 +965,3 @@ export class TaskRegistry {
540
965
  }
541
966
  }
542
967
  }
543
-