@vincemakes/kiso-tools-node 0.45.2 → 0.46.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,26 @@
1
+ /**
2
+ * ADR-0058 §6 — the process task backend: it spawns a task's runner.
3
+ *
4
+ * It satisfies the runtime's `TaskBackend` structurally — tools-node takes
5
+ * no dependency on the runtime; the host hands this backend to the
6
+ * runtime's TaskManager, and the compiler checks the fit there.
7
+ *
8
+ * The runner is detached (its own session and process group) and unref'd:
9
+ * kiso's exit or death does not take it along. A clean exit stops tasks
10
+ * through the manager, deliberately.
11
+ */
12
+ export interface ProcessTaskBackendOptions {
13
+ /** The runner script. Default: the one shipped beside this module. */
14
+ readonly runnerPath?: string;
15
+ /** How long the runner has to record itself. Default 10 s. */
16
+ readonly startTimeoutMs?: number;
17
+ }
18
+ export interface ProcessTaskBackend {
19
+ spawn(spec: {
20
+ readonly dir: string;
21
+ readonly env: Readonly<Record<string, string | undefined>>;
22
+ }): Promise<void>;
23
+ identify(pid: number, startedAt: string): "verified" | "gone" | "unverifiable";
24
+ signalStop(pid: number): void;
25
+ }
26
+ export declare function processTaskBackend(options?: ProcessTaskBackendOptions): ProcessTaskBackend;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ADR-0058 §6 — the process task backend: it spawns a task's runner.
3
+ *
4
+ * It satisfies the runtime's `TaskBackend` structurally — tools-node takes
5
+ * no dependency on the runtime; the host hands this backend to the
6
+ * runtime's TaskManager, and the compiler checks the fit there.
7
+ *
8
+ * The runner is detached (its own session and process group) and unref'd:
9
+ * kiso's exit or death does not take it along. A clean exit stops tasks
10
+ * through the manager, deliberately.
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { processStartTime } from "./process.js";
17
+ export function processTaskBackend(options = {}) {
18
+ const runnerPath = options.runnerPath ?? fileURLToPath(new URL("./task-runner.js", import.meta.url));
19
+ return {
20
+ async spawn({ dir, env }) {
21
+ const clean = {};
22
+ for (const [k, v] of Object.entries(env))
23
+ if (v !== undefined)
24
+ clean[k] = v;
25
+ const child = spawn(process.execPath, [runnerPath, dir], { detached: true, windowsHide: true, stdio: "ignore", env: clean });
26
+ child.unref();
27
+ const journal = join(dir, "journal.jsonl");
28
+ const deadline = Date.now() + (options.startTimeoutMs ?? 10_000);
29
+ while (Date.now() < deadline) {
30
+ if (existsSync(journal) && readFileSync(journal, "utf8").includes('"type":"runner_started"'))
31
+ return;
32
+ await new Promise((r) => setTimeout(r, 15));
33
+ }
34
+ throw new Error(`the task runner did not record itself within ${options.startTimeoutMs ?? 10_000} ms (${dir})`);
35
+ },
36
+ identify(pid, startedAt) {
37
+ try {
38
+ process.kill(pid, 0);
39
+ }
40
+ catch (err) {
41
+ if (err.code !== "EPERM")
42
+ return "gone";
43
+ }
44
+ // The pid is live. Only its OS start time says whose it is: equal to
45
+ // the recorded one — the runner; another — a stranger holds the
46
+ // pid; none readable (now, or recorded as "") — unverifiable,
47
+ // which is never taken for the runner (ADR-0058 §6: "when identity
48
+ // cannot be verified, the verdict is the gone row").
49
+ const id = processStartTime(pid);
50
+ if (id.kind === "gone")
51
+ return "gone";
52
+ if (id.kind === "unknown" || startedAt === "")
53
+ return "unverifiable";
54
+ return id.startedAt === startedAt ? "verified" : "gone";
55
+ },
56
+ signalStop(pid) {
57
+ // win32: process.kill is TerminateProcess — the runner would die
58
+ // before its stop runs. The journal's stop_requested record, written
59
+ // before this call, is the channel there; the runner watches it.
60
+ if (process.platform === "win32")
61
+ return;
62
+ try {
63
+ process.kill(pid, "SIGTERM");
64
+ }
65
+ catch {
66
+ // already gone
67
+ }
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * How a command starts, how a process tree dies, and a process's start
3
+ * time — the one place the shell tool and the task runner (ADR-0058) both
4
+ * stand on. The win32 branches sit behind the same contracts: kiso's shell
5
+ * commands use POSIX shell syntax, and on Windows they run through Git
6
+ * Bash, so the shell safety checks keep reading the language that runs.
7
+ */
8
+ import { type ChildProcess, type StdioOptions } from "node:child_process";
9
+ /** The code on the error `startCommand` throws when win32 has no usable bash. */
10
+ export declare const NO_BASH = "KISO_NO_BASH";
11
+ /**
12
+ * Start `command` through the shell. `detached`: the command gets its OWN
13
+ * process group, so a stop can kill the WHOLE TREE (children included), not
14
+ * just the outer shell (Area 4). On win32: `bash -c` with no shell of
15
+ * Node's own and no process group (`killTree` walks the tree by parent);
16
+ * no usable bash throws an error whose `code` is `NO_BASH`.
17
+ */
18
+ export declare function startCommand(command: string, opts: {
19
+ readonly cwd: string;
20
+ readonly env: NodeJS.ProcessEnv;
21
+ readonly stdio?: StdioOptions;
22
+ }): ChildProcess;
23
+ /**
24
+ * Start `file` with exactly `args` — no shell, so nothing is split, quoted
25
+ * or expanded (ADR-0058 3d: a child kiso is launched this way). Its own
26
+ * process group, like `startCommand`. On win32 it stays detached (a
27
+ * background child outlives its parent) and opens no console window; it
28
+ * never goes through bash or cmd.exe.
29
+ */
30
+ export declare function startExec(file: string, args: readonly string[], opts: {
31
+ readonly cwd: string;
32
+ readonly env: NodeJS.ProcessEnv;
33
+ readonly stdio?: StdioOptions;
34
+ }): ChildProcess;
35
+ /**
36
+ * Kill the whole tree and CONFIRM it exited (rounds 8/11):
37
+ *
38
+ * 1. FREEZE the root (SIGSTOP) FIRST — a stopped shell cannot fork new
39
+ * descendants while we enumerate;
40
+ * 2. repeatedly discover AND freeze descendants (pid-table sweep — the only
41
+ * way to see a setsid()-escaped process) until the set is STABLE (two
42
+ * identical scans), so the enumeration cannot miss a mid-sweep fork;
43
+ * 3. SIGKILL the process group and every tracked pid;
44
+ * 4. poll every tracked pid to death. Any tracked pid still alive at the
45
+ * deadline comes back in `unconfirmed`: the caller must not report the
46
+ * tree gone — the side effect may have outlived the stop.
47
+ *
48
+ * `graceMs` (ADR-0058, a task's stop): first send SIGTERM to the group and
49
+ * to every descendant seen now, and give the root that long to exit — a
50
+ * server cleans up — then run the same sweep on whatever is left. The
51
+ * descendants seen before the TERM stay tracked, so a child reparented when
52
+ * its parent exits is still found.
53
+ */
54
+ export declare function killTree(child: ChildProcess, opts?: {
55
+ readonly graceMs?: number;
56
+ }): Promise<{
57
+ unconfirmed: number[];
58
+ }>;
59
+ /**
60
+ * ADR-0058 §6 — a process's identity is its pid AND the time the OS started
61
+ * it: after a runner dies its pid can be handed to an unrelated process, and
62
+ * a live pid alone would then "prove" a task is running. `ps -o lstart=`
63
+ * gives the start time on macOS and Linux alike; on win32 a CIM query does,
64
+ * and says `gone` in so many words. "Cannot ask" is `unknown`, never
65
+ * `gone`: a failed query is not evidence of death.
66
+ */
67
+ export declare function processStartTime(pid: number): {
68
+ kind: "running";
69
+ startedAt: string;
70
+ } | {
71
+ kind: "gone";
72
+ } | {
73
+ kind: "unknown";
74
+ };
75
+ /** The output file, rotated at the cap so its tail is always kept. */
76
+ export declare class RotatingOutput {
77
+ #private;
78
+ constructor(path: string, cap: number);
79
+ write(chunk: Buffer): void;
80
+ close(): void;
81
+ }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * How a command starts, how a process tree dies, and a process's start
3
+ * time — the one place the shell tool and the task runner (ADR-0058) both
4
+ * stand on. The win32 branches sit behind the same contracts: kiso's shell
5
+ * commands use POSIX shell syntax, and on Windows they run through Git
6
+ * Bash, so the shell safety checks keep reading the language that runs.
7
+ */
8
+ import { execFileSync, spawn } from "node:child_process";
9
+ import { closeSync, existsSync, openSync, renameSync, writeSync } from "node:fs";
10
+ import { win32 } from "node:path";
11
+ /** Children whose output has closed — the shell tool's `exited`, kept here
12
+ * so `killTree` reads the same moment the tool always did. */
13
+ const closed = new WeakSet();
14
+ /** The code on the error `startCommand` throws when win32 has no usable bash. */
15
+ export const NO_BASH = "KISO_NO_BASH";
16
+ /**
17
+ * Start `command` through the shell. `detached`: the command gets its OWN
18
+ * process group, so a stop can kill the WHOLE TREE (children included), not
19
+ * just the outer shell (Area 4). On win32: `bash -c` with no shell of
20
+ * Node's own and no process group (`killTree` walks the tree by parent);
21
+ * no usable bash throws an error whose `code` is `NO_BASH`.
22
+ */
23
+ export function startCommand(command, opts) {
24
+ const stdio = opts.stdio ?? ["ignore", "pipe", "pipe"];
25
+ const child = process.platform === "win32"
26
+ ? spawn(resolveBash(process.env), ["-c", command], { shell: false, windowsHide: true, cwd: opts.cwd, stdio, env: opts.env })
27
+ : spawn(command, { shell: true, detached: true, cwd: opts.cwd, stdio, env: opts.env });
28
+ child.once("close", () => closed.add(child));
29
+ return child;
30
+ }
31
+ /**
32
+ * The bash a command runs through on win32: `KISO_BASH` (it must name a
33
+ * bash — never a door to PowerShell or cmd — and must exist: no silent
34
+ * fallback), then Git for Windows in Program Files and its x86 twin, then
35
+ * `bash.exe` on PATH, skipping the legacy WSL launcher in System32.
36
+ */
37
+ function resolveBash(env) {
38
+ const fail = (message) => {
39
+ throw Object.assign(new Error(message), { code: NO_BASH });
40
+ };
41
+ const override = env.KISO_BASH;
42
+ if (override !== undefined && override !== "") {
43
+ if (!/^bash(\.exe)?$/i.test(win32.basename(override))) {
44
+ fail(`KISO_BASH must name a bash executable (bash.exe), not ${win32.basename(override)}: kiso's shell commands are POSIX shell`);
45
+ }
46
+ if (!existsSync(override))
47
+ fail(`KISO_BASH is set to ${override}, which does not exist`);
48
+ return override;
49
+ }
50
+ for (const root of [env.ProgramFiles ?? "C:\\Program Files", env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"]) {
51
+ const candidate = win32.join(root, "Git", "bin", "bash.exe");
52
+ if (existsSync(candidate))
53
+ return candidate;
54
+ }
55
+ const system32 = win32.join(env.SystemRoot ?? "C:\\Windows", "System32").toLowerCase();
56
+ for (const dir of (env.PATH ?? "").split(win32.delimiter)) {
57
+ if (dir === "" || win32.join(dir, ".").toLowerCase() === system32)
58
+ continue;
59
+ const candidate = win32.join(dir, "bash.exe");
60
+ if (existsSync(candidate))
61
+ return candidate;
62
+ }
63
+ return fail("no bash found — kiso runs shell commands through Git Bash on Windows: install Git for Windows, or set KISO_BASH to the path of bash.exe");
64
+ }
65
+ /**
66
+ * Start `file` with exactly `args` — no shell, so nothing is split, quoted
67
+ * or expanded (ADR-0058 3d: a child kiso is launched this way). Its own
68
+ * process group, like `startCommand`. On win32 it stays detached (a
69
+ * background child outlives its parent) and opens no console window; it
70
+ * never goes through bash or cmd.exe.
71
+ */
72
+ export function startExec(file, args, opts) {
73
+ const child = spawn(file, [...args], {
74
+ shell: false,
75
+ detached: true,
76
+ ...(process.platform === "win32" ? { windowsHide: true } : {}),
77
+ cwd: opts.cwd,
78
+ stdio: opts.stdio ?? ["ignore", "pipe", "pipe"],
79
+ env: opts.env,
80
+ });
81
+ child.once("close", () => closed.add(child));
82
+ return child;
83
+ }
84
+ /**
85
+ * Kill the whole tree and CONFIRM it exited (rounds 8/11):
86
+ *
87
+ * 1. FREEZE the root (SIGSTOP) FIRST — a stopped shell cannot fork new
88
+ * descendants while we enumerate;
89
+ * 2. repeatedly discover AND freeze descendants (pid-table sweep — the only
90
+ * way to see a setsid()-escaped process) until the set is STABLE (two
91
+ * identical scans), so the enumeration cannot miss a mid-sweep fork;
92
+ * 3. SIGKILL the process group and every tracked pid;
93
+ * 4. poll every tracked pid to death. Any tracked pid still alive at the
94
+ * deadline comes back in `unconfirmed`: the caller must not report the
95
+ * tree gone — the side effect may have outlived the stop.
96
+ *
97
+ * `graceMs` (ADR-0058, a task's stop): first send SIGTERM to the group and
98
+ * to every descendant seen now, and give the root that long to exit — a
99
+ * server cleans up — then run the same sweep on whatever is left. The
100
+ * descendants seen before the TERM stay tracked, so a child reparented when
101
+ * its parent exits is still found.
102
+ */
103
+ export function killTree(child, opts = {}) {
104
+ if (process.platform === "win32")
105
+ return killTreeWin32(child);
106
+ const grace = opts.graceMs ?? 0;
107
+ const pid = child.pid;
108
+ if (grace <= 0 || pid === undefined || pid <= 0 || closed.has(child) || child.exitCode !== null || child.signalCode !== null) {
109
+ return sweep(child, new Set());
110
+ }
111
+ const seen = new Set(descendantsOf(pid));
112
+ try {
113
+ process.kill(-pid, "SIGTERM");
114
+ }
115
+ catch {
116
+ // the group is already gone
117
+ }
118
+ for (const p of seen) {
119
+ try {
120
+ process.kill(p, "SIGTERM");
121
+ }
122
+ catch {
123
+ // already gone
124
+ }
125
+ }
126
+ return new Promise((resolve) => {
127
+ const timer = setTimeout(() => resolve(sweep(child, seen)), grace);
128
+ child.once("exit", () => {
129
+ clearTimeout(timer);
130
+ resolve(sweep(child, seen));
131
+ });
132
+ });
133
+ }
134
+ function sweep(child, seen) {
135
+ return new Promise((resolveKill) => {
136
+ const tracked = new Set(seen);
137
+ // round 11 (adversarial): the ROOT itself is tracked too — the
138
+ // verdict must not read "aborted" while the root survives.
139
+ // DOCUMENTED LIMITS: (1) a process that forks between SIGSTOP
140
+ // delivery and the next scan, setsids, and is then reparented when
141
+ // its parent is killed can escape the enumeration entirely — it is
142
+ // untracked and unknowable from the pid table; the platform cannot
143
+ // confirm it. (2) if THIS process is killed between the first
144
+ // SIGSTOP and the SIGKILL sweep, the stopped descendants stay
145
+ // permanently stopped (nobody SIGCONTs orphans) — the inherent cost
146
+ // of freeze-first. Both limits are recorded here so no claim of "the
147
+ // whole tree is gone" is ever stronger than what the platform can
148
+ // prove.
149
+ if (child.pid !== undefined && child.pid > 0) {
150
+ tracked.add(child.pid);
151
+ try {
152
+ process.kill(child.pid, "SIGSTOP"); // freeze the root
153
+ }
154
+ catch {
155
+ // already gone
156
+ }
157
+ }
158
+ for (const pid of seen) {
159
+ try {
160
+ process.kill(pid, "SIGSTOP");
161
+ }
162
+ catch {
163
+ // already gone
164
+ }
165
+ }
166
+ // Stable discovery: freeze as we go; stop when two consecutive scans
167
+ // are identical.
168
+ let previous = new Set();
169
+ for (let i = 0; i < 10; i++) {
170
+ const current = new Set(descendantsOf(child.pid ?? 0));
171
+ for (const pid of current) {
172
+ tracked.add(pid);
173
+ try {
174
+ process.kill(pid, "SIGSTOP"); // freeze each descendant
175
+ }
176
+ catch {
177
+ // already gone
178
+ }
179
+ }
180
+ if (current.size === previous.size && [...current].every((pid) => previous.has(pid))) {
181
+ break;
182
+ }
183
+ previous = current;
184
+ }
185
+ // The process group (E group: never kill an undefined/0 pid), which
186
+ // also takes the frozen root down.
187
+ if (child.pid !== undefined && child.pid > 0) {
188
+ try {
189
+ process.kill(-child.pid, "SIGKILL");
190
+ }
191
+ catch {
192
+ try {
193
+ child.kill("SIGKILL");
194
+ }
195
+ catch {
196
+ // already gone
197
+ }
198
+ }
199
+ }
200
+ for (const pid of tracked) {
201
+ try {
202
+ process.kill(pid, "SIGKILL");
203
+ }
204
+ catch {
205
+ // already gone
206
+ }
207
+ }
208
+ const confirm = () => {
209
+ void waitAllDead([...tracked]).then((unconfirmed) => resolveKill({ unconfirmed }));
210
+ };
211
+ if (closed.has(child)) {
212
+ confirm();
213
+ return;
214
+ }
215
+ const fallback = setTimeout(confirm, 2000);
216
+ child.once("close", () => {
217
+ clearTimeout(fallback);
218
+ confirm();
219
+ });
220
+ });
221
+ }
222
+ /**
223
+ * win32: no process groups, no SIGSTOP, and no TERM a console tree hears —
224
+ * so no grace: `taskkill /T /F` walks the tree from the root by parent and
225
+ * forces it. The root is `unconfirmed` when taskkill cannot start, exits
226
+ * non-zero (some process in the tree survived it), or the root is still
227
+ * alive afterwards.
228
+ */
229
+ function killTreeWin32(child) {
230
+ const pid = child.pid;
231
+ if (pid === undefined || pid <= 0)
232
+ return Promise.resolve({ unconfirmed: [] });
233
+ let killed = true;
234
+ try {
235
+ execFileSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", windowsHide: true });
236
+ }
237
+ catch {
238
+ killed = false;
239
+ }
240
+ return waitAllDead([pid]).then((alive) => ({ unconfirmed: killed && alive.length === 0 ? [] : [pid] }));
241
+ }
242
+ /**
243
+ * All live pids whose ancestor chain includes `pid`, from the pid table
244
+ * (round 8: `ps -axo pid=,ppid=` — the ONLY way to see a setsid()-escaped
245
+ * process, which is in its own group and invisible to a group kill).
246
+ */
247
+ function descendantsOf(pid) {
248
+ if (pid <= 0)
249
+ return [];
250
+ let table;
251
+ try {
252
+ table = execFileSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8", maxBuffer: 1 << 20 });
253
+ }
254
+ catch {
255
+ return [];
256
+ }
257
+ const children = new Map();
258
+ for (const line of table.split("\n")) {
259
+ const m = line.trim().match(/^(\d+)\s+(\d+)$/);
260
+ if (m === null)
261
+ continue;
262
+ const child = Number(m[1]);
263
+ const parent = Number(m[2]);
264
+ if (!children.has(parent))
265
+ children.set(parent, []);
266
+ children.get(parent).push(child);
267
+ }
268
+ const out = [];
269
+ const queue = [pid];
270
+ while (queue.length > 0) {
271
+ const current = queue.shift();
272
+ for (const c of children.get(current) ?? []) {
273
+ out.push(c);
274
+ queue.push(c);
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+ /**
280
+ * Poll the pid table until NONE of the tracked pids is alive (bounded).
281
+ * Returns the pids still alive at the deadline — the caller MUST NOT
282
+ * report "aborted"/"timed out" while any tracked pid survives (round 11).
283
+ */
284
+ function waitAllDead(pids) {
285
+ if (pids.length === 0)
286
+ return Promise.resolve([]);
287
+ return new Promise((resolve) => {
288
+ const deadline = Date.now() + 2000;
289
+ const poll = () => {
290
+ const alive = [];
291
+ for (const pid of pids) {
292
+ try {
293
+ process.kill(pid, 0);
294
+ alive.push(pid);
295
+ }
296
+ catch (err) {
297
+ if (err.code === "EPERM")
298
+ alive.push(pid);
299
+ // ESRCH — gone
300
+ }
301
+ }
302
+ if (alive.length === 0 || Date.now() > deadline)
303
+ return resolve(alive);
304
+ setTimeout(poll, 50);
305
+ };
306
+ poll();
307
+ });
308
+ }
309
+ /**
310
+ * ADR-0058 §6 — a process's identity is its pid AND the time the OS started
311
+ * it: after a runner dies its pid can be handed to an unrelated process, and
312
+ * a live pid alone would then "prove" a task is running. `ps -o lstart=`
313
+ * gives the start time on macOS and Linux alike; on win32 a CIM query does,
314
+ * and says `gone` in so many words. "Cannot ask" is `unknown`, never
315
+ * `gone`: a failed query is not evidence of death.
316
+ */
317
+ export function processStartTime(pid) {
318
+ if (process.platform === "win32") {
319
+ const query = `$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}' -ErrorAction Stop; if ($p) { $p.CreationDate.ToUniversalTime().ToString('o') } else { 'gone' }`;
320
+ try {
321
+ const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", query], {
322
+ encoding: "utf8",
323
+ stdio: ["ignore", "pipe", "ignore"],
324
+ windowsHide: true,
325
+ }).trim();
326
+ if (out === "gone")
327
+ return { kind: "gone" };
328
+ return /^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(out) ? { kind: "running", startedAt: out } : { kind: "unknown" };
329
+ }
330
+ catch {
331
+ return { kind: "unknown" };
332
+ }
333
+ }
334
+ try {
335
+ const startedAt = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
336
+ return startedAt === "" ? { kind: "gone" } : { kind: "running", startedAt };
337
+ }
338
+ catch (err) {
339
+ const e = err;
340
+ // `ps -p` exits 1 with nothing printed when no such process exists;
341
+ // anything else (no `ps`, a signal, other output) could not tell
342
+ if (e.status === 1 && String(e.stdout ?? "").trim() === "")
343
+ return { kind: "gone" };
344
+ return { kind: "unknown" };
345
+ }
346
+ }
347
+ /** The output file, rotated at the cap so its tail is always kept. */
348
+ export class RotatingOutput {
349
+ #path;
350
+ #cap;
351
+ #fd;
352
+ #size = 0;
353
+ constructor(path, cap) {
354
+ this.#path = path;
355
+ this.#cap = cap;
356
+ this.#fd = openSync(path, "a");
357
+ }
358
+ write(chunk) {
359
+ if (this.#size > 0 && this.#size + chunk.length > this.#cap)
360
+ this.#rotate();
361
+ writeSync(this.#fd, chunk);
362
+ this.#size += chunk.length;
363
+ }
364
+ #rotate() {
365
+ closeSync(this.#fd);
366
+ renameSync(this.#path, this.#path.replace(/\.log$/, ".1.log"));
367
+ this.#fd = openSync(this.#path, "a");
368
+ const marker = Buffer.from(`[kiso: output rotated after ${this.#size} bytes — the part before is in output.1.log]\n`);
369
+ writeSync(this.#fd, marker);
370
+ this.#size = marker.length;
371
+ }
372
+ close() {
373
+ closeSync(this.#fd);
374
+ }
375
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * ADR-0058 §6 — the task runner: `node task-runner.js <task dir>`.
3
+ *
4
+ * A small process of its own, detached from kiso, that owns one task's
5
+ * command, its output file and its terminal record — and outlives kiso,
6
+ * so a task survives the agent's death and its outcome is still written.
7
+ *
8
+ * Its records, each written and fsynced BEFORE the step it gates:
9
+ * runner_started its pid and OS start time — its verifiable identity
10
+ * command_started then, and only then, the command is spawned
11
+ * ready the first time the output contains `readyWhen`
12
+ * terminal the exit code or signal, once the command's output closed
13
+ *
14
+ * The command runs in its own process group. A stop is asked for by the
15
+ * journal's `stop_requested` record — durable before any signal, and the
16
+ * one channel every platform has (on win32 a signal to the runner is
17
+ * TerminateProcess): the runner watches its journal and stops the whole
18
+ * tree (TERM, a grace, then the confirmed sweep of the process module).
19
+ * SIGTERM is the same stop, sooner, where signals exist.
20
+ *
21
+ * A command that never starts (no shell, a missing cwd) ends with a
22
+ * `terminal` carrying `error` and no exit code — none is invented. A stop that cannot confirm every process dead writes NO
23
+ * terminal — `stop_unconfirmed` names the survivors and the runner exits,
24
+ * so the journal reads `unknown`, never ended. The output passes
25
+ * through the runner so it can rotate at the cap (64 MiB: the file moves to
26
+ * output.1.log and a fresh output.log starts with a marker — the tail,
27
+ * where errors live, is always kept) and match `readyWhen`.
28
+ *
29
+ * Nothing secret is written down: the environment arrives with the process
30
+ * and is handed to the command, never recorded.
31
+ */
32
+ export {};