@vincemakes/kiso-subagent-ext 0.48.0 → 0.49.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/kiso-subagent.mjs +846 -132
- package/index.d.ts +50 -1
- package/package.json +2 -2
package/dist/kiso-subagent.mjs
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import { spawn, execFileSync } from "node:child_process";
|
|
31
31
|
import { createHash, randomBytes } from "node:crypto";
|
|
32
|
-
import { closeSync, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
32
|
+
import { appendFileSync, chmodSync, closeSync, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readlinkSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
33
33
|
import { homedir, tmpdir } from "node:os";
|
|
34
34
|
import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
|
35
35
|
import { fileURLToPath } from "node:url";
|
|
@@ -43,14 +43,30 @@ const BACKGROUND_MAX = 20;
|
|
|
43
43
|
/** ADR-0058 3d (D6): a background child's model requests, by default — about
|
|
44
44
|
* twice the longest real explorer measured (17); 3f tunes it. */
|
|
45
45
|
const BACKGROUND_MAX_TURNS = 32;
|
|
46
|
+
/** 0.49.0 B: a writer's model requests, by default. The registered rule
|
|
47
|
+
* (ceil(1.25 x p95 of the probe's completed writers)) gave 12 on a
|
|
48
|
+
* one-function probe; the only real writers on record (41 and 66 requests)
|
|
49
|
+
* were both cut by the wall clock unfinished, so 12 would cut healthy work.
|
|
50
|
+
* 128 is the kit's own fallback, above both; re-measured on a representative
|
|
51
|
+
* writer task in 0.50 (the owner's ruling, 2026-10-10). */
|
|
52
|
+
const WRITER_MAX_TURNS = 128;
|
|
46
53
|
/** ADR-0058 3d (D1): the roles whose unattended contract is defined. */
|
|
47
|
-
const BACKGROUND_ROLES = ["explorer", "reviewer"];
|
|
48
54
|
|
|
49
55
|
const SIX_TOOLS = ["read_file", "list_dir", "search_text", "write_file", "edit_file", "shell"];
|
|
50
56
|
const READ_ONLY = ["read_file", "list_dir", "search_text"];
|
|
51
57
|
// verifier: runs checks and reports evidence; its changes are never
|
|
52
58
|
// collected (ADR-0032 Amendment 1 — it was called tester until 0.46.0)
|
|
53
59
|
const ROLES = ["explorer", "implementer", "reviewer", "verifier"];
|
|
60
|
+
// 0.49.0 C2: a reader's turn budget, whether its parent waits or not. A
|
|
61
|
+
// writer has none until B measures one; it keeps the wall clock.
|
|
62
|
+
const READER_ROLES = ["explorer", "reviewer"];
|
|
63
|
+
// 0.49.0 A: how long a foreground reader delegation waits for its group —
|
|
64
|
+
// the shell's foreground wait (ADR-0058 §3, Amendment 10); the host's
|
|
65
|
+
// `joinMs` overrides it
|
|
66
|
+
const JOIN_MS = 60_000;
|
|
67
|
+
// 0.49.0 C3: what of a foreground child's printed output stays in memory —
|
|
68
|
+
// the rest is in its output.log, never in a string that grows with it.
|
|
69
|
+
const OUTPUT_TAIL_KEEP = 8 * 1024;
|
|
54
70
|
|
|
55
71
|
// DT1a-F2 (owner dogfood 2026-09-08): the model's first delegate call was
|
|
56
72
|
// refused twice — `scope` on an explorer, then an `acceptance` naming no
|
|
@@ -59,79 +75,117 @@ const ROLES = ["explorer", "implementer", "reviewer", "verifier"];
|
|
|
59
75
|
// check and profile names are written into the schema when the extension
|
|
60
76
|
// loads (the tool table is snapshotted per request — F7b — so this is the
|
|
61
77
|
// table the model reads).
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
78
|
+
// Plan B (the prefix diet): every field the model can use is listed, and
|
|
79
|
+
// none it cannot — `acceptance` only when the user configured a check or an
|
|
80
|
+
// evaluator, `model` only when a profile exists (an unconfigured field was a
|
|
81
|
+
// paragraph saying "omit this"). The descriptions name the contract once; the
|
|
82
|
+
// refusals in validateTask carry the detail when a call gets it wrong.
|
|
83
|
+
const delegateParameters = (cfg, background) => {
|
|
84
|
+
const checks = Object.keys(cfg.checks);
|
|
85
|
+
const profiles = cfg.profiles.map((x) => (typeof x === "string" ? x : x.name ?? x.id ?? JSON.stringify(x)));
|
|
86
|
+
const acceptanceForms = [
|
|
87
|
+
...(checks.length > 0 ? [`{ check: ${checks.join(" | ")} }`] : []),
|
|
88
|
+
...(cfg.evaluators.length > 0 ? ["{ evaluator: a configured evaluator path }"] : []),
|
|
89
|
+
];
|
|
90
|
+
return {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
tasks: {
|
|
94
|
+
type: "array",
|
|
95
|
+
minItems: 1,
|
|
96
|
+
maxItems: 8,
|
|
97
|
+
items: {
|
|
98
|
+
type: "object",
|
|
99
|
+
properties: {
|
|
100
|
+
role: { type: "string", enum: ROLES },
|
|
101
|
+
task: { type: "string", minLength: 1 },
|
|
102
|
+
scope: { type: "array", items: { type: "string", minLength: 1 }, maxItems: 32, description: "implementer/verifier only: globs the child may write (a scoped child has no shell)" },
|
|
103
|
+
...(acceptanceForms.length > 0
|
|
104
|
+
? {
|
|
105
|
+
acceptance: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: { check: { type: "string", minLength: 1 }, evaluator: { type: "string", minLength: 1 } },
|
|
108
|
+
additionalProperties: false,
|
|
109
|
+
description: `implementer/verifier only, one of ${acceptanceForms.join(" or ")} — never a command; run after the child completes`,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
: {}),
|
|
113
|
+
...(profiles.length > 0 ? { model: { type: "string", minLength: 1, description: `a model profile: ${profiles.join(", ")}` } } : {}),
|
|
114
|
+
after: { type: "string", minLength: 1, description: "verifier only: the 1-based index of the task whose worktree it checks" },
|
|
115
|
+
// 0.49.0 A: with tasks wired, a child's bound is its turn budget and
|
|
116
|
+
// the parent's is the join — no wall-clock kill to ask for
|
|
117
|
+
...(background ? {} : { timeoutMs: { type: "integer", minimum: 1000, description: "the child's wall-clock budget, ms" } }),
|
|
92
118
|
},
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
timeoutMs: { type: "integer", minimum: 1000, description: "the child's wall-clock budget in milliseconds" },
|
|
119
|
+
required: ["role", "task"],
|
|
120
|
+
additionalProperties: false,
|
|
96
121
|
},
|
|
97
|
-
required: ["role", "task"],
|
|
98
|
-
additionalProperties: false,
|
|
99
122
|
},
|
|
123
|
+
// ADR-0058 3d: present only when the host wired tasks — without them
|
|
124
|
+
// the schema is byte-for-byte the foreground one. The description is
|
|
125
|
+
// kept verbatim: "start the tasks" marks it as the CALL's flag. The
|
|
126
|
+
// diet's shorter "run in the background" put it inside tasks[] in 4
|
|
127
|
+
// of 5 smoke legs (0 of 5 with this text; plan-prefix-diet A10).
|
|
128
|
+
// 0.49.0 B drops only the role limit ("explorer and reviewer only: ")
|
|
129
|
+
// — writers run in the background too; the subagents kit counts
|
|
130
|
+
// the schema refusals this wording could move (rc ≤ ctl + 2).
|
|
131
|
+
...(background
|
|
132
|
+
? {
|
|
133
|
+
background: {
|
|
134
|
+
type: "boolean",
|
|
135
|
+
description:
|
|
136
|
+
"start the tasks in the background and return at once; you are told when all of them have ended. They read the workspace as it is while they run; task_stop stops one",
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
100
140
|
},
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
background: {
|
|
106
|
-
type: "boolean",
|
|
107
|
-
description:
|
|
108
|
-
"explorer and reviewer only: start the tasks in the background and return at once; you are told when all of them have ended. They read the workspace as it is while they run; task_stop stops one",
|
|
109
|
-
},
|
|
110
|
-
}
|
|
111
|
-
: {}),
|
|
112
|
-
},
|
|
113
|
-
required: ["tasks"],
|
|
114
|
-
additionalProperties: false,
|
|
115
|
-
});
|
|
141
|
+
required: ["tasks"],
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
};
|
|
144
|
+
};
|
|
116
145
|
|
|
117
146
|
/**
|
|
118
|
-
* @param {{ tasks?: (sessionId: string | undefined) => any, backgroundMax?: number, backgroundMaxTurns?: number }} [host]
|
|
147
|
+
* @param {{ tasks?: (sessionId: string | undefined) => any, backgroundMax?: number, backgroundMaxTurns?: number, currentBinding?: (sessionId: string | undefined) => ({ profile: string | null, model?: string, reasoning?: unknown } | null) }} [host]
|
|
119
148
|
* ADR-0058 3d: the host's per-session task manager (the runtime's
|
|
120
149
|
* TaskManager satisfies it structurally — no dependency), the cap on live
|
|
121
150
|
* background children per session, and a background child's turn budget.
|
|
151
|
+
* 0.49.0 C1: the conversation's live binding — the profile its session is
|
|
152
|
+
* bound to and the effort it runs at — so a child that names no model
|
|
153
|
+
* runs on the conversation's, never on the config's default.
|
|
122
154
|
*/
|
|
123
155
|
export default async function createSubagentExtension(host = {}) {
|
|
124
156
|
const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
|
|
125
157
|
if (depth >= 1) return { name: "subagent", tools: [] }; // depth guard — no nesting
|
|
126
158
|
const tasksOf = typeof host.tasks === "function" ? host.tasks : undefined;
|
|
127
|
-
const background =
|
|
159
|
+
const background =
|
|
160
|
+
tasksOf === undefined
|
|
161
|
+
? undefined
|
|
162
|
+
: {
|
|
163
|
+
tasksOf,
|
|
164
|
+
max: host.backgroundMax ?? BACKGROUND_MAX,
|
|
165
|
+
maxTurns: host.backgroundMaxTurns ?? BACKGROUND_MAX_TURNS,
|
|
166
|
+
// 0.49.0 B: writers — their cap, their turn budget (128: the
|
|
167
|
+
// probe's 12 would cut real work), and the snapshot's size cap
|
|
168
|
+
writerMax: host.writerMax ?? WRITER_MAX,
|
|
169
|
+
writerMaxTurns: host.writerMaxTurns ?? WRITER_MAX_TURNS,
|
|
170
|
+
snapshotMaxBytes: host.snapshotMaxBytes ?? SNAPSHOT_MAX_BYTES,
|
|
171
|
+
reserved: new Map(),
|
|
172
|
+
};
|
|
173
|
+
// Plan B: ONE configuration snapshot per extension instance — the schema
|
|
174
|
+
// the model is shown and the executor that validates its call read the
|
|
175
|
+
// same values, so a session has one delegate contract (a config change
|
|
176
|
+
// takes effect in the next session).
|
|
177
|
+
const cfg = delegationConfig();
|
|
128
178
|
return {
|
|
129
179
|
name: "subagent",
|
|
130
180
|
tools: [
|
|
131
181
|
{
|
|
132
182
|
name: "delegate",
|
|
133
|
-
description:
|
|
134
|
-
|
|
183
|
+
description:
|
|
184
|
+
background !== undefined
|
|
185
|
+
? "run subagent tasks (explorer/implementer/reviewer/verifier) in child kiso processes. Use background when the next step does not need the result; a foreground wait is bounded and continues in the background on its own."
|
|
186
|
+
: "run subagent tasks (explorer/implementer/reviewer/verifier) in child kiso processes",
|
|
187
|
+
parameters: delegateParameters(cfg, background !== undefined),
|
|
188
|
+
promptSnippet: "delegate — run subagent tasks",
|
|
135
189
|
execute: async (input, ctx) => {
|
|
136
190
|
const tasks = ((input ?? {}).tasks ?? []).slice(0, 8);
|
|
137
191
|
if (tasks.length === 0) return { content: "delegate: no tasks", isError: true, errorKind: "precondition" };
|
|
@@ -140,7 +194,7 @@ export default async function createSubagentExtension(host = {}) {
|
|
|
140
194
|
// 0.40.0: the parent's own folder (one per project), handed over
|
|
141
195
|
// by the CLI; a pinned KISO_SESSIONS_DIR, or the legacy folder,
|
|
142
196
|
// when no CLI said (a direct load)
|
|
143
|
-
const sessionsDir =
|
|
197
|
+
const sessionsDir = cfg.sessionsDir ?? (process.env.KISO_SESSIONS_DIR || join(process.env.KISO_HOME ?? join(homedir(), ".kiso"), "sessions"));
|
|
144
198
|
// P3: the loop now threads the session id through
|
|
145
199
|
// ToolContext.sessionId — the discovery heuristic below is
|
|
146
200
|
// kept ONLY as a fallback for direct tool use / tests.
|
|
@@ -159,17 +213,22 @@ export default async function createSubagentExtension(host = {}) {
|
|
|
159
213
|
mkdirSync(manifestDir, { recursive: true });
|
|
160
214
|
// DT-1a: every task is validated BEFORE any child runs — a refusal
|
|
161
215
|
// spawns nothing (acceptance never carries a model-supplied command).
|
|
162
|
-
const cfg = delegationConfig();
|
|
163
216
|
const parentCwd = process.cwd();
|
|
217
|
+
const binding = bindingOf(host, ctx.sessionId);
|
|
164
218
|
for (let i = 0; i < tasks.length; i += 1) {
|
|
165
|
-
const why = validateTask(tasks[i], cfg, parentCwd, manifestDir);
|
|
219
|
+
const why = validateTask(tasks[i], cfg, parentCwd, manifestDir, { afterByIndex: background !== undefined });
|
|
166
220
|
// DT1a-F1 (owner dogfood 2026-09-08): a refusal BEFORE any child exists is a
|
|
167
221
|
// precondition — nothing ran, nothing could have partially applied — so the
|
|
168
222
|
// kernel must not append the non-idempotent "side effects may have partially
|
|
169
223
|
// applied" banner (loop.ts keys that banner on errorKind !== "precondition")
|
|
170
224
|
if (why !== null) return { content: `delegate: refused — task ${i + 1}: ${why}`, isError: true, errorKind: "precondition" };
|
|
171
225
|
}
|
|
172
|
-
if (inBackground) return startBackground({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir });
|
|
226
|
+
if (inBackground) return startBackground({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding });
|
|
227
|
+
// 0.49.0 A/B: with tasks wired, every delegation is a group of
|
|
228
|
+
// agent tasks the call joins for up to joinMs — writers included
|
|
229
|
+
if (background !== undefined) {
|
|
230
|
+
return joinTasks({ tasks, ctx, background, joinMs: host.joinMs ?? JOIN_MS, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding });
|
|
231
|
+
}
|
|
173
232
|
const sections = await runLimited(tasks, CONCURRENCY, (task, i) =>
|
|
174
233
|
runChild({
|
|
175
234
|
childId: `sub-${parentId}-${delegationId}-${i + 1}-${task.role}`,
|
|
@@ -179,7 +238,8 @@ export default async function createSubagentExtension(host = {}) {
|
|
|
179
238
|
task: task.task,
|
|
180
239
|
scope: task.scope,
|
|
181
240
|
acceptance: task.acceptance,
|
|
182
|
-
|
|
241
|
+
resolved: resolveChild(task, cfg, binding),
|
|
242
|
+
maxTurns: READER_ROLES.includes(task.role) ? (host.backgroundMaxTurns ?? BACKGROUND_MAX_TURNS) : undefined,
|
|
183
243
|
after: task.after,
|
|
184
244
|
cfg,
|
|
185
245
|
sessionsDir,
|
|
@@ -196,7 +256,17 @@ export default async function createSubagentExtension(host = {}) {
|
|
|
196
256
|
// The per-section text below is unchanged — the model's
|
|
197
257
|
// view is preserved, the summary is additive.
|
|
198
258
|
const summary = delegateSummary(sections, new Set(tasks.map((t) => t.role)).size);
|
|
199
|
-
|
|
259
|
+
// C3: each child's handoff within 4 KiB, the call's within 16 KiB
|
|
260
|
+
let budget = GROUP_HANDOFF_BYTES;
|
|
261
|
+
const texts = sections.map((s) => {
|
|
262
|
+
if (s.handoff === undefined) return s.text;
|
|
263
|
+
// a failed acceptance's tail counts against its child's 4 KiB
|
|
264
|
+
const reserved = Math.min(s.reserved ?? 0, budget);
|
|
265
|
+
const body = handoffBody(s.handoff, Math.max(0, Math.min(CHILD_HANDOFF_BYTES, budget) - reserved));
|
|
266
|
+
budget -= body.bytes + reserved;
|
|
267
|
+
return `${s.text}${body.text}${s.trailer ?? ""}`;
|
|
268
|
+
});
|
|
269
|
+
return { content: `${summary}\n${texts.join("\n")}`, isError: sections.every((s) => s.failed) };
|
|
200
270
|
},
|
|
201
271
|
},
|
|
202
272
|
],
|
|
@@ -218,41 +288,107 @@ const refuse = (why) => ({ content: `delegate: refused — ${why}`, isError: tru
|
|
|
218
288
|
* runs, launched by argv (no shell), with its turn budget and its result
|
|
219
289
|
* file beside its task (D6).
|
|
220
290
|
*/
|
|
221
|
-
async function startBackground({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir }) {
|
|
291
|
+
async function startBackground({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding }) {
|
|
222
292
|
for (const task of tasks) {
|
|
223
|
-
if (!BACKGROUND_ROLES.includes(task.role)) return refuse(`background is not supported for the ${task.role} role in this release; run this delegation in the foreground`);
|
|
224
293
|
if (task.timeoutMs !== undefined) return refuse("timeoutMs applies to a foreground delegation; a background child has no wall-clock kill — task_stop stops one");
|
|
225
294
|
}
|
|
295
|
+
const r = await startTasks({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding });
|
|
296
|
+
if (r.refusal !== undefined) return r.refusal;
|
|
297
|
+
const n = r.started.length;
|
|
298
|
+
const writes = tasks.some((t) => WRITER_ROLES.includes(t.role));
|
|
299
|
+
const content =
|
|
300
|
+
`started ${n} background ${n === 1 ? "child" : "children"}: ${r.lines.join(", ")}. ` +
|
|
301
|
+
(writes ? "Readers read the workspace as it is while they run; writers work in a snapshot of it taken now, and each one's patch is collected when it ends. " : "They read the workspace as it is while they run. ") +
|
|
302
|
+
"You will be told when all of them have ended; task_stop stops one.";
|
|
303
|
+
return { content, isError: n === 0 };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** ADR-0058 3d — start a batch as agent tasks: the cap and the reservation
|
|
307
|
+
* as one step (D4), then each child's durable inputs and its launch, with
|
|
308
|
+
* its turn budget and its result file (D6). 0.49.0 A: the background call
|
|
309
|
+
* and the join share it. */
|
|
310
|
+
async function startTasks({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding }) {
|
|
226
311
|
const manager = background.tasksOf(ctx.sessionId);
|
|
227
|
-
if (manager === undefined) return refuse("background is not available here — run the delegation in the foreground");
|
|
312
|
+
if (manager === undefined) return { refusal: refuse("background is not available here — run the delegation in the foreground") };
|
|
313
|
+
const childIdOf = (i) => `sub-${parentId}-${delegationId}-${i + 1}-${tasks[i].role}`;
|
|
314
|
+
// 0.49.0 B2: `after` names an implementer of THIS call by its 1-based
|
|
315
|
+
// index; that implementer's collection hands it its tree and starts it
|
|
316
|
+
const after = new Map();
|
|
317
|
+
for (let i = 0; i < tasks.length; i += 1) {
|
|
318
|
+
if (tasks[i].after === undefined) continue;
|
|
319
|
+
const n = Number(tasks[i].after);
|
|
320
|
+
if (tasks[i].role !== "verifier" || !Number.isInteger(n) || n < 1 || n > i || tasks[n - 1].role !== "implementer" || tasks[n - 1].after !== undefined) {
|
|
321
|
+
return { refusal: refuse(`task ${i + 1}: \`after\` must give the 1-based index of an earlier implementer in this call — its tree is collected and handed to the verifier; an earlier call's implementer has no tree left to hand over`) };
|
|
322
|
+
}
|
|
323
|
+
after.set(i, n - 1);
|
|
324
|
+
}
|
|
228
325
|
const key = ctx.sessionId ?? "";
|
|
229
|
-
const live = manager.list().filter((t) => t.agent !== undefined && t.state.kind !== "
|
|
326
|
+
const live = manager.list().filter((t) => t.agent !== undefined && t.state.kind !== "not_run" && (t.state.kind !== "ended" || (t.agent.collect === true && t.collection === undefined))).length + (background.reserved.get(key) ?? 0);
|
|
230
327
|
if (live + tasks.length > background.max) {
|
|
231
|
-
return refuse(`${live} background children are running in this session and the cap is ${background.max}; this call asks for ${tasks.length}. Do not retry — wait for some to end, or stop one with task_stop`);
|
|
328
|
+
return { refusal: refuse(`${live} background children are running in this session and the cap is ${background.max}; this call asks for ${tasks.length}. Do not retry — wait for some to end, or stop one with task_stop`) };
|
|
329
|
+
}
|
|
330
|
+
// B4: a writer holds a workspace and runs commands — its own cap
|
|
331
|
+
const writers = tasks.filter((t) => WRITER_ROLES.includes(t.role)).length;
|
|
332
|
+
const liveWriters = manager.list().filter((t) => t.agent?.collect === true && t.state.kind !== "not_run" && (t.state.kind !== "ended" || t.collection === undefined)).length;
|
|
333
|
+
if (writers > 0 && liveWriters + writers > background.writerMax) {
|
|
334
|
+
return { refusal: refuse(`${liveWriters} writers (implementer or verifier) are running in this session and the writer cap is ${background.writerMax}; this call asks for ${writers}. Do not retry — wait for some to end, or stop one with task_stop`) };
|
|
232
335
|
}
|
|
233
336
|
background.reserved.set(key, (background.reserved.get(key) ?? 0) + tasks.length);
|
|
234
337
|
let unstarted = tasks.length;
|
|
235
338
|
const release = (n) => background.reserved.set(key, Math.max(0, (background.reserved.get(key) ?? 0) - n));
|
|
236
339
|
const lines = [];
|
|
237
|
-
|
|
340
|
+
const started = [];
|
|
341
|
+
const failed = [];
|
|
342
|
+
const pending = [];
|
|
238
343
|
try {
|
|
344
|
+
// B6.1: every writer that starts now gets its snapshot FIRST — all or
|
|
345
|
+
// nothing: a refusal (the cap, a moving tree, no repository) starts no child
|
|
346
|
+
const spaces = new Map();
|
|
347
|
+
for (let i = 0; i < tasks.length; i += 1) {
|
|
348
|
+
if (!WRITER_ROLES.includes(tasks[i].role) || after.has(i)) continue;
|
|
349
|
+
try {
|
|
350
|
+
spaces.set(i, snapshotWorkspace(process.cwd(), join(manifestDir, childIdOf(i), "ws"), background.snapshotMaxBytes));
|
|
351
|
+
} catch (err) {
|
|
352
|
+
for (const j of spaces.keys()) rmSync(join(manifestDir, childIdOf(j), "ws"), { recursive: true, force: true });
|
|
353
|
+
return { refusal: refuse(err instanceof SnapshotRefusal ? err.message : `a writer's workspace could not be built: ${msg(err)}`) };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
239
356
|
for (let i = 0; i < tasks.length; i += 1) {
|
|
240
357
|
const task = tasks[i];
|
|
241
|
-
const childId =
|
|
358
|
+
const childId = childIdOf(i);
|
|
359
|
+
const writer = WRITER_ROLES.includes(task.role);
|
|
360
|
+
const maxTurns = writer ? background.writerMaxTurns : background.maxTurns;
|
|
242
361
|
try {
|
|
243
|
-
const
|
|
244
|
-
const
|
|
362
|
+
const resolved = resolveChild(task, cfg, binding);
|
|
363
|
+
const manifest = { parentId, delegationId, index: i + 1, role: task.role, startedAt: Date.now(), ...modelFieldsOf(resolved), ...(maxTurns !== undefined ? { maxTurns } : {}) };
|
|
364
|
+
if (after.has(i)) {
|
|
365
|
+
// durable now, started by its implementer's collection
|
|
366
|
+
writeChildInputs(manifestDir, childId, task, { ...manifest, after: after.get(i) + 1, deferred: true });
|
|
367
|
+
pending.push({ index: i, task, childId, behind: after.get(i) });
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const space = spaces.get(i);
|
|
371
|
+
const dependents = [...after].filter(([, j]) => j === i).map(([v]) => ({ childId: childIdOf(v) }));
|
|
372
|
+
const inputs = writeChildInputs(
|
|
373
|
+
manifestDir,
|
|
374
|
+
childId,
|
|
375
|
+
task,
|
|
376
|
+
{ ...manifest, ...(space !== undefined ? { workspace: join(manifestDir, childId, "ws"), base: space.base, head: space.head, sub: space.sub, dependents } : {}) },
|
|
377
|
+
space?.cwd,
|
|
378
|
+
);
|
|
379
|
+
const info = await manager.start({
|
|
245
380
|
command: `${task.role}: ${task.task.split("\n")[0].slice(0, 80)}`,
|
|
246
|
-
cwd: process.cwd(),
|
|
247
|
-
env: childEnv(inputs.policyDir, sessionsDir),
|
|
381
|
+
cwd: space?.cwd ?? process.cwd(),
|
|
382
|
+
env: childEnv(inputs.policyDir, sessionsDir, resolved.reasoning),
|
|
248
383
|
...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}),
|
|
249
|
-
agent: { role: task.role, session: childId },
|
|
250
|
-
exec: (dir) => ({ file: process.execPath, args: [...childArgs(bin, childId, inputs.taskPath,
|
|
384
|
+
agent: writer ? { role: task.role, session: childId, collect: true } : { role: task.role, session: childId },
|
|
385
|
+
exec: (dir) => ({ file: process.execPath, args: [...childArgs(bin, childId, inputs.taskPath, resolved.profile), ...(maxTurns !== undefined ? ["--max-turns", String(maxTurns)] : []), "--result-file", join(dir, "result.md")] }),
|
|
251
386
|
});
|
|
252
|
-
lines.push(`${
|
|
387
|
+
lines.push(`${info.id} ${task.role} (session ${childId})`);
|
|
388
|
+
started.push({ id: info.id, task, childId, resolved });
|
|
253
389
|
} catch (err) {
|
|
254
|
-
failures += 1;
|
|
255
390
|
lines.push(`task ${i + 1} (${task.role}) did not start: ${msg(err)}`);
|
|
391
|
+
failed.push({ task, why: msg(err) });
|
|
256
392
|
}
|
|
257
393
|
unstarted -= 1;
|
|
258
394
|
release(1);
|
|
@@ -260,23 +396,164 @@ async function startBackground({ tasks, ctx, background, sessionsDir, parentId,
|
|
|
260
396
|
} finally {
|
|
261
397
|
release(unstarted);
|
|
262
398
|
}
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
`
|
|
266
|
-
|
|
267
|
-
return {
|
|
399
|
+
for (const p of pending) {
|
|
400
|
+
const host = started.find((s) => s.childId === childIdOf(p.behind));
|
|
401
|
+
lines.push(`task ${p.index + 1} verifier starts once ${host !== undefined ? host.id : `task ${p.behind + 1}`} is collected`);
|
|
402
|
+
}
|
|
403
|
+
return { manager, started, failed, pending, lines };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 0.49.0 A — the join: a reader delegation is a group of agent tasks
|
|
407
|
+
* from its start, and the call waits for them up to `joinMs` — the
|
|
408
|
+
* shell's foreground wait (ADR-0058 Amendment 10), reused. What ended
|
|
409
|
+
* while it waited is CLAIMED by this call (its result reports it, I2);
|
|
410
|
+
* what is still running continues as the group, whose notice wakes the
|
|
411
|
+
* parent. Nothing is killed: the budget, the person's key (detach), a
|
|
412
|
+
* steer and Esc all end the WAIT, never a child. */
|
|
413
|
+
async function joinTasks({ tasks, ctx, background, joinMs, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding }) {
|
|
414
|
+
const r = await startTasks({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir, cfg, binding });
|
|
415
|
+
if (r.refusal !== undefined) return r.refusal;
|
|
416
|
+
const { manager, started, failed, pending } = r;
|
|
417
|
+
const release = new AbortController();
|
|
418
|
+
let reason = null;
|
|
419
|
+
const onEsc = () => {
|
|
420
|
+
reason ??= "interrupted";
|
|
421
|
+
release.abort();
|
|
422
|
+
};
|
|
423
|
+
if (ctx.signal?.aborted) onEsc();
|
|
424
|
+
else ctx.signal?.addEventListener?.("abort", onEsc);
|
|
425
|
+
const undetach =
|
|
426
|
+
typeof manager.registerDetachable === "function" && ctx.executionId !== undefined
|
|
427
|
+
? manager.registerDetachable(ctx.executionId, {
|
|
428
|
+
startedAt: Date.now(),
|
|
429
|
+
detach: (by) => {
|
|
430
|
+
reason ??= by;
|
|
431
|
+
release.abort();
|
|
432
|
+
},
|
|
433
|
+
})
|
|
434
|
+
: () => {};
|
|
435
|
+
let settled;
|
|
436
|
+
try {
|
|
437
|
+
settled = await Promise.all(started.map((s) => manager.awaitSettled(s.id, "end", joinMs, { ...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}), signal: release.signal, agentJoin: true })));
|
|
438
|
+
} finally {
|
|
439
|
+
undetach();
|
|
440
|
+
ctx.signal?.removeEventListener?.("abort", onEsc);
|
|
441
|
+
}
|
|
442
|
+
const sections = [];
|
|
443
|
+
const deferred = [];
|
|
444
|
+
const running = [];
|
|
445
|
+
// B2: a verifier its implementer's collection started is in the group too
|
|
446
|
+
const known = new Set(started.map((s) => s.id));
|
|
447
|
+
const late = manager.list().filter((t) => t.agent !== undefined && t.executionId === ctx.executionId && !known.has(t.id));
|
|
448
|
+
started.forEach((s, i) => {
|
|
449
|
+
const w = settled[i];
|
|
450
|
+
if (w.settled && w.claimed) sections.push(memberSection(s, w.info));
|
|
451
|
+
else if (w.settled) deferred.push(s);
|
|
452
|
+
else running.push(s);
|
|
453
|
+
});
|
|
454
|
+
for (const f of failed) sections.push({ failed: true, failKind: "error", text: `[subagent] ${f.task.role}: ${f.task.task}\n FAILED: did not start: ${f.why}`, toolCalls: 0 });
|
|
455
|
+
const summary = delegateSummary([...sections, ...[...deferred, ...running].map(() => ({ failed: false, toolCalls: 0 }))], new Set(tasks.map((t) => t.role)).size);
|
|
456
|
+
let budget = GROUP_HANDOFF_BYTES;
|
|
457
|
+
const texts = sections.map((sec) => {
|
|
458
|
+
if (sec.handoff === undefined) return sec.text;
|
|
459
|
+
const body = handoffBody(sec.handoff, Math.min(CHILD_HANDOFF_BYTES, budget));
|
|
460
|
+
budget -= body.bytes;
|
|
461
|
+
return `${sec.text}${body.text}${sec.trailer ?? ""}`;
|
|
462
|
+
});
|
|
463
|
+
// I2: an end this call could not claim is the group's to deliver
|
|
464
|
+
for (const d of deferred) texts.push(`[subagent] ${d.task.role}: ${d.task.task}\n ended as task ${d.id} — its result follows in the group's notice`);
|
|
465
|
+
const waiting = pending.filter((p) => !late.some((t) => t.agent?.session === p.childId));
|
|
466
|
+
if (running.length > 0) {
|
|
467
|
+
const why = reason === null ? `still running after ${joinMs} ms` : reason === "person" ? "moved to the background by the person" : reason === "steer" ? "moved to the background so the person's message could land" : "interrupted";
|
|
468
|
+
const ids = running.map((s) => s.id);
|
|
469
|
+
texts.push(`${why}: continued as background ${ids.length === 1 ? "task" : "tasks"} ${ids.join(", ")}; you will be told when all of them have ended; task_stop stops one.`);
|
|
470
|
+
}
|
|
471
|
+
// B2: a verifier its implementer's collection started — not a wait cut short
|
|
472
|
+
if (late.length > 0) {
|
|
473
|
+
const ids = late.map((t) => t.id);
|
|
474
|
+
texts.push(`started by its implementer's collection: background ${ids.length === 1 ? "task" : "tasks"} ${ids.join(", ")}; you will be told when all of them have ended; task_stop stops one.`);
|
|
475
|
+
}
|
|
476
|
+
for (const p of waiting) texts.push(`[subagent] verifier: ${p.task.task}\n starts once task ${p.behind + 1}'s implementer is collected, in its tree (base plus its patch); its result follows in the group's notice`);
|
|
477
|
+
const isError = running.length === 0 && late.length === 0 && waiting.length === 0 && deferred.length === 0 && sections.length > 0 && sections.every((sec) => sec.failed);
|
|
478
|
+
return { content: `${summary}\n${texts.join("\n")}`, isError };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** 0.49.0 A — a joined child's section, from its task directory: the
|
|
482
|
+
* same head and handoff a foreground child's has (C3), read from its
|
|
483
|
+
* result record (I5/I6) and its output. */
|
|
484
|
+
function memberSection(s, info) {
|
|
485
|
+
const dir = dirname(info.outputPath);
|
|
486
|
+
const { record, full, answer } = readChildResult(dir);
|
|
487
|
+
const exitCode = info.state?.kind === "ended" ? info.state.exitCode : null;
|
|
488
|
+
const status = record !== null ? record.outcome : "failed";
|
|
489
|
+
const failed = record === null || record.outcome === "failed";
|
|
490
|
+
const toolCalls = typeof full?.toolCalls === "number" ? full.toolCalls : 0;
|
|
491
|
+
const ranOn = typeof full?.profile === "string" ? full.profile : typeof full?.model === "string" ? full.model : (s.resolved.profile ?? "unknown");
|
|
492
|
+
let text = `[subagent] ${s.task.role}: ${s.task.task}\n status: ${status} · model: ${ranOn}${s.resolved.source === "environment" ? " (default)" : ""} · verification: none · tools: ${toolCalls} · task ${s.id}`;
|
|
493
|
+
if (record === null) text += `\n FAILED: the child ${exitCode !== null && exitCode !== 0 ? `exited with code ${exitCode} and ` : ""}left no result record`;
|
|
494
|
+
else if (record.outcome === "failed") text += `\n FAILED: the child's run ended with ${typeof full?.endedBy === "string" ? full.endedBy : "a failure"}`;
|
|
495
|
+
const tail = rawTail(info.outputPath, HANDOFF_TAIL_BYTES);
|
|
496
|
+
const unresolved = record === null ? null : parseUnresolved(answer);
|
|
497
|
+
let trailer = unresolved === null && record !== null && answer.trim() !== "" ? "\n unresolved: not reported" : "";
|
|
498
|
+
let writerFailed = false;
|
|
499
|
+
if (s.task.role === "implementer") {
|
|
500
|
+
const w = writerLines(dir, info);
|
|
501
|
+
trailer += w.text;
|
|
502
|
+
writerFailed = w.failed;
|
|
503
|
+
}
|
|
504
|
+
const all = failed || writerFailed;
|
|
505
|
+
return { failed: all, ...(all ? { failKind: "error" } : {}), text, handoff: { record, answer, tail: tail.text, tailCut: tail.cut, resultPath: join(dir, "result.md") }, trailer, toolCalls };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** B — what an implementer's handoff adds: its patch (by path, never
|
|
509
|
+
* inline), the base it was written against, the child's acceptance, the
|
|
510
|
+
* command that adopts it, and what became of a verifier behind it. */
|
|
511
|
+
function writerLines(dir, info) {
|
|
512
|
+
const c = info.collection;
|
|
513
|
+
if (c?.outcome === "failed") return { text: `\n FAILED: collecting its changes: ${c.reason ?? "unknown"}`, failed: true };
|
|
514
|
+
let patch = null;
|
|
515
|
+
try {
|
|
516
|
+
patch = JSON.parse(readFileSync(join(dir, "patch.json"), "utf8"));
|
|
517
|
+
} catch {
|
|
518
|
+
return { text: "\n FAILED: its patch record is missing", failed: true };
|
|
519
|
+
}
|
|
520
|
+
if (patch.files.length === 0) return { text: "\n no changes", failed: false };
|
|
521
|
+
let acc = "none";
|
|
522
|
+
try {
|
|
523
|
+
const v = JSON.parse(readFileSync(join(dir, "acceptance.json"), "utf8"));
|
|
524
|
+
acc = v.passed ? "PASSED" : "FAILED";
|
|
525
|
+
} catch {
|
|
526
|
+
// no acceptance named, or the child did not complete
|
|
527
|
+
}
|
|
528
|
+
const files = patch.files.map((f) => f.path);
|
|
529
|
+
const bin = process.env.KISO_SUBAGENT_BIN ?? process.argv[1];
|
|
530
|
+
let text = `\n patch: ${join(dir, "patch.diff")} (${files.length} ${files.length === 1 ? "file" : "files"}: ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", …" : ""}) · base ${String(patch.base).slice(0, 7)} · child acceptance: ${acc}`;
|
|
531
|
+
text += patch.applyable ? `\n apply: node ${shellQuote(bin)} apply-patch ${shellQuote(dir)}` : `\n the child was stopped: the patch is partial — apply only with: node ${shellQuote(bin)} apply-patch ${shellQuote(dir)} --allow-partial`;
|
|
532
|
+
try {
|
|
533
|
+
const deps = JSON.parse(readFileSync(join(dir, "dependents.json"), "utf8"));
|
|
534
|
+
for (const d of Object.values(deps)) text += d.taskId !== undefined ? `\n its verifier runs as task ${d.taskId}, in base plus this patch` : `\n its verifier was skipped: ${d.skipped}`;
|
|
535
|
+
} catch {
|
|
536
|
+
// no verifier behind it
|
|
537
|
+
}
|
|
538
|
+
return { text, failed: acc === "FAILED" };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** A path for the shell: single-quoted when it needs quoting. */
|
|
542
|
+
function shellQuote(p) {
|
|
543
|
+
return /^[\w@%+=:,./-]+$/.test(p) ? p : `'${String(p).replaceAll("'", "'\\''")}'`;
|
|
268
544
|
}
|
|
269
545
|
|
|
270
546
|
/** The durable inputs of a background child: the task file (the fixed
|
|
271
547
|
* UNRESOLVED trailer included), its role policy, its manifest — fsynced
|
|
272
548
|
* with their directories before the task is planned. */
|
|
273
|
-
function writeChildInputs(manifestDir, childId, task, manifest) {
|
|
549
|
+
function writeChildInputs(manifestDir, childId, task, manifest, policyRoot) {
|
|
274
550
|
const taskPath = join(manifestDir, `${childId}.task`);
|
|
275
551
|
const policyDir = join(manifestDir, `${childId}.policy`);
|
|
276
552
|
mkdirSync(policyDir, { recursive: true });
|
|
277
|
-
|
|
553
|
+
// DT-1a R2.1: a scoped writer's policy checks every write against its globs, from its own root
|
|
554
|
+
writeDurable(join(policyDir, "policy.mjs"), rolePolicyContent(task.role, task.scope !== undefined && policyRoot !== undefined ? { root: policyRoot, globs: task.scope } : undefined));
|
|
278
555
|
writeDurable(taskPath, `${task.task}\n\n${UNRESOLVED_INSTRUCTION}\n`);
|
|
279
|
-
writeDurable(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId, task: task.task, background: true, ...(task.
|
|
556
|
+
writeDurable(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId, task: task.task, background: true, ...(task.scope !== undefined ? { scope: task.scope } : {}), ...(task.acceptance !== undefined ? { acceptance: task.acceptance } : {}) })}\n`);
|
|
280
557
|
fsyncPath(policyDir);
|
|
281
558
|
fsyncPath(manifestDir);
|
|
282
559
|
return { taskPath, policyDir };
|
|
@@ -341,13 +618,13 @@ function runLimited(items, limit, fn) {
|
|
|
341
618
|
return Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)).then(() => results);
|
|
342
619
|
}
|
|
343
620
|
|
|
344
|
-
async function runChild({ childId, role, task, scope, acceptance,
|
|
621
|
+
async function runChild({ childId, role, task, scope, acceptance, resolved, maxTurns, after, cfg, sessionsDir, bin, timeout, signal, parentCwd, manifestDir, manifest }) {
|
|
345
622
|
// CX-1 F6: the manifest binds this invocation to its child session
|
|
346
623
|
// BEFORE anything runs — the durable record of "which run is mine".
|
|
347
624
|
// DT-1a: the contract's inputs ride in it, verbatim.
|
|
348
625
|
const startedAt = manifest?.startedAt ?? Date.now();
|
|
349
626
|
if (manifestDir !== undefined) {
|
|
350
|
-
writeFileSync(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId, task, ...(scope !== undefined ? { scope } : {}), ...(acceptance !== undefined ? { acceptance } : {}), ...(
|
|
627
|
+
writeFileSync(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId, task, ...(scope !== undefined ? { scope } : {}), ...(acceptance !== undefined ? { acceptance } : {}), ...modelFieldsOf(resolved), ...(after !== undefined ? { after } : {}) })}\n`, "utf8");
|
|
351
628
|
}
|
|
352
629
|
// Isolation (DT-1a R2.2): implementer → its own detached worktree from
|
|
353
630
|
// the parent's HEAD (the parent's UNCOMMITTED changes are not visible —
|
|
@@ -404,10 +681,17 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
|
|
|
404
681
|
// the fixed UNRESOLVED instruction the result parser reads back.
|
|
405
682
|
const taskPath = join(manifestDir ?? policyDir, `${childId}.task`);
|
|
406
683
|
writeFileSync(taskPath, `${task}\n\n${UNRESOLVED_INSTRUCTION}\n`, "utf8");
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
684
|
+
// 0.49.0 C2/C3: the child's own directory — its result record, its
|
|
685
|
+
// answer and its printed output, as a background child's task has
|
|
686
|
+
const childDir = join(manifestDir ?? tmpdir(), childId);
|
|
687
|
+
mkdirSync(childDir, { recursive: true });
|
|
688
|
+
const { code, killed, unconfirmed, outputPath } = await runProcess({ childId, bin, cwd: childCwd, policyDir, taskPath, timeout, signal, resolved, sessionsDir, childDir, maxTurns });
|
|
689
|
+
// I5/I6: the result is what the child's record commits — never
|
|
690
|
+
// inferred from its printed output or from its session's runs
|
|
691
|
+
const { record, full, answer } = readChildResult(childDir);
|
|
692
|
+
const status = killed === "timeout" ? "timeout" : killed === "abort" ? "killed" : record !== null ? record.outcome : code !== 0 ? "failed" : "no-result";
|
|
693
|
+
let failed = code !== 0 || killed !== null || record === null || record.outcome === "failed";
|
|
694
|
+
const toolCalls = typeof full?.toolCalls === "number" ? full.toolCalls : 0;
|
|
411
695
|
const lines = [];
|
|
412
696
|
// Windows P6: a kill that did not take is never reported as a kill
|
|
413
697
|
const killNote = unconfirmed.length > 0 ? ` — could not confirm the child exited (pid ${unconfirmed.join(", ")}): treat its side effects as UNCERTAIN` : " (the child process group was killed)";
|
|
@@ -415,12 +699,12 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
|
|
|
415
699
|
lines.push(` FAILED: timed out after ${timeout}ms${killNote}`);
|
|
416
700
|
} else if (killed === "abort") {
|
|
417
701
|
lines.push(` FAILED: aborted by the parent run${killNote}`);
|
|
418
|
-
} else if (
|
|
419
|
-
lines.push(` FAILED: the child exited with code ${code}
|
|
420
|
-
} else if (
|
|
421
|
-
lines.push(` FAILED: ${
|
|
702
|
+
} else if (record === null) {
|
|
703
|
+
lines.push(` FAILED: the child ${code !== 0 ? `exited with code ${code} and ` : ""}left no result record`);
|
|
704
|
+
} else if (record.outcome === "failed") {
|
|
705
|
+
lines.push(` FAILED: the child's run ended with ${typeof full?.endedBy === "string" ? full.endedBy : "a failure"}`);
|
|
422
706
|
}
|
|
423
|
-
const unresolved = parseUnresolved(
|
|
707
|
+
const unresolved = record === null ? null : parseUnresolved(answer);
|
|
424
708
|
// the collection (implementers only — a verifier's worktree is a
|
|
425
709
|
// throwaway copy; its changes are not its result)
|
|
426
710
|
let changedFiles = null;
|
|
@@ -459,6 +743,7 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
|
|
|
459
743
|
else verification = await runAcceptance(acceptance, cfg, worktree ?? childCwd, baseRev, timeout, signal);
|
|
460
744
|
if (verification.passed === false) failed = true;
|
|
461
745
|
}
|
|
746
|
+
const tail = rawTail(outputPath, HANDOFF_TAIL_BYTES);
|
|
462
747
|
const endedAt = Date.now();
|
|
463
748
|
const result = {
|
|
464
749
|
identity: { ...manifest, childId, role, startedAt, endedAt },
|
|
@@ -466,7 +751,7 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
|
|
|
466
751
|
task,
|
|
467
752
|
...(scope !== undefined ? { scope } : {}),
|
|
468
753
|
...(acceptance !== undefined ? { acceptance } : {}),
|
|
469
|
-
...(
|
|
754
|
+
...modelFieldsOf(resolved),
|
|
470
755
|
...(after !== undefined ? { after } : {}),
|
|
471
756
|
worktree,
|
|
472
757
|
baseRev,
|
|
@@ -476,39 +761,45 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
|
|
|
476
761
|
verification,
|
|
477
762
|
...(unconfirmed.length > 0 ? { unconfirmed } : {}),
|
|
478
763
|
unresolved,
|
|
479
|
-
answer:
|
|
480
|
-
usage:
|
|
481
|
-
toolCalls
|
|
764
|
+
answer: record === null ? "" : answer.trimEnd(),
|
|
765
|
+
usage: full?.usage ?? NO_USAGE,
|
|
766
|
+
toolCalls,
|
|
482
767
|
failed,
|
|
483
|
-
diag: failed ?
|
|
768
|
+
diag: failed ? tail.text : "",
|
|
769
|
+
childDir,
|
|
484
770
|
};
|
|
485
771
|
if (manifestDir !== undefined) writeFileSync(join(manifestDir, `${childId}.result.json`), `${JSON.stringify(result)}\n`, "utf8");
|
|
486
|
-
// The section (what the model reads) —
|
|
772
|
+
// The section (what the model reads) — its head, then the child's
|
|
773
|
+
// handoff (C3: the shared format, within the call's budget), then
|
|
774
|
+
// what only a writer has.
|
|
487
775
|
const verdict = verification === null ? "none" : verification.skipped !== undefined ? `SKIPPED (${verification.skipped})` : verification.passed ? "PASSED" : "FAILED";
|
|
488
|
-
|
|
776
|
+
const ranOn = typeof full?.profile === "string" ? full.profile : typeof full?.model === "string" ? full.model : (resolved.profile ?? "unknown");
|
|
777
|
+
let text = `[subagent] ${role}: ${task}\n status: ${status} · model: ${ranOn}${resolved.source === "environment" ? " (default)" : ""} · verification: ${verdict}${changedFiles !== null ? ` · files changed: ${changedFiles.length}` : ""} · tools: ${toolCalls}`;
|
|
489
778
|
if (scope !== undefined) text += `\n scoped: no shell · writes only under ${scope.join(", ")}`;
|
|
490
779
|
if (baseRev !== null) text += `\n child saw HEAD ${baseRev.slice(0, 7)}; the parent's uncommitted changes were not visible`;
|
|
780
|
+
let reserved = 0;
|
|
491
781
|
if (verification !== null && verification.skipped === undefined) {
|
|
492
782
|
const exit = verification.unconfirmed !== undefined ? `killed, not confirmed (pid ${verification.unconfirmed.join(", ")} may still be running — treat its side effects as UNCERTAIN)` : verification.exitCode === null ? "killed" : verification.exitCode;
|
|
493
783
|
text += `\n verification: ${verification.kind} ${verification.passed ? "PASSED" : "FAILED"} · exit ${exit} · ${verification.durationMs}ms · ${verification.kind === "check" ? verification.command : verification.evaluator}`;
|
|
494
|
-
if (!verification.passed && verification.tail !== "")
|
|
784
|
+
if (!verification.passed && verification.tail !== "") {
|
|
785
|
+
text += `\n${verification.tail}`;
|
|
786
|
+
reserved = Buffer.byteLength(verification.tail);
|
|
787
|
+
}
|
|
495
788
|
}
|
|
496
789
|
for (const l of lines) text += `\n${l}`;
|
|
497
|
-
|
|
498
|
-
|
|
790
|
+
let trailer = "";
|
|
791
|
+
if (unresolved === null && record !== null && answer.trim() !== "") trailer += "\n unresolved: not reported";
|
|
499
792
|
if (collection !== null) {
|
|
500
793
|
if (collection.kind === "collected") {
|
|
501
|
-
|
|
502
|
-
if (collection.bytes <= INLINE_PATCH_BYTES) text += `\n${readFileSync(patchPath, "utf8")}`;
|
|
503
|
-
else text += `\n (patch is ${collection.bytes} bytes — read it with the shell: cat ${patchPath}, or git -C ${worktree} diff ${baseRev})`;
|
|
504
|
-
text += `\n worktree kept at: ${worktree}`;
|
|
794
|
+
trailer += `\n diff:\n${collection.stat}\n patch: ${patchPath}\n worktree kept at: ${worktree}`;
|
|
505
795
|
} else if (collection.kind === "failed") {
|
|
506
|
-
|
|
796
|
+
trailer += `\n FAILED: collecting the worktree's changes: ${collection.reason}${collection.partialPath !== undefined ? ` (partial patch at ${collection.partialPath})` : ""}\n worktree kept at: ${worktree}`;
|
|
507
797
|
}
|
|
508
798
|
} else if (unconfirmed.length > 0 && worktree !== null) {
|
|
509
|
-
|
|
799
|
+
trailer += `\n worktree kept at: ${worktree} — the child may still be writing to it; nothing was collected`;
|
|
510
800
|
}
|
|
511
|
-
|
|
801
|
+
const handoff = { record, answer, tail: tail.text, tailCut: tail.cut, resultPath: join(childDir, "result.md") };
|
|
802
|
+
return { failed, ...(failed ? { failKind: failKindOf(status, verification) } : {}), text, handoff, reserved, trailer, toolCalls };
|
|
512
803
|
} finally {
|
|
513
804
|
rmSync(policyDir, { recursive: true, force: true });
|
|
514
805
|
if (worktree !== null && ownsWorktree && !keepWorktree) removeWorktree(parentCwd, worktree);
|
|
@@ -545,8 +836,11 @@ export function delegateSummary(sections, roles) {
|
|
|
545
836
|
return `summary: ${n} task${n === 1 ? "" : "s"} · ${toolCalls} tool calls · ${roles} role${roles === 1 ? "" : "s"} · ${failedSections.length} failed${kinds.length > 0 ? ` (${kinds.join(", ")})` : ""}`;
|
|
546
837
|
}
|
|
547
838
|
|
|
548
|
-
/** DT-1a: the fixed trailer every task file ends with — the parser reads
|
|
549
|
-
|
|
839
|
+
/** DT-1a: the fixed trailer every task file ends with — the parser reads
|
|
840
|
+
* the section back. 0.49.0 C3: it states the handoff contract too — the
|
|
841
|
+
* child's final answer is all the parent reads of its run. */
|
|
842
|
+
export const UNRESOLVED_INSTRUCTION =
|
|
843
|
+
'Your final answer is a handoff to the parent: the conclusion first, only the evidence needed to act on it, no raw command output or large diffs. End it with a section titled UNRESOLVED listing what you could not do or verify, one item per line starting with "- ", or the single word none.';
|
|
550
844
|
|
|
551
845
|
/** DT-1a: what a delegated task may NAME — the parent CLI hands the configured
|
|
552
846
|
* checks and model profiles through the environment. Absent = nothing configured. */
|
|
@@ -559,9 +853,11 @@ function delegationConfig() {
|
|
|
559
853
|
evaluators: Array.isArray(parsed.evaluators) ? parsed.evaluators.filter((p) => typeof p === "string") : [],
|
|
560
854
|
profiles: parsed.profiles ?? [],
|
|
561
855
|
sessionsDir: typeof parsed.sessionsDir === "string" && parsed.sessionsDir !== "" ? parsed.sessionsDir : undefined,
|
|
856
|
+
// 0.49.0 C1: the user's subagents.model (validated by the host)
|
|
857
|
+
subagentsModel: typeof parsed.subagentsModel === "string" && parsed.subagentsModel !== "" ? parsed.subagentsModel : undefined,
|
|
562
858
|
};
|
|
563
859
|
} catch {
|
|
564
|
-
return { checks: {}, evaluators: [], profiles: [], sessionsDir: undefined };
|
|
860
|
+
return { checks: {}, evaluators: [], profiles: [], sessionsDir: undefined, subagentsModel: undefined };
|
|
565
861
|
}
|
|
566
862
|
}
|
|
567
863
|
|
|
@@ -576,7 +872,7 @@ function artifactDir(home, sessionsDir) {
|
|
|
576
872
|
}
|
|
577
873
|
|
|
578
874
|
/** DT-1a: every task's inputs are judged BEFORE any child runs; a string is the refusal. */
|
|
579
|
-
export function validateTask(task, cfg, parentCwd, manifestDir) {
|
|
875
|
+
export function validateTask(task, cfg, parentCwd, manifestDir, opts = {}) {
|
|
580
876
|
if (task === null || typeof task !== "object") return "a task must be an object";
|
|
581
877
|
if (task.role === "tester") return 'unknown role "tester" — it is called "verifier" since 0.46.0';
|
|
582
878
|
if (!ROLES.includes(task.role)) return `unknown role ${JSON.stringify(task.role)}`;
|
|
@@ -611,7 +907,11 @@ export function validateTask(task, cfg, parentCwd, manifestDir) {
|
|
|
611
907
|
}
|
|
612
908
|
}
|
|
613
909
|
if (task.model !== undefined && !cfg.profiles.includes(task.model)) return `refused: unknown model profile ${JSON.stringify(task.model)} (configured: ${cfg.profiles.join(", ") || "none"})`;
|
|
614
|
-
|
|
910
|
+
// 0.49.0 B2: with tasks wired, `after` is an index into this call — the
|
|
911
|
+
// batch checks it (startTasks); an earlier call's tree no longer exists
|
|
912
|
+
if (task.after !== undefined && opts.afterByIndex === true) {
|
|
913
|
+
if (task.role !== "verifier") return "refused: `after` is for verifier tasks";
|
|
914
|
+
} else if (task.after !== undefined) {
|
|
615
915
|
if (task.role !== "verifier") return "refused: `after` is for verifier tasks";
|
|
616
916
|
let prior;
|
|
617
917
|
try {
|
|
@@ -873,11 +1173,16 @@ export function childArgs(bin, childId, taskPath, model) {
|
|
|
873
1173
|
}
|
|
874
1174
|
|
|
875
1175
|
/** A child's environment — the parent's, plus the depth guard, its role
|
|
876
|
-
* policy and the sessions folder (the foreground and background share it).
|
|
877
|
-
|
|
1176
|
+
* policy and the sessions folder (the foreground and background share it).
|
|
1177
|
+
* 0.49.0 C1: a child that runs on the conversation's profile runs at the
|
|
1178
|
+
* conversation's effort too (KISO_CHILD_REASONING) — and only that child:
|
|
1179
|
+
* a profile the task or subagents.model named starts at its own effort. */
|
|
1180
|
+
function childEnv(policyDir, sessionsDir, reasoning) {
|
|
878
1181
|
const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
|
|
1182
|
+
const { KISO_CHILD_REASONING: _inherited, ...parent } = process.env;
|
|
879
1183
|
return {
|
|
880
|
-
...
|
|
1184
|
+
...(reasoning !== undefined && reasoning !== null ? { KISO_CHILD_REASONING: JSON.stringify(reasoning) } : {}),
|
|
1185
|
+
...parent,
|
|
881
1186
|
KISO_SUBAGENT_DEPTH: String(depth + 1),
|
|
882
1187
|
KISO_EXTENSIONS_DIR: policyDir,
|
|
883
1188
|
// 0.40.0: the child writes its log beside its parent's — the
|
|
@@ -893,19 +1198,50 @@ function childEnv(policyDir, sessionsDir) {
|
|
|
893
1198
|
};
|
|
894
1199
|
}
|
|
895
1200
|
|
|
896
|
-
|
|
897
|
-
|
|
1201
|
+
/** A foreground child's process. 0.49.0 C3: what it prints — stdout and
|
|
1202
|
+
* stderr — streams into its output.log; nothing of it accumulates in
|
|
1203
|
+
* memory, so a child that prints without bound costs the parent nothing.
|
|
1204
|
+
* C2: a reader runs under its turn budget; every child writes its result
|
|
1205
|
+
* record (--result-file), the parent's only source for its result. */
|
|
1206
|
+
function runProcess({ childId, bin, cwd, policyDir, taskPath, timeout, signal, resolved, sessionsDir, childDir, maxTurns }) {
|
|
1207
|
+
const args = [...childArgs(bin, childId, taskPath, resolved.profile), ...(maxTurns !== undefined ? ["--max-turns", String(maxTurns)] : []), "--result-file", join(childDir, "result.md")];
|
|
1208
|
+
const child = spawn(process.execPath, args, {
|
|
898
1209
|
cwd,
|
|
899
|
-
env: childEnv(policyDir, sessionsDir),
|
|
1210
|
+
env: childEnv(policyDir, sessionsDir, resolved.reasoning),
|
|
900
1211
|
...ownGroup(),
|
|
901
|
-
stdio: ["pipe", "pipe", "
|
|
1212
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
902
1213
|
});
|
|
903
1214
|
child.stdin.end(); // CX-1 F5: nothing rides stdin — the task is the file
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1215
|
+
const outputPath = join(childDir, "output.log");
|
|
1216
|
+
let fd = openSync(outputPath, "a");
|
|
1217
|
+
let open = 2;
|
|
1218
|
+
let drained;
|
|
1219
|
+
const allClosed = new Promise((r) => {
|
|
1220
|
+
drained = r;
|
|
1221
|
+
});
|
|
1222
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
1223
|
+
stream.on("data", (d) => {
|
|
1224
|
+
if (fd === -1) return;
|
|
1225
|
+
try {
|
|
1226
|
+
writeSync(fd, d);
|
|
1227
|
+
} catch {
|
|
1228
|
+
// best effort: the output is diagnostics, never the result
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
stream.on("close", () => {
|
|
1232
|
+
open -= 1;
|
|
1233
|
+
if (open === 0) drained();
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
return superviseExit(child, timeout, signal).then(async ({ code, killed, unconfirmed }) => {
|
|
1237
|
+
// the pipes drain after the exit; a child let go (unconfirmed) has
|
|
1238
|
+
// had them destroyed — the wait is bounded either way
|
|
1239
|
+
await Promise.race([allClosed, new Promise((r) => setTimeout(r, 1_000))]);
|
|
1240
|
+
const closing = fd;
|
|
1241
|
+
fd = -1;
|
|
1242
|
+
closeSync(closing);
|
|
1243
|
+
return { code: code ?? -1, killed, unconfirmed, outputPath };
|
|
907
1244
|
});
|
|
908
|
-
return superviseExit(child, timeout, signal).then(({ code, killed, unconfirmed }) => ({ code: code ?? -1, stdout, killed, unconfirmed }));
|
|
909
1245
|
}
|
|
910
1246
|
|
|
911
1247
|
/** The role policy: read-only for explorer/reviewer, the full six for
|
|
@@ -1045,9 +1381,6 @@ function finalText(events) {
|
|
|
1045
1381
|
return text;
|
|
1046
1382
|
}
|
|
1047
1383
|
|
|
1048
|
-
/** CX-1 F2: a patch body this size or smaller rides inline in the section;
|
|
1049
|
-
* larger ones are named by path (the parent reads them with the shell). */
|
|
1050
|
-
const INLINE_PATCH_BYTES = 64 * 1024;
|
|
1051
1384
|
|
|
1052
1385
|
/** CX-1 F2: the implementer's changes against the BASE revision, streamed to
|
|
1053
1386
|
* a file — intent-to-add first so NEW files are part of the diff. Three
|
|
@@ -1111,6 +1444,387 @@ function removeWorktree(parentCwd, worktree) {
|
|
|
1111
1444
|
}
|
|
1112
1445
|
}
|
|
1113
1446
|
|
|
1447
|
+
// ── 0.49.0 B: writers — a private snapshot, the parent's collection ──────
|
|
1448
|
+
// A writer owns an isolated workspace; adoption is a separate, crash-honest
|
|
1449
|
+
// effect (the plan's third sentence). Its end is its COLLECTION: the task
|
|
1450
|
+
// is started with agent.collect, and the host's TaskManager calls
|
|
1451
|
+
// collectWriter once its process has ended; only `collected` ends it.
|
|
1452
|
+
|
|
1453
|
+
const WRITER_ROLES = ["implementer", "verifier"];
|
|
1454
|
+
const WRITER_MAX = 4;
|
|
1455
|
+
const SNAPSHOT_MAX_BYTES = 50 * 1024 * 1024;
|
|
1456
|
+
|
|
1457
|
+
class SnapshotRefusal extends Error {}
|
|
1458
|
+
|
|
1459
|
+
const gitOut = (args, opts = {}) => execFileSync("git", args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024, ...opts }).trim();
|
|
1460
|
+
|
|
1461
|
+
/** I3 — the dirty part of the working tree, in git's machine format
|
|
1462
|
+
* (porcelain v2, NUL-separated: names with spaces, quotes or newlines
|
|
1463
|
+
* survive): path → its type, the sha256 of its bytes and its exec bit, or
|
|
1464
|
+
* null when it is deleted. Ignored paths are never listed. --no-optional-
|
|
1465
|
+
* locks: not even the person's index stat cache is refreshed. */
|
|
1466
|
+
function workingManifest(root, skip = []) {
|
|
1467
|
+
const raw = execFileSync("git", ["-C", root, "--no-optional-locks", "status", "--porcelain=v2", "-z", "--untracked-files=all", "--no-renames"], { maxBuffer: 256 * 1024 * 1024 }).toString("utf8");
|
|
1468
|
+
const paths = [];
|
|
1469
|
+
for (const e of raw.split("\0")) {
|
|
1470
|
+
if (e.startsWith("? ")) paths.push(e.slice(2));
|
|
1471
|
+
else if (e.startsWith("1 ")) paths.push(e.split(" ").slice(8).join(" "));
|
|
1472
|
+
else if (e.startsWith("u ")) paths.push(e.split(" ").slice(10).join(" "));
|
|
1473
|
+
}
|
|
1474
|
+
const entries = new Map();
|
|
1475
|
+
let bytes = 0;
|
|
1476
|
+
for (const path of paths.sort()) {
|
|
1477
|
+
const full = join(root, path);
|
|
1478
|
+
// kiso's own home and the writers' workspaces are never part of a
|
|
1479
|
+
// snapshot — a home inside the repository would copy itself
|
|
1480
|
+
if (skip.some((r) => full === r || full.startsWith(`${r}${sep}`))) continue;
|
|
1481
|
+
let st;
|
|
1482
|
+
try {
|
|
1483
|
+
st = lstatSync(full);
|
|
1484
|
+
} catch {
|
|
1485
|
+
entries.set(path, null); // deleted in the working tree
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
if (st.isSymbolicLink()) {
|
|
1489
|
+
const target = readlinkSync(full);
|
|
1490
|
+
entries.set(path, { type: "link", sha256: createHash("sha256").update(target).digest("hex"), exec: false });
|
|
1491
|
+
} else if (st.isFile()) {
|
|
1492
|
+
const buf = readFileSync(full);
|
|
1493
|
+
bytes += buf.length;
|
|
1494
|
+
entries.set(path, { type: "file", sha256: createHash("sha256").update(buf).digest("hex"), exec: (st.mode & 0o111) !== 0 });
|
|
1495
|
+
}
|
|
1496
|
+
// a directory here is a submodule (gitlink): not part of a snapshot,
|
|
1497
|
+
// as a worktree never initialised one
|
|
1498
|
+
}
|
|
1499
|
+
return { entries, bytes };
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const sameManifest = (a, b) => a.entries.size === b.entries.size && [...a.entries].every(([k, v]) => JSON.stringify(b.entries.get(k)) === JSON.stringify(v) && b.entries.has(k));
|
|
1503
|
+
|
|
1504
|
+
/** B6.1 — a writer's workspace: a task-private repository that borrows the
|
|
1505
|
+
* person's objects read-only (`clone --shared`), checked out at HEAD, with
|
|
1506
|
+
* the working tree's dirty files copied in; the snapshot is committed in
|
|
1507
|
+
* the private repository and is the writer's base. Nothing is written into
|
|
1508
|
+
* the person's repository. I3: accepted only if HEAD and the manifest are
|
|
1509
|
+
* unchanged across the capture — else once more, then a refusal. Over the
|
|
1510
|
+
* cap: a refusal, never HEAD instead. */
|
|
1511
|
+
export function snapshotWorkspace(parentCwd, ws, maxBytes = SNAPSHOT_MAX_BYTES) {
|
|
1512
|
+
let root;
|
|
1513
|
+
let h0;
|
|
1514
|
+
try {
|
|
1515
|
+
root = gitOut(["-C", parentCwd, "rev-parse", "--show-toplevel"]);
|
|
1516
|
+
h0 = gitOut(["-C", root, "rev-parse", "--verify", "HEAD"]);
|
|
1517
|
+
} catch (err) {
|
|
1518
|
+
throw new SnapshotRefusal(`a writer needs a git repository with a commit: ${msg(err)}`);
|
|
1519
|
+
}
|
|
1520
|
+
const sub = relative(realpathSync(root), realpathSync(parentCwd));
|
|
1521
|
+
const real = (p) => {
|
|
1522
|
+
try {
|
|
1523
|
+
return realpathSync(p);
|
|
1524
|
+
} catch {
|
|
1525
|
+
return resolve(p);
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1528
|
+
// both sides real paths: git's top level is, and these are made so. The
|
|
1529
|
+
// writers' workspaces live under kiso's home; `ws` itself is named for a
|
|
1530
|
+
// caller that puts it elsewhere
|
|
1531
|
+
const skip = [real(process.env.KISO_HOME ?? join(homedir(), ".kiso")), real(ws)];
|
|
1532
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1533
|
+
const m0 = workingManifest(root, skip);
|
|
1534
|
+
if (m0.bytes > maxBytes) {
|
|
1535
|
+
const size = (n) => (n >= 1024 * 1024 ? `${Math.floor(n / (1024 * 1024))} MiB` : `${n} bytes`);
|
|
1536
|
+
throw new SnapshotRefusal(`the working-tree snapshot would copy ${size(m0.bytes)}, over ${size(maxBytes)}; commit, stash or shrink the workspace, or raise the host's limit`);
|
|
1537
|
+
}
|
|
1538
|
+
rmSync(ws, { recursive: true, force: true });
|
|
1539
|
+
mkdirSync(dirname(ws), { recursive: true });
|
|
1540
|
+
execFileSync("git", ["clone", "-q", "--shared", "--no-checkout", root, ws], { stdio: "ignore" });
|
|
1541
|
+
execFileSync("git", ["-C", ws, "checkout", "-q", "--detach", h0], { stdio: "ignore" });
|
|
1542
|
+
let stable = true;
|
|
1543
|
+
for (const [path, e] of m0.entries) {
|
|
1544
|
+
const from = join(root, path);
|
|
1545
|
+
const to = join(ws, path);
|
|
1546
|
+
if (e === null) {
|
|
1547
|
+
rmSync(to, { force: true });
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
1551
|
+
rmSync(to, { force: true });
|
|
1552
|
+
if (e.type === "link") {
|
|
1553
|
+
symlinkSync(readlinkSync(from), to);
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
// copy what was hashed: a file that changed since the manifest is unstable
|
|
1557
|
+
const buf = readFileSync(from);
|
|
1558
|
+
if (createHash("sha256").update(buf).digest("hex") !== e.sha256) stable = false;
|
|
1559
|
+
writeFileSync(to, buf);
|
|
1560
|
+
chmodSync(to, e.exec ? 0o755 : 0o644);
|
|
1561
|
+
}
|
|
1562
|
+
// test-only: a file the gate touches between the copy and the re-read
|
|
1563
|
+
if (process.env.KISO_TEST_SNAPSHOT_TOUCH !== undefined && (attempt === 0 || process.env.KISO_TEST_SNAPSHOT_TOUCH_ALWAYS === "1")) appendFileSync(process.env.KISO_TEST_SNAPSHOT_TOUCH, "x");
|
|
1564
|
+
const h1 = gitOut(["-C", root, "rev-parse", "--verify", "HEAD"]);
|
|
1565
|
+
if (stable && h1 === h0 && sameManifest(m0, workingManifest(root, skip))) {
|
|
1566
|
+
execFileSync("git", ["-C", ws, "add", "-A"], { stdio: "ignore" });
|
|
1567
|
+
execFileSync("git", ["-C", ws, "-c", "user.name=kiso", "-c", "user.email=kiso@localhost", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "--no-verify", "-m", "kiso: the working tree at dispatch"], { stdio: "ignore" });
|
|
1568
|
+
return { root, head: h0, base: gitOut(["-C", ws, "rev-parse", "HEAD"]), cwd: join(ws, sub), sub };
|
|
1569
|
+
}
|
|
1570
|
+
h0 = h1;
|
|
1571
|
+
}
|
|
1572
|
+
rmSync(ws, { recursive: true, force: true });
|
|
1573
|
+
throw new SnapshotRefusal("the workspace changed while the snapshot was being captured (twice); let the edits settle and delegate again");
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** B — collect a writer that ended: called once by the host's TaskManager
|
|
1577
|
+
* (its `collect` option), live or at the next open. Each step is keyed by
|
|
1578
|
+
* its own record file and skipped when it is there, so a kill -9 resumes
|
|
1579
|
+
* where it stopped; `collected` is written LAST. An implementer's result is
|
|
1580
|
+
* its patch (and the child's acceptance); a verifier's writes are never
|
|
1581
|
+
* collected — its result is its report. */
|
|
1582
|
+
export async function collectWriter(info, manager) {
|
|
1583
|
+
const cfg = delegationConfig();
|
|
1584
|
+
const home = process.env.KISO_HOME ?? join(homedir(), ".kiso");
|
|
1585
|
+
const sessionsDir = cfg.sessionsDir ?? (process.env.KISO_SESSIONS_DIR || join(home, "sessions"));
|
|
1586
|
+
const manifestDir = artifactDir(home, sessionsDir);
|
|
1587
|
+
const childId = info.agent?.session;
|
|
1588
|
+
if (manifestDir === null || typeof childId !== "string") throw new Error("no manifest for this writer");
|
|
1589
|
+
const man = JSON.parse(readFileSync(join(manifestDir, `${childId}.json`), "utf8"));
|
|
1590
|
+
const taskDir = dirname(info.outputPath);
|
|
1591
|
+
const journal = join(taskDir, "journal.jsonl");
|
|
1592
|
+
const done = (outcome, reason) => appendFileSync(journal, `${JSON.stringify({ type: "collected", ts: Date.now(), outcome, ...(reason !== undefined ? { reason } : {}) })}\n`);
|
|
1593
|
+
const ws = man.workspace;
|
|
1594
|
+
const stopped = info.stoppedBy !== undefined;
|
|
1595
|
+
const { record } = readChildResult(taskDir);
|
|
1596
|
+
const completed = record !== null && record.outcome === "completed" && !stopped;
|
|
1597
|
+
if (man.role !== "implementer") {
|
|
1598
|
+
rmSync(ws, { recursive: true, force: true });
|
|
1599
|
+
return done(stopped ? "stopped" : "collected");
|
|
1600
|
+
}
|
|
1601
|
+
// 1. the patch, its index and each changed file's two versions — the one
|
|
1602
|
+
// step that needs the workspace: once it is recorded, a resume after a
|
|
1603
|
+
// crash past the workspace's removal goes straight on to `collected`
|
|
1604
|
+
const patchJson = join(taskDir, "patch.json");
|
|
1605
|
+
if (!existsSync(patchJson)) {
|
|
1606
|
+
if (typeof ws !== "string" || !existsSync(ws)) return done("failed", `the workspace is gone before its patch was collected: ${ws}`);
|
|
1607
|
+
try {
|
|
1608
|
+
writePatch(ws, man.base, taskDir, stopped, man.acceptance);
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
return done("failed", `collecting the patch: ${msg(err)}; the workspace is kept at ${ws}`);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
const patch = JSON.parse(readFileSync(patchJson, "utf8"));
|
|
1614
|
+
// 2. the child's acceptance — after a completed child only (DT-1a R2.3)
|
|
1615
|
+
const accPath = join(taskDir, "acceptance.json");
|
|
1616
|
+
if (man.acceptance !== undefined && completed && patch.files.length > 0 && !existsSync(accPath) && existsSync(ws)) {
|
|
1617
|
+
const v = await runAcceptance(man.acceptance, cfg, ws, man.base, TIMEOUT_MS, undefined);
|
|
1618
|
+
writeAtomic(accPath, `${JSON.stringify(v)}\n`);
|
|
1619
|
+
}
|
|
1620
|
+
// 3. a verifier `after` it: handed this tree (base + patch), started in
|
|
1621
|
+
// its group BEFORE `collected`, so the group never looks complete between
|
|
1622
|
+
const depsPath = join(taskDir, "dependents.json");
|
|
1623
|
+
const deps = existsSync(depsPath) ? JSON.parse(readFileSync(depsPath, "utf8")) : {};
|
|
1624
|
+
for (const [i, dep] of (man.dependents ?? []).entries()) {
|
|
1625
|
+
if (deps[dep.childId] !== undefined) continue;
|
|
1626
|
+
const accepted = existsSync(accPath) ? JSON.parse(readFileSync(accPath, "utf8")).passed !== false : true;
|
|
1627
|
+
if (!completed || patch.files.length === 0 || !accepted) {
|
|
1628
|
+
deps[dep.childId] = { skipped: !completed ? `the implementer did not complete (${stopped ? "stopped" : record?.outcome ?? "no result"})` : patch.files.length === 0 ? "the implementer changed nothing" : "the implementer's acceptance failed" };
|
|
1629
|
+
} else if (!existsSync(ws)) {
|
|
1630
|
+
deps[dep.childId] = { skipped: "the implementer's tree was already removed" };
|
|
1631
|
+
} else {
|
|
1632
|
+
const depWs = join(manifestDir, dep.childId, "ws");
|
|
1633
|
+
mkdirSync(dirname(depWs), { recursive: true });
|
|
1634
|
+
if (i === (man.dependents ?? []).length - 1 && existsSync(ws)) renameSync(ws, depWs);
|
|
1635
|
+
else cpSync(ws, depWs, { recursive: true, verbatimSymlinks: true });
|
|
1636
|
+
const depMan = JSON.parse(readFileSync(join(manifestDir, `${dep.childId}.json`), "utf8"));
|
|
1637
|
+
const depCwd = join(depWs, man.sub ?? "");
|
|
1638
|
+
writeDurable(join(manifestDir, `${dep.childId}.policy`, "policy.mjs"), rolePolicyContent("verifier", depMan.scope !== undefined ? { root: depCwd, globs: depMan.scope } : undefined));
|
|
1639
|
+
writeFileSync(join(manifestDir, `${dep.childId}.json`), `${JSON.stringify({ ...depMan, workspace: depWs, base: man.base, sub: man.sub })}\n`);
|
|
1640
|
+
const started = await manager.start({
|
|
1641
|
+
command: `verifier: ${String(depMan.task).split("\n")[0].slice(0, 80)}`,
|
|
1642
|
+
cwd: depCwd,
|
|
1643
|
+
env: childEnv(join(manifestDir, `${dep.childId}.policy`), sessionsDir, depMan.reasoning),
|
|
1644
|
+
...(info.executionId !== undefined ? { executionId: info.executionId } : {}),
|
|
1645
|
+
agent: { role: "verifier", session: dep.childId, collect: true },
|
|
1646
|
+
exec: (dir) => ({ file: process.execPath, args: [...childArgs(process.env.KISO_SUBAGENT_BIN ?? process.argv[1], dep.childId, join(manifestDir, `${dep.childId}.task`), depMan.model), ...(depMan.maxTurns !== undefined ? ["--max-turns", String(depMan.maxTurns)] : []), "--result-file", join(dir, "result.md")] }),
|
|
1647
|
+
});
|
|
1648
|
+
deps[dep.childId] = { taskId: started.id };
|
|
1649
|
+
}
|
|
1650
|
+
writeAtomic(depsPath, `${JSON.stringify(deps)}\n`);
|
|
1651
|
+
}
|
|
1652
|
+
// 4. the workspace goes (unless it was handed over), 5. `collected`, last
|
|
1653
|
+
rmSync(ws, { recursive: true, force: true });
|
|
1654
|
+
return done(stopped ? "stopped" : patch.files.length === 0 ? "unchanged" : "collected");
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/** B — the patch against the snapshot (binary-safe, renames as a delete
|
|
1658
|
+
* plus a create), each changed file's base and child versions, and the
|
|
1659
|
+
* index that names them: patch.json, written last of the three. */
|
|
1660
|
+
function writePatch(ws, base, taskDir, stopped, acceptance) {
|
|
1661
|
+
execFileSync("git", ["-C", ws, "add", "-A"], { stdio: "ignore" });
|
|
1662
|
+
const diff = execFileSync("git", ["-C", ws, "diff", "--cached", "--binary", "--no-renames", base], { maxBuffer: 1024 * 1024 * 1024 });
|
|
1663
|
+
writeAtomic(join(taskDir, "patch.diff"), diff);
|
|
1664
|
+
const raw = execFileSync("git", ["-C", ws, "diff", "--cached", "--no-renames", "--raw", "-z", base], { maxBuffer: 256 * 1024 * 1024 }).toString("utf8").split("\0");
|
|
1665
|
+
const files = [];
|
|
1666
|
+
const vdir = join(taskDir, "versions");
|
|
1667
|
+
mkdirSync(vdir, { recursive: true });
|
|
1668
|
+
for (let i = 0; i + 1 < raw.length; i += 2) {
|
|
1669
|
+
const [srcMode, dstMode, , , status] = raw[i].replace(/^:/, "").split(" ");
|
|
1670
|
+
const path = raw[i + 1];
|
|
1671
|
+
const n = files.length;
|
|
1672
|
+
const entry = { path, status, baseMode: srcMode, mode: dstMode };
|
|
1673
|
+
if (status !== "A") {
|
|
1674
|
+
writeFileSync(join(vdir, `${n}.base`), execFileSync("git", ["-C", ws, "show", `${base}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
|
|
1675
|
+
}
|
|
1676
|
+
if (status !== "D") {
|
|
1677
|
+
writeFileSync(join(vdir, `${n}.child`), execFileSync("git", ["-C", ws, "show", `:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
|
|
1678
|
+
}
|
|
1679
|
+
files.push(entry);
|
|
1680
|
+
}
|
|
1681
|
+
const bytes = statSync(join(taskDir, "patch.diff")).size;
|
|
1682
|
+
// the acceptance by its name: apply-patch re-resolves it from the configuration
|
|
1683
|
+
writeAtomic(join(taskDir, "patch.json"), `${JSON.stringify({ base, bytes, files, completeness: stopped ? "partial" : "complete", applyable: !stopped, ...(acceptance !== undefined ? { acceptance } : {}) })}\n`);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function writeAtomic(path, content) {
|
|
1687
|
+
const tmp = `${path}.tmp`;
|
|
1688
|
+
writeFileSync(tmp, content);
|
|
1689
|
+
renameSync(tmp, path);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/** 0.49.0 C1 — the conversation's live binding, as the host reports it;
|
|
1693
|
+
* null when the host gives none (an SDK caller, a test) or fails. */
|
|
1694
|
+
function bindingOf(host, sessionId) {
|
|
1695
|
+
if (typeof host.currentBinding !== "function") return null;
|
|
1696
|
+
try {
|
|
1697
|
+
const b = host.currentBinding(sessionId);
|
|
1698
|
+
return b !== null && typeof b === "object" ? b : null;
|
|
1699
|
+
} catch {
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
/** 0.49.0 C1 — which profile a child runs on, and at what effort. The first
|
|
1705
|
+
* that applies wins: the task's own `model`; the user's subagents.model;
|
|
1706
|
+
* the conversation's profile, with the conversation's effort; else none —
|
|
1707
|
+
* the child inherits the environment, and its handoff says so. Resolved at
|
|
1708
|
+
* dispatch, so a /model switch between two delegations is honoured. */
|
|
1709
|
+
export function resolveChild(task, cfg, binding) {
|
|
1710
|
+
if (task.model !== undefined) return { profile: task.model, source: "task" };
|
|
1711
|
+
if (cfg.subagentsModel !== undefined) return { profile: cfg.subagentsModel, source: "subagents.model" };
|
|
1712
|
+
if (binding !== null && typeof binding.profile === "string" && binding.profile !== "") {
|
|
1713
|
+
return { profile: binding.profile, ...(binding.reasoning !== undefined && binding.reasoning !== null ? { reasoning: binding.reasoning } : {}), source: "conversation" };
|
|
1714
|
+
}
|
|
1715
|
+
return { source: "environment" };
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/** The resolution as a manifest records it — a restart replays it. */
|
|
1719
|
+
function modelFieldsOf(resolved) {
|
|
1720
|
+
return { modelSource: resolved.source, ...(resolved.profile !== undefined ? { model: resolved.profile } : {}), ...(resolved.reasoning !== undefined ? { reasoning: resolved.reasoning } : {}) };
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/** I5: a child's result — its record (result.json) commits its answer
|
|
1724
|
+
* (result.md); without a valid record there is no answer to read. */
|
|
1725
|
+
function readChildResult(childDir) {
|
|
1726
|
+
let raw = null;
|
|
1727
|
+
try {
|
|
1728
|
+
raw = readFileSync(join(childDir, "result.json"), "utf8");
|
|
1729
|
+
} catch {
|
|
1730
|
+
raw = null;
|
|
1731
|
+
}
|
|
1732
|
+
const record = handoffRecordOf(raw);
|
|
1733
|
+
if (record === null) return { record: null, full: null, answer: "" };
|
|
1734
|
+
let answer = "";
|
|
1735
|
+
try {
|
|
1736
|
+
answer = readFileSync(join(childDir, "result.md"), "utf8");
|
|
1737
|
+
} catch {
|
|
1738
|
+
answer = "";
|
|
1739
|
+
}
|
|
1740
|
+
return { record, full: JSON.parse(raw), answer };
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/** The last `bytes` of a file as read, and whether it held more — the
|
|
1744
|
+
* runtime delivery's reader, the same way. */
|
|
1745
|
+
function rawTail(path, bytes) {
|
|
1746
|
+
try {
|
|
1747
|
+
const fd = openSync(path, "r");
|
|
1748
|
+
try {
|
|
1749
|
+
const size = statSync(path).size;
|
|
1750
|
+
const n = Math.min(size, bytes);
|
|
1751
|
+
const buf = Buffer.alloc(n);
|
|
1752
|
+
readSync(fd, buf, 0, n, size - n);
|
|
1753
|
+
return { text: buf.toString("utf8"), cut: size > n };
|
|
1754
|
+
} finally {
|
|
1755
|
+
closeSync(fd);
|
|
1756
|
+
}
|
|
1757
|
+
} catch {
|
|
1758
|
+
return { text: "", cut: false };
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// ── The handoff format (0.49.0 C3/I7) ───────────────────────────────────
|
|
1763
|
+
// The runtime's tasks/handoff.ts, byte for byte: this extension has no
|
|
1764
|
+
// runtime dependency, so it carries its own copy, and the corpus in
|
|
1765
|
+
// tests/fixtures/handoff holds both to the same expected bytes.
|
|
1766
|
+
|
|
1767
|
+
export const CHILD_HANDOFF_BYTES = 4_096;
|
|
1768
|
+
export const GROUP_HANDOFF_BYTES = 16_384;
|
|
1769
|
+
export const HANDOFF_TAIL_BYTES = 2_048;
|
|
1770
|
+
export const HANDOFF_ERROR_BYTES = 1_024;
|
|
1771
|
+
|
|
1772
|
+
/** The record a result.json holds, or null — the commit test (I5). */
|
|
1773
|
+
export function handoffRecordOf(raw) {
|
|
1774
|
+
if (raw === null) return null;
|
|
1775
|
+
try {
|
|
1776
|
+
const parsed = JSON.parse(raw);
|
|
1777
|
+
if (parsed === null || typeof parsed !== "object" || typeof parsed.outcome !== "string" || parsed.outcome === "") return null;
|
|
1778
|
+
return typeof parsed.error === "string" && parsed.error !== "" ? { outcome: parsed.outcome, error: parsed.error } : { outcome: parsed.outcome };
|
|
1779
|
+
} catch {
|
|
1780
|
+
return null;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/** One child's handoff body: lines each led by "\n", and the bytes shown. */
|
|
1785
|
+
export function handoffBody(input, budget) {
|
|
1786
|
+
let text = "";
|
|
1787
|
+
let used = 0;
|
|
1788
|
+
const room = () => Math.max(0, budget - used);
|
|
1789
|
+
if (input.record?.error !== undefined) {
|
|
1790
|
+
const line = headOf(`error: ${input.record.error.replace(/\s*\n\s*/g, " ").trim()}`, Math.min(HANDOFF_ERROR_BYTES, room()));
|
|
1791
|
+
if (line.text !== "") {
|
|
1792
|
+
text += `\n${line.text}${line.cut ? "…" : ""}`;
|
|
1793
|
+
used += Buffer.byteLength(line.text);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
const answer = input.record === null ? "" : input.answer.trimEnd();
|
|
1797
|
+
if (answer !== "") {
|
|
1798
|
+
if (Buffer.byteLength(answer) <= room()) return { text: `${text}\n${answer}`, bytes: used + Buffer.byteLength(answer) };
|
|
1799
|
+
if (room() === 0) return { text: `${text}\n[the whole answer: ${input.resultPath}]`, bytes: used };
|
|
1800
|
+
const cut = headOf(answer, room()).text;
|
|
1801
|
+
return { text: `${text}\n${cut}\n… [truncated; the whole answer: ${input.resultPath}]`, bytes: used + Buffer.byteLength(cut) };
|
|
1802
|
+
}
|
|
1803
|
+
const tail = input.tail.trimEnd();
|
|
1804
|
+
if (tail !== "") {
|
|
1805
|
+
const kept = tailOf(tail, Math.min(HANDOFF_TAIL_BYTES, room()));
|
|
1806
|
+
if (kept.text !== "") {
|
|
1807
|
+
text += `\n${input.tailCut || kept.cut ? "…" : ""}${kept.text}`;
|
|
1808
|
+
used += Buffer.byteLength(kept.text);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
return { text, bytes: used };
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/** The first `bytes` of `s`, never splitting a character. */
|
|
1815
|
+
function headOf(s, bytes) {
|
|
1816
|
+
const buf = Buffer.from(s, "utf8");
|
|
1817
|
+
if (buf.length <= bytes) return { text: s, cut: false };
|
|
1818
|
+
return { text: buf.subarray(0, Math.max(0, bytes)).toString("utf8").replace(/�+$/, ""), cut: true };
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/** The last `bytes` of `s`, never splitting a character. */
|
|
1822
|
+
function tailOf(s, bytes) {
|
|
1823
|
+
const buf = Buffer.from(s, "utf8");
|
|
1824
|
+
if (buf.length <= bytes) return { text: s, cut: false };
|
|
1825
|
+
return { text: buf.subarray(buf.length - Math.max(0, bytes)).toString("utf8").replace(/^�+/, ""), cut: true };
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1114
1828
|
function failSection(childId, role, task, reason) {
|
|
1115
1829
|
return { failed: true, failKind: "error", text: `[subagent] ${role}: ${task}\n FAILED: ${reason}`, toolCalls: 0 };
|
|
1116
1830
|
}
|