@vincemakes/kiso-tools-node 0.45.3 → 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,189 @@
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
+ import { closeSync, fsyncSync, openSync, readFileSync, statSync, writeSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { killTree, processStartTime, RotatingOutput, startCommand, startExec } from "./process.js";
35
+ const OUTPUT_CAP_DEFAULT = 64 * 1024 * 1024;
36
+ const STOP_GRACE_MS = 5_000;
37
+ /** How often the runner looks for a `stop_requested` in its journal. */
38
+ const STOP_POLL_MS = 250;
39
+ function append(journal, record) {
40
+ const fd = openSync(journal, "a");
41
+ try {
42
+ writeSync(fd, `${JSON.stringify(record)}\n`);
43
+ // Windows: an append handle cannot be flushed; the flush goes
44
+ // through a read-write handle (the runtime journal's syncFile)
45
+ if (process.platform === "win32") {
46
+ const sync = openSync(journal, "r+");
47
+ try {
48
+ fsyncSync(sync);
49
+ }
50
+ finally {
51
+ closeSync(sync);
52
+ }
53
+ }
54
+ else
55
+ fsyncSync(fd);
56
+ }
57
+ finally {
58
+ closeSync(fd);
59
+ }
60
+ }
61
+ function plannedOf(journal) {
62
+ for (const line of readFileSync(journal, "utf8").split("\n")) {
63
+ if (line.trim() === "")
64
+ continue;
65
+ const record = JSON.parse(line);
66
+ if (record.type === "planned")
67
+ return record;
68
+ }
69
+ throw new Error(`no planned record in ${journal}`);
70
+ }
71
+ function main(dir) {
72
+ const journal = join(dir, "journal.jsonl");
73
+ const planned = plannedOf(journal);
74
+ // test-only crash points: the process dies right after a record, before
75
+ // the step that record gates
76
+ const dieAfter = process.env.KISO_TASK_RUNNER_DIE_AFTER;
77
+ // test-only: report a stop as unconfirmed (a survivor the platform could
78
+ // not kill cannot be made on purpose)
79
+ const forceUnconfirmed = process.env.KISO_TASK_RUNNER_STOP_UNCONFIRMED === "1";
80
+ const cap = Number(process.env.KISO_TASK_OUTPUT_CAP ?? OUTPUT_CAP_DEFAULT);
81
+ const env = { ...process.env };
82
+ delete env.KISO_TASK_RUNNER_DIE_AFTER;
83
+ delete env.KISO_TASK_RUNNER_STOP_UNCONFIRMED;
84
+ delete env.KISO_TASK_OUTPUT_CAP;
85
+ // "" when the start time cannot be read: the identity is then the pid
86
+ // alone, and the backend treats it as unverifiable, never as dead
87
+ const self = processStartTime(process.pid);
88
+ append(journal, { type: "runner_started", ts: Date.now(), pid: process.pid, startedAt: self.kind === "running" ? self.startedAt : "" });
89
+ if (dieAfter === "runner_started")
90
+ process.exit(99);
91
+ append(journal, { type: "command_started", ts: Date.now() });
92
+ if (dieAfter === "command_started")
93
+ process.exit(99);
94
+ const out = new RotatingOutput(join(dir, "output.log"), cap);
95
+ let finished = false;
96
+ /** The last record; the output closes first, and nothing is written after. */
97
+ const finish = (record) => {
98
+ if (finished)
99
+ return;
100
+ finished = true;
101
+ out.close();
102
+ append(journal, record);
103
+ process.exit(0);
104
+ };
105
+ /** The command never started: its reason is the record, and the output says it too. */
106
+ const notStarted = (err) => {
107
+ out.write(Buffer.from(`[kiso: the command could not start: ${err.message}]\n`));
108
+ finish({ type: "terminal", ts: Date.now(), exitCode: null, signal: null, error: err.message });
109
+ };
110
+ let child;
111
+ try {
112
+ child = planned.launch?.kind === "exec" ? startExec(planned.launch.file, planned.launch.args, { cwd: planned.cwd, env }) : startCommand(planned.command, { cwd: planned.cwd, env });
113
+ }
114
+ catch (err) {
115
+ // win32 without bash throws here (the Windows line's process module)
116
+ notStarted(err instanceof Error ? err : new Error(String(err)));
117
+ return;
118
+ }
119
+ let seen = "";
120
+ let ready = false;
121
+ const onData = (chunk) => {
122
+ if (finished)
123
+ return;
124
+ out.write(chunk);
125
+ if (planned.readyWhen === undefined || ready)
126
+ return;
127
+ seen = (seen + chunk.toString("utf8")).slice(-(planned.readyWhen.length + 65_536));
128
+ if (seen.includes(planned.readyWhen)) {
129
+ ready = true;
130
+ append(journal, { type: "ready", ts: Date.now(), match: planned.readyWhen });
131
+ }
132
+ };
133
+ child.stdout?.on("data", onData);
134
+ child.stderr?.on("data", onData);
135
+ child.on("error", (err) => {
136
+ // a spawn failure (no pid) is a start that never happened; the `close`
137
+ // that follows would otherwise carry an errno as if it were an exit code
138
+ if (child.pid === undefined)
139
+ notStarted(err);
140
+ else
141
+ out.write(Buffer.from(`[kiso: ${err.message}]\n`));
142
+ });
143
+ let exit = null;
144
+ child.on("exit", (code, signal) => {
145
+ exit = { code, signal };
146
+ });
147
+ const outputClosed = new Promise((resolve) => child.once("close", () => resolve()));
148
+ let stopping = false;
149
+ const stop = () => {
150
+ if (stopping || finished || child.pid === undefined)
151
+ return;
152
+ stopping = true;
153
+ void killTree(child, { graceMs: STOP_GRACE_MS }).then(async ({ unconfirmed }) => {
154
+ const survivors = forceUnconfirmed ? [child.pid] : unconfirmed;
155
+ if (survivors.length > 0) {
156
+ finish({ type: "stop_unconfirmed", ts: Date.now(), pids: survivors });
157
+ return;
158
+ }
159
+ // every tracked process is confirmed dead; the output drains
160
+ // (bounded: an untracked holder of the pipe must not hang the stop)
161
+ await Promise.race([outputClosed, new Promise((r) => setTimeout(r, 1_000))]);
162
+ const e = exit;
163
+ finish({ type: "terminal", ts: Date.now(), exitCode: e?.code ?? null, signal: e?.signal ?? null });
164
+ });
165
+ };
166
+ process.on("SIGTERM", stop);
167
+ // the journal is the stop channel: re-read only when it grew
168
+ let journalSize = -1;
169
+ const watch = setInterval(() => {
170
+ try {
171
+ const size = statSync(journal).size;
172
+ if (size === journalSize)
173
+ return;
174
+ journalSize = size;
175
+ if (readFileSync(journal, "utf8").includes('"type":"stop_requested"'))
176
+ stop();
177
+ }
178
+ catch {
179
+ // a transient read failure is retried on the next tick
180
+ }
181
+ }, STOP_POLL_MS);
182
+ watch.unref();
183
+ // the command ended on its own: the terminal is written once its output closed
184
+ child.on("close", (code, signal) => {
185
+ if (!stopping)
186
+ finish({ type: "terminal", ts: Date.now(), exitCode: code, signal });
187
+ });
188
+ }
189
+ main(process.argv[2]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.45.3",
3
+ "version": "0.46.0",
4
4
  "description": "kiso coding tools for Node hosts \u2014 read file, list directory, search text, write/edit file, shell command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@
25
25
  "test": "vitest run"
26
26
  },
27
27
  "dependencies": {
28
- "@vincemakes/kiso-core": "0.45.3",
28
+ "@vincemakes/kiso-core": "0.46.0",
29
29
  "ignore": "^7.0.9"
30
30
  },
31
31
  "devDependencies": {