pi-async 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import type { TaskSnapshot } from "./task.ts";
6
+
7
+ export interface SpawnHandle { pid: number; exit: Promise<{ code: number }>; }
8
+
9
+ /** 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. */
19
+ export function spawnDetached(command: string, opts: { outputFile: string; env?: Record<string, string> }): SpawnHandle {
20
+ mkdirSync(dirname(opts.outputFile), { recursive: true });
21
+ const { stdoutFile, stderrFile } = streamPaths(opts.outputFile);
22
+ const capBytes = finiteNumber(opts.env?.PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES ?? process.env.PI_ASYNC_MAX_OUTPUT_BYTES, 20 * 1024 * 1024);
23
+ const env = { ...process.env, ...opts.env };
24
+ delete env.PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES;
25
+ const child = spawn("/bin/sh", ["-c", command], { detached: true, stdio: ["ignore", "pipe", "pipe"], env });
26
+ if (!child.pid) throw new Error("Failed to start detached task process");
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);
32
+ let totalBytes = 0;
33
+ let capReached = false;
34
+ const copy = (chunk: Buffer, stream: "stdout" | "stderr"): void => {
35
+ if (capReached) return;
36
+ const remaining = Math.max(0, capBytes - totalBytes);
37
+ const accepted = chunk.subarray(0, remaining);
38
+ if (accepted.length) {
39
+ merged.write(accepted);
40
+ (stream === "stdout" ? stdoutOut : stderrOut).write(accepted);
41
+ totalBytes += accepted.length;
42
+ }
43
+ if (accepted.length < chunk.length) {
44
+ capReached = true;
45
+ persistOutputCap(opts.outputFile);
46
+ void killTree(child.pid ?? 0, { graceMs: 3000 }).then(({ confirmed }) => {
47
+ if (!confirmed) console.error(`pi-async: output cap could not confirm process group ${child.pid} exited`);
48
+ }).catch((error: unknown) => {
49
+ console.error(`pi-async: failed to kill process group ${child.pid} after output cap`, error);
50
+ });
51
+ }
52
+ };
53
+ child.stdout?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk), "stdout"));
54
+ child.stderr?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk), "stderr"));
55
+ const exit = new Promise<{ code: number }>((resolve, reject) => {
56
+ child.once("error", reject);
57
+ child.once("close", (code) => {
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);
66
+ });
67
+ });
68
+ return { pid: child.pid, exit };
69
+ }
70
+
71
+ function finiteNumber(value: string | undefined, fallback: number): number {
72
+ const parsed = Number(value);
73
+ return Number.isFinite(parsed) ? parsed : fallback;
74
+ }
75
+
76
+ function persistOutputCap(outputFile: string): void {
77
+ const metadataFile = outputFile.endsWith(".output") ? `${outputFile.slice(0, -7)}.json` : `${outputFile}.json`;
78
+ try {
79
+ if (!existsSync(metadataFile)) throw new Error(`Task metadata is not available at ${metadataFile}`);
80
+ const snapshot = JSON.parse(readFileSync(metadataFile, "utf8")) as TaskSnapshot;
81
+ snapshot.metadata = { ...snapshot.metadata, capped: true };
82
+ const temp = `${metadataFile}.${process.pid}.tmp`;
83
+ writeFileSync(temp, JSON.stringify(snapshot), { mode: 0o600 });
84
+ renameSync(temp, metadataFile);
85
+ } catch (error) {
86
+ console.error(`pi-async: failed to persist output cap metadata for ${outputFile}`, error);
87
+ }
88
+ }
89
+
90
+ function groupExists(pid: number): boolean {
91
+ try {
92
+ process.kill(-pid, 0);
93
+ return true;
94
+ } catch (error) {
95
+ return (error as NodeJS.ErrnoException).code === "EPERM";
96
+ }
97
+ }
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
+
120
+ async function waitForGroupExit(pid: number, timeoutMs: number): Promise<boolean> {
121
+ const deadline = Date.now() + timeoutMs;
122
+ while (groupHasLiveMember(pid) && Date.now() < deadline) await delay(Math.min(100, Math.max(1, deadline - Date.now())));
123
+ return !groupHasLiveMember(pid);
124
+ }
125
+
126
+ export async function killTree(pid: number, opts?: { graceMs?: number }): Promise<{ confirmed: boolean }> {
127
+ const graceMs = opts?.graceMs ?? 3000;
128
+ if (!Number.isInteger(pid) || pid <= 1) throw new RangeError("pid must identify a child process");
129
+ if (!groupHasLiveMember(pid)) return { confirmed: true };
130
+ try { process.kill(-pid, "SIGTERM"); } catch (error) {
131
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
132
+ }
133
+ if (await waitForGroupExit(pid, graceMs)) return { confirmed: true };
134
+ try { process.kill(-pid, "SIGKILL"); } catch (error) {
135
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
136
+ }
137
+ return { confirmed: await waitForGroupExit(pid, 1500) };
138
+ }
@@ -0,0 +1,102 @@
1
+ export const TASK_STATUSES = ["pending", "running", "completed", "failed", "killed"] as const;
2
+ export type TaskStatus = (typeof TASK_STATUSES)[number];
3
+ export type TaskMode = "background" | "sync" | "delayed";
4
+ export type KillKind = "user" | "timeout" | "output_cap" | "shutdown" | "watch_failure";
5
+ export type FailureKind = "lost" | "blind_exhausted" | "watch_timeout";
6
+
7
+ export const TERMINAL_STATUSES = ["completed", "failed", "killed"] as const satisfies readonly TaskStatus[];
8
+
9
+ export function isTaskStatus(value: unknown): value is TaskStatus {
10
+ return typeof value === "string" && (TASK_STATUSES as readonly string[]).includes(value);
11
+ }
12
+
13
+ export function isTerminal(status: TaskStatus): boolean {
14
+ return TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]);
15
+ }
16
+
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). */
25
+ export function deriveName(command: string): string {
26
+ const firstContentLine = command.split(/\r?\n/).find((line) => line.trim().length > 0);
27
+ const normalized = firstContentLine?.trim().replace(/\s+/g, " ") ?? "";
28
+ if (normalized.length === 0) return "async task";
29
+ return capName(normalized);
30
+ }
31
+
32
+ export type Condition =
33
+ | { type: "exit_code"; equals: number }
34
+ | { type: "stdout_contains"; value: string }
35
+ | { type: "stderr_contains"; value: string }
36
+ | { type: "json_path_exists"; path: string }
37
+ | { type: "output_matches"; regex: string };
38
+
39
+ export interface WatchSpec {
40
+ intervalMs: number;
41
+ timeoutMs: number;
42
+ success_when: Condition[];
43
+ failure_when: Condition[];
44
+ blind_checks?: number;
45
+ }
46
+
47
+ export interface RunOptions {
48
+ command: string;
49
+ name?: string;
50
+ mode?: TaskMode;
51
+ delayMs?: number;
52
+ timeoutMs?: number;
53
+ onTimeout?: "promote" | "abort";
54
+ taskTimeoutMs?: number;
55
+ notifyOnCompletion?: boolean;
56
+ triggerOnCompletion?: boolean;
57
+ env?: Record<string, string>;
58
+ watch?: WatchSpec;
59
+ }
60
+
61
+ export interface RunReceipt {
62
+ taskId: string;
63
+ name: string;
64
+ status: TaskStatus;
65
+ mode: TaskMode;
66
+ outputHint: string;
67
+ }
68
+
69
+ export interface SyncResult extends RunReceipt {
70
+ exitCode?: number;
71
+ outputTail: string;
72
+ promoted: boolean;
73
+ }
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). */
78
+ export type WaitOutcome =
79
+ | { results: WaitResultItem[] }
80
+ | { notReady: WaitNotReadyItem[] }
81
+ | { results: WaitResultItem[]; notReady: WaitNotReadyItem[] };
82
+
83
+ export interface TaskSnapshot {
84
+ taskId: string;
85
+ name: string;
86
+ command: string;
87
+ mode: TaskMode;
88
+ status: TaskStatus;
89
+ pid?: number;
90
+ exitCode?: number;
91
+ killKind?: KillKind;
92
+ failureKind?: FailureKind;
93
+ createdAt: number;
94
+ startedAt?: number;
95
+ finishedAt?: number;
96
+ dueAt?: number;
97
+ taskTimeoutMs?: number;
98
+ notified: boolean;
99
+ outputConsumed: boolean;
100
+ watch?: WatchSpec;
101
+ metadata: Record<string, unknown>;
102
+ }
@@ -0,0 +1,308 @@
1
+ import type { Condition, WatchSpec } from "./task.ts";
2
+
3
+ const compiledRegexes = new WeakMap<Condition, RegExp>();
4
+
5
+ /** Captured streams for watch evaluation: stdout/stderr are distinct; output is merged. */
6
+ export interface WatchOutput {
7
+ output: string;
8
+ stdout: string;
9
+ stderr: string;
10
+ }
11
+
12
+ /** Cap regex subject size so catastrophic patterns cannot grow without bound (#24). */
13
+ export const MAX_OUTPUT_MATCH_BYTES = 64 * 1024;
14
+
15
+ /**
16
+ * Classic exponential-backtracking shape: a group that itself ends with a
17
+ * quantifier, then another quantifier — e.g. (a+)+ / (a*)* / (a|b+)+.
18
+ * Conservative; does not catch every polynomial case.
19
+ */
20
+ const NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[+*]\)\s*[+*]/;
21
+
22
+ function rejectDangerousRegex(regex: string): string | undefined {
23
+ if (NESTED_QUANTIFIER.test(regex)) {
24
+ return "regex has nested quantifiers (ReDoS risk); simplify or use stdout_contains";
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ function matchSubject(output: string): string {
30
+ if (output.length <= MAX_OUTPUT_MATCH_BYTES) return output;
31
+ return output.slice(-MAX_OUTPUT_MATCH_BYTES);
32
+ }
33
+
34
+ export function validateWatchSpec(spec: WatchSpec): { ok: true } | { ok: false; error: string } {
35
+ const invalid = (error: string) => ({ ok: false as const, error });
36
+ if (!spec || typeof spec !== "object") return invalid("watch must be an object");
37
+ if (!Number.isFinite(spec.intervalMs) || !Number.isInteger(spec.intervalMs) || spec.intervalMs < 1_000) {
38
+ return invalid("intervalMs must be an integer greater than or equal to 1000");
39
+ }
40
+ if (!Number.isFinite(spec.timeoutMs) || !Number.isInteger(spec.timeoutMs) || spec.timeoutMs <= 0) {
41
+ return invalid("timeoutMs must be a positive finite integer");
42
+ }
43
+ if (spec.blind_checks !== undefined &&
44
+ (!Number.isFinite(spec.blind_checks) || !Number.isInteger(spec.blind_checks) || spec.blind_checks < 1)) {
45
+ return invalid("blind_checks must be a positive integer");
46
+ }
47
+ if (!Array.isArray(spec.success_when) || !Array.isArray(spec.failure_when)) {
48
+ return invalid("success_when and failure_when must be arrays");
49
+ }
50
+
51
+ for (const [group, conditions] of [
52
+ ["success_when", spec.success_when],
53
+ ["failure_when", spec.failure_when],
54
+ ] as const) {
55
+ for (const [index, condition] of conditions.entries()) {
56
+ const location = `${group}[${index}]`;
57
+ if (!condition || typeof condition !== "object" || !("type" in condition)) {
58
+ return invalid(`${location} is invalid`);
59
+ }
60
+ switch (condition.type) {
61
+ case "exit_code":
62
+ if (!Number.isFinite(condition.equals)) return invalid(`${location}: equals must be finite`);
63
+ break;
64
+ case "stdout_contains":
65
+ case "stderr_contains":
66
+ if (typeof condition.value !== "string") return invalid(`${location}: value must be a string`);
67
+ break;
68
+ case "json_path_exists":
69
+ if (typeof condition.path !== "string" || parseJsonPath(condition.path) === undefined) {
70
+ const shown = typeof condition.path === "string" ? JSON.stringify(condition.path) : String(condition.path);
71
+ return invalid(`${location}: invalid JSON path ${shown}: paths must start with "$" (e.g. $.ready or $.items[0].state)`);
72
+ }
73
+ break;
74
+ case "output_matches":
75
+ if (typeof condition.regex !== "string") return invalid(`${location}: regex must be a string`);
76
+ {
77
+ const danger = rejectDangerousRegex(condition.regex);
78
+ if (danger) return invalid(`${location}: ${danger}`);
79
+ }
80
+ try {
81
+ compiledRegexes.set(condition, new RegExp(condition.regex, "m"));
82
+ } catch {
83
+ return invalid(`${location}: invalid regex`);
84
+ }
85
+ break;
86
+ default:
87
+ return invalid(`${location}: unknown condition type`);
88
+ }
89
+ }
90
+ }
91
+ return { ok: true };
92
+ }
93
+
94
+ export type WatchEvalContext = WatchOutput & { exitCode?: number };
95
+
96
+ export function evalConditions(conds: Condition[], ctx: WatchEvalContext): boolean {
97
+ const result = evaluateConditionsDetailed(conds, ctx);
98
+ return result.status === "matched" && result.matched;
99
+ }
100
+
101
+ export interface WatchLoopDeps {
102
+ taskId: string;
103
+ spec: WatchSpec;
104
+ readOutput: () => WatchOutput;
105
+ getExitCode: () => number | undefined;
106
+ onSuccess: (detail: string) => void;
107
+ onFailure: () => void;
108
+ onBlindExhausted: () => void;
109
+ onWatchTimeout: () => void;
110
+ }
111
+
112
+ export class WatchLoop {
113
+ private timer: ReturnType<typeof setTimeout> | undefined;
114
+ private stopped = true;
115
+ private finished = false;
116
+ private deadline = 0;
117
+ private blindCount = 0;
118
+ private readonly deps: WatchLoopDeps;
119
+
120
+ constructor(deps: WatchLoopDeps) {
121
+ this.deps = deps;
122
+ }
123
+
124
+ start(): void {
125
+ if (!this.stopped || this.finished) return;
126
+ this.stopped = false;
127
+ this.blindCount = 0;
128
+ this.deadline = Date.now() + this.deps.spec.timeoutMs;
129
+ this.check();
130
+ }
131
+
132
+ stop(): void {
133
+ this.stopped = true;
134
+ if (this.timer !== undefined) {
135
+ clearTimeout(this.timer);
136
+ this.timer = undefined;
137
+ }
138
+ }
139
+
140
+ private check(): void {
141
+ if (this.stopped) return;
142
+ if (Date.now() >= this.deadline) {
143
+ this.finish(this.deps.onWatchTimeout);
144
+ return;
145
+ }
146
+
147
+ const conditions = [...this.deps.spec.failure_when, ...this.deps.spec.success_when];
148
+ const needsOutput = conditions.some((condition) => condition.type !== "exit_code");
149
+ const needsExitCode = conditions.some((condition) => condition.type === "exit_code");
150
+ let streams: WatchOutput = { output: "", stdout: "", stderr: "" };
151
+ let exitCode: number | undefined;
152
+ try {
153
+ if (needsOutput) streams = this.deps.readOutput();
154
+ if (needsExitCode) exitCode = this.deps.getExitCode();
155
+ } catch {
156
+ this.countBlindCheck();
157
+ return;
158
+ }
159
+
160
+ const ctx: WatchEvalContext = { ...streams, exitCode };
161
+ const failure = evaluateConditionsDetailed(this.deps.spec.failure_when, ctx);
162
+ if (failure.status === "matched" && failure.matched) {
163
+ this.finish(this.deps.onFailure);
164
+ return;
165
+ }
166
+ const success = evaluateConditionsDetailed(this.deps.spec.success_when, ctx);
167
+ if (success.status === "matched" && success.matched) {
168
+ const matched = success.condition;
169
+ this.finish(() => this.deps.onSuccess(`success_when matched: ${JSON.stringify(matched)}`));
170
+ return;
171
+ }
172
+
173
+ // Pending observations (e.g. a process still running or incomplete JSON output)
174
+ // are not infrastructure failures and must not consume the blind-check budget.
175
+ if (failure.status === "error" || success.status === "error") {
176
+ this.countBlindCheck();
177
+ } else {
178
+ this.blindCount = 0;
179
+ this.scheduleNext();
180
+ }
181
+ }
182
+
183
+ private countBlindCheck(): void {
184
+ this.blindCount++;
185
+ if (this.blindCount >= (this.deps.spec.blind_checks ?? 3)) {
186
+ this.finish(this.deps.onBlindExhausted);
187
+ return;
188
+ }
189
+ this.scheduleNext();
190
+ }
191
+
192
+ private scheduleNext(): void {
193
+ if (this.stopped) return;
194
+ const remaining = this.deadline - Date.now();
195
+ if (remaining <= 0) {
196
+ this.finish(this.deps.onWatchTimeout);
197
+ return;
198
+ }
199
+ this.timer = setTimeout(() => {
200
+ this.timer = undefined;
201
+ this.check();
202
+ }, Math.min(this.deps.spec.intervalMs, remaining));
203
+ }
204
+
205
+ private finish(callback: () => void): void {
206
+ this.finished = true;
207
+ this.stop();
208
+ callback();
209
+ }
210
+ }
211
+
212
+ type ConditionEvaluation =
213
+ | { status: "matched"; matched: boolean; condition?: Condition }
214
+ | { status: "pending" }
215
+ | { status: "error" };
216
+
217
+ function evaluateConditionsDetailed(conditions: Condition[], ctx: WatchEvalContext): ConditionEvaluation {
218
+ let pending = false;
219
+ let error = false;
220
+ for (const condition of conditions) {
221
+ const result = evaluateCondition(condition, ctx);
222
+ if (result.status === "matched" && result.matched) return { status: "matched", matched: true, condition };
223
+ if (result.status === "pending") pending = true;
224
+ if (result.status === "error") error = true;
225
+ }
226
+ if (error) return { status: "error" };
227
+ if (pending) return { status: "pending" };
228
+ return { status: "matched", matched: false };
229
+ }
230
+
231
+ function evaluateCondition(condition: Condition, ctx: WatchEvalContext): ConditionEvaluation {
232
+ switch (condition.type) {
233
+ case "exit_code":
234
+ return ctx.exitCode === undefined
235
+ ? { status: "pending" }
236
+ : { status: "matched", matched: ctx.exitCode === condition.equals };
237
+ case "stdout_contains":
238
+ return { status: "matched", matched: ctx.stdout.includes(condition.value) };
239
+ case "stderr_contains":
240
+ return { status: "matched", matched: ctx.stderr.includes(condition.value) };
241
+ case "output_matches": {
242
+ let regex = compiledRegexes.get(condition);
243
+ if (!regex) {
244
+ try {
245
+ regex = new RegExp(condition.regex, "m");
246
+ compiledRegexes.set(condition, regex);
247
+ } catch {
248
+ return { status: "error" };
249
+ }
250
+ }
251
+ // Global/sticky expressions otherwise retain lastIndex between watch polls.
252
+ regex.lastIndex = 0;
253
+ return { status: "matched", matched: regex.test(matchSubject(ctx.output)) };
254
+ }
255
+ case "json_path_exists": {
256
+ let root: unknown;
257
+ try {
258
+ root = JSON.parse(ctx.output);
259
+ } catch {
260
+ return { status: "pending" };
261
+ }
262
+ const tokens = parseJsonPath(condition.path);
263
+ if (tokens === undefined) return { status: "error" };
264
+ let current = root;
265
+ for (const token of tokens) {
266
+ if (typeof token === "number") {
267
+ if (!Array.isArray(current) || token >= current.length) return { status: "matched", matched: false };
268
+ current = current[token];
269
+ } else {
270
+ if (current === null || typeof current !== "object" || !Object.hasOwn(current, token)) {
271
+ return { status: "matched", matched: false };
272
+ }
273
+ current = (current as Record<string, unknown>)[token];
274
+ }
275
+ }
276
+ return { status: "matched", matched: true };
277
+ }
278
+ }
279
+ }
280
+
281
+ function parseJsonPath(path: string): Array<string | number> | undefined {
282
+ if (!path.startsWith("$") || path.length === 0) return undefined;
283
+ const tokens: Array<string | number> = [];
284
+ let index = 1;
285
+ while (index < path.length) {
286
+ if (path[index] === ".") {
287
+ const start = ++index;
288
+ if (index >= path.length || !/[A-Za-z_$]/.test(path[index]!)) return undefined;
289
+ index++;
290
+ while (index < path.length && /[A-Za-z0-9_$-]/.test(path[index]!)) index++;
291
+ tokens.push(path.slice(start, index));
292
+ continue;
293
+ }
294
+ if (path[index] === "[") {
295
+ const end = path.indexOf("]", index + 1);
296
+ if (end < 0) return undefined;
297
+ const raw = path.slice(index + 1, end);
298
+ if (!/^\d+$/.test(raw)) return undefined;
299
+ const arrayIndex = Number(raw);
300
+ if (!Number.isSafeInteger(arrayIndex)) return undefined;
301
+ tokens.push(arrayIndex);
302
+ index = end + 1;
303
+ continue;
304
+ }
305
+ return undefined;
306
+ }
307
+ return tokens;
308
+ }
@@ -0,0 +1,75 @@
1
+ import type { PiCtx } from "../core/notify.ts";
2
+ import type { TaskRegistry } from "../core/registry.ts";
3
+ import { envNumber } from "../core/env.ts";
4
+
5
+ export class SettleGuard {
6
+ private readonly maxReminders: number;
7
+ private readonly heartbeatMs: number;
8
+ private reminders = 0;
9
+ private heartbeat?: ReturnType<typeof setInterval>;
10
+ private removeHooks: Array<() => void> = [];
11
+ private readonly pi: PiCtx;
12
+ private readonly registry: TaskRegistry;
13
+
14
+ constructor(pi: PiCtx, registry: TaskRegistry, opts?: { maxReminders?: number; heartbeatMs?: number }) {
15
+ this.pi = pi;
16
+ this.registry = registry;
17
+ this.maxReminders = opts?.maxReminders ?? 3;
18
+ this.heartbeatMs = opts?.heartbeatMs ?? envNumber("PI_ASYNC_HEARTBEAT_MS", 60_000, { integer: true, min: 0 });
19
+ }
20
+
21
+ register(): void {
22
+ // SAFETY: PiCtx hooks vary by runtime; these optional operations are only invoked after presence checks.
23
+ const hooks = this.pi as unknown as {
24
+ on?: (event: string, handler: (...args: unknown[]) => unknown) => void;
25
+ off?: (event: string, handler: (...args: unknown[]) => unknown) => void;
26
+ appendEntry?: (customType: string, data: unknown) => void;
27
+ };
28
+ const beforeSettle = () => {
29
+ const active = this.registry.list({ includeHistory: false });
30
+ if (active.length === 0) { this.reminders = 0; this.reportActivity(); return; }
31
+ this.reportActivity();
32
+ if (this.reminders >= this.maxReminders) {
33
+ hooks.appendEntry?.("async-task-unresolved", { message: `${active.length} async task(s) remain unresolved: ${active.map((task) => task.taskId).join(", ")}` });
34
+ return;
35
+ }
36
+ this.reminders++;
37
+ hooks.appendEntry?.("async-task-settle-reminder", { message: `${active.length} async task(s) not collected (${active.map((task) => task.taskId).join(", ")}); use async_wait/async_status before reporting verdict.` });
38
+ return { continue: true };
39
+ };
40
+ const beforeRun = () => { this.reminders = 0; };
41
+ // SAFETY: PI_LEGION_NODE_ID is the documented legion-child marker; presence opts into settle reminders.
42
+ const legionChild = process.env.PI_LEGION_NODE_ID !== undefined;
43
+ if (legionChild) hooks.on?.("agent_before_settle", beforeSettle);
44
+ hooks.on?.("agent_start", beforeRun);
45
+ const cleanup = () => {
46
+ if (this.heartbeat) clearInterval(this.heartbeat);
47
+ if (legionChild) hooks.off?.("agent_before_settle", beforeSettle);
48
+ hooks.off?.("agent_start", beforeRun);
49
+ this.removeHooks = [];
50
+ };
51
+ hooks.on?.("session_shutdown", () => { void cleanup(); });
52
+ this.removeHooks.push(cleanup);
53
+ this.startHeartbeat(hooks);
54
+ this.reportActivity();
55
+ }
56
+
57
+ private reportActivity(): void {
58
+ // SAFETY: setActivity is feature-detected because older Pi hosts do not implement the optional API.
59
+ const ui = (this.pi as unknown as { ui?: { setActivity?: (id: string, value: { message: string } | undefined) => void } }).ui;
60
+ if (typeof ui?.setActivity !== "function") return;
61
+ const count = this.registry.activeCount();
62
+ const total = count.running + count.pending;
63
+ ui.setActivity("pi-async", total ? { message: `${total} async task${total === 1 ? "" : "s"}` } : undefined);
64
+ }
65
+
66
+ private startHeartbeat(hooks: { appendEntry?: (customType: string, data: unknown) => void }): void {
67
+ if (this.heartbeat || this.heartbeatMs <= 0) return;
68
+ this.heartbeat = setInterval(() => {
69
+ if (!this.registry.hasActiveWork()) return;
70
+ hooks.appendEntry?.("async-task-heartbeat", { timestamp: Date.now(), active: this.registry.activeCount() });
71
+ }, this.heartbeatMs);
72
+ this.heartbeat.unref?.();
73
+ }
74
+ }
75
+