@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.
- package/README.md +7 -0
- package/dist/index.d.ts +99 -2
- package/dist/index.js +360 -206
- package/dist/process-backend.d.ts +26 -0
- package/dist/process-backend.js +70 -0
- package/dist/process.d.ts +81 -0
- package/dist/process.js +375 -0
- package/dist/task-runner.d.ts +32 -0
- package/dist/task-runner.js +189 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
* states what was dropped (deterministic per file state), so the model
|
|
18
18
|
* always has a path to the full content.
|
|
19
19
|
*/
|
|
20
|
-
import { execFile
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
21
|
import { promisify } from "node:util";
|
|
22
|
-
import { appendFileSync, chmodSync, existsSync, linkSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, linkSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { Worker } from "node:worker_threads";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { killTree, NO_BASH, processStartTime, RotatingOutput, startCommand } from "./process.js";
|
|
25
26
|
import { createHash } from "node:crypto";
|
|
26
27
|
import { tmpdir } from "node:os";
|
|
27
28
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
@@ -78,6 +79,16 @@ const ACI2_LINES_SHOWN = 5;
|
|
|
78
79
|
const ACI2_OFFSETS_KEPT = 500;
|
|
79
80
|
const OUTPUT_CAP = 100_000; // chars of output a tool result may carry
|
|
80
81
|
const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
|
|
82
|
+
/** ADR-0058 §3: with tasks, how long the shell waits before a command
|
|
83
|
+
* continues as a task (the model's own value wins). */
|
|
84
|
+
const DEFAULT_FOREGROUND_MS = 60_000;
|
|
85
|
+
/** A promoted task's output file rotates here, as a runner's does. */
|
|
86
|
+
const TASK_OUTPUT_CAP = 64 * 1024 * 1024;
|
|
87
|
+
/** A task's stop: SIGTERM, this long, then the confirmed sweep. */
|
|
88
|
+
const TASK_STOP_GRACE_MS = 5_000;
|
|
89
|
+
/** ADR-0058 Amendment 7: how long task_stop waits for the end it reports —
|
|
90
|
+
* the runner's journal poll, TERM, the grace, KILL and the sweep. */
|
|
91
|
+
const TASK_STOP_WAIT_MS = 8_000;
|
|
81
92
|
// the token round: the scoped-read defaults — read_file shows the head 200 lines
|
|
82
93
|
// of a large file (with an actionable continuation note, never a silent
|
|
83
94
|
// drop), search_text caps at 50 excerpts, list_dir at 200 entries. The
|
|
@@ -167,6 +178,25 @@ export class PathEscapeError extends Error {
|
|
|
167
178
|
this.name = "PathEscapeError";
|
|
168
179
|
}
|
|
169
180
|
}
|
|
181
|
+
/** ADR-0058 §4: read_file's resolution — the workspace, or an absolute path
|
|
182
|
+
* inside one of the host-granted read-only roots. The containment check is
|
|
183
|
+
* the workspace's own, applied to that root. */
|
|
184
|
+
function resolveReadable(opts, input, sessionId) {
|
|
185
|
+
if (isAbsolute(input)) {
|
|
186
|
+
// 3b: the session's own task directory, when the host wires tasks
|
|
187
|
+
const taskRoot = opts.tasks?.(sessionId)?.root;
|
|
188
|
+
for (const root of [...(opts.extraReadRoots ?? []), ...(taskRoot !== undefined ? [taskRoot] : [])]) {
|
|
189
|
+
if (!existsSync(root))
|
|
190
|
+
continue;
|
|
191
|
+
for (const base of [root, realpathSync(root)]) {
|
|
192
|
+
const rel = relative(base, input);
|
|
193
|
+
if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel))
|
|
194
|
+
return { full: resolveWithinRoot(root, rel), root };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { full: resolveWithinRoot(opts.workspaceRoot, input), root: opts.workspaceRoot };
|
|
199
|
+
}
|
|
170
200
|
export function resolveWithinRoot(root, input) {
|
|
171
201
|
if (isAbsolute(input)) {
|
|
172
202
|
throw new PathEscapeError(`absolute paths are not allowed — use workspace-relative paths: ${input}`);
|
|
@@ -264,6 +294,10 @@ async function inodeReadPolicy(root, full) {
|
|
|
264
294
|
return `not a regular file — refusing to read (${full})`;
|
|
265
295
|
if (st.nlink <= 1)
|
|
266
296
|
return null;
|
|
297
|
+
// Windows: no find can list a file's links (find there is the system's
|
|
298
|
+
// text search) — refused without a scan, and the refusal says why
|
|
299
|
+
if (process.platform === "win32")
|
|
300
|
+
return `file has ${st.nlink} hard links, and on Windows kiso cannot check where they all are — refusing to read (${full})`;
|
|
267
301
|
const key = `${st.dev}:${st.ino}`;
|
|
268
302
|
const cached = inodeVerdict.get(key);
|
|
269
303
|
if (cached !== undefined)
|
|
@@ -350,16 +384,16 @@ export function readFileTool(opts) {
|
|
|
350
384
|
effects: { precommitSafe: true, concurrency: "shared" },
|
|
351
385
|
promptSnippet: "read_file — whole files or offset/limit ranges, workspace-relative paths",
|
|
352
386
|
promptGuidelines: ["read only the range you need — offset/limit beat whole-file reads"],
|
|
353
|
-
execute: async ({ path, offset, limit }) => {
|
|
387
|
+
execute: async ({ path, offset, limit }, ctx) => {
|
|
354
388
|
const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
|
|
355
389
|
try {
|
|
356
|
-
const full =
|
|
390
|
+
const { full, root } = resolveReadable(opts, path, ctx?.sessionId);
|
|
357
391
|
// the credential store is never served (protected.ts) — checked
|
|
358
392
|
// by the disk's own resolution and by inode, before any read
|
|
359
393
|
const guard = protectedIdentity(opts.protectedFiles);
|
|
360
394
|
if (isProtectedPath(full, guard))
|
|
361
395
|
return precondition(protectedRefusalText("read_file", path));
|
|
362
|
-
const denied = await inodeReadPolicy(
|
|
396
|
+
const denied = await inodeReadPolicy(root, full);
|
|
363
397
|
if (denied !== null)
|
|
364
398
|
return escapeResult(denied);
|
|
365
399
|
// DC-54 — the ceiling. `read_file` had the same unbounded
|
|
@@ -1133,9 +1167,15 @@ export function editFileTool(opts) {
|
|
|
1133
1167
|
// against the first one's output. Still all or nothing: nothing is
|
|
1134
1168
|
// written unless every hunk resolves, and each must match exactly
|
|
1135
1169
|
// once in the text it meets (ACI-2).
|
|
1136
|
-
|
|
1170
|
+
// Windows P3: a uniformly CRLF file is matched on its LF form and
|
|
1171
|
+
// written back CRLF — the model's search text uses LF, and an edit
|
|
1172
|
+
// never changes the file's endings. A file mixing the two keeps
|
|
1173
|
+
// exact matching: normalizing it would rewrite untouched lines.
|
|
1174
|
+
const crlf = text.includes("\r\n") && !/(^|[^\r])\n/.test(text);
|
|
1175
|
+
const lf = (t) => (crlf ? t.replace(/\r\n/g, "\n") : t);
|
|
1176
|
+
let edited = lf(text);
|
|
1137
1177
|
for (let i = 0; i < hunks.length; i += 1) {
|
|
1138
|
-
const h = hunks[i];
|
|
1178
|
+
const h = { search: lf(hunks[i].search), replace: lf(hunks[i].replace) };
|
|
1139
1179
|
const { count, offsets } = occurrencesOf(edited, h.search);
|
|
1140
1180
|
const which = hunks.length === 1 && edits === undefined ? "" : i === 0 ? " (hunk 1)" : ` (hunk ${i + 1}, after ${i === 1 ? "hunk 1" : `hunks 1–${i}`} applied)`;
|
|
1141
1181
|
if (count === 0) {
|
|
@@ -1165,7 +1205,10 @@ export function editFileTool(opts) {
|
|
|
1165
1205
|
// E group: safe replacement — never rewrite a shared external inode via a hard link.
|
|
1166
1206
|
// round 8: the edited file keeps its mode.
|
|
1167
1207
|
preservedMode = statSync(full).mode & 0o7777;
|
|
1168
|
-
|
|
1208
|
+
// ONE buffer is written and hashed: the revision returned is the
|
|
1209
|
+
// file's on disk, CRLF or not (the review's finding)
|
|
1210
|
+
const bytesOut = Buffer.from(crlf ? edited.replace(/\n/g, "\r\n") : edited, "utf8");
|
|
1211
|
+
writeFileSync(tmp, bytesOut);
|
|
1169
1212
|
chmodSync(tmp, preservedMode);
|
|
1170
1213
|
// WR-1A ③: revalidate against the citation right before the
|
|
1171
1214
|
// replacement commits.
|
|
@@ -1180,7 +1223,7 @@ export function editFileTool(opts) {
|
|
|
1180
1223
|
// WR-1A ①: post-effect — fatal, the rename already landed.
|
|
1181
1224
|
return postEffectEscape("edit", path);
|
|
1182
1225
|
}
|
|
1183
|
-
return { content: `edited ${path}\n[${contentRevision(
|
|
1226
|
+
return { content: `edited ${path}\n[${contentRevision(bytesOut)}]`, isError: false };
|
|
1184
1227
|
}
|
|
1185
1228
|
catch (err) {
|
|
1186
1229
|
// round 8: a failed edit never leaves a temp file behind.
|
|
@@ -1205,51 +1248,154 @@ export { PROTECTED_REFUSAL, diskPath, isProtectedPath, protectedIdentity, protec
|
|
|
1205
1248
|
* read-only shell allow holds a shell read to the same definition rather
|
|
1206
1249
|
* than a copy of it. */
|
|
1207
1250
|
export { isCredentialName, isCredentialPath } from "./corpus.js";
|
|
1251
|
+
// ADR-0058: the process task backend (the runner ships beside it)
|
|
1252
|
+
export { processTaskBackend } from "./process-backend.js";
|
|
1253
|
+
/** How a task ended, in a few words: its signal, its exit code, or why it
|
|
1254
|
+
* never ran. */
|
|
1255
|
+
function howEnded(task) {
|
|
1256
|
+
const s = task.state;
|
|
1257
|
+
if (s.kind === "unknown")
|
|
1258
|
+
return "its runner is gone and its end is unknown";
|
|
1259
|
+
if (s.error !== undefined)
|
|
1260
|
+
return `it never started: ${s.error}`;
|
|
1261
|
+
if (s.signal !== undefined && s.signal !== null)
|
|
1262
|
+
return s.signal;
|
|
1263
|
+
if (s.exitCode !== undefined && s.exitCode !== null)
|
|
1264
|
+
return `exit code ${s.exitCode}`;
|
|
1265
|
+
return "no exit code";
|
|
1266
|
+
}
|
|
1267
|
+
/** The last `bytes` of a task's output file ("" when there is none). */
|
|
1268
|
+
function outputTail(path, bytes = 2_048) {
|
|
1269
|
+
try {
|
|
1270
|
+
const fd = openSync(path, "r");
|
|
1271
|
+
try {
|
|
1272
|
+
const size = fstatSync(fd).size;
|
|
1273
|
+
const len = Math.min(size, bytes);
|
|
1274
|
+
const buf = Buffer.alloc(len);
|
|
1275
|
+
readSync(fd, buf, 0, len, size - len);
|
|
1276
|
+
return `${size > len ? "…" : ""}${buf.toString("utf8")}`.trim();
|
|
1277
|
+
}
|
|
1278
|
+
finally {
|
|
1279
|
+
closeSync(fd);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
return "";
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
/** This process as a promoted task's owner — read once. */
|
|
1287
|
+
let selfIdentity;
|
|
1288
|
+
function ownerIdentity() {
|
|
1289
|
+
if (selfIdentity === undefined) {
|
|
1290
|
+
const id = processStartTime(process.pid);
|
|
1291
|
+
selfIdentity = { pid: process.pid, startedAt: id.kind === "running" ? id.startedAt : "" };
|
|
1292
|
+
}
|
|
1293
|
+
return selfIdentity;
|
|
1294
|
+
}
|
|
1208
1295
|
export function shellTool(opts) {
|
|
1296
|
+
// ADR-0058 (3b): the task-aware contract exists only where tasks are
|
|
1297
|
+
// wired; a host without them keeps today's schema byte for byte.
|
|
1298
|
+
const withTasks = opts.tasks !== undefined;
|
|
1299
|
+
// Windows P1: commands run through Git Bash there, /bin/sh elsewhere
|
|
1300
|
+
const sh = process.platform === "win32" ? "bash" : "/bin/sh";
|
|
1209
1301
|
return defineTool({
|
|
1210
1302
|
name: "shell",
|
|
1211
|
-
description:
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1303
|
+
description: withTasks
|
|
1304
|
+
? `Run a shell command through ${sh} with the workspace root as the working directory: builds, tests, git, package managers, curl for HTTP APIs, system queries. Side effects are real; the human may be asked to approve the run. A command still running after foregroundMs is never killed: it continues as a background task, the result gives its id and output path, and you are notified when it ends. Fails loudly on a non-zero exit.`
|
|
1305
|
+
: `Run a shell command through ${sh} with the workspace root as the working directory: builds, tests, git, package managers, curl for HTTP APIs, system queries. Side effects are real; the human may be asked to approve the run. Fails loudly on timeout or non-zero exit.`,
|
|
1306
|
+
parameters: withTasks
|
|
1307
|
+
? {
|
|
1308
|
+
type: "object",
|
|
1309
|
+
properties: {
|
|
1310
|
+
command: { type: "string", description: "The command to run" },
|
|
1311
|
+
foregroundMs: { type: "number", description: "How long to wait for the result, in ms (default 60000); if you need the result to go on, allow enough time" },
|
|
1312
|
+
background: {
|
|
1313
|
+
type: "boolean",
|
|
1314
|
+
description: "Run independently as a task. Use for services/watchers or work whose exit result is not needed next. If you need the result, keep it foreground and raise foregroundMs. You are notified when it ends; do not sleep/poll.",
|
|
1315
|
+
},
|
|
1316
|
+
readyWhen: { type: "string", description: "For a continuing service, wait up to foregroundMs for this literal output before returning; the task keeps running afterward." },
|
|
1317
|
+
timeoutMs: { type: "number", description: "Deprecated: use foregroundMs" },
|
|
1318
|
+
},
|
|
1319
|
+
required: ["command"],
|
|
1320
|
+
additionalProperties: false,
|
|
1321
|
+
}
|
|
1322
|
+
: {
|
|
1323
|
+
type: "object",
|
|
1324
|
+
properties: {
|
|
1325
|
+
command: { type: "string", description: "The command to run" },
|
|
1326
|
+
timeoutMs: { type: "number", description: "Timeout in ms (default 30000)" },
|
|
1327
|
+
},
|
|
1328
|
+
required: ["command"],
|
|
1329
|
+
additionalProperties: false,
|
|
1217
1330
|
},
|
|
1218
|
-
required: ["command"],
|
|
1219
|
-
additionalProperties: false,
|
|
1220
|
-
},
|
|
1221
1331
|
promptSnippet: "shell — any command the task needs (builds, tests, git, curl, system queries)",
|
|
1222
1332
|
promptGuidelines: ["commands run in the workspace root; on failure read the error and adjust — never repeat blindly"],
|
|
1223
|
-
execute: async ({ command, timeoutMs }, ctx) => {
|
|
1224
|
-
|
|
1333
|
+
execute: async ({ command, timeoutMs, foregroundMs, background, readyWhen }, ctx) => {
|
|
1334
|
+
// the session's tasks, when wired and this call has a session
|
|
1335
|
+
const tasks = opts.tasks?.(ctx.sessionId);
|
|
1336
|
+
const timeout = foregroundMs ?? timeoutMs ?? (tasks !== undefined ? DEFAULT_FOREGROUND_MS : DEFAULT_SHELL_TIMEOUT_MS);
|
|
1225
1337
|
// E group: a PRE-aborted signal never spawns the command.
|
|
1226
1338
|
if (ctx.signal.aborted) {
|
|
1227
1339
|
return { content: "shell aborted before start", isError: true, errorKind: "fatal" };
|
|
1228
1340
|
}
|
|
1341
|
+
// bootstrap #3 (finding #7): the shell child NEVER inherits kiso's own
|
|
1342
|
+
// provider credentials by default — only the explicit
|
|
1343
|
+
// shellEnv: "inherit" opt-in keeps them. A record (ADR-0031
|
|
1344
|
+
// Amendment 1) rides on the STRIPPED base and wins per key.
|
|
1345
|
+
const env = opts.shellEnv === "inherit" ? process.env : { ...strippedShellEnv(process.env, opts.secretEnvNames), ...(opts.shellEnv ?? {}) };
|
|
1346
|
+
if (background === true) {
|
|
1347
|
+
if (tasks === undefined)
|
|
1348
|
+
return { content: "background tasks are not available here — run the command in the foreground", isError: true, errorKind: "precondition" };
|
|
1349
|
+
const task = await tasks.start({
|
|
1350
|
+
command,
|
|
1351
|
+
cwd: opts.workspaceRoot,
|
|
1352
|
+
env,
|
|
1353
|
+
...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}),
|
|
1354
|
+
...(readyWhen !== undefined ? { readyWhen } : {}),
|
|
1355
|
+
profile: readyWhen !== undefined ? "service" : "oneshot",
|
|
1356
|
+
});
|
|
1357
|
+
const settle = tasks.awaitSettled?.bind(tasks);
|
|
1358
|
+
if (readyWhen === undefined || settle === undefined)
|
|
1359
|
+
return { content: `started background task ${task.id}. ${taskNote(task.outputPath)}`, isError: false };
|
|
1360
|
+
return waitForReady(tasks, settle, task, readyWhen, timeout, ctx);
|
|
1361
|
+
}
|
|
1229
1362
|
return new Promise((resolvePromise) => {
|
|
1230
1363
|
// detached: the command gets its OWN process group, so a
|
|
1231
1364
|
// timeout/abort can kill the WHOLE TREE (children included),
|
|
1232
1365
|
// not just the outer shell (Area 4). cwd is the workspace.
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
: { ...strippedShellEnv(process.env, opts.secretEnvNames), ...(opts.shellEnv ?? {}) },
|
|
1245
|
-
});
|
|
1366
|
+
let child;
|
|
1367
|
+
try {
|
|
1368
|
+
child = startCommand(command, { cwd: opts.workspaceRoot, env });
|
|
1369
|
+
}
|
|
1370
|
+
catch (err) {
|
|
1371
|
+
// win32 with no usable bash: an answer the model can act on
|
|
1372
|
+
if (err.code !== NO_BASH)
|
|
1373
|
+
throw err;
|
|
1374
|
+
resolvePromise({ content: `shell failed: ${err.message}`, isError: true, errorKind: "fatal" });
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1246
1377
|
let stdout = "";
|
|
1247
1378
|
let stderr = "";
|
|
1248
1379
|
let stdoutDropped = 0;
|
|
1249
1380
|
let stderrDropped = 0;
|
|
1250
|
-
let exited = false;
|
|
1251
1381
|
let settled = false;
|
|
1252
1382
|
let killing = false;
|
|
1383
|
+
// ADR-0058 (3b): once promoted, the child's output goes to its task
|
|
1384
|
+
let promoted = null;
|
|
1385
|
+
let stopping = false;
|
|
1386
|
+
let readySeen = false;
|
|
1387
|
+
/** 3e: the unregister of this command's detach — while it runs in the foreground */
|
|
1388
|
+
let undetachable = () => { };
|
|
1389
|
+
let recent = ""; // the tail readyWhen is matched against
|
|
1390
|
+
const matchReady = (text) => {
|
|
1391
|
+
if (readyWhen === undefined || readySeen)
|
|
1392
|
+
return false;
|
|
1393
|
+
recent = (recent + text).slice(-(readyWhen.length + 65_536));
|
|
1394
|
+
if (!recent.includes(readyWhen))
|
|
1395
|
+
return false;
|
|
1396
|
+
readySeen = true;
|
|
1397
|
+
return true;
|
|
1398
|
+
};
|
|
1253
1399
|
// TUI2-R1 (C): the progress sidecar — truncated at the start
|
|
1254
1400
|
// (a kill -9 leftover is cleared, never appended to), appended
|
|
1255
1401
|
// per chunk, removed at settle. Every operation best-effort:
|
|
@@ -1284,6 +1430,7 @@ export function shellTool(opts) {
|
|
|
1284
1430
|
if (settled)
|
|
1285
1431
|
return;
|
|
1286
1432
|
settled = true;
|
|
1433
|
+
undetachable();
|
|
1287
1434
|
dropProgress();
|
|
1288
1435
|
clearTimeout(timer);
|
|
1289
1436
|
// E group: the abort listener is removed once settled — it
|
|
@@ -1291,134 +1438,52 @@ export function shellTool(opts) {
|
|
|
1291
1438
|
ctx.signal.removeEventListener("abort", onAbort);
|
|
1292
1439
|
resolvePromise(result);
|
|
1293
1440
|
};
|
|
1294
|
-
/**
|
|
1295
|
-
* Kill the whole tree and CONFIRM it exited (rounds 8/11):
|
|
1296
|
-
*
|
|
1297
|
-
* 1. FREEZE the root (SIGSTOP) FIRST — a stopped shell cannot
|
|
1298
|
-
* fork new descendants while we enumerate;
|
|
1299
|
-
* 2. repeatedly discover AND freeze descendants (pid-table
|
|
1300
|
-
* sweep — the only way to see a setsid()-escaped process)
|
|
1301
|
-
* until the set is STABLE (two identical scans), so the
|
|
1302
|
-
* enumeration cannot miss a mid-sweep fork;
|
|
1303
|
-
* 3. SIGKILL the process group and every tracked pid;
|
|
1304
|
-
* 4. poll every tracked pid to death. If ANY tracked pid is
|
|
1305
|
-
* still alive at the deadline, the verdict is NOT
|
|
1306
|
-
* "aborted"/"timed out" — it is an explicit UNCERTAIN
|
|
1307
|
-
* error naming the survivors: the side effect may have
|
|
1308
|
-
* outlived the tool, and the caller must not assume it
|
|
1309
|
-
* was killed.
|
|
1310
|
-
*/
|
|
1311
|
-
const killTree = () => new Promise((resolveKill) => {
|
|
1312
|
-
const tracked = new Set();
|
|
1313
|
-
// round 11 (adversarial): the ROOT itself is tracked too — the
|
|
1314
|
-
// verdict must not read "aborted" while the root
|
|
1315
|
-
// survives. DOCUMENTED LIMITS: (1) a process that
|
|
1316
|
-
// forks between SIGSTOP delivery and the next scan,
|
|
1317
|
-
// setsids, and is then reparented when its parent is
|
|
1318
|
-
// killed can escape the enumeration entirely — it is
|
|
1319
|
-
// untracked and unknowable from the pid table; the
|
|
1320
|
-
// platform cannot confirm it. (2) if THIS process is
|
|
1321
|
-
// killed between the first SIGSTOP and the SIGKILL
|
|
1322
|
-
// sweep, the stopped descendants stay permanently
|
|
1323
|
-
// stopped (nobody SIGCONTs orphans) — the inherent
|
|
1324
|
-
// cost of freeze-first. Both limits are recorded here
|
|
1325
|
-
// so no claim of "the whole tree is gone" is ever
|
|
1326
|
-
// stronger than what the platform can prove.
|
|
1327
|
-
if (child.pid !== undefined && child.pid > 0) {
|
|
1328
|
-
tracked.add(child.pid);
|
|
1329
|
-
try {
|
|
1330
|
-
process.kill(child.pid, "SIGSTOP"); // freeze the root
|
|
1331
|
-
}
|
|
1332
|
-
catch {
|
|
1333
|
-
// already gone
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
// Stable discovery: freeze as we go; stop when two
|
|
1337
|
-
// consecutive scans are identical.
|
|
1338
|
-
let previous = new Set();
|
|
1339
|
-
for (let i = 0; i < 10; i++) {
|
|
1340
|
-
const current = new Set(descendantsOf(child.pid ?? 0));
|
|
1341
|
-
for (const pid of current) {
|
|
1342
|
-
tracked.add(pid);
|
|
1343
|
-
try {
|
|
1344
|
-
process.kill(pid, "SIGSTOP"); // freeze each descendant
|
|
1345
|
-
}
|
|
1346
|
-
catch {
|
|
1347
|
-
// already gone
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
if (current.size === previous.size && [...current].every((pid) => previous.has(pid))) {
|
|
1351
|
-
break;
|
|
1352
|
-
}
|
|
1353
|
-
previous = current;
|
|
1354
|
-
}
|
|
1355
|
-
// The process group (E group: never kill an undefined/0
|
|
1356
|
-
// pid), which also takes the frozen root down.
|
|
1357
|
-
if (child.pid !== undefined && child.pid > 0) {
|
|
1358
|
-
try {
|
|
1359
|
-
process.kill(-child.pid, "SIGKILL");
|
|
1360
|
-
}
|
|
1361
|
-
catch {
|
|
1362
|
-
try {
|
|
1363
|
-
child.kill("SIGKILL");
|
|
1364
|
-
}
|
|
1365
|
-
catch {
|
|
1366
|
-
// already gone
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
for (const pid of tracked) {
|
|
1371
|
-
try {
|
|
1372
|
-
process.kill(pid, "SIGKILL");
|
|
1373
|
-
}
|
|
1374
|
-
catch {
|
|
1375
|
-
// already gone
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
const confirm = () => {
|
|
1379
|
-
void waitAllDead([...tracked]).then((unconfirmed) => resolveKill({ unconfirmed }));
|
|
1380
|
-
};
|
|
1381
|
-
if (exited) {
|
|
1382
|
-
confirm();
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1385
|
-
const fallback = setTimeout(confirm, 2000);
|
|
1386
|
-
child.once("close", () => {
|
|
1387
|
-
clearTimeout(fallback);
|
|
1388
|
-
confirm();
|
|
1389
|
-
});
|
|
1390
|
-
});
|
|
1391
1441
|
child.stdout?.on("data", (d) => {
|
|
1442
|
+
if (promoted !== null)
|
|
1443
|
+
return feedTask(d);
|
|
1392
1444
|
const text = d.toString();
|
|
1393
1445
|
progress(text);
|
|
1394
1446
|
const r = capAccumulate(stdout, text);
|
|
1395
1447
|
stdout = r.text;
|
|
1396
1448
|
stdoutDropped += r.dropped;
|
|
1449
|
+
if (tasks !== undefined && matchReady(text))
|
|
1450
|
+
promote("ready");
|
|
1397
1451
|
});
|
|
1398
1452
|
child.stderr?.on("data", (d) => {
|
|
1453
|
+
if (promoted !== null)
|
|
1454
|
+
return feedTask(d);
|
|
1399
1455
|
const text = d.toString();
|
|
1400
1456
|
progress(text);
|
|
1401
1457
|
const r = capAccumulate(stderr, text);
|
|
1402
1458
|
stderr = r.text;
|
|
1403
1459
|
stderrDropped += r.dropped;
|
|
1460
|
+
if (tasks !== undefined && matchReady(text))
|
|
1461
|
+
promote("ready");
|
|
1404
1462
|
});
|
|
1405
1463
|
child.on("error", (err) => {
|
|
1406
1464
|
settle({ content: `shell failed: ${err.message}`, isError: true, errorKind: "fatal" });
|
|
1407
1465
|
});
|
|
1408
|
-
|
|
1409
|
-
|
|
1466
|
+
// R-C item 2: the overflow note names WHAT was dropped and
|
|
1467
|
+
// the recovery path — a silent tail-cut would be the exact
|
|
1468
|
+
// destructive class this round kills (W10 made model-facing).
|
|
1469
|
+
const overflowNote = (dropped, stream) => dropped === 0
|
|
1470
|
+
? ""
|
|
1471
|
+
: `\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]`;
|
|
1472
|
+
const combinedOutput = () => (stdout + overflowNote(stdoutDropped, "stdout") + (stderr ? `\n[stderr] ${stderr}` : "") + overflowNote(stderrDropped, "stderr")).trim();
|
|
1473
|
+
let exitInfo = null;
|
|
1474
|
+
child.on("close", (code, signal) => {
|
|
1475
|
+
if (promoted !== null) {
|
|
1476
|
+
// a stop's verdict owns the task's last record
|
|
1477
|
+
exitInfo = { code, signal };
|
|
1478
|
+
if (!stopping) {
|
|
1479
|
+
promoted.out.close();
|
|
1480
|
+
promoted.task.ended(code, signal);
|
|
1481
|
+
}
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1410
1484
|
if (killing)
|
|
1411
1485
|
return; // the timeout/abort verdict owns the result
|
|
1412
|
-
|
|
1413
|
-
// the recovery path — a silent tail-cut would be the exact
|
|
1414
|
-
// destructive class this round kills (W10 made model-facing).
|
|
1415
|
-
const overflowNote = (dropped, stream) => dropped === 0
|
|
1416
|
-
? ""
|
|
1417
|
-
: `\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]`;
|
|
1418
|
-
const combined = (stdout +
|
|
1419
|
-
overflowNote(stdoutDropped, "stdout") +
|
|
1420
|
-
(stderr ? `\n[stderr] ${stderr}` : "") +
|
|
1421
|
-
overflowNote(stderrDropped, "stderr")).trim();
|
|
1486
|
+
const combined = combinedOutput();
|
|
1422
1487
|
settle(code === 0
|
|
1423
1488
|
? { content: combined || "(no output)", isError: false }
|
|
1424
1489
|
: { content: `exit ${code}: ${combined}`, isError: true, errorKind: "fatal" });
|
|
@@ -1430,16 +1495,86 @@ export function shellTool(opts) {
|
|
|
1430
1495
|
: "";
|
|
1431
1496
|
const onAbort = () => {
|
|
1432
1497
|
killing = true;
|
|
1433
|
-
void killTree().then(({ unconfirmed }) => settle({
|
|
1498
|
+
void killTree(child).then(({ unconfirmed }) => settle({
|
|
1434
1499
|
content: `shell aborted${uncertainVerdict(unconfirmed) ? ` — ${uncertainVerdict(unconfirmed)}` : ""}`,
|
|
1435
1500
|
isError: true,
|
|
1436
1501
|
errorKind: "fatal",
|
|
1437
1502
|
}));
|
|
1438
1503
|
};
|
|
1439
1504
|
ctx.signal.addEventListener("abort", onAbort);
|
|
1505
|
+
/** ADR-0058 §3: the wait is over — the command continues as a
|
|
1506
|
+
* task this process owns; nothing is killed. */
|
|
1507
|
+
const promote = (reason) => {
|
|
1508
|
+
if (settled || killing || promoted !== null || tasks === undefined)
|
|
1509
|
+
return;
|
|
1510
|
+
const task = tasks.adopt({
|
|
1511
|
+
command,
|
|
1512
|
+
cwd: opts.workspaceRoot,
|
|
1513
|
+
...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}),
|
|
1514
|
+
...(readyWhen !== undefined ? { readyWhen } : {}),
|
|
1515
|
+
runner: ownerIdentity(),
|
|
1516
|
+
ready: reason === "ready",
|
|
1517
|
+
stop: () => stopPromoted(),
|
|
1518
|
+
});
|
|
1519
|
+
const out = new RotatingOutput(task.outputPath, TASK_OUTPUT_CAP);
|
|
1520
|
+
const sofar = combinedOutput();
|
|
1521
|
+
if (sofar !== "")
|
|
1522
|
+
out.write(Buffer.from(`${sofar}\n`));
|
|
1523
|
+
promoted = { task, out };
|
|
1524
|
+
const tail = sofar.length > 2048 ? `…${sofar.slice(-2048)}` : sofar;
|
|
1525
|
+
settle({
|
|
1526
|
+
content: (reason === "ready"
|
|
1527
|
+
? `ready — the output contains "${readyWhen}"; `
|
|
1528
|
+
: reason === "person"
|
|
1529
|
+
? "moved to the background by the person; "
|
|
1530
|
+
: reason === "steer"
|
|
1531
|
+
? "moved to the background so the person's message could land; "
|
|
1532
|
+
: `still running after ${timeout} ms; `) +
|
|
1533
|
+
`continued as background task ${task.id} (not killed — it keeps running). ${taskNote(task.outputPath)}` +
|
|
1534
|
+
(tail !== "" ? `\nOutput so far:\n${tail}` : ""),
|
|
1535
|
+
isError: false,
|
|
1536
|
+
});
|
|
1537
|
+
};
|
|
1538
|
+
const feedTask = (d) => {
|
|
1539
|
+
if (promoted === null)
|
|
1540
|
+
return;
|
|
1541
|
+
promoted.out.write(d);
|
|
1542
|
+
if (matchReady(d.toString()))
|
|
1543
|
+
promoted.task.ready(readyWhen);
|
|
1544
|
+
};
|
|
1545
|
+
/** task_stop, or a clean exit: TERM, a grace, the confirmed
|
|
1546
|
+
* sweep; survivors mean no terminal — the task stays unknown. */
|
|
1547
|
+
const stopPromoted = () => {
|
|
1548
|
+
if (stopping)
|
|
1549
|
+
return;
|
|
1550
|
+
stopping = true;
|
|
1551
|
+
void killTree(child, { graceMs: TASK_STOP_GRACE_MS }).then(async ({ unconfirmed }) => {
|
|
1552
|
+
if (promoted === null)
|
|
1553
|
+
return;
|
|
1554
|
+
if (unconfirmed.length > 0) {
|
|
1555
|
+
promoted.task.unconfirmed(unconfirmed);
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
const waitClose = Date.now() + 1_000;
|
|
1559
|
+
while (exitInfo === null && Date.now() < waitClose)
|
|
1560
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
1561
|
+
promoted.out.close();
|
|
1562
|
+
const e = exitInfo;
|
|
1563
|
+
promoted.task.ended(e?.code ?? null, e?.signal ?? null);
|
|
1564
|
+
});
|
|
1565
|
+
};
|
|
1566
|
+
// 3e: while it runs in the foreground, the person (ctrl+b) or a
|
|
1567
|
+
// steer may move it to the background — through the manager
|
|
1568
|
+
if (tasks?.registerDetachable !== undefined && ctx.executionId !== undefined) {
|
|
1569
|
+
undetachable = tasks.registerDetachable(ctx.executionId, { startedAt: Date.now(), detach: (by) => promote(by) });
|
|
1570
|
+
}
|
|
1440
1571
|
const timer = setTimeout(() => {
|
|
1572
|
+
if (tasks !== undefined) {
|
|
1573
|
+
promote("waited");
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1441
1576
|
killing = true;
|
|
1442
|
-
void killTree().then(({ unconfirmed }) => settle({
|
|
1577
|
+
void killTree(child).then(({ unconfirmed }) => settle({
|
|
1443
1578
|
content: `shell timed out after ${timeout}ms${uncertainVerdict(unconfirmed) ? ` — ${uncertainVerdict(unconfirmed)}` : ""}`,
|
|
1444
1579
|
isError: true,
|
|
1445
1580
|
errorKind: "fatal",
|
|
@@ -1450,71 +1585,89 @@ export function shellTool(opts) {
|
|
|
1450
1585
|
},
|
|
1451
1586
|
});
|
|
1452
1587
|
}
|
|
1453
|
-
/**
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
1456
|
-
*
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
let
|
|
1588
|
+
/** ADR-0058 Amendment 7: `background` with `readyWhen` — the task is the
|
|
1589
|
+
* runner's from the start; this call waits for its ready line (up to the
|
|
1590
|
+
* foreground wait) and reports what it saw. The ready (or an end before
|
|
1591
|
+
* it) is claimed for this call, so no notice repeats it. The person
|
|
1592
|
+
* (ctrl+b), a steer or Esc RELEASES the wait: the work is already a task,
|
|
1593
|
+
* so nothing is promoted and no second task is made. */
|
|
1594
|
+
async function waitForReady(tasks, settle, task, readyWhen, timeout, ctx) {
|
|
1595
|
+
const release = new AbortController();
|
|
1596
|
+
let reason = null;
|
|
1597
|
+
const onEsc = () => {
|
|
1598
|
+
reason ??= "interrupted";
|
|
1599
|
+
release.abort();
|
|
1600
|
+
};
|
|
1601
|
+
if (ctx.signal.aborted)
|
|
1602
|
+
onEsc();
|
|
1603
|
+
else
|
|
1604
|
+
ctx.signal.addEventListener("abort", onEsc);
|
|
1605
|
+
const undetach = tasks.registerDetachable !== undefined && ctx.executionId !== undefined
|
|
1606
|
+
? tasks.registerDetachable(ctx.executionId, {
|
|
1607
|
+
startedAt: Date.now(),
|
|
1608
|
+
detach: (by) => {
|
|
1609
|
+
reason ??= by;
|
|
1610
|
+
release.abort();
|
|
1611
|
+
},
|
|
1612
|
+
})
|
|
1613
|
+
: () => { };
|
|
1462
1614
|
try {
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
const m = line.trim().match(/^(\d+)\s+(\d+)$/);
|
|
1471
|
-
if (m === null)
|
|
1472
|
-
continue;
|
|
1473
|
-
const child = Number(m[1]);
|
|
1474
|
-
const parent = Number(m[2]);
|
|
1475
|
-
if (!children.has(parent))
|
|
1476
|
-
children.set(parent, []);
|
|
1477
|
-
children.get(parent).push(child);
|
|
1478
|
-
}
|
|
1479
|
-
const out = [];
|
|
1480
|
-
const queue = [pid];
|
|
1481
|
-
while (queue.length > 0) {
|
|
1482
|
-
const current = queue.shift();
|
|
1483
|
-
for (const c of children.get(current) ?? []) {
|
|
1484
|
-
out.push(c);
|
|
1485
|
-
queue.push(c);
|
|
1615
|
+
const w = await settle(task.id, "ready", timeout, { ...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}), signal: release.signal });
|
|
1616
|
+
const where = `Output: ${task.outputPath} (read it with read_file); task_stop stops it.`;
|
|
1617
|
+
if (w.settled && w.info.state.kind === "running")
|
|
1618
|
+
return { content: `started background task ${task.id}; ready — the output contains "${readyWhen}". ${taskNote(task.outputPath)}`, isError: false };
|
|
1619
|
+
if (w.settled) {
|
|
1620
|
+
const tail = outputTail(task.outputPath);
|
|
1621
|
+
return { content: `background task ${task.id} ended before it was ready (${howEnded(w.info)})${tail !== "" ? `\n${tail}` : ""}`, isError: true, errorKind: "fatal" };
|
|
1486
1622
|
}
|
|
1623
|
+
if (reason === null)
|
|
1624
|
+
return { content: `not ready after ${timeout} ms — no "${readyWhen}" in the output yet; it keeps running as background task ${task.id}, and you will be notified when it is ready. ${where}`, isError: false };
|
|
1625
|
+
const why = reason === "person" ? "moved on by the person" : reason === "steer" ? "so the person's message could land" : "interrupted";
|
|
1626
|
+
return { content: `started background task ${task.id}; stopped waiting for "${readyWhen}" (${why}) — not ready yet; you will be notified when it is ready. ${where}`, isError: false };
|
|
1627
|
+
}
|
|
1628
|
+
finally {
|
|
1629
|
+
undetach();
|
|
1630
|
+
ctx.signal.removeEventListener("abort", onEsc);
|
|
1487
1631
|
}
|
|
1488
|
-
return out;
|
|
1489
1632
|
}
|
|
1490
|
-
/**
|
|
1491
|
-
*
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1633
|
+
/** Where a task's output is, and what happens next — one wording for the
|
|
1634
|
+
* shell's results. */
|
|
1635
|
+
function taskNote(outputPath) {
|
|
1636
|
+
return `Output: ${outputPath} (read it with read_file). You will be notified when it ends; task_stop stops it.`;
|
|
1637
|
+
}
|
|
1638
|
+
/** ADR-0058 §4: stop a task this session started — its whole process group. */
|
|
1639
|
+
export function taskStopTool(opts) {
|
|
1640
|
+
return defineTool({
|
|
1641
|
+
name: "task_stop",
|
|
1642
|
+
description: "Stop a task, wait briefly for its terminal state, and report how it ended.",
|
|
1643
|
+
parameters: {
|
|
1644
|
+
type: "object",
|
|
1645
|
+
properties: { id: { type: "string", description: "The task id, as the shell reported it (t1, t2, …)" } },
|
|
1646
|
+
required: ["id"],
|
|
1647
|
+
additionalProperties: false,
|
|
1648
|
+
},
|
|
1649
|
+
promptSnippet: "task_stop — stop a background task by id",
|
|
1650
|
+
execute: async ({ id }, ctx) => {
|
|
1651
|
+
const tasks = opts.tasks?.(ctx.sessionId);
|
|
1652
|
+
const info = tasks?.get(id);
|
|
1653
|
+
if (tasks === undefined || info === undefined)
|
|
1654
|
+
return { content: `no task ${id} in this session`, isError: true, errorKind: "precondition" };
|
|
1655
|
+
if (info.state.kind === "ended")
|
|
1656
|
+
return { content: `task ${id} had already ended`, isError: false };
|
|
1657
|
+
if (!tasks.stop(id, "model"))
|
|
1658
|
+
return { content: `task ${id} is not running (${info.state.kind}); nothing to stop`, isError: false };
|
|
1659
|
+
if (tasks.awaitSettled === undefined)
|
|
1660
|
+
return { content: `stopping task ${id}; you will be notified when it has ended`, isError: false };
|
|
1661
|
+
// Amendment 7: wait for the end this result reports — claimed for
|
|
1662
|
+
// THIS call, so no stopped notice follows (Esc: nothing claimed)
|
|
1663
|
+
const w = await tasks.awaitSettled(id, "end", TASK_STOP_WAIT_MS, { ...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}), signal: ctx.signal });
|
|
1664
|
+
if (!w.settled)
|
|
1665
|
+
return { content: `stop requested for task ${id}; its end is not confirmed yet — you will be notified when it ends`, isError: false };
|
|
1666
|
+
if (w.info.state.kind === "unknown")
|
|
1667
|
+
return { content: `task ${id} could not be confirmed stopped — some of its processes may still be running`, isError: true, errorKind: "fatal" };
|
|
1668
|
+
const ran = w.info.endedAt !== undefined ? `; it ran ${Math.round((w.info.endedAt - w.info.startedAt) / 100) / 10}s` : "";
|
|
1669
|
+
return { content: `stopped task ${id} (${howEnded(w.info)}${ran})`, isError: false };
|
|
1670
|
+
},
|
|
1518
1671
|
});
|
|
1519
1672
|
}
|
|
1520
1673
|
/** The full coding toolset, bound to one workspace root (Area 5). */
|
|
@@ -1526,5 +1679,6 @@ export function createCodingTools(opts) {
|
|
|
1526
1679
|
writeFileTool(opts),
|
|
1527
1680
|
editFileTool(opts),
|
|
1528
1681
|
shellTool(opts),
|
|
1682
|
+
...(opts.tasks !== undefined ? [taskStopTool(opts)] : []),
|
|
1529
1683
|
];
|
|
1530
1684
|
}
|