pi-daddy 0.15.0 → 0.16.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 +91 -0
- package/README.md +27 -12
- package/dist/cli.js +0 -0
- package/dist/executor.d.ts +38 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +93 -0
- package/dist/executor.js.map +1 -0
- package/dist/herdr-cli.d.ts +78 -0
- package/dist/herdr-cli.d.ts.map +1 -0
- package/dist/herdr-cli.js +113 -0
- package/dist/herdr-cli.js.map +1 -0
- package/dist/herdr-name.d.ts +37 -0
- package/dist/herdr-name.d.ts.map +1 -0
- package/dist/herdr-name.js +59 -0
- package/dist/herdr-name.js.map +1 -0
- package/dist/herdr-poll.d.ts +104 -0
- package/dist/herdr-poll.d.ts.map +1 -0
- package/dist/herdr-poll.js +150 -0
- package/dist/herdr-poll.js.map +1 -0
- package/dist/herdr-stage.d.ts +40 -0
- package/dist/herdr-stage.d.ts.map +1 -0
- package/dist/herdr-stage.js +54 -0
- package/dist/herdr-stage.js.map +1 -0
- package/dist/ledger-report.d.ts +18 -0
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +10 -0
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger.d.ts +17 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +1 -0
- package/dist/ledger.js.map +1 -1
- package/dist/pane-reaper.d.ts +66 -4
- package/dist/pane-reaper.d.ts.map +1 -1
- package/dist/pane-reaper.js +131 -9
- package/dist/pane-reaper.js.map +1 -1
- package/dist/progress.d.ts +96 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +167 -0
- package/dist/progress.js.map +1 -0
- package/dist/run-child.d.ts +27 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +84 -7
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +41 -28
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +150 -167
- package/dist/run-herdr.js.map +1 -1
- package/extensions/delegation.ts +94 -2
- package/extensions/grants-command.ts +26 -1
- package/extensions/grants.ts +85 -163
- package/extensions/run-delegation.ts +70 -8
- package/extensions/session-report.ts +231 -0
- package/extensions/session.ts +63 -11
- package/extensions/tripwire.ts +44 -0
- package/package.json +17 -1
- package/src/executor.ts +122 -0
- package/src/herdr-cli.ts +125 -0
- package/src/herdr-name.ts +61 -0
- package/src/herdr-poll.ts +185 -0
- package/src/herdr-stage.ts +55 -0
- package/src/ledger-report.ts +21 -0
- package/src/ledger.ts +18 -0
- package/src/pane-reaper.ts +147 -9
- package/src/progress.ts +206 -0
- package/src/run-child.ts +96 -7
- package/src/run-herdr.ts +170 -174
package/src/pane-reaper.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Close herdr panes this process opened but never got to close.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* **As of ADR-0032 a pane belongs to the AGENT RUN, not to the tool call.** `runHerdrPane` closes a tab only for
|
|
5
|
+
* a child that did *not* settle — closing the tab is the only kill herdr offers, so a child still working must
|
|
6
|
+
* lose its pane. A child that answered keeps its pane so a human can read it, and this module reaps those: at
|
|
7
|
+
* `agent_settled` (async) and at process `exit` (sync backstop).
|
|
8
|
+
*
|
|
9
|
+
* The gap that remains is the one this module was created for. A pi session **killed outright** runs no `exit`
|
|
10
|
+
* handler, so it leaves one pane per tracked child, and `docs/probes/g16-herdr` records that an orphaned pane is
|
|
11
|
+
* not trivially closable afterwards. R-62 was re-rated L×L → M×L when ADR-0031 made panes the default path.
|
|
7
12
|
*
|
|
8
13
|
* **Registered on `exit` only, deliberately — not on SIGINT or SIGTERM.** That is the part worth reading,
|
|
9
14
|
* because the obvious fix is the dangerous one. Adding a signal listener *suppresses Node's default
|
|
@@ -24,14 +29,52 @@
|
|
|
24
29
|
|
|
25
30
|
import { execFileSync } from "node:child_process";
|
|
26
31
|
import { rmSync } from "node:fs";
|
|
32
|
+
import { rm } from "node:fs/promises";
|
|
33
|
+
import { MAX_CHILDREN_PER_CALL } from "./fanout.ts";
|
|
34
|
+
import { defaultExec, parseReply, type HerdrExec } from "./herdr-cli.ts";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How many panes may be open at once — ADR-0032.
|
|
38
|
+
*
|
|
39
|
+
* **Derived from `MAX_CHILDREN_PER_CALL` rather than declared, so the two cannot drift.** The bound is not
|
|
40
|
+
* decoration: `delegate_all` is capped per call and the fan-out budget bounds a subtree, but a plain blocking
|
|
41
|
+
* `delegate` spends **nothing** from that budget by design — so thirty sequential `delegate` calls in one agent
|
|
42
|
+
* run would otherwise hold thirty panes open until it settled.
|
|
43
|
+
*/
|
|
44
|
+
export const MAX_OPEN_PANES = MAX_CHILDREN_PER_CALL;
|
|
27
45
|
|
|
28
46
|
export interface OpenPane {
|
|
29
47
|
/** herdr tab id — what `tab close` takes. */
|
|
30
48
|
tab: string;
|
|
31
|
-
/**
|
|
49
|
+
/** herdr agent name, for diagnostics. There is no `agent stop`, so nothing acts on it — see `closePane`. */
|
|
32
50
|
name: string;
|
|
33
51
|
/** Staged system-prompt directory, removed with the pane it belonged to. */
|
|
34
52
|
promptDir?: string;
|
|
53
|
+
/**
|
|
54
|
+
* True once the child has answered and stopped working.
|
|
55
|
+
*
|
|
56
|
+
* **The trim may only close a pane with this set**, and that is a correctness rule rather than politeness. pi
|
|
57
|
+
* executes tool calls in **parallel** by default, and a plain `delegate` spends nothing from the fan-out
|
|
58
|
+
* budget — so one assistant message can hold `delegate_all(8)` *and* a `delegate`, and the ninth pane's trim
|
|
59
|
+
* used to close the oldest **live sibling**. Measured: two `delegate_all(8)` in one message killed **8 of 16
|
|
60
|
+
* children mid-work**, each reported as "could not be started" with its partial output discarded, while the
|
|
61
|
+
* ledger recorded all sixteen as provisioned. ADR-0032 argued the cap from *sequential* delegates only.
|
|
62
|
+
*/
|
|
63
|
+
settled?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* The operator asked to keep this tab (`PI_GRANTS_HERDR_KEEP_PANE=1`), so no sweep may close it.
|
|
66
|
+
*
|
|
67
|
+
* It is registered anyway, and only for its `promptDir`: that temp dir was otherwise unreachable by either
|
|
68
|
+
* sweep and leaked one directory per kept pane, forever. So `exit` removes the staged prompt and leaves the
|
|
69
|
+
* tab, which is exactly what the flag promises.
|
|
70
|
+
*/
|
|
71
|
+
keepTab?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A run has finished with its pane: the trim may now reclaim it. */
|
|
75
|
+
export function markPaneSettled(tab: string): void {
|
|
76
|
+
const pane = open.get(tab);
|
|
77
|
+
if (pane) pane.settled = true;
|
|
35
78
|
}
|
|
36
79
|
|
|
37
80
|
/**
|
|
@@ -108,25 +151,120 @@ export function reapOpenPanes(syncExec: (args: string[]) => void = defaultSyncEx
|
|
|
108
151
|
// Panes left behind stay in the map; there is no later sweep, and saying so is the honest position —
|
|
109
152
|
// `openPaneCount()` is non-zero afterwards precisely so a caller could report it if it ever wanted to.
|
|
110
153
|
if (now() >= deadline) break;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
154
|
+
// No `agent stop`: it is not a herdr command (see `closePane`). Closing the tab is the kill.
|
|
155
|
+
//
|
|
156
|
+
// A `keepTab` pane is registered only for its staged prompt: remove that and leave the tab alone, which is
|
|
157
|
+
// the whole of what `PI_GRANTS_HERDR_KEEP_PANE=1` promises.
|
|
158
|
+
let closedThisPane = pane.keepTab === true;
|
|
159
|
+
if (pane.keepTab) {
|
|
160
|
+
if (pane.promptDir) {
|
|
161
|
+
try {
|
|
162
|
+
rmSync(pane.promptDir, { recursive: true, force: true });
|
|
163
|
+
} catch {
|
|
164
|
+
/* /tmp litter, not correctness */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
open.delete(pane.tab);
|
|
168
|
+
continue;
|
|
115
169
|
}
|
|
116
170
|
try {
|
|
117
171
|
syncExec(["tab", "close", pane.tab]);
|
|
118
172
|
closed.push(pane.tab);
|
|
173
|
+
closedThisPane = true;
|
|
119
174
|
} catch {
|
|
120
175
|
/* an orphan we could not close: `herdr tab close` is the manual remedy, as documented above */
|
|
121
176
|
}
|
|
122
|
-
|
|
177
|
+
// **The staged prompt is deleted only when the tab is provably gone.** It used to be removed
|
|
178
|
+
// unconditionally, so a pane herdr refused to close lost its `--append-system-prompt` file — and pi treats a
|
|
179
|
+
// missing path there as **literal text, with no warning** (`resource-loader.js`: `existsSync` fails, the
|
|
180
|
+
// input is returned). Re-running that pane's echoed argv would hand the child a pathname as its
|
|
181
|
+
// instructions. Litter is cheaper than silently swapping a definition's body for its filename.
|
|
182
|
+
if (closedThisPane && pane.promptDir) {
|
|
123
183
|
try {
|
|
124
184
|
rmSync(pane.promptDir, { recursive: true, force: true });
|
|
125
185
|
} catch {
|
|
126
186
|
/* /tmp litter, not correctness */
|
|
127
187
|
}
|
|
128
188
|
}
|
|
189
|
+
// Untracked either way, and only here: at `exit` there is no later sweep, so keeping an unclosable pane
|
|
190
|
+
// registered buys nothing. `closePane`'s async path deliberately differs — another sweep may still run.
|
|
129
191
|
open.delete(pane.tab);
|
|
130
192
|
}
|
|
131
193
|
return closed;
|
|
132
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Close one tracked pane. Shared by the async sweep and the trim so they cannot disagree about what "closed"
|
|
198
|
+
* means — which is the whole of R-62's lesson, one layer up.
|
|
199
|
+
*
|
|
200
|
+
* Returns whether the tab is **provably** gone. A close herdr refused leaves the pane tracked, so the `exit`
|
|
201
|
+
* handler retries it; untracking on a failed close is what disabled the one control built for that failure.
|
|
202
|
+
*/
|
|
203
|
+
async function closePane(exec: HerdrExec, pane: OpenPane): Promise<boolean> {
|
|
204
|
+
// **No `agent stop`.** It is not a herdr command — measured against 0.7.5, where it prints the usage banner
|
|
205
|
+
// and exits 0, which `defaultExec` reports as success. Two call sites here and one in `run-herdr.ts` issued it
|
|
206
|
+
// for nothing, and `docs/probes/g16-herdr` asserted it worked from a block that was never run. Closing the tab
|
|
207
|
+
// is the only kill herdr offers, and it does kill the child.
|
|
208
|
+
const reply = await exec(["tab", "close", pane.tab]).catch(() => undefined);
|
|
209
|
+
const closed = reply !== undefined && !parseReply(reply).error;
|
|
210
|
+
if (!closed) return false;
|
|
211
|
+
if (pane.promptDir) await rm(pane.promptDir, { recursive: true, force: true }).catch(() => undefined);
|
|
212
|
+
open.delete(pane.tab);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Close every outstanding pane — the `agent_settled` path (ADR-0032).
|
|
218
|
+
*
|
|
219
|
+
* **A separate function from `reapOpenPanes` rather than a shared implementation**, and the reason is not
|
|
220
|
+
* style. The sync one is `execFileSync` with a six-second total budget *by necessity*: an `exit` handler cannot
|
|
221
|
+
* await. Running that at `agent_settled` would freeze pi for up to six seconds **every time the operator gets
|
|
222
|
+
* their prompt back** — turning a feature that exists to make work visible into a stall.
|
|
223
|
+
*
|
|
224
|
+
* Both drain the same `Map`, keyed by tab id, so a double close is impossible and a pane herdr refused stays
|
|
225
|
+
* registered for whichever sweep runs next.
|
|
226
|
+
*
|
|
227
|
+
* Sequential rather than concurrent: a fan-out's panes are at most `MAX_OPEN_PANES`, and eight `tab close`
|
|
228
|
+
* calls in parallel against one herdr server buys nothing worth the burst.
|
|
229
|
+
*/
|
|
230
|
+
export async function reapOpenPanesAsync(exec: HerdrExec = defaultExec): Promise<string[]> {
|
|
231
|
+
const closed: string[] = [];
|
|
232
|
+
for (const pane of [...open.values()]) {
|
|
233
|
+
// `keepTab` panes are registered only so their staged prompt can be reaped at `exit`; the tab itself is the
|
|
234
|
+
// operator's to close.
|
|
235
|
+
if (pane.keepTab) continue;
|
|
236
|
+
if (await closePane(exec, pane)) closed.push(pane.tab);
|
|
237
|
+
}
|
|
238
|
+
return closed;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Keep at most `MAX_OPEN_PANES` open, closing the oldest first.
|
|
243
|
+
*
|
|
244
|
+
* The `Map`'s insertion order **is** the age order, so no timestamp is needed — and that is why `trackPane`
|
|
245
|
+
* must never re-`set` an existing tab, which would move it to the back of the queue and make an old pane look
|
|
246
|
+
* new.
|
|
247
|
+
*
|
|
248
|
+
* Whatever it closes is returned so the caller can **say so** rather than silently dropping a pane the operator
|
|
249
|
+
* was reading (R-48's rule, applied to a display).
|
|
250
|
+
*/
|
|
251
|
+
export async function trimOpenPanes(exec: HerdrExec = defaultExec): Promise<string[]> {
|
|
252
|
+
if (open.size <= MAX_OPEN_PANES) return [];
|
|
253
|
+
const closed: string[] = [];
|
|
254
|
+
|
|
255
|
+
// **Only SETTLED panes are candidates, oldest first.** A live sibling's pane is its child's only terminal, and
|
|
256
|
+
// closing a tab kills the child — see `OpenPane.settled`. If every open pane is live the cap is exceeded and
|
|
257
|
+
// nothing is closed, which is the right failure: a pane too many costs an operator a keystroke, and a killed
|
|
258
|
+
// child costs them the work.
|
|
259
|
+
//
|
|
260
|
+
// **Walks past what it cannot close**, rather than attempting `excess` panes from the front and calling it
|
|
261
|
+
// done. A pane herdr refuses to close used to sit at the head forever, so the cap silently stopped holding
|
|
262
|
+
// (measured: 30 panes for 30 delegates when every close was refused) and the same corpse was re-attacked on
|
|
263
|
+
// every spawn — O(n²) round-trips.
|
|
264
|
+
for (const pane of [...open.values()]) {
|
|
265
|
+
if (open.size - closed.length <= MAX_OPEN_PANES) break;
|
|
266
|
+
if (!pane.settled || pane.keepTab) continue;
|
|
267
|
+
if (await closePane(exec, pane)) closed.push(pane.tab);
|
|
268
|
+
}
|
|
269
|
+
return closed;
|
|
270
|
+
}
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status block a running delegation renders — ADR-0032, in the shape the operator chose on 2026-08-17.
|
|
3
|
+
*
|
|
4
|
+
* Three properties are load-bearing rather than cosmetic, and each has a test:
|
|
5
|
+
*
|
|
6
|
+
* - **Bounded height AND bounded width.** Five lines per child — header, up to `TAIL_LINES` of tail, and a
|
|
7
|
+
* blank separator — so eight children is at most 42 lines, plus `MAX_LINE_CHARS` per line. The width half was
|
|
8
|
+
* missing and the omission mattered: a line cap is what actually bounds the block, because eight children
|
|
9
|
+
* each printing one megabyte-long line rendered 25 lines and 8 MiB. "Four lines per child" was also simply
|
|
10
|
+
* wrong arithmetic — the separator was never counted.
|
|
11
|
+
* - **No braiding.** Each child's text stays under its own header, which is what a chronological stream of two
|
|
12
|
+
* concurrent children cannot offer.
|
|
13
|
+
* - **The pane id is on screen while the child is alive.** That is the entire difference between a pane you can
|
|
14
|
+
* switch to and one you learn about after it closed.
|
|
15
|
+
*
|
|
16
|
+
* What it gives up is stated rather than implied (R-48): output older than the last `TAIL_LINES` lines is not
|
|
17
|
+
* here. It is in the pane while the pane lives, and in the returned result afterwards. **The block is a display
|
|
18
|
+
* and never the result** — conflating the two is R-03 with a new cause.
|
|
19
|
+
*
|
|
20
|
+
* Pure: no pi, no herdr, and no clock. `now` is a parameter so elapsed time is testable.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Lines of context kept per child. Three, because four children of four lines each is already a screenful. */
|
|
24
|
+
export const TAIL_LINES = 3;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Longest a single displayed line may be.
|
|
28
|
+
*
|
|
29
|
+
* **The line COUNT cap was never a height cap, and this is what makes the module header's claim true.** A child
|
|
30
|
+
* printing a minified bundle, a base64 blob or single-line JSON produces one line of megabytes, so
|
|
31
|
+
* `TAIL_LINES` bounded nothing that matters: eight such children rendered **25 lines and 8 MiB — about 84,000
|
|
32
|
+
* wrapped rows** — and were repainted every `PAINT_INTERVAL_MS`. Measured. The test asserting "fixed height"
|
|
33
|
+
* passed throughout, because 25 ≤ 34.
|
|
34
|
+
*
|
|
35
|
+
* 200 is a little over two rows on a wide terminal: enough to read a long path or a stack frame, short enough
|
|
36
|
+
* that eight children cannot flood the screen.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_LINE_CHARS = 200;
|
|
39
|
+
|
|
40
|
+
/** Trim one display line to `MAX_LINE_CHARS`, saying so rather than cutting silently (R-48). */
|
|
41
|
+
function clampLine(line: string): string {
|
|
42
|
+
if (line.length <= MAX_LINE_CHARS) return line;
|
|
43
|
+
return `${line.slice(0, MAX_LINE_CHARS)}… (+${line.length - MAX_LINE_CHARS} chars)`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ChildState = "starting" | "running" | "completed" | "failed";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The last few lines a child printed, plus whether the final one is still being written.
|
|
50
|
+
*
|
|
51
|
+
* **`open` is why this is not a bare `string[]`.** A pipe delivers bytes, not lines, so `Read` and
|
|
52
|
+
* `ing file.ts` arrive as two chunks and must render as one line — and knowing whether to join or to start a new
|
|
53
|
+
* line is state that `(lines, chunk)` alone cannot carry. The first draft encoded it as a trailing-space
|
|
54
|
+
* sentinel inside the array, which worked and was unreadable; a named boolean is the same information without
|
|
55
|
+
* the puzzle.
|
|
56
|
+
*/
|
|
57
|
+
export interface Tail {
|
|
58
|
+
lines: string[];
|
|
59
|
+
/** True when the last element is a partial line awaiting more bytes. */
|
|
60
|
+
open: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const emptyTail: Tail = { lines: [], open: false };
|
|
64
|
+
|
|
65
|
+
export interface ChildProgress {
|
|
66
|
+
/** Definition name, or `delegate` for a `tools:` spawn. */
|
|
67
|
+
label: string;
|
|
68
|
+
/** herdr agent name, when this child runs in a pane. */
|
|
69
|
+
agentName?: string;
|
|
70
|
+
paneId?: string;
|
|
71
|
+
state: ChildState;
|
|
72
|
+
startedAt: number;
|
|
73
|
+
/** Set once terminal, so elapsed time freezes instead of counting forever. */
|
|
74
|
+
settledAt?: number;
|
|
75
|
+
tail: Tail;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Collapse a terminal's carriage returns the way a terminal would: what follows the last `\r` wins.
|
|
80
|
+
*
|
|
81
|
+
* A spinner writes `working \r working \r done`. Splitting on `\r` as if it were a newline would fill the whole
|
|
82
|
+
* three-line tail with one frame per tick and push the child's real output out of the block.
|
|
83
|
+
*/
|
|
84
|
+
function lastAfterCarriageReturn(line: string): string {
|
|
85
|
+
const at = line.lastIndexOf("\r");
|
|
86
|
+
return at === -1 ? line : line.slice(at + 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fold a raw chunk into a tail.
|
|
91
|
+
*
|
|
92
|
+
* Blank lines are dropped so a child printing newlines cannot blank the block — at three lines of context, four
|
|
93
|
+
* newlines would erase everything the operator was reading.
|
|
94
|
+
*/
|
|
95
|
+
export function appendTail(tail: Tail, chunk: string): Tail {
|
|
96
|
+
if (chunk.length === 0) return tail;
|
|
97
|
+
|
|
98
|
+
const parts = chunk.split("\n");
|
|
99
|
+
const lines = [...tail.lines];
|
|
100
|
+
|
|
101
|
+
// The first part continues the previous line when that line was left open.
|
|
102
|
+
//
|
|
103
|
+
// **Clamped on every write, not only at render.** A child that never emits a newline keeps extending one
|
|
104
|
+
// line, and an unclamped join grew it without bound — measured at 12,692 characters from a pane that never
|
|
105
|
+
// held more than 16. Clamping here also bounds what is retained, not merely what is shown.
|
|
106
|
+
if (tail.open && lines.length > 0) {
|
|
107
|
+
lines[lines.length - 1] = clampLine(lastAfterCarriageReturn(lines[lines.length - 1] + parts[0]));
|
|
108
|
+
} else if (parts[0].trim().length > 0) {
|
|
109
|
+
lines.push(clampLine(lastAfterCarriageReturn(parts[0])));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const part of parts.slice(1)) {
|
|
113
|
+
if (part.trim().length > 0) lines.push(clampLine(lastAfterCarriageReturn(part)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { lines: lines.slice(-TAIL_LINES), open: !chunk.endsWith("\n") };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Replace a tail wholesale from a pane SNAPSHOT — the herdr path's counterpart to `appendTail`.
|
|
121
|
+
*
|
|
122
|
+
* `agent read` returns a snapshot of a bounded terminal, so the right operation is *replace*, not *append*.
|
|
123
|
+
* Appending snapshots is what fabricated text the child never printed: a pane whose bottom line was
|
|
124
|
+
* `working on step 29` followed by a re-report beginning `line30` rendered `working on step 29line30`.
|
|
125
|
+
* Measured. `open` is always false here, because a snapshot is never mid-line as far as the display is
|
|
126
|
+
* concerned — there is nothing further to join onto.
|
|
127
|
+
*/
|
|
128
|
+
export function replaceTail(lines: string[]): Tail {
|
|
129
|
+
return { lines: lines.slice(-TAIL_LINES).map(clampLine), open: false };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A child label is a DIRECTORY name, so it is third-party text on a line this package composes.
|
|
134
|
+
*
|
|
135
|
+
* R-77 and R-78 were both "a name from somewhere else reached a generated artefact"; here a newline forges an
|
|
136
|
+
* entire extra child row in the operator's status block, complete with a plausible agent and pane. Same class,
|
|
137
|
+
* same treatment as `spawn-summary.ts`'s `safeName`: rendered inert rather than trusted.
|
|
138
|
+
*/
|
|
139
|
+
function safeLabel(label: string): string {
|
|
140
|
+
return /[\n\r\t]/.test(label) ? JSON.stringify(label) : label;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `m:ss`. A delegation is minutes-scale, and an hour-long one has problems this line will not explain. */
|
|
144
|
+
function elapsed(child: ChildProgress, now: number): string {
|
|
145
|
+
const total = Math.floor(Math.max(0, (child.settledAt ?? now) - child.startedAt) / 1000);
|
|
146
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function renderProgress(children: ChildProgress[], executorKind: "herdr" | "process", now: number): string {
|
|
150
|
+
const where = executorKind === "herdr" ? "herdr panes" : "captured subprocesses";
|
|
151
|
+
const lines = [`${children.length} ${children.length === 1 ? "child" : "children"} · ${where}`, ""];
|
|
152
|
+
|
|
153
|
+
for (const child of children) {
|
|
154
|
+
const header = [safeLabel(child.label).padEnd(10)];
|
|
155
|
+
if (child.agentName) header.push(`agent ${child.agentName}`);
|
|
156
|
+
if (child.paneId) header.push(`pane ${child.paneId}`);
|
|
157
|
+
header.push(child.state, elapsed(child, now));
|
|
158
|
+
lines.push(header.join(" "));
|
|
159
|
+
// Sliced again here as well as in `appendTail`, so a caller that assembled a `Tail` by hand cannot make the
|
|
160
|
+
// block unbounded. The height guarantee belongs to the renderer, not to its inputs.
|
|
161
|
+
// Clamped again here as well as on write, so a caller that assembled a `Tail` by hand cannot make the block
|
|
162
|
+
// unbounded either. The height guarantee belongs to the renderer, not to its inputs.
|
|
163
|
+
for (const line of child.tail.lines.slice(-TAIL_LINES)) lines.push(` ${clampLine(line)}`);
|
|
164
|
+
lines.push("");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return lines.join("\n").trimEnd();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** How often the block is repainted. A child printing fast must not re-render it hundreds of times a second. */
|
|
171
|
+
export const PAINT_INTERVAL_MS = 250;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Call `fn` at most once per `intervalMs`, and always once more after the last call.
|
|
175
|
+
*
|
|
176
|
+
* The trailing call is the point: without it the final frame — the one showing every child settled — is the one
|
|
177
|
+
* most likely to be dropped, so the block would freeze mid-run and never show completion. `flush` exists so a
|
|
178
|
+
* caller that knows it is finished can paint immediately rather than waiting out the interval.
|
|
179
|
+
*/
|
|
180
|
+
export function throttle(fn: () => void, intervalMs: number): { call: () => void; flush: () => void } {
|
|
181
|
+
let last = 0;
|
|
182
|
+
let timer: NodeJS.Timeout | undefined;
|
|
183
|
+
|
|
184
|
+
const run = (at: number) => {
|
|
185
|
+
last = at;
|
|
186
|
+
timer = undefined;
|
|
187
|
+
fn();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
call: () => {
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
if (now - last >= intervalMs) return run(now);
|
|
194
|
+
// Already scheduled: the pending call will render whatever state exists when it fires, which is newer
|
|
195
|
+
// than this one. Queueing another would only repaint the same thing twice.
|
|
196
|
+
if (timer) return;
|
|
197
|
+
timer = setTimeout(() => run(Date.now()), intervalMs - (now - last)).unref?.() as never;
|
|
198
|
+
},
|
|
199
|
+
flush: () => {
|
|
200
|
+
if (timer) clearTimeout(timer);
|
|
201
|
+
timer = undefined;
|
|
202
|
+
last = Date.now();
|
|
203
|
+
fn();
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
package/src/run-child.ts
CHANGED
|
@@ -10,8 +10,35 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
|
+
import { StringDecoder } from "node:string_decoder";
|
|
13
14
|
import { parseBound } from "./propagation.ts";
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The longest prefix of `text` that fits in `budget` BYTES, never splitting a character.
|
|
18
|
+
*
|
|
19
|
+
* Both halves matter. Truncating by `String.prototype.slice` counts UTF-16 code units against a byte budget,
|
|
20
|
+
* which overruns by the encoding width of whatever the child printed. Truncating the *buffer* instead would
|
|
21
|
+
* respect the budget and split a multi-byte character or a surrogate pair, putting a lone `\ud83d` in the
|
|
22
|
+
* result. So the walk is by code POINT (`for…of` iterates code points, keeping surrogate pairs whole) and the
|
|
23
|
+
* budget is checked in bytes before each one is admitted.
|
|
24
|
+
*
|
|
25
|
+
* Exported for the test that feeds it CJK and emoji: an ASCII-only test cannot fail on any of this, which is
|
|
26
|
+
* exactly why the original defect survived a test named for it.
|
|
27
|
+
*/
|
|
28
|
+
export function takeBytes(text: string, budget: number): string {
|
|
29
|
+
if (budget <= 0) return "";
|
|
30
|
+
if (Buffer.byteLength(text) <= budget) return text;
|
|
31
|
+
let out = "";
|
|
32
|
+
let used = 0;
|
|
33
|
+
for (const character of text) {
|
|
34
|
+
const size = Buffer.byteLength(character);
|
|
35
|
+
if (used + size > budget) break;
|
|
36
|
+
out += character;
|
|
37
|
+
used += size;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
15
42
|
/**
|
|
16
43
|
* Operator override for the child wall-clock limit, in seconds.
|
|
17
44
|
*
|
|
@@ -40,6 +67,20 @@ export interface ChildRunRequest {
|
|
|
40
67
|
/** Wall-clock cap. On expiry: SIGTERM, then SIGKILL after `killGraceMs`. */
|
|
41
68
|
timeoutMs?: number;
|
|
42
69
|
killGraceMs?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Called with each chunk as it arrives, for the parent's progress display (ADR-0032).
|
|
72
|
+
*
|
|
73
|
+
* **Display only, and the distinction is load-bearing.** The child's answer is still `text`, assembled here
|
|
74
|
+
* and returned; a caller must never treat what it saw streamed as the result. A partial stream that could be
|
|
75
|
+
* mistaken for a complete answer is R-03's defect — a missing result indistinguishable from an empty one —
|
|
76
|
+
* with a new cause.
|
|
77
|
+
*
|
|
78
|
+
* Bounded by the same `maxOutputBytes` as `text`, so the cap governs the transcript and not merely memory.
|
|
79
|
+
*
|
|
80
|
+
* Exceptions are swallowed: a renderer is not a governance control, and one that throws must not kill a
|
|
81
|
+
* governed child mid-task.
|
|
82
|
+
*/
|
|
83
|
+
onOutput?: (chunk: string) => void;
|
|
43
84
|
}
|
|
44
85
|
|
|
45
86
|
export interface ChildRunResult {
|
|
@@ -85,6 +126,9 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
|
|
|
85
126
|
return;
|
|
86
127
|
}
|
|
87
128
|
|
|
129
|
+
// One decoder for BOTH streams is deliberate: they are already interleaved into one `text`, and two
|
|
130
|
+
// decoders would each hold their own partial character, so a split byte could surface out of order.
|
|
131
|
+
const decoder = new StringDecoder("utf8");
|
|
88
132
|
let text = "";
|
|
89
133
|
let bytes = 0;
|
|
90
134
|
let truncated = false;
|
|
@@ -101,20 +145,65 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
|
|
|
101
145
|
timers.push(setTimeout(() => child.kill("SIGKILL"), killGraceMs));
|
|
102
146
|
};
|
|
103
147
|
|
|
148
|
+
/** How much of `text` the progress display has already been shown. */
|
|
149
|
+
let emittedUpTo = 0;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Stream whatever `text` has gained since the last call.
|
|
153
|
+
*
|
|
154
|
+
* **Derived from `text` rather than from the incoming chunk**, and that is the whole correctness argument:
|
|
155
|
+
* what gets streamed is then *by construction* a prefix of what gets returned, so the output cap bounds the
|
|
156
|
+
* parent's transcript exactly as it bounds the result. The first version emitted the raw chunk and pushed
|
|
157
|
+
* 1924 bytes through a 1024-byte cap on the truncating write — the cap bounded memory and not the screen
|
|
158
|
+
* it had just been extended to protect. Found by the test, not by reading it.
|
|
159
|
+
*
|
|
160
|
+
* Swallowing is deliberate: `onOutput` renders, and a renderer that throws must not become a way to kill a
|
|
161
|
+
* governed child. Nothing downstream depends on it having run.
|
|
162
|
+
*/
|
|
163
|
+
const flush = () => {
|
|
164
|
+
if (!request.onOutput || text.length <= emittedUpTo) return;
|
|
165
|
+
const chunk = text.slice(emittedUpTo);
|
|
166
|
+
emittedUpTo = text.length;
|
|
167
|
+
try {
|
|
168
|
+
request.onOutput(chunk);
|
|
169
|
+
} catch {
|
|
170
|
+
/* display only */
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
104
174
|
const capture = (chunk: unknown) => {
|
|
105
175
|
if (truncated) return;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
176
|
+
// **Decoded through a StringDecoder, not `String(chunk)`.** A pipe splits on byte boundaries, not
|
|
177
|
+
// character ones, so a multi-byte character straddling two `data` events was decoded as two invalid
|
|
178
|
+
// halves and became U+FFFD — in the child's ANSWER, not merely the display. Measured: 300,000 bytes of
|
|
179
|
+
// CJK produced **twelve** replacement characters, because 65536 % 3 == 1. Emoji happened to survive
|
|
180
|
+
// (65536 % 4 == 0), which is why width-dependent corruption went unnoticed. The decoder holds the
|
|
181
|
+
// partial bytes until the rest arrives.
|
|
182
|
+
const s = decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
183
|
+
if (s.length === 0) return;
|
|
184
|
+
|
|
185
|
+
const size = Buffer.byteLength(s);
|
|
186
|
+
if (bytes + size > maxOutputBytes) {
|
|
187
|
+
// Keep what fits, mark it, and stop the child: an unbounded producer must not be able to exhaust the
|
|
188
|
+
// orchestrator's memory just because it was granted a tool that prints.
|
|
189
|
+
//
|
|
190
|
+
// **Trimmed by BYTES, and that is a fix rather than a refinement.** This was
|
|
191
|
+
// `text.slice(0, maxOutputBytes)` — a count of UTF-16 code units against a budget measured in bytes,
|
|
192
|
+
// so any non-ASCII output overran the cap by its own encoding width: 300 bytes of CJK through a
|
|
193
|
+
// 100-byte cap, and a measured **2048 bytes through the real 1024 default**. It is the same defect as
|
|
194
|
+
// the 1924-byte one already fixed here, one layer down: that fix bounded the *unit* count, and the
|
|
195
|
+
// budget was never in units. An odd cap also split a surrogate pair, putting a lone `\ud83d` into
|
|
196
|
+
// both the transcript and the returned answer.
|
|
197
|
+
text += takeBytes(s, maxOutputBytes - bytes);
|
|
198
|
+
bytes = maxOutputBytes;
|
|
113
199
|
truncated = true;
|
|
200
|
+
flush();
|
|
114
201
|
stop();
|
|
115
202
|
return;
|
|
116
203
|
}
|
|
204
|
+
bytes += size;
|
|
117
205
|
text += s;
|
|
206
|
+
flush();
|
|
118
207
|
};
|
|
119
208
|
|
|
120
209
|
child.stdout?.on("data", capture);
|