@vincemakes/kiso-tools-node 0.51.1 → 0.53.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/dist/index.d.ts +11 -0
- package/dist/index.js +185 -11
- package/dist/process-backend.d.ts +5 -0
- package/dist/process-backend.js +6 -1
- package/dist/task-runner.js +5 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -174,6 +174,15 @@ export interface WorkspaceToolsOptions {
|
|
|
174
174
|
readonly maxForegroundMs?: number;
|
|
175
175
|
};
|
|
176
176
|
}
|
|
177
|
+
/** WR-1: does this read_file call, with the result it got, show the file from its first
|
|
178
|
+
* line to its last? It starts at the top (no offset, or offset 1) and carries none of the
|
|
179
|
+
* three notices that say more remains: the continuation note, the output cap, the
|
|
180
|
+
* over-long-line cap. A `limit` that covers the file is fine, because a short one always
|
|
181
|
+
* leaves the continuation note. The hosts' observed-revision rule uses this for the
|
|
182
|
+
* whole-file overwrite; it lives beside the notices so the two cannot drift apart. */
|
|
183
|
+
export declare function readShowsWholeFile(input: {
|
|
184
|
+
readonly offset?: unknown;
|
|
185
|
+
}, body: string): boolean;
|
|
177
186
|
export declare function readFileTool(opts: WorkspaceToolsOptions): Tool<{
|
|
178
187
|
path: string;
|
|
179
188
|
offset?: number;
|
|
@@ -264,6 +273,8 @@ export interface ShellTasks {
|
|
|
264
273
|
ready(match: string): void;
|
|
265
274
|
ended(exitCode: number | null, signal: string | null): void;
|
|
266
275
|
unconfirmed(pids: readonly number[]): void;
|
|
276
|
+
/** the hand-over failed after the task was recorded; absent in a manager that predates it */
|
|
277
|
+
handoverFailed?(reason: string): void;
|
|
267
278
|
};
|
|
268
279
|
stop(id: string, by: "model"): boolean;
|
|
269
280
|
get(id: string): {
|
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { execFile } from "node:child_process";
|
|
21
21
|
import { promisify } from "node:util";
|
|
22
|
-
import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, linkSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, linkSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { Worker } from "node:worker_threads";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
import { killTree, NO_BASH, processStartTime, RotatingOutput, startCommand } from "./process.js";
|
|
@@ -89,6 +89,12 @@ const DEFAULT_MAX_FOREGROUND_MS = 300_000;
|
|
|
89
89
|
const TASK_OUTPUT_CAP = 64 * 1024 * 1024;
|
|
90
90
|
/** A task's stop: SIGTERM, this long, then the confirmed sweep. */
|
|
91
91
|
const TASK_STOP_GRACE_MS = 5_000;
|
|
92
|
+
/** The shell exited but its pipes did not close: how long after the exit (or
|
|
93
|
+
* the last byte that followed it) before a descendant is taken to hold them. */
|
|
94
|
+
const EXIT_DRAIN_MS = 300;
|
|
95
|
+
/** ...and the most it may be renewed: a descendant that writes every few
|
|
96
|
+
* hundred ms must not keep a finished command in the foreground. */
|
|
97
|
+
const EXIT_DRAIN_MAX_MS = 2_000;
|
|
92
98
|
/** ADR-0058 Amendment 7: how long task_stop waits for the end it reports —
|
|
93
99
|
* the runner's journal poll, TERM, the grace, KILL and the sweep. */
|
|
94
100
|
const TASK_STOP_WAIT_MS = 8_000;
|
|
@@ -372,6 +378,18 @@ function moreLinesNote(nextOffset, remaining, limit) {
|
|
|
372
378
|
// read onward. Measured before the fix: 7.3% of real reads took that path.
|
|
373
379
|
return `\n… ${remaining} more ${remaining === 1 ? "line" : "lines"} (call again with offset=${nextOffset} limit=${limit})`;
|
|
374
380
|
}
|
|
381
|
+
/** WR-1: does this read_file call, with the result it got, show the file from its first
|
|
382
|
+
* line to its last? It starts at the top (no offset, or offset 1) and carries none of the
|
|
383
|
+
* three notices that say more remains: the continuation note, the output cap, the
|
|
384
|
+
* over-long-line cap. A `limit` that covers the file is fine, because a short one always
|
|
385
|
+
* leaves the continuation note. The hosts' observed-revision rule uses this for the
|
|
386
|
+
* whole-file overwrite; it lives beside the notices so the two cannot drift apart. */
|
|
387
|
+
export function readShowsWholeFile(input, body) {
|
|
388
|
+
const offset = input.offset;
|
|
389
|
+
if (offset !== undefined && !(typeof offset === "number" && offset <= 1))
|
|
390
|
+
return false;
|
|
391
|
+
return !body.includes("(call again with offset=") && !body.includes("[output capped at") && !/\[a line near .* exceeds the \d+-char cap/.test(body);
|
|
392
|
+
}
|
|
375
393
|
export function readFileTool(opts) {
|
|
376
394
|
return defineTool({
|
|
377
395
|
name: "read_file",
|
|
@@ -907,7 +925,7 @@ export function writeFileTool(opts) {
|
|
|
907
925
|
// the trajectory audit: after ranged reads the model retried this
|
|
908
926
|
// in a loop — say that only a WHOLE read lets it replace the file
|
|
909
927
|
if (expectedRevision === undefined)
|
|
910
|
-
return precondition(`write_file: ${path} already exists — to replace it, first read the whole file (
|
|
928
|
+
return precondition(`write_file: ${path} already exists — to replace it, first read the whole file from its first line (a read that starts later or stops early is not enough; for a long file pass a limit that covers it), or change part of it with edit_file`);
|
|
911
929
|
}
|
|
912
930
|
// WR-1 — the observed-revision stale-write guard. The decision
|
|
913
931
|
// lattice runs BEFORE any bytes move; every refusal is a
|
|
@@ -953,8 +971,39 @@ export function writeFileTool(opts) {
|
|
|
953
971
|
return precondition(opts.observedRevision !== undefined ? `write_file: ${path} changed since you last observed it — read it again, then re-apply the change` : `write_file: ${path} changed since ${expectedRevision} — read it again and cite its [rev:…] line, then re-apply the change`);
|
|
954
972
|
}
|
|
955
973
|
}
|
|
974
|
+
// A new file may sit under directories that do not exist yet. The
|
|
975
|
+
// containment check above already walked the missing tail up to its
|
|
976
|
+
// deepest existing ancestor, so creating the tail cannot leave the
|
|
977
|
+
// workspace; the directories this call creates are removed again
|
|
978
|
+
// if the file never lands.
|
|
979
|
+
let createdDirs;
|
|
980
|
+
if (!exists) {
|
|
981
|
+
try {
|
|
982
|
+
createdDirs = mkdirSync(dirname(full), { recursive: true });
|
|
983
|
+
}
|
|
984
|
+
catch (err) {
|
|
985
|
+
return precondition(`write_file: cannot create the directory for ${path} — ${describeFsError(err, path)}`);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
const dropCreatedDirs = () => {
|
|
989
|
+
if (createdDirs === undefined)
|
|
990
|
+
return;
|
|
991
|
+
for (let dir = dirname(full); dir.length >= createdDirs.length; dir = dirname(dir)) {
|
|
992
|
+
try {
|
|
993
|
+
rmdirSync(dir);
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
return; // not empty or not ours — leave it
|
|
997
|
+
}
|
|
998
|
+
if (dir === createdDirs)
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
956
1002
|
const tmp = `${full}.kiso-tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
957
1003
|
let preservedMode;
|
|
1004
|
+
// Nothing reaches the target before the commit step below, so a
|
|
1005
|
+
// failure while staging the temp file leaves the world as it was.
|
|
1006
|
+
let committing = false;
|
|
958
1007
|
try {
|
|
959
1008
|
// E group: SAFE REPLACEMENT — write a temp file next to the
|
|
960
1009
|
// target and rename it over the directory entry. A hard link
|
|
@@ -969,12 +1018,15 @@ export function writeFileTool(opts) {
|
|
|
969
1018
|
writeFileSync(tmp, content, "utf8");
|
|
970
1019
|
if (preservedMode !== undefined)
|
|
971
1020
|
chmodSync(tmp, preservedMode);
|
|
1021
|
+
committing = true;
|
|
972
1022
|
if (!exists) {
|
|
973
1023
|
// WR-1A ②: link(2) or refusal — atomic no-clobber, fail
|
|
974
1024
|
// closed. No rename fallback: "absent" is a contract.
|
|
975
1025
|
const refused = publishNewFile(tmp, full, path);
|
|
976
|
-
if (refused !== null)
|
|
1026
|
+
if (refused !== null) {
|
|
1027
|
+
dropCreatedDirs();
|
|
977
1028
|
return refused;
|
|
1029
|
+
}
|
|
978
1030
|
}
|
|
979
1031
|
else {
|
|
980
1032
|
// WR-1A ③: the world must STILL match the citation right
|
|
@@ -1019,11 +1071,21 @@ export function writeFileTool(opts) {
|
|
|
1019
1071
|
}
|
|
1020
1072
|
if (err instanceof PathEscapeError)
|
|
1021
1073
|
return escapeResult(err.message);
|
|
1022
|
-
|
|
1074
|
+
if (!committing) {
|
|
1075
|
+
dropCreatedDirs();
|
|
1076
|
+
return precondition(`write_file: could not write ${path} — ${describeFsError(err, path)}; nothing was changed`);
|
|
1077
|
+
}
|
|
1078
|
+
return { content: `write_file failed: ${describeFsError(err, path)}`, isError: true, errorKind: "fatal" };
|
|
1023
1079
|
}
|
|
1024
1080
|
},
|
|
1025
1081
|
});
|
|
1026
1082
|
}
|
|
1083
|
+
/** An fs error as the model should read it: the file it asked for, not the
|
|
1084
|
+
* `.kiso-tmp-*` staging name the failing syscall happened to see. */
|
|
1085
|
+
function describeFsError(err, path) {
|
|
1086
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1087
|
+
return message.replace(/'[^']*\.kiso-tmp-[^']*'/g, `'${path}'`);
|
|
1088
|
+
}
|
|
1027
1089
|
/** ACI-2: every offset where `search` occurs, OVERLAPPING ones included —
|
|
1028
1090
|
* "aa" occurs twice in "aaa", and the two resolutions differ, so the call
|
|
1029
1091
|
* is ambiguous. A scan that steps past each match by its own length
|
|
@@ -1212,7 +1274,7 @@ export function editFileTool(opts) {
|
|
|
1212
1274
|
if (expectedRevision === undefined && opts.observedRevision !== undefined) {
|
|
1213
1275
|
expectedRevision = opts.observedRevision(ctx?.sessionId, full);
|
|
1214
1276
|
if (expectedRevision === undefined)
|
|
1215
|
-
return precondition(`edit_file: ${path} — read it first; there is no observation of it in this session`);
|
|
1277
|
+
return precondition(`edit_file: ${path} — read it first with read_file; there is no observation of it in this session (shell output does not count)`);
|
|
1216
1278
|
}
|
|
1217
1279
|
// WR-1: edits always target an existing file — the revision is
|
|
1218
1280
|
// not optional here, and the refusal teaches the protocol.
|
|
@@ -1346,7 +1408,7 @@ export function editFileTool(opts) {
|
|
|
1346
1408
|
catch {
|
|
1347
1409
|
// already renamed or never created
|
|
1348
1410
|
}
|
|
1349
|
-
return { content: `edit_file failed: ${err
|
|
1411
|
+
return { content: `edit_file failed: ${describeFsError(err, path)}`, isError: true, errorKind: "fatal" };
|
|
1350
1412
|
}
|
|
1351
1413
|
},
|
|
1352
1414
|
});
|
|
@@ -1516,6 +1578,22 @@ export function shellTool(opts) {
|
|
|
1516
1578
|
let promoted = null;
|
|
1517
1579
|
let stopping = false;
|
|
1518
1580
|
let readySeen = false;
|
|
1581
|
+
/** the top-level shell's own exit, once seen: `close` waits for every holder of its pipes */
|
|
1582
|
+
let exitSeen = null;
|
|
1583
|
+
let drainTimer;
|
|
1584
|
+
let exitAtMs = 0;
|
|
1585
|
+
/** the command had exited when the manager took its pipes: its root is gone, so a stop can reach only the descendants it can find */
|
|
1586
|
+
let rootGone = false;
|
|
1587
|
+
/** the stop of a hand-over was recorded unconfirmed: the sink stays open for whatever still holds the pipes, and closes with them */
|
|
1588
|
+
let stopLeftHolders = false;
|
|
1589
|
+
/** after the shell's exit: one more drain window from now (a byte that follows the exit renews it) */
|
|
1590
|
+
const armDrain = () => {
|
|
1591
|
+
if (exitSeen === null)
|
|
1592
|
+
return;
|
|
1593
|
+
clearTimeout(drainTimer);
|
|
1594
|
+
drainTimer = setTimeout(onDrainExpired, Math.max(0, Math.min(EXIT_DRAIN_MS, exitAtMs + EXIT_DRAIN_MAX_MS - Date.now())));
|
|
1595
|
+
drainTimer.unref?.();
|
|
1596
|
+
};
|
|
1519
1597
|
/** 3e: the unregister of this command's detach — while it runs in the foreground */
|
|
1520
1598
|
let undetachable = () => { };
|
|
1521
1599
|
let recent = ""; // the tail readyWhen is matched against
|
|
@@ -1565,6 +1643,7 @@ export function shellTool(opts) {
|
|
|
1565
1643
|
undetachable();
|
|
1566
1644
|
dropProgress();
|
|
1567
1645
|
clearTimeout(timer);
|
|
1646
|
+
clearTimeout(drainTimer);
|
|
1568
1647
|
// E group: the abort listener is removed once settled — it
|
|
1569
1648
|
// must not accumulate across runs.
|
|
1570
1649
|
ctx.signal.removeEventListener("abort", onAbort);
|
|
@@ -1578,6 +1657,7 @@ export function shellTool(opts) {
|
|
|
1578
1657
|
const r = capAccumulate(stdout, text);
|
|
1579
1658
|
stdout = r.text;
|
|
1580
1659
|
stdoutDropped += r.dropped;
|
|
1660
|
+
armDrain();
|
|
1581
1661
|
if (tasks !== undefined && matchReady(text))
|
|
1582
1662
|
promote("ready");
|
|
1583
1663
|
});
|
|
@@ -1589,6 +1669,7 @@ export function shellTool(opts) {
|
|
|
1589
1669
|
const r = capAccumulate(stderr, text);
|
|
1590
1670
|
stderr = r.text;
|
|
1591
1671
|
stderrDropped += r.dropped;
|
|
1672
|
+
armDrain();
|
|
1592
1673
|
if (tasks !== undefined && matchReady(text))
|
|
1593
1674
|
promote("ready");
|
|
1594
1675
|
});
|
|
@@ -1602,6 +1683,11 @@ export function shellTool(opts) {
|
|
|
1602
1683
|
? ""
|
|
1603
1684
|
: `\n… [${stream} capped at ${OUTPUT_CAP} chars — ${dropped} more chars dropped; capture to a file and read it with read_file, or narrow the command]`;
|
|
1604
1685
|
const combinedOutput = () => (stdout + overflowNote(stdoutDropped, "stdout") + (stderr ? `\n[stderr] ${stderr}` : "") + overflowNote(stderrDropped, "stderr")).trim();
|
|
1686
|
+
/** the output captured so far, for a timeout or abort verdict: bounded like a normal result (the same caps and notes) */
|
|
1687
|
+
const partialOutput = () => {
|
|
1688
|
+
const o = combinedOutput();
|
|
1689
|
+
return o === "" ? "" : `\n${o}`;
|
|
1690
|
+
};
|
|
1605
1691
|
let exitInfo = null;
|
|
1606
1692
|
child.on("close", (code, signal) => {
|
|
1607
1693
|
if (promoted !== null) {
|
|
@@ -1611,6 +1697,9 @@ export function shellTool(opts) {
|
|
|
1611
1697
|
promoted.out.close();
|
|
1612
1698
|
promoted.task.ended(code, signal);
|
|
1613
1699
|
}
|
|
1700
|
+
else if (stopLeftHolders) {
|
|
1701
|
+
promoted.out.close();
|
|
1702
|
+
}
|
|
1614
1703
|
return;
|
|
1615
1704
|
}
|
|
1616
1705
|
if (killing)
|
|
@@ -1620,6 +1709,44 @@ export function shellTool(opts) {
|
|
|
1620
1709
|
? { content: combined || "(no output)", isError: false }
|
|
1621
1710
|
: { content: `exit ${code}: ${combined}`, isError: true, errorKind: "fatal" });
|
|
1622
1711
|
});
|
|
1712
|
+
// The shell's own exit arrives before `close` whenever a descendant keeps
|
|
1713
|
+
// its stdout/stderr open (a daemon started by the command): `close` then
|
|
1714
|
+
// waits for the descendant, not for the command. The exit is recorded;
|
|
1715
|
+
// after a short drain window with no `close`, a manager takes the
|
|
1716
|
+
// descendants over (below). It is never read as "done" on its own.
|
|
1717
|
+
child.on("exit", (code, signal) => {
|
|
1718
|
+
// our own kill (timeout, abort) is not the command's exit
|
|
1719
|
+
if (killing)
|
|
1720
|
+
return;
|
|
1721
|
+
exitAtMs = Date.now();
|
|
1722
|
+
exitSeen = { code, signal, afterMs: exitAtMs - spawnedAt };
|
|
1723
|
+
if (settled || promoted !== null)
|
|
1724
|
+
return;
|
|
1725
|
+
armDrain();
|
|
1726
|
+
});
|
|
1727
|
+
const exitHow = () => (exitSeen === null ? "" : exitSeen.signal !== null ? `signal ${exitSeen.signal}` : `code ${exitSeen.code}`);
|
|
1728
|
+
/** a timeout/abort verdict on a command that had already exited says so */
|
|
1729
|
+
const exitNote = () => exitSeen === null ? "" : `; the command itself had already exited (${exitHow()}, after ${exitSeen.afterMs} ms) but processes it started still held its output`;
|
|
1730
|
+
const onDrainExpired = () => {
|
|
1731
|
+
if (settled || killing || promoted !== null || exitSeen === null)
|
|
1732
|
+
return;
|
|
1733
|
+
// No manager: nothing can own the descendants, so the call is NOT complete
|
|
1734
|
+
// and is not reported as such — it keeps waiting for `close` under the
|
|
1735
|
+
// same kill timeout and abort.
|
|
1736
|
+
if (tasks === undefined)
|
|
1737
|
+
return;
|
|
1738
|
+
// win32: `taskkill /T` walks from a live root, so a stop could not reach
|
|
1739
|
+
// the descendants of an exited one. Not handed over there until it can.
|
|
1740
|
+
if (process.platform === "win32")
|
|
1741
|
+
return;
|
|
1742
|
+
try {
|
|
1743
|
+
promote("exited");
|
|
1744
|
+
}
|
|
1745
|
+
catch {
|
|
1746
|
+
// the hand-over was refused: keep waiting. Success is never reported for
|
|
1747
|
+
// work no manager owns.
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1623
1750
|
// The kernel's abort reaches the command AND its whole tree.
|
|
1624
1751
|
// The listener is removed by settle (E group).
|
|
1625
1752
|
const uncertainVerdict = (unconfirmed) => unconfirmed.length > 0
|
|
@@ -1628,7 +1755,7 @@ export function shellTool(opts) {
|
|
|
1628
1755
|
const onAbort = () => {
|
|
1629
1756
|
killing = true;
|
|
1630
1757
|
void killTree(child).then(({ unconfirmed }) => settle({
|
|
1631
|
-
content: `shell aborted${uncertainVerdict(unconfirmed) ? ` — ${uncertainVerdict(unconfirmed)}` : ""}`,
|
|
1758
|
+
content: `shell aborted${uncertainVerdict(unconfirmed) ? ` — ${uncertainVerdict(unconfirmed)}` : ""}${exitNote()}${partialOutput()}`,
|
|
1632
1759
|
isError: true,
|
|
1633
1760
|
errorKind: "fatal",
|
|
1634
1761
|
}));
|
|
@@ -1649,12 +1776,47 @@ export function shellTool(opts) {
|
|
|
1649
1776
|
startedAt: spawnedAt,
|
|
1650
1777
|
stop: () => stopPromoted(),
|
|
1651
1778
|
});
|
|
1652
|
-
|
|
1779
|
+
// the task is in the journal from here. If its output cannot be prepared the
|
|
1780
|
+
// hand-over failed and NOTHING about the command ended: it still runs under
|
|
1781
|
+
// this call. The record says only that the task's outcome is not known (no
|
|
1782
|
+
// terminal is invented: the journal has no record for a failed hand-over, see
|
|
1783
|
+
// ADR-0058 Amendment 17); the failure is the caller's to see.
|
|
1784
|
+
let out;
|
|
1653
1785
|
const sofar = combinedOutput();
|
|
1654
|
-
|
|
1655
|
-
out
|
|
1786
|
+
try {
|
|
1787
|
+
out = new RotatingOutput(task.outputPath, TASK_OUTPUT_CAP);
|
|
1788
|
+
if (sofar !== "")
|
|
1789
|
+
out.write(Buffer.from(`${sofar}\n`));
|
|
1790
|
+
}
|
|
1791
|
+
catch (err) {
|
|
1792
|
+
try {
|
|
1793
|
+
out?.close();
|
|
1794
|
+
}
|
|
1795
|
+
catch {
|
|
1796
|
+
// nothing more to release
|
|
1797
|
+
}
|
|
1798
|
+
if (task.handoverFailed !== undefined)
|
|
1799
|
+
task.handoverFailed(err instanceof Error ? err.message : String(err));
|
|
1800
|
+
else
|
|
1801
|
+
task.unconfirmed([]);
|
|
1802
|
+
throw err;
|
|
1803
|
+
}
|
|
1804
|
+
rootGone = reason === "exited";
|
|
1656
1805
|
promoted = { task, out };
|
|
1657
1806
|
const tail = sofar.length > 2048 ? `…${sofar.slice(-2048)}` : sofar;
|
|
1807
|
+
if (reason === "exited" && exitSeen !== null) {
|
|
1808
|
+
// the command is done; what the manager now owns is whatever it left running on its pipes
|
|
1809
|
+
const failed = exitSeen.signal !== null || exitSeen.code !== 0;
|
|
1810
|
+
settle({
|
|
1811
|
+
content: `the command exited (${exitHow()}, after ${exitSeen.afterMs} ms)${failed ? " and FAILED" : ""}, but processes it started are still running and hold its output` +
|
|
1812
|
+
`${failed ? " — its side effects may be incomplete" : ""}; ` +
|
|
1813
|
+
`they continue as background task ${task.id} (not killed — it keeps running). ${taskNote(task.outputPath)}` +
|
|
1814
|
+
(tail !== "" ? `\nOutput so far:\n${tail}` : ""),
|
|
1815
|
+
isError: failed,
|
|
1816
|
+
...(failed ? { errorKind: "fatal" } : {}),
|
|
1817
|
+
});
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1658
1820
|
settle({
|
|
1659
1821
|
content: (reason === "ready"
|
|
1660
1822
|
? `ready — the output contains "${readyWhen}"; `
|
|
@@ -1688,6 +1850,18 @@ export function shellTool(opts) {
|
|
|
1688
1850
|
promoted.task.unconfirmed(unconfirmed);
|
|
1689
1851
|
return;
|
|
1690
1852
|
}
|
|
1853
|
+
// A root that had already exited leaves nothing to enumerate a descendant
|
|
1854
|
+
// from if that descendant left its group and was reparented, and the pipes
|
|
1855
|
+
// closing proves nothing about it: it may have closed its stdout/stderr and
|
|
1856
|
+
// lived on. What the sweep found is gone; that nothing else is left cannot be
|
|
1857
|
+
// shown, so the stop is recorded as unconfirmed and never as stopped.
|
|
1858
|
+
if (rootGone) {
|
|
1859
|
+
promoted.task.unconfirmed([]);
|
|
1860
|
+
stopLeftHolders = true;
|
|
1861
|
+
if (exitInfo !== null)
|
|
1862
|
+
promoted.out.close();
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1691
1865
|
const waitClose = Date.now() + 1_000;
|
|
1692
1866
|
while (exitInfo === null && Date.now() < waitClose)
|
|
1693
1867
|
await new Promise((r) => setTimeout(r, 25));
|
|
@@ -1708,7 +1882,7 @@ export function shellTool(opts) {
|
|
|
1708
1882
|
}
|
|
1709
1883
|
killing = true;
|
|
1710
1884
|
void killTree(child).then(({ unconfirmed }) => settle({
|
|
1711
|
-
content: `shell timed out after ${timeout}ms${uncertainVerdict(unconfirmed) ? ` — ${uncertainVerdict(unconfirmed)}` : ""}`,
|
|
1885
|
+
content: `shell timed out after ${timeout}ms${uncertainVerdict(unconfirmed) ? ` — ${uncertainVerdict(unconfirmed)}` : ""}${exitNote()}${partialOutput()}`,
|
|
1712
1886
|
isError: true,
|
|
1713
1887
|
errorKind: "fatal",
|
|
1714
1888
|
}));
|
|
@@ -14,6 +14,11 @@ export interface ProcessTaskBackendOptions {
|
|
|
14
14
|
readonly runnerPath?: string;
|
|
15
15
|
/** How long the runner has to record itself. Default 10 s. */
|
|
16
16
|
readonly startTimeoutMs?: number;
|
|
17
|
+
/** Environment for the RUNNER process only. The command the runner starts
|
|
18
|
+
* does not inherit it. A host whose `process.execPath` is not node (an
|
|
19
|
+
* Electron app: `ELECTRON_RUN_AS_NODE=1` makes the runner a plain node
|
|
20
|
+
* process and keeps it out of the Dock) says so here. */
|
|
21
|
+
readonly runnerEnv?: Readonly<Record<string, string>>;
|
|
17
22
|
}
|
|
18
23
|
export interface ProcessTaskBackend {
|
|
19
24
|
spawn(spec: {
|
package/dist/process-backend.js
CHANGED
|
@@ -22,7 +22,12 @@ export function processTaskBackend(options = {}) {
|
|
|
22
22
|
for (const [k, v] of Object.entries(env))
|
|
23
23
|
if (v !== undefined)
|
|
24
24
|
clean[k] = v;
|
|
25
|
-
|
|
25
|
+
// the runner hands its own environment to the command; the names it
|
|
26
|
+
// was given for itself are listed so it can take them out again
|
|
27
|
+
const own = options.runnerEnv ?? {};
|
|
28
|
+
const ownNames = Object.keys(own);
|
|
29
|
+
const runnerEnv = ownNames.length === 0 ? clean : { ...clean, ...own, KISO_TASK_RUNNER_OWN_ENV: ownNames.join(",") };
|
|
30
|
+
const child = spawn(process.execPath, [runnerPath, dir], { detached: true, windowsHide: true, stdio: "ignore", env: runnerEnv });
|
|
26
31
|
child.unref();
|
|
27
32
|
const journal = join(dir, "journal.jsonl");
|
|
28
33
|
const deadline = Date.now() + (options.startTimeoutMs ?? 10_000);
|
package/dist/task-runner.js
CHANGED
|
@@ -105,6 +105,11 @@ function main(dir) {
|
|
|
105
105
|
delete env.KISO_TASK_RUNNER_PAUSE_AFTER;
|
|
106
106
|
delete env.KISO_TASK_RUNNER_STOP_UNCONFIRMED;
|
|
107
107
|
delete env.KISO_TASK_OUTPUT_CAP;
|
|
108
|
+
// names the backend set for the runner alone (processTaskBackend runnerEnv)
|
|
109
|
+
for (const name of (env.KISO_TASK_RUNNER_OWN_ENV ?? "").split(","))
|
|
110
|
+
if (name !== "")
|
|
111
|
+
delete env[name];
|
|
112
|
+
delete env.KISO_TASK_RUNNER_OWN_ENV;
|
|
108
113
|
// "" when the start time cannot be read: the identity is then the pid
|
|
109
114
|
// alone, and the backend treats it as unverifiable, never as dead
|
|
110
115
|
const self = processStartTime(process.pid);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tools-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.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.
|
|
28
|
+
"@vincemakes/kiso-core": "0.53.0",
|
|
29
29
|
"ignore": "^7.0.9"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|