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.
- package/CHANGELOG.md +54 -0
- package/README.md +20 -0
- package/docs/CONTRACT.md +68 -17
- package/extensions/index.ts +33 -2
- package/package.json +3 -2
- package/src/core/bus.ts +11 -3
- package/src/core/duration.ts +16 -0
- package/src/core/env.ts +23 -0
- package/src/core/notify.ts +19 -3
- package/src/core/output.ts +48 -2
- package/src/core/registry.ts +197 -70
- package/src/core/spawn.ts +54 -14
- package/src/core/task.ts +25 -8
- package/src/core/watch.ts +49 -13
- package/src/guard/settle-guard.ts +3 -6
- package/src/tools/index.ts +50 -11
- package/src/tui/panel.ts +99 -38
- package/src/tui/rail.ts +73 -20
- package/src/tui/status.ts +23 -2
- package/src/tui/width.ts +53 -0
package/src/core/registry.ts
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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 ??
|
|
34
|
-
this.maxOutputBytes = config.maxOutputBytes ??
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
172
|
-
if (evalConditions(task.watch.failure_when, {
|
|
173
|
-
else if (evalConditions(task.watch.success_when, {
|
|
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):
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
280
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
this.
|
|
349
|
-
if (snapshot.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
this.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (live.
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
if (snapshot.status === "running"
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
}
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
81
|
-
return !
|
|
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 (!
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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:
|
|
64
|
-
| { notReady:
|
|
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?:
|
|
92
|
+
failureKind?: FailureKind;
|
|
76
93
|
createdAt: number;
|
|
77
94
|
startedAt?: number;
|
|
78
95
|
finishedAt?: number;
|