pi-async 0.1.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.
- package/CHANGELOG.md +188 -0
- package/README.md +93 -19
- package/docs/CONTRACT.md +301 -61
- package/extensions/index.ts +221 -38
- package/package.json +6 -3
- package/src/core/bus.ts +11 -3
- package/src/core/duration.ts +20 -0
- package/src/core/env.ts +134 -0
- package/src/core/notify.ts +82 -18
- package/src/core/output.ts +48 -2
- package/src/core/registry.ts +646 -95
- package/src/core/spawn.ts +95 -20
- package/src/core/task.ts +38 -8
- package/src/core/watch.ts +49 -13
- package/src/guard/settle-guard.ts +152 -46
- package/src/tools/index.ts +221 -27
- package/src/tui/panel.ts +375 -80
- package/src/tui/rail.ts +117 -33
- package/src/tui/status.ts +102 -25
- package/src/tui/width.ts +53 -0
package/src/core/spawn.ts
CHANGED
|
@@ -1,28 +1,78 @@
|
|
|
1
|
-
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, 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";
|
|
5
|
+
import { buildChildEnv, INTERNAL_MAX_OUTPUT_ENV, type EnvPolicy } from "./env.ts";
|
|
5
6
|
import type { TaskSnapshot } from "./task.ts";
|
|
6
7
|
|
|
8
|
+
export type { EnvPolicy };
|
|
9
|
+
|
|
7
10
|
export interface SpawnHandle { pid: number; exit: Promise<{ code: number }>; }
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
export interface SpawnOptions {
|
|
13
|
+
outputFile: string;
|
|
14
|
+
env?: Record<string, string>;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
envPolicy?: EnvPolicy;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Derive per-stream sidecar paths next to the merged `.output` file. */
|
|
20
|
+
export function streamPaths(outputFile: string): { stdout: string; stderr: string } {
|
|
21
|
+
if (outputFile.endsWith(".output")) {
|
|
22
|
+
const base = outputFile.slice(0, -".output".length);
|
|
23
|
+
return { stdout: `${base}.stdout`, stderr: `${base}.stderr` };
|
|
24
|
+
}
|
|
25
|
+
return { stdout: `${outputFile}.stdout`, stderr: `${outputFile}.stderr` };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Resolve the inheritance policy; an unknown `PI_ASYNC_ENV_INHERIT` falls back to `safe` (D-10). */
|
|
29
|
+
export function resolveEnvPolicy(explicit?: EnvPolicy): EnvPolicy {
|
|
30
|
+
if (explicit) return explicit;
|
|
31
|
+
const inherited = process.env.PI_ASYNC_ENV_INHERIT;
|
|
32
|
+
if (inherited === "all") return "all";
|
|
33
|
+
if (inherited === "none") return "none";
|
|
34
|
+
return "safe";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fail loudly before forking when the requested cwd cannot be used. */
|
|
38
|
+
function assertUsableCwd(cwd: string | undefined): void {
|
|
39
|
+
if (cwd === undefined) return;
|
|
40
|
+
let stats: ReturnType<typeof statSync>;
|
|
41
|
+
try {
|
|
42
|
+
stats = statSync(cwd);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
45
|
+
}
|
|
46
|
+
if (!stats.isDirectory()) throw new Error(`Working directory is not a directory: ${cwd}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Start a shell command in its own POSIX process group; capture merged + separate streams. */
|
|
50
|
+
export function spawnDetached(command: string, opts: SpawnOptions): SpawnHandle {
|
|
51
|
+
assertUsableCwd(opts.cwd);
|
|
11
52
|
mkdirSync(dirname(opts.outputFile), { recursive: true });
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
53
|
+
const { stdout: stdoutFile, stderr: stderrFile } = streamPaths(opts.outputFile);
|
|
54
|
+
const capBytes = finiteNumber(opts.env?.[INTERNAL_MAX_OUTPUT_ENV] ?? process.env.PI_ASYNC_MAX_OUTPUT_BYTES, 20 * 1024 * 1024);
|
|
55
|
+
const env = buildChildEnv(process.env, opts.env, resolveEnvPolicy(opts.envPolicy));
|
|
56
|
+
// Always pass cwd explicitly: the host process cwd is not the session cwd (REV-13).
|
|
57
|
+
const child = spawn("/bin/sh", ["-c", command], { detached: true, stdio: ["ignore", "pipe", "pipe"], env, cwd: opts.cwd });
|
|
58
|
+
// Attach a no-op error listener before any throw so a failed spawn can never surface as an
|
|
59
|
+
// unhandled 'error' event (which would crash the host); the exit promise re-observes it.
|
|
60
|
+
child.on("error", () => undefined);
|
|
61
|
+
if (!child.pid) throw new Error(`Failed to start detached task process${opts.cwd ? ` in ${opts.cwd}` : ""}`);
|
|
62
|
+
const merged = createWriteStream(opts.outputFile, { flags: "w", mode: 0o600 });
|
|
63
|
+
const stdoutOut = createWriteStream(stdoutFile, { flags: "w", mode: 0o600 });
|
|
64
|
+
const stderrOut = createWriteStream(stderrFile, { flags: "w", mode: 0o600 });
|
|
65
|
+
// Cleanup may unlink the data dir while pipes still flush; ignore late write errors.
|
|
66
|
+
for (const stream of [merged, stdoutOut, stderrOut]) stream.on("error", () => undefined);
|
|
18
67
|
let totalBytes = 0;
|
|
19
68
|
let capReached = false;
|
|
20
|
-
const copy = (chunk: Buffer): void => {
|
|
69
|
+
const copy = (chunk: Buffer, stream: "stdout" | "stderr"): void => {
|
|
21
70
|
if (capReached) return;
|
|
22
71
|
const remaining = Math.max(0, capBytes - totalBytes);
|
|
23
72
|
const accepted = chunk.subarray(0, remaining);
|
|
24
73
|
if (accepted.length) {
|
|
25
|
-
|
|
74
|
+
merged.write(accepted);
|
|
75
|
+
(stream === "stdout" ? stdoutOut : stderrOut).write(accepted);
|
|
26
76
|
totalBytes += accepted.length;
|
|
27
77
|
}
|
|
28
78
|
if (accepted.length < chunk.length) {
|
|
@@ -35,12 +85,19 @@ export function spawnDetached(command: string, opts: { outputFile: string; env?:
|
|
|
35
85
|
});
|
|
36
86
|
}
|
|
37
87
|
};
|
|
38
|
-
child.stdout?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk)));
|
|
39
|
-
child.stderr?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk)));
|
|
88
|
+
child.stdout?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk), "stdout"));
|
|
89
|
+
child.stderr?.on("data", (chunk: Buffer) => copy(Buffer.from(chunk), "stderr"));
|
|
40
90
|
const exit = new Promise<{ code: number }>((resolve, reject) => {
|
|
41
91
|
child.once("error", reject);
|
|
42
92
|
child.once("close", (code) => {
|
|
43
|
-
|
|
93
|
+
let pending = 3;
|
|
94
|
+
const done = (): void => {
|
|
95
|
+
pending--;
|
|
96
|
+
if (pending === 0) resolve({ code: code ?? 1 });
|
|
97
|
+
};
|
|
98
|
+
merged.end(done);
|
|
99
|
+
stdoutOut.end(done);
|
|
100
|
+
stderrOut.end(done);
|
|
44
101
|
});
|
|
45
102
|
});
|
|
46
103
|
return { pid: child.pid, exit };
|
|
@@ -51,7 +108,6 @@ function finiteNumber(value: string | undefined, fallback: number): number {
|
|
|
51
108
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
52
109
|
}
|
|
53
110
|
|
|
54
|
-
/** Merge the cap marker into the task snapshot with the same atomic-rename strategy as the registry. */
|
|
55
111
|
function persistOutputCap(outputFile: string): void {
|
|
56
112
|
const metadataFile = outputFile.endsWith(".output") ? `${outputFile.slice(0, -7)}.json` : `${outputFile}.json`;
|
|
57
113
|
try {
|
|
@@ -75,17 +131,37 @@ function groupExists(pid: number): boolean {
|
|
|
75
131
|
}
|
|
76
132
|
}
|
|
77
133
|
|
|
134
|
+
/** True only if the process group has a non-zombie member (#6). */
|
|
135
|
+
function groupHasLiveMember(pgid: number): boolean {
|
|
136
|
+
try {
|
|
137
|
+
for (const entry of readdirSync("/proc")) {
|
|
138
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
139
|
+
try {
|
|
140
|
+
const stat = readFileSync(`/proc/${entry}/stat`, "utf8");
|
|
141
|
+
const closeParen = stat.lastIndexOf(") ");
|
|
142
|
+
if (closeParen < 0) continue;
|
|
143
|
+
const rest = stat.slice(closeParen + 2).split(" ");
|
|
144
|
+
const state = rest[0];
|
|
145
|
+
const pgrp = Number(rest[2]);
|
|
146
|
+
if (pgrp === pgid && state !== "Z") return true;
|
|
147
|
+
} catch { /* raced */ }
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
} catch {
|
|
151
|
+
return groupExists(pgid);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
78
155
|
async function waitForGroupExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
79
156
|
const deadline = Date.now() + timeoutMs;
|
|
80
|
-
while (
|
|
81
|
-
return !
|
|
157
|
+
while (groupHasLiveMember(pid) && Date.now() < deadline) await delay(Math.min(100, Math.max(1, deadline - Date.now())));
|
|
158
|
+
return !groupHasLiveMember(pid);
|
|
82
159
|
}
|
|
83
160
|
|
|
84
|
-
/** Terminate the detached process group, escalating from TERM to KILL. */
|
|
85
161
|
export async function killTree(pid: number, opts?: { graceMs?: number }): Promise<{ confirmed: boolean }> {
|
|
86
162
|
const graceMs = opts?.graceMs ?? 3000;
|
|
87
163
|
if (!Number.isInteger(pid) || pid <= 1) throw new RangeError("pid must identify a child process");
|
|
88
|
-
if (!
|
|
164
|
+
if (!groupHasLiveMember(pid)) return { confirmed: true };
|
|
89
165
|
try { process.kill(-pid, "SIGTERM"); } catch (error) {
|
|
90
166
|
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
91
167
|
}
|
|
@@ -93,6 +169,5 @@ export async function killTree(pid: number, opts?: { graceMs?: number }): Promis
|
|
|
93
169
|
try { process.kill(-pid, "SIGKILL"); } catch (error) {
|
|
94
170
|
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
95
171
|
}
|
|
96
|
-
// Bounded probe: do not hang shutdown forever if the kernel cannot prove death.
|
|
97
172
|
return { confirmed: await waitForGroupExit(pid, 1500) };
|
|
98
173
|
}
|
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 =
|
|
@@ -43,6 +56,8 @@ export interface RunOptions {
|
|
|
43
56
|
triggerOnCompletion?: boolean;
|
|
44
57
|
env?: Record<string, string>;
|
|
45
58
|
watch?: WatchSpec;
|
|
59
|
+
/** 0.3.0: directory the task runs in; defaults to the registry/session cwd, then process.cwd(). */
|
|
60
|
+
cwd?: string;
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
export interface RunReceipt {
|
|
@@ -59,9 +74,13 @@ export interface SyncResult extends RunReceipt {
|
|
|
59
74
|
promoted: boolean;
|
|
60
75
|
}
|
|
61
76
|
|
|
77
|
+
export type WaitResultItem = { taskId: string; status: TaskStatus; exitCode?: number; outputTail: string; killKind?: KillKind; failureKind?: FailureKind };
|
|
78
|
+
export type WaitNotReadyItem = { taskId: string; status: "running" | "pending"; elapsedMs: number };
|
|
79
|
+
/** All-terminal → { results }; timeout-only → { notReady }; mixed timeout → { results, notReady } (REV-7 / #43 + #37 kinds). */
|
|
62
80
|
export type WaitOutcome =
|
|
63
|
-
| { results:
|
|
64
|
-
| { notReady:
|
|
81
|
+
| { results: WaitResultItem[] }
|
|
82
|
+
| { notReady: WaitNotReadyItem[] }
|
|
83
|
+
| { results: WaitResultItem[]; notReady: WaitNotReadyItem[] };
|
|
65
84
|
|
|
66
85
|
export interface TaskSnapshot {
|
|
67
86
|
taskId: string;
|
|
@@ -72,7 +91,7 @@ export interface TaskSnapshot {
|
|
|
72
91
|
pid?: number;
|
|
73
92
|
exitCode?: number;
|
|
74
93
|
killKind?: KillKind;
|
|
75
|
-
failureKind?:
|
|
94
|
+
failureKind?: FailureKind;
|
|
76
95
|
createdAt: number;
|
|
77
96
|
startedAt?: number;
|
|
78
97
|
finishedAt?: number;
|
|
@@ -82,4 +101,15 @@ export interface TaskSnapshot {
|
|
|
82
101
|
outputConsumed: boolean;
|
|
83
102
|
watch?: WatchSpec;
|
|
84
103
|
metadata: Record<string, unknown>;
|
|
104
|
+
// 0.3.0 additions (all optional so pre-0.3.0 snapshots stay readable).
|
|
105
|
+
/** Working directory the task executes in (REV-13). */
|
|
106
|
+
cwd?: string;
|
|
107
|
+
/** Absolute deadline for `taskTimeoutMs` (reload re-arms the remaining time). */
|
|
108
|
+
timeoutAt?: number;
|
|
109
|
+
/** Absolute deadline for the watch window (reload restores the remaining budget). */
|
|
110
|
+
watchDeadline?: number;
|
|
111
|
+
/** Number of completion-notification attempts so far (honesty accounting, no retries). */
|
|
112
|
+
notifyAttempts?: number;
|
|
113
|
+
/** Process liveness seen when the snapshot was produced (false once finalized). */
|
|
114
|
+
processAlive?: boolean;
|
|
85
115
|
}
|
package/src/core/watch.ts
CHANGED
|
@@ -2,6 +2,35 @@ import type { Condition, WatchSpec } from "./task.ts";
|
|
|
2
2
|
|
|
3
3
|
const compiledRegexes = new WeakMap<Condition, RegExp>();
|
|
4
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
|
+
|
|
5
34
|
export function validateWatchSpec(spec: WatchSpec): { ok: true } | { ok: false; error: string } {
|
|
6
35
|
const invalid = (error: string) => ({ ok: false as const, error });
|
|
7
36
|
if (!spec || typeof spec !== "object") return invalid("watch must be an object");
|
|
@@ -38,13 +67,18 @@ export function validateWatchSpec(spec: WatchSpec): { ok: true } | { ok: false;
|
|
|
38
67
|
break;
|
|
39
68
|
case "json_path_exists":
|
|
40
69
|
if (typeof condition.path !== "string" || parseJsonPath(condition.path) === undefined) {
|
|
41
|
-
|
|
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)`);
|
|
42
72
|
}
|
|
43
73
|
break;
|
|
44
74
|
case "output_matches":
|
|
45
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
|
+
}
|
|
46
80
|
try {
|
|
47
|
-
compiledRegexes.set(condition, new RegExp(condition.regex));
|
|
81
|
+
compiledRegexes.set(condition, new RegExp(condition.regex, "m"));
|
|
48
82
|
} catch {
|
|
49
83
|
return invalid(`${location}: invalid regex`);
|
|
50
84
|
}
|
|
@@ -57,7 +91,9 @@ export function validateWatchSpec(spec: WatchSpec): { ok: true } | { ok: false;
|
|
|
57
91
|
return { ok: true };
|
|
58
92
|
}
|
|
59
93
|
|
|
60
|
-
export
|
|
94
|
+
export type WatchEvalContext = WatchOutput & { exitCode?: number };
|
|
95
|
+
|
|
96
|
+
export function evalConditions(conds: Condition[], ctx: WatchEvalContext): boolean {
|
|
61
97
|
const result = evaluateConditionsDetailed(conds, ctx);
|
|
62
98
|
return result.status === "matched" && result.matched;
|
|
63
99
|
}
|
|
@@ -65,7 +101,7 @@ export function evalConditions(conds: Condition[], ctx: { output: string; exitCo
|
|
|
65
101
|
export interface WatchLoopDeps {
|
|
66
102
|
taskId: string;
|
|
67
103
|
spec: WatchSpec;
|
|
68
|
-
readOutput: () =>
|
|
104
|
+
readOutput: () => WatchOutput;
|
|
69
105
|
getExitCode: () => number | undefined;
|
|
70
106
|
onSuccess: (detail: string) => void;
|
|
71
107
|
onFailure: () => void;
|
|
@@ -111,17 +147,17 @@ export class WatchLoop {
|
|
|
111
147
|
const conditions = [...this.deps.spec.failure_when, ...this.deps.spec.success_when];
|
|
112
148
|
const needsOutput = conditions.some((condition) => condition.type !== "exit_code");
|
|
113
149
|
const needsExitCode = conditions.some((condition) => condition.type === "exit_code");
|
|
114
|
-
let
|
|
150
|
+
let streams: WatchOutput = { output: "", stdout: "", stderr: "" };
|
|
115
151
|
let exitCode: number | undefined;
|
|
116
152
|
try {
|
|
117
|
-
if (needsOutput)
|
|
153
|
+
if (needsOutput) streams = this.deps.readOutput();
|
|
118
154
|
if (needsExitCode) exitCode = this.deps.getExitCode();
|
|
119
155
|
} catch {
|
|
120
156
|
this.countBlindCheck();
|
|
121
157
|
return;
|
|
122
158
|
}
|
|
123
159
|
|
|
124
|
-
const ctx = {
|
|
160
|
+
const ctx: WatchEvalContext = { ...streams, exitCode };
|
|
125
161
|
const failure = evaluateConditionsDetailed(this.deps.spec.failure_when, ctx);
|
|
126
162
|
if (failure.status === "matched" && failure.matched) {
|
|
127
163
|
this.finish(this.deps.onFailure);
|
|
@@ -178,7 +214,7 @@ type ConditionEvaluation =
|
|
|
178
214
|
| { status: "pending" }
|
|
179
215
|
| { status: "error" };
|
|
180
216
|
|
|
181
|
-
function evaluateConditionsDetailed(conditions: Condition[], ctx:
|
|
217
|
+
function evaluateConditionsDetailed(conditions: Condition[], ctx: WatchEvalContext): ConditionEvaluation {
|
|
182
218
|
let pending = false;
|
|
183
219
|
let error = false;
|
|
184
220
|
for (const condition of conditions) {
|
|
@@ -192,21 +228,21 @@ function evaluateConditionsDetailed(conditions: Condition[], ctx: { output: stri
|
|
|
192
228
|
return { status: "matched", matched: false };
|
|
193
229
|
}
|
|
194
230
|
|
|
195
|
-
function evaluateCondition(condition: Condition, ctx:
|
|
231
|
+
function evaluateCondition(condition: Condition, ctx: WatchEvalContext): ConditionEvaluation {
|
|
196
232
|
switch (condition.type) {
|
|
197
233
|
case "exit_code":
|
|
198
234
|
return ctx.exitCode === undefined
|
|
199
235
|
? { status: "pending" }
|
|
200
236
|
: { status: "matched", matched: ctx.exitCode === condition.equals };
|
|
201
237
|
case "stdout_contains":
|
|
238
|
+
return { status: "matched", matched: ctx.stdout.includes(condition.value) };
|
|
202
239
|
case "stderr_contains":
|
|
203
|
-
|
|
204
|
-
return { status: "matched", matched: ctx.output.includes(condition.value) };
|
|
240
|
+
return { status: "matched", matched: ctx.stderr.includes(condition.value) };
|
|
205
241
|
case "output_matches": {
|
|
206
242
|
let regex = compiledRegexes.get(condition);
|
|
207
243
|
if (!regex) {
|
|
208
244
|
try {
|
|
209
|
-
regex = new RegExp(condition.regex);
|
|
245
|
+
regex = new RegExp(condition.regex, "m");
|
|
210
246
|
compiledRegexes.set(condition, regex);
|
|
211
247
|
} catch {
|
|
212
248
|
return { status: "error" };
|
|
@@ -214,7 +250,7 @@ function evaluateCondition(condition: Condition, ctx: { output: string; exitCode
|
|
|
214
250
|
}
|
|
215
251
|
// Global/sticky expressions otherwise retain lastIndex between watch polls.
|
|
216
252
|
regex.lastIndex = 0;
|
|
217
|
-
return { status: "matched", matched: regex.test(ctx.output) };
|
|
253
|
+
return { status: "matched", matched: regex.test(matchSubject(ctx.output)) };
|
|
218
254
|
}
|
|
219
255
|
case "json_path_exists": {
|
|
220
256
|
let root: unknown;
|
|
@@ -1,78 +1,184 @@
|
|
|
1
1
|
import type { PiCtx } from "../core/notify.ts";
|
|
2
2
|
import type { TaskRegistry } from "../core/registry.ts";
|
|
3
|
+
import type { TaskSnapshot } from "../core/task.ts";
|
|
3
4
|
|
|
5
|
+
/** `custom_message` draft pi accepts from `agent_before_settle` (PLAN §2.6). */
|
|
6
|
+
interface CustomMessageDraft {
|
|
7
|
+
type: "custom_message";
|
|
8
|
+
customType: string;
|
|
9
|
+
content: string;
|
|
10
|
+
display: false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The slice of pi's `AgentBeforeSettleEvent` this guard reads; `entries` may be absent. */
|
|
14
|
+
interface BeforeSettleEvent {
|
|
15
|
+
entries?: unknown[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The slice of pi's `AgentSettledEvent` this guard reads. */
|
|
19
|
+
interface SettledEvent {
|
|
20
|
+
aborted?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `agent_before_settle` result: appended drafts plus an optional extra provider request. */
|
|
24
|
+
interface BeforeSettleResult {
|
|
25
|
+
entries: unknown[];
|
|
26
|
+
continue?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type HostHooks = {
|
|
30
|
+
on?: (event: string, handler: (...args: unknown[]) => unknown) => unknown;
|
|
31
|
+
off?: (event: string, handler: (...args: unknown[]) => unknown) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type MessageHost = {
|
|
35
|
+
sendMessage?: (message: unknown, options: unknown) => unknown;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const idsOf = (active: TaskSnapshot[]): string => active.map((task) => task.taskId).join(", ");
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Keeps a legion child from reporting a verdict while async tasks are still in flight.
|
|
42
|
+
*
|
|
43
|
+
* 0.3.0 (D-12) removed the old `setInterval` heartbeat: it only ever wrote
|
|
44
|
+
* `async-task-heartbeat` entries nobody read, and because timer callbacks run outside pi's
|
|
45
|
+
* event-loop `try/catch` a stale ctx there escaped as an `uncaughtException` (crash
|
|
46
|
+
* 2026-10-21, `Timeout._onTimeout` at settle-guard heartbeat). Reminders are now produced
|
|
47
|
+
* inside the `agent_before_settle` boundary, where pi tolerates (and contains) extension
|
|
48
|
+
* errors, and `agent_settled` (`aborted` runs) sends at most one follow-up per generation.
|
|
49
|
+
*/
|
|
4
50
|
export class SettleGuard {
|
|
5
51
|
private readonly maxReminders: number;
|
|
6
|
-
private readonly heartbeatMs: number;
|
|
7
52
|
private reminders = 0;
|
|
8
|
-
private
|
|
53
|
+
private settledSent = false;
|
|
9
54
|
private removeHooks: Array<() => void> = [];
|
|
55
|
+
private disposed = false;
|
|
10
56
|
private readonly pi: PiCtx;
|
|
11
57
|
private readonly registry: TaskRegistry;
|
|
12
58
|
|
|
13
|
-
constructor(pi: PiCtx, registry: TaskRegistry, opts?: { maxReminders?: number
|
|
59
|
+
constructor(pi: PiCtx, registry: TaskRegistry, opts?: { maxReminders?: number }) {
|
|
14
60
|
this.pi = pi;
|
|
15
61
|
this.registry = registry;
|
|
16
62
|
this.maxReminders = opts?.maxReminders ?? 3;
|
|
17
|
-
this.heartbeatMs = opts?.heartbeatMs ?? finiteEnvNumber("PI_ASYNC_HEARTBEAT_MS", 60_000);
|
|
18
63
|
}
|
|
19
64
|
|
|
20
65
|
register(): void {
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
66
|
+
// D-12: one env switch disarms the guard entirely — it registers nothing at all.
|
|
67
|
+
if (process.env.PI_ASYNC_SETTLE_GUARD === "0") return;
|
|
68
|
+
if (this.disposed) return;
|
|
69
|
+
// SAFETY: PiCtx hooks vary by runtime; `on` returns the unsubscribe function (the real
|
|
70
|
+
// host has no `pi.off`), so both shapes are feature-detected before use.
|
|
71
|
+
const hooks = this.pi as unknown as HostHooks;
|
|
72
|
+
const unsubscribers: Array<() => void> = [];
|
|
73
|
+
const listen = (event: string, handler: (...args: unknown[]) => unknown): void => {
|
|
74
|
+
const result = hooks.on?.(event, handler);
|
|
75
|
+
// SAFETY: pi.on() returns an unsubscribe function; older/mocked hosts may return void, in
|
|
76
|
+
// which case there is nothing to release and the guard relies on `off`/`disposed` alone.
|
|
77
|
+
if (typeof result === "function") unsubscribers.push(result as () => void);
|
|
78
|
+
else unsubscribers.push(() => hooks.off?.(event, handler));
|
|
26
79
|
};
|
|
27
|
-
|
|
80
|
+
|
|
81
|
+
const beforeSettle = (event?: unknown): BeforeSettleResult | undefined => {
|
|
82
|
+
if (this.disposed) return undefined;
|
|
28
83
|
const active = this.registry.list({ includeHistory: false });
|
|
29
|
-
if (active.length === 0) { this.reminders = 0; this.reportActivity(); return; }
|
|
30
84
|
this.reportActivity();
|
|
85
|
+
// A stale ctx made reportActivity tear us down; the generation is gone, so stay silent.
|
|
86
|
+
if (this.disposed) return undefined;
|
|
87
|
+
if (active.length === 0) {
|
|
88
|
+
this.reminders = 0;
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
// Any drafts pi already queued for this boundary are preserved in front of ours.
|
|
92
|
+
const entries = [...((event as BeforeSettleEvent | undefined)?.entries ?? [])];
|
|
93
|
+
const ids = idsOf(active);
|
|
31
94
|
if (this.reminders >= this.maxReminders) {
|
|
32
|
-
|
|
33
|
-
|
|
95
|
+
// Budget spent: record the unresolved list for the transcript, then let the run settle
|
|
96
|
+
// (`continue` omitted so pi does not force another provider request).
|
|
97
|
+
const unresolved: CustomMessageDraft = {
|
|
98
|
+
type: "custom_message",
|
|
99
|
+
customType: "async-task-unresolved",
|
|
100
|
+
content: `${active.length} async task(s) left unresolved: ${ids}. Collect them with async_wait or async_status before finishing.`,
|
|
101
|
+
display: false,
|
|
102
|
+
};
|
|
103
|
+
return { entries: [...entries, unresolved] };
|
|
34
104
|
}
|
|
35
105
|
this.reminders++;
|
|
36
|
-
|
|
37
|
-
|
|
106
|
+
const reminder: CustomMessageDraft = {
|
|
107
|
+
type: "custom_message",
|
|
108
|
+
customType: "async-task-settle-reminder",
|
|
109
|
+
content: `${active.length} async task(s) not collected yet: ${ids}. Call async_wait or async_status before reporting a verdict.`,
|
|
110
|
+
display: false,
|
|
111
|
+
};
|
|
112
|
+
return { entries: [...entries, reminder], continue: true };
|
|
38
113
|
};
|
|
39
|
-
|
|
114
|
+
|
|
115
|
+
const agentStart = (): void => {
|
|
116
|
+
this.reminders = 0;
|
|
117
|
+
this.settledSent = false;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const agentSettled = (event?: unknown): void => {
|
|
121
|
+
if (this.disposed || this.settledSent) return;
|
|
122
|
+
if ((event as SettledEvent | undefined)?.aborted !== true) return;
|
|
123
|
+
if (!this.registry.hasActiveWork()) return;
|
|
124
|
+
// D-13: at most one follow-up per generation; latch before the host call so a throw
|
|
125
|
+
// cannot turn into a retry storm.
|
|
126
|
+
this.settledSent = true;
|
|
127
|
+
let ids = "";
|
|
128
|
+
try {
|
|
129
|
+
ids = idsOf(this.registry.list({ includeHistory: false }));
|
|
130
|
+
} catch { /* best effort: the snapshot is decoration for the message body */ }
|
|
131
|
+
const content = `This run was aborted with async task(s) still running${ids ? `: ${ids}` : ""}. Use async_wait or async_status to collect them.`;
|
|
132
|
+
try {
|
|
133
|
+
// SAFETY: sendMessage is part of ExtensionAPI; feature-detected because mocks and older
|
|
134
|
+
// hosts may omit it, and a stale ctx getter throws on access.
|
|
135
|
+
const host = this.pi as unknown as MessageHost;
|
|
136
|
+
const send = host.sendMessage;
|
|
137
|
+
if (typeof send !== "function") return;
|
|
138
|
+
const result = send.call(host, { customType: "async-task-unresolved", content }, { deliverAs: "followUp" });
|
|
139
|
+
// SAFETY: hosts return void; a mock/future host may return a promise, which must never
|
|
140
|
+
// surface as an unhandled rejection.
|
|
141
|
+
if (result && typeof (result as PromiseLike<unknown>).then === "function") {
|
|
142
|
+
void Promise.resolve(result as PromiseLike<unknown>).catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
} catch { /* best effort: sending is advisory, never fatal to settle */ }
|
|
145
|
+
};
|
|
146
|
+
|
|
40
147
|
// SAFETY: PI_LEGION_NODE_ID is the documented legion-child marker; presence opts into settle reminders.
|
|
41
148
|
const legionChild = process.env.PI_LEGION_NODE_ID !== undefined;
|
|
42
|
-
if (legionChild)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
this.startHeartbeat(hooks);
|
|
149
|
+
if (legionChild) listen("agent_before_settle", beforeSettle);
|
|
150
|
+
listen("agent_start", agentStart);
|
|
151
|
+
// Thread 2 of the guard: registered for every session (not only legion children).
|
|
152
|
+
listen("agent_settled", agentSettled);
|
|
153
|
+
listen("session_shutdown", () => { this.dispose(); });
|
|
154
|
+
this.removeHooks.push(() => {
|
|
155
|
+
for (const unsubscribe of unsubscribers.splice(0)) {
|
|
156
|
+
try { unsubscribe(); } catch { /* best effort: the host ctx may already be stale */ }
|
|
157
|
+
}
|
|
158
|
+
});
|
|
53
159
|
this.reportActivity();
|
|
54
160
|
}
|
|
55
161
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
ui.setActivity("pi-async", total ? { message: `${total} async tasks` } : undefined);
|
|
162
|
+
/** Idempotent teardown; a second call is a no-op. */
|
|
163
|
+
dispose(): void {
|
|
164
|
+
this.disposed = true;
|
|
165
|
+
for (const remove of this.removeHooks.splice(0)) {
|
|
166
|
+
try { remove(); } catch { /* best effort: the host ctx may already be stale */ }
|
|
167
|
+
}
|
|
63
168
|
}
|
|
64
169
|
|
|
65
|
-
private
|
|
66
|
-
if (this.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
170
|
+
private reportActivity(): void {
|
|
171
|
+
if (this.disposed) return;
|
|
172
|
+
try {
|
|
173
|
+
// SAFETY: `pi.ui` itself throws on a stale ctx, so the read is guarded with the call.
|
|
174
|
+
const ui = (this.pi as unknown as { ui?: { setActivity?: (id: string, value: { message: string } | undefined) => void } }).ui;
|
|
175
|
+
if (typeof ui?.setActivity !== "function") return;
|
|
176
|
+
const count = this.registry.activeCount();
|
|
177
|
+
const total = count.running + count.pending;
|
|
178
|
+
ui.setActivity("pi-async", total ? { message: `${total} async task${total === 1 ? "" : "s"}` } : undefined);
|
|
179
|
+
} catch {
|
|
180
|
+
// The host abandoned this generation; detach instead of retrying into a dead API.
|
|
181
|
+
this.dispose();
|
|
182
|
+
}
|
|
72
183
|
}
|
|
73
184
|
}
|
|
74
|
-
|
|
75
|
-
function finiteEnvNumber(name: string, fallback: number): number {
|
|
76
|
-
const parsed = Number(process.env[name] ?? fallback);
|
|
77
|
-
return Number.isFinite(parsed) ? parsed : fallback;
|
|
78
|
-
}
|