@vincemakes/kiso-runtime 0.45.3 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,482 @@
1
+ /**
2
+ * ADR-0058 §7–§8, step 3c — a task's transitions reach the model.
3
+ *
4
+ * One delivery per session. It hears the TaskManager's transitions,
5
+ * decides each by the §8 defaults (silent | notify | wake), merges what
6
+ * arrives within a window into ONE notice, and hands it on:
7
+ *
8
+ * a live run → `run.notify` — admitted at the next safe point, in
9
+ * arrival order with the person's steers (ADR-0057);
10
+ * an idle session → held for the next run a person starts, or — when a
11
+ * transition asks to wake — ONE continuation run whose
12
+ * first input is the notice (the host starts it, after
13
+ * the previous run has fully settled).
14
+ *
15
+ * Exactly once, from the log: a transition counts as delivered when a
16
+ * `user_input` whose `via.items` names it is in the session's log (ADR-0051
17
+ * Amendment 8). The set is a cache rebuilt from the log, never a second
18
+ * truth. Lineage from the log too: a task started inside a wake run (a run
19
+ * whose first input is a task notice) never wakes — depth 1.
20
+ *
21
+ * The summary snapshot is what the MODEL was told, never the live journal:
22
+ * a compaction must not reveal a transition that has not been delivered.
23
+ *
24
+ * A transition a tool result reports (ADR-0058 Amendment 7) is CLAIMED in
25
+ * the task journal by that execution and never noticed — the manager does
26
+ * not announce it, and a restart does not deliver it. The claim decides
27
+ * only that; what the model knows stays the log's: the summary learns a
28
+ * claimed transition from the claiming execution's durable, successful
29
+ * tool_result, never from the journal alone.
30
+ *
31
+ * Background children (ADR-0058 3d) arrive as a GROUP: the agent tasks
32
+ * started by the calls of one model turn. The group closes once that turn
33
+ * has ended and every call in it has its result — before that, a fast
34
+ * child's end says nothing about the group. A closed group whose members
35
+ * have all ended is delivered ONCE: every member's line, then excerpts of
36
+ * the answers (result.md, which the child wrote) within 4 KiB each and
37
+ * 16 KiB together; members already delivered are named as reported, and
38
+ * only the rest are receipted. A failure goes into a live run at once and
39
+ * never wakes on its own; idle, it waits for its group.
40
+ */
41
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
42
+ import { dirname, join } from "node:path";
43
+ import { isRuntimeInput } from "@vincemakes/kiso-core";
44
+ import { NOTICE_FOOTER as FOOTER } from "../run.js";
45
+ import { endTransitionOf } from "./manager.js";
46
+ const key = (i) => `${i.taskId}:${i.transition}`;
47
+ /** ADR-0058 3d (D3): a child's excerpt, and a group's excerpts together. */
48
+ const CHILD_EXCERPT_BYTES = 4_096;
49
+ const GROUP_EXCERPT_BYTES = 16_384;
50
+ export class TaskDelivery {
51
+ #o;
52
+ #receipts = new Set();
53
+ #scanned = 0;
54
+ /** handed to a run, not yet seen landing in the log */
55
+ #inFlight = new Set();
56
+ #buffer = [];
57
+ #timer = null;
58
+ #pending = [];
59
+ /** a wake was asked for and its run has not begun: no second wake */
60
+ #waking = false;
61
+ /** ended agent tasks waiting for their group (restart: heard at startup) */
62
+ #ended = new Map();
63
+ #recheck = null;
64
+ #unsubscribe;
65
+ constructor(options) {
66
+ this.#o = options;
67
+ this.#unsubscribe = options.manager.subscribe((task, transition) => this.#hear(task, transition));
68
+ // a restart: what ended while nobody listened, and was never
69
+ // delivered, is delivered now as a notify — never a wake at startup
70
+ this.#scan();
71
+ const missed = [];
72
+ for (const task of this.#safeList()) {
73
+ const item = itemOf(task);
74
+ if (item === null || item.transition === "ready" || this.#receipts.has(key(item)))
75
+ continue;
76
+ // a tool result reported it: the claim alone decides (Amendment 7)
77
+ if (task.claims?.some((c) => c.transition === item.transition) === true)
78
+ continue;
79
+ if (task.agent !== undefined)
80
+ this.#ended.set(task.id, { restart: true });
81
+ else
82
+ missed.push({ item, line: this.#line(task, item) });
83
+ }
84
+ if (missed.length > 0)
85
+ this.#pending.push({ notice: { lines: missed.map((m) => m.line), items: missed.map((m) => m.item) }, wake: false });
86
+ if (this.#ended.size > 0)
87
+ this.#groups();
88
+ }
89
+ close() {
90
+ this.#unsubscribe();
91
+ if (this.#timer !== null)
92
+ clearTimeout(this.#timer);
93
+ this.#timer = null;
94
+ if (this.#recheck !== null)
95
+ clearTimeout(this.#recheck);
96
+ this.#recheck = null;
97
+ }
98
+ /** A run a person started: pending notices ride its first admission. */
99
+ takeForRun(run) {
100
+ this.#waking = false;
101
+ for (const p of this.#pending.splice(0))
102
+ this.#hand(run, p.notice);
103
+ }
104
+ /** The run has fully settled (its terminal durable, the slot free):
105
+ * notices it never admitted come back, and a wake may start. */
106
+ onRunSettled(run) {
107
+ for (const notice of run.unadmittedNotices()) {
108
+ for (const i of notice.items)
109
+ this.#inFlight.delete(key(i));
110
+ this.#pending.push({ notice, wake: notice.items.some((i) => this.#wakes(i)) });
111
+ }
112
+ this.#scan();
113
+ // its turns have all ended: a group held open by one may close
114
+ if (this.#ended.size > 0)
115
+ this.#groups();
116
+ // never start a run from inside the settling one
117
+ setTimeout(() => this.#pump(), 0);
118
+ }
119
+ /** The conversation's view of its tasks for a summary: a task the model
120
+ * was told started, at the last transition it was TOLD about. Empty
121
+ * when the model knows of no task. Told started = a durable, successful
122
+ * tool_result for the execution that started it — a started execution
123
+ * with no result (the crash window), or one resolved as not applied,
124
+ * never told the model a task exists. Told a transition = a notice
125
+ * naming it, or (Amendment 7) a durable, successful tool_result of the
126
+ * execution that claimed it — in log order, the last one wins. */
127
+ snapshot(events) {
128
+ const isTold = (e) => e.type === "tool_result" && !e.isError && e.executionId !== undefined;
129
+ const started = new Set(events.filter(isTold).map((e) => e.executionId));
130
+ const tasks = this.#safeList();
131
+ const claimedBy = new Map();
132
+ for (const task of tasks)
133
+ for (const c of task.claims ?? [])
134
+ claimedBy.set(c.executionId, [...(claimedBy.get(c.executionId) ?? []), { taskId: task.id, transition: c.transition }]);
135
+ const told = new Map();
136
+ for (const e of events) {
137
+ if (isTold(e))
138
+ for (const c of claimedBy.get(e.executionId) ?? [])
139
+ told.set(c.taskId, c.transition);
140
+ if (e.type !== "user_input" || e.via?.kind !== "tasks")
141
+ continue;
142
+ for (const i of e.via.items)
143
+ told.set(i.taskId, i.transition);
144
+ }
145
+ const rows = [];
146
+ for (const task of tasks) {
147
+ if (task.executionId === undefined || !started.has(task.executionId))
148
+ continue;
149
+ rows.push(`${task.id} ${told.get(task.id) ?? "running"} — ${task.command}`);
150
+ }
151
+ return rows.length === 0 ? "" : `Background tasks, as you were last told: ${rows.join("; ")}.`;
152
+ }
153
+ #hear(task, transition) {
154
+ const item = itemOf(task, transition);
155
+ if (item === null)
156
+ return;
157
+ if (task.agent !== undefined && item.transition !== "ready")
158
+ return this.#agentEnded(task, item);
159
+ const mode = this.#modeOf(task, item);
160
+ if (mode === "silent")
161
+ return;
162
+ this.#buffer.push({ item, line: this.#line(task, item), mode });
163
+ if (this.#timer === null)
164
+ this.#timer = setTimeout(() => this.#flush(), this.#o.windowMs ?? 1_000);
165
+ }
166
+ #flush() {
167
+ this.#timer = null;
168
+ this.#scan();
169
+ const held = new Set(this.#pending.flatMap((p) => p.notice.items.map(key)));
170
+ const fresh = this.#buffer.splice(0).filter((b) => !this.#receipts.has(key(b.item)) && !this.#inFlight.has(key(b.item)) && !held.has(key(b.item)));
171
+ if (fresh.length === 0)
172
+ return;
173
+ const notice = { lines: fresh.map((b) => b.line), items: fresh.map((b) => b.item) };
174
+ const run = this.#o.liveRun();
175
+ if (run !== undefined && this.#hand(run, notice))
176
+ return;
177
+ this.#pending.push({ notice, wake: fresh.some((b) => b.mode === "wake") });
178
+ this.#pump();
179
+ }
180
+ /** Idle: a pending wake starts ONE run carrying everything pending. */
181
+ #pump() {
182
+ if (this.#waking || this.#o.liveRun() !== undefined || this.#pending.length === 0)
183
+ return;
184
+ if (this.#o.wake === false || this.#o.onWake === undefined || !this.#pending.some((p) => p.wake))
185
+ return;
186
+ const all = this.#pending.splice(0);
187
+ const items = all.flatMap((p) => p.notice.items);
188
+ for (const i of items)
189
+ this.#inFlight.add(key(i));
190
+ const content = [...all.flatMap((p) => p.notice.lines), FOOTER].join("\n");
191
+ this.#waking = true;
192
+ this.#o.onWake({ content, via: { kind: "tasks", items } });
193
+ }
194
+ #hand(run, notice, wake) {
195
+ if (!run.notify(notice)) {
196
+ this.#pending.push({ notice, wake: wake ?? notice.items.some((i) => this.#wakes(i)) });
197
+ return false;
198
+ }
199
+ for (const i of notice.items)
200
+ this.#inFlight.add(key(i));
201
+ return true;
202
+ }
203
+ /** The receipts cache, extended from the log since the last scan. */
204
+ #scan() {
205
+ const events = this.#o.events();
206
+ for (let n = this.#scanned; n < events.length; n++) {
207
+ const e = events[n];
208
+ if (e.type !== "user_input" || e.via?.kind !== "tasks")
209
+ continue;
210
+ for (const i of e.via.items) {
211
+ this.#receipts.add(key(i));
212
+ this.#inFlight.delete(key(i));
213
+ }
214
+ }
215
+ this.#scanned = events.length;
216
+ }
217
+ /** ADR-0058 §8 defaults, then the guards: the switch, and lineage. */
218
+ #modeOf(task, item) {
219
+ if (item.transition === "ready" || item.transition === "stopped" || item.transition === "unknown")
220
+ return "notify";
221
+ if (task.executionId === undefined)
222
+ return "notify"; // started by the person: never a wake
223
+ if (task.profile === "service")
224
+ return "notify"; // a service's unexpected exit
225
+ return this.#wakes(item) ? "wake" : "notify";
226
+ }
227
+ #wakes(item) {
228
+ if (this.#o.wake === false)
229
+ return false;
230
+ if (item.transition !== "exited" && item.transition !== "failed")
231
+ return false;
232
+ const task = this.#o.manager.get(item.taskId);
233
+ if (task === undefined || task.executionId === undefined || task.profile === "service")
234
+ return false;
235
+ // a child wakes only with its whole group (D2): never on its own
236
+ if (task.agent !== undefined && !this.#complete(this.#groupOf(task)))
237
+ return false;
238
+ return !this.#startedInWakeRun(task.executionId);
239
+ }
240
+ /** ADR-0058 3d: an agent task has ended — a failure goes into a live run
241
+ * at once; every end waits for its group. */
242
+ #agentEnded(task, item) {
243
+ if (this.#known(key(item)) || this.#ended.has(task.id))
244
+ return;
245
+ const run = this.#o.liveRun();
246
+ if (item.transition === "failed" && run !== undefined)
247
+ this.#hand(run, { lines: [`${this.#agentTag(task, item)}${excerptOf(task, CHILD_EXCERPT_BYTES).text}`], items: [item] });
248
+ this.#ended.set(task.id, { restart: false });
249
+ this.#groups();
250
+ }
251
+ /** Deliver every closed group whose members have all ended; look again
252
+ * later while a group is still open. */
253
+ #groups() {
254
+ if (this.#recheck !== null)
255
+ clearTimeout(this.#recheck);
256
+ this.#recheck = null;
257
+ this.#scan();
258
+ let open = false;
259
+ const seen = new Set();
260
+ for (const id of [...this.#ended.keys()]) {
261
+ if (seen.has(id) || !this.#ended.has(id))
262
+ continue;
263
+ const task = this.#o.manager.get(id);
264
+ if (task === undefined) {
265
+ this.#ended.delete(id);
266
+ continue;
267
+ }
268
+ const group = this.#groupOf(task);
269
+ for (const m of group.members)
270
+ seen.add(m);
271
+ if (!group.closed)
272
+ open = true;
273
+ else if (this.#complete(group))
274
+ this.#deliverGroup(group.members);
275
+ }
276
+ if (open) {
277
+ this.#recheck = setTimeout(() => this.#groups(), this.#o.windowMs ?? 1_000);
278
+ this.#recheck.unref?.();
279
+ }
280
+ }
281
+ /** The group an agent task belongs to — derived from the log, never
282
+ * stored: the model turn holding the call that started it. Closed once
283
+ * that turn has ended and every call in it has its result — or the run
284
+ * that held it has ended, when no more results can come. A call an
285
+ * abandoned attempt voided never runs and is not waited for. A task the
286
+ * log cannot place is a group of one. */
287
+ #groupOf(task) {
288
+ const alone = { closed: true, members: [task.id] };
289
+ if (task.executionId === undefined)
290
+ return alone;
291
+ const events = this.#o.events();
292
+ const started = events.find((e) => e.type === "tool_execution_started" && e.executionId === task.executionId);
293
+ const at = started?.invocationSeq === undefined ? -1 : events.findIndex((e) => e.seq === started.invocationSeq);
294
+ if (at < 0)
295
+ return alone;
296
+ const boundary = (e) => e.type === "stop" || e.type === "terminal";
297
+ let from = at;
298
+ while (from > 0 && !boundary(events[from - 1]) && events[from - 1].type !== "user_input")
299
+ from--;
300
+ let to = at;
301
+ while (to < events.length && !boundary(events[to]))
302
+ to++;
303
+ const voided = events.filter((e) => e.type === "model_output_abandoned").map((e) => [e.voidFromSeq, e.seq]);
304
+ const calls = new Set(events.slice(from, to).filter((e) => e.type === "tool_call_end" && !voided.some(([lo, hi]) => e.seq > lo && e.seq <= hi)).map((e) => e.seq));
305
+ const runEnded = events.slice(to).some((e) => e.type === "terminal");
306
+ const answered = new Set(events.filter((e) => e.type === "tool_result").map((e) => e.invocationSeq));
307
+ const executions = new Set(events.filter((e) => e.type === "tool_execution_started" && e.invocationSeq !== undefined && calls.has(e.invocationSeq)).map((e) => e.executionId));
308
+ const members = this.#safeList()
309
+ .filter((t) => t.agent !== undefined && t.executionId !== undefined && executions.has(t.executionId))
310
+ .map((t) => t.id);
311
+ return { closed: to < events.length && (runEnded || [...calls].every((c) => answered.has(c))), members: members.includes(task.id) ? members : [...members, task.id] };
312
+ }
313
+ #complete(group) {
314
+ return group.closed && group.members.every((id) => {
315
+ const kind = this.#o.manager.get(id)?.state.kind;
316
+ return kind === undefined || kind === "ended" || kind === "unknown" || kind === "not_run";
317
+ });
318
+ }
319
+ /** ONE notice for a complete group: every member's line, excerpts within
320
+ * the budget, receipts only for what was not delivered before. */
321
+ #deliverGroup(members) {
322
+ const restart = members.some((id) => this.#ended.get(id)?.restart === true);
323
+ for (const id of members)
324
+ this.#ended.delete(id);
325
+ const tasks = members.map((id) => this.#o.manager.get(id)).filter((t) => t !== undefined);
326
+ const fresh = [];
327
+ const lines = [];
328
+ for (const task of tasks) {
329
+ const item = itemOf(task);
330
+ if (item === null)
331
+ continue;
332
+ if (this.#receipts.has(key(item)) || this.#inFlight.has(key(item))) {
333
+ lines.push(`<kiso-task id="${task.id}" kind="agent" role="${task.agent?.role ?? ""}" status="${item.transition}" reported="earlier"/>`);
334
+ continue;
335
+ }
336
+ this.#unpend(key(item)); // held for the next run: it rides this notice instead
337
+ fresh.push({ task, item });
338
+ }
339
+ if (fresh.length === 0)
340
+ return;
341
+ let budget = GROUP_EXCERPT_BYTES;
342
+ for (const { task, item } of fresh) {
343
+ const excerpt = excerptOf(task, Math.min(CHILD_EXCERPT_BYTES, budget));
344
+ budget -= excerpt.bytes;
345
+ lines.push(`${this.#agentTag(task, item)}${excerpt.text}`);
346
+ }
347
+ const items = fresh.map((f) => f.item);
348
+ const notice = { lines, items };
349
+ const wake = !restart && items.some((i) => this.#wakes(i));
350
+ const run = this.#o.liveRun();
351
+ if (run !== undefined && this.#hand(run, notice, wake))
352
+ return;
353
+ if (run === undefined)
354
+ this.#pending.push({ notice, wake });
355
+ this.#pump();
356
+ }
357
+ /** handed to a run, or held for one, or already in the log */
358
+ #known(k) {
359
+ return this.#receipts.has(k) || this.#inFlight.has(k) || this.#pending.some((p) => p.notice.items.some((i) => key(i) === k));
360
+ }
361
+ /** A pending notice holding only `k` (a child's failure held for the
362
+ * next run) is dropped: its group's notice carries it now. */
363
+ #unpend(k) {
364
+ this.#pending = this.#pending.filter((p) => !(p.notice.items.length === 1 && key(p.notice.items[0]) === k));
365
+ }
366
+ #agentTag(task, item) {
367
+ const attrs = [`id="${task.id}"`, `kind="agent"`, `role="${task.agent?.role ?? ""}"`, `status="${item.transition}"`];
368
+ const outcome = outcomeOf(task);
369
+ if (outcome !== undefined)
370
+ attrs.push(`outcome="${outcome}"`);
371
+ if (task.agent !== undefined)
372
+ attrs.push(`session="${task.agent.session}"`);
373
+ if (task.endedAt !== undefined)
374
+ attrs.push(`duration="${duration(task.endedAt - task.startedAt)}"`);
375
+ const result = resultPath(task);
376
+ attrs.push(existsSync(result) ? `result="${result}"` : `output="${task.outputPath}"`);
377
+ return `<kiso-task ${attrs.join(" ")}/>`;
378
+ }
379
+ /** Lineage depth 1: the run that started the task began with a notice. */
380
+ #startedInWakeRun(executionId) {
381
+ const events = this.#o.events();
382
+ const at = events.findIndex((e) => e.type === "tool_execution_started" && e.executionId === executionId);
383
+ if (at < 0)
384
+ return false;
385
+ let start = 0;
386
+ for (let n = at - 1; n >= 0; n--) {
387
+ if (events[n].type === "terminal") {
388
+ start = n + 1;
389
+ break;
390
+ }
391
+ }
392
+ const first = events.slice(start, at).find((e) => e.type === "user_input");
393
+ return isRuntimeInput(first);
394
+ }
395
+ #line(task, item) {
396
+ const attrs = [`id="${task.id}"`, `status="${item.transition}"`];
397
+ if (task.state.kind === "ended" && task.state.exitCode !== null)
398
+ attrs.push(`code="${task.state.exitCode}"`);
399
+ if (task.state.kind === "ended" && task.state.signal !== null)
400
+ attrs.push(`signal="${task.state.signal}"`);
401
+ if (task.endedAt !== undefined)
402
+ attrs.push(`duration="${duration(task.endedAt - task.startedAt)}"`);
403
+ attrs.push(`output="${task.outputPath}"`);
404
+ const tag = `<kiso-task ${attrs.join(" ")}/>`;
405
+ if (item.transition !== "failed")
406
+ return tag;
407
+ if (task.state.kind === "ended" && task.state.error !== undefined)
408
+ return `${tag}\nerror: ${task.state.error}`;
409
+ const tail = (this.#o.readTail ?? readTail)(task, this.#o.tailBytes ?? 2_048);
410
+ return tail === "" ? tag : `${tag}\n${tail}`;
411
+ }
412
+ #safeList() {
413
+ try {
414
+ return this.#o.manager.list();
415
+ }
416
+ catch {
417
+ return []; // a corrupt journal is reported where it is read on purpose
418
+ }
419
+ }
420
+ }
421
+ /** The transition a task's state stands for, as a receipt names it. */
422
+ function itemOf(task, transition) {
423
+ if (transition === "ready")
424
+ return { taskId: task.id, transition: "ready" };
425
+ const end = endTransitionOf(task);
426
+ return end === null ? null : { taskId: task.id, transition: end };
427
+ }
428
+ const resultPath = (task) => join(dirname(task.outputPath), "result.md");
429
+ /** The child's outcome, as it wrote it beside its answer. */
430
+ function outcomeOf(task) {
431
+ try {
432
+ const parsed = JSON.parse(readFileSync(join(dirname(task.outputPath), "result.json"), "utf8"));
433
+ return typeof parsed.outcome === "string" ? parsed.outcome : undefined;
434
+ }
435
+ catch {
436
+ return undefined;
437
+ }
438
+ }
439
+ /** A child's answer (result.md) cut to `bytes`, or — with no answer — the
440
+ * tail of what it printed. `bytes` counts the excerpt, never the pointer. */
441
+ function excerptOf(task, bytes) {
442
+ const path = resultPath(task);
443
+ let answer;
444
+ try {
445
+ answer = readFileSync(path, "utf8").trimEnd();
446
+ }
447
+ catch {
448
+ const tail = bytes > 0 ? readTail(task, Math.min(bytes, 2_048)) : "";
449
+ return tail === "" ? { text: "", bytes: 0 } : { text: `\n${tail}`, bytes: Buffer.byteLength(tail) };
450
+ }
451
+ if (answer === "")
452
+ return { text: "", bytes: 0 };
453
+ const size = Buffer.byteLength(answer);
454
+ if (size <= bytes)
455
+ return { text: `\n${answer}`, bytes: size };
456
+ if (bytes <= 0)
457
+ return { text: `\n[the whole answer: ${path}]`, bytes: 0 };
458
+ const cut = Buffer.from(answer).subarray(0, bytes).toString("utf8").replace(/\uFFFD+$/, "");
459
+ return { text: `\n${cut}\n… [truncated; the whole answer: ${path}]`, bytes: Buffer.byteLength(cut) };
460
+ }
461
+ function duration(ms) {
462
+ const s = Math.max(0, Math.round(ms / 1000));
463
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
464
+ }
465
+ function readTail(task, bytes) {
466
+ try {
467
+ const fd = openSync(task.outputPath, "r");
468
+ try {
469
+ const size = fstatSync(fd).size;
470
+ const n = Math.min(size, bytes);
471
+ const buf = Buffer.alloc(n);
472
+ readSync(fd, buf, 0, n, size - n);
473
+ return (size > n ? "…" : "") + buf.toString("utf8").trimEnd();
474
+ }
475
+ finally {
476
+ closeSync(fd);
477
+ }
478
+ }
479
+ catch {
480
+ return "";
481
+ }
482
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * ADR-0058 §6 — the task journal: execution truth for work that no longer
3
+ * sits inside a tool call.
4
+ *
5
+ * One append-only JSONL file per task. Every record that gates an external
6
+ * effect is DURABLE — written and fsynced — before the effect may begin:
7
+ * `planned` before the runner is spawned, `command_started` before the
8
+ * command runs, `terminal` before the task counts as ended. That is what
9
+ * lets `verdictOf` read a crash honestly: an absent record proves its effect
10
+ * never began; a present one proves only that it may have.
11
+ *
12
+ * The session log is conversation truth and never records a process
13
+ * lifecycle; this journal never records what the model knows. Neither
14
+ * decides the other's facts. `result_claimed` (ADR-0058 Amendment 7) is no
15
+ * exception: it says which tool execution took a transition over — so the
16
+ * delivery never notices it — not that the model saw it; that is the
17
+ * session log's, a durable successful tool_result for that execution.
18
+ */
19
+ export type TaskProfile = "oneshot" | "service";
20
+ /** A launch without a shell: the file and its arguments, verbatim. */
21
+ export interface TaskLaunch {
22
+ readonly kind: "exec";
23
+ readonly file: string;
24
+ readonly args: readonly string[];
25
+ }
26
+ /** Who an agent task is: its role and the child's session id. */
27
+ export interface TaskAgent {
28
+ readonly role: string;
29
+ readonly session: string;
30
+ }
31
+ export type TaskRecord = {
32
+ readonly type: "planned";
33
+ readonly ts: number;
34
+ readonly taskId: string;
35
+ /** "process": a runner owns the command (it survives kiso).
36
+ * "foreground": a shell command promoted past its wait — the kiso
37
+ * process that started it owns it (ADR-0058 3b, D1). */
38
+ readonly backend: "process" | "foreground";
39
+ readonly command: string;
40
+ readonly cwd: string;
41
+ readonly profile: TaskProfile;
42
+ /** The tool invocation that started the task, when one did. */
43
+ readonly executionId?: string;
44
+ /** A literal substring of the output that means "ready". */
45
+ readonly readyWhen?: string;
46
+ /** ADR-0058 3d: an argv launch — the runner starts `file` with
47
+ * exactly `args`, no shell; `command` is then only the label. */
48
+ readonly launch?: TaskLaunch;
49
+ /** ADR-0058 §5: an agent task — a child kiso and its own session. */
50
+ readonly agent?: TaskAgent;
51
+ } | {
52
+ readonly type: "runner_started";
53
+ readonly ts: number;
54
+ readonly pid: number;
55
+ readonly startedAt: string;
56
+ } | {
57
+ readonly type: "command_started";
58
+ readonly ts: number;
59
+ } | {
60
+ readonly type: "ready";
61
+ readonly ts: number;
62
+ readonly match: string;
63
+ } | {
64
+ readonly type: "stop_requested";
65
+ readonly ts: number;
66
+ readonly by: "person" | "model" | "exit";
67
+ }
68
+ /** A stop the runner could not confirm: these pids may outlive it. No
69
+ * terminal follows — the verdict stays `unknown`. */
70
+ | {
71
+ readonly type: "stop_unconfirmed";
72
+ readonly ts: number;
73
+ readonly pids: readonly number[];
74
+ }
75
+ /** `error`: the command never started (no shell, a missing cwd) — then
76
+ * there is no exit code and none is invented. */
77
+ | {
78
+ readonly type: "terminal";
79
+ readonly ts: number;
80
+ readonly exitCode: number | null;
81
+ readonly signal: string | null;
82
+ readonly error?: string;
83
+ }
84
+ /** ADR-0058 Amendment 7: the tool execution `executionId` — the CLAIMING
85
+ * call (task_stop's own, the shell call's own), not the task's starter —
86
+ * reports `transition` in its result, so it is never noticed. */
87
+ | {
88
+ readonly type: "result_claimed";
89
+ readonly ts: number;
90
+ readonly transition: ClaimedTransition;
91
+ readonly executionId: string;
92
+ };
93
+ /** A transition as the model is told it — a notice's or a result's name. */
94
+ export type ClaimedTransition = "ready" | "exited" | "failed" | "stopped" | "unknown";
95
+ /** Append one record and fsync it before returning — the write-ahead step. */
96
+ export declare function appendRecord(file: string, record: TaskRecord): void;
97
+ /** Windows: an append handle cannot be flushed (FlushFileBuffers needs
98
+ * write access, which append mode drops), and the append must stay an
99
+ * append (the runner and the manager both write the journal) — so the
100
+ * flush goes through a read-write handle of its own. */
101
+ export declare function syncFile(file: string): void;
102
+ /** fsync a directory, so a file created in it survives a power loss too. */
103
+ export declare function fsyncDir(dir: string): void;
104
+ /** A journal that is not a sequence of records with at most a torn tail. */
105
+ export declare class TaskJournalCorruptError extends Error {
106
+ readonly file: string;
107
+ readonly line: number;
108
+ constructor(file: string, line: number);
109
+ }
110
+ /** Every complete record, in order. A torn LAST line (a write cut by a
111
+ * crash) is not a record: it is dropped, never guessed at. An unreadable
112
+ * line with a record after it is corruption, and it fails loudly — the
113
+ * verdicts are inferences from what is present, so nothing is skipped. */
114
+ export declare function readRecords(file: string): TaskRecord[];
115
+ /**
116
+ * What the journal proves, given whether the recorded runner is still the
117
+ * live process it was (pid AND OS start time — never a bare pid).
118
+ *
119
+ * starting planned (or runner_started) and the runner may still act
120
+ * not_run the command provably never ran
121
+ * running command_started, the runner verified alive
122
+ * unknown command_started, the runner gone, no terminal — the
123
+ * person decides; the task is never re-run
124
+ * ended the terminal, as recorded
125
+ */
126
+ export type TaskState = {
127
+ readonly kind: "starting";
128
+ } | {
129
+ readonly kind: "not_run";
130
+ } | {
131
+ readonly kind: "running";
132
+ readonly ready: boolean;
133
+ } | {
134
+ readonly kind: "unknown";
135
+ } | {
136
+ readonly kind: "ended";
137
+ readonly exitCode: number | null;
138
+ readonly signal: string | null;
139
+ readonly stopped: boolean;
140
+ readonly error?: string;
141
+ };
142
+ /** How long a `planned` without a runner may wait for its runner to speak
143
+ * before it counts as never run — the runner's first act is its record. */
144
+ export declare const RUNNER_START_WINDOW_MS = 30000;
145
+ export declare function verdictOf(records: readonly TaskRecord[], runnerAlive: boolean, now?: number): TaskState;
146
+ /** The recorded runner identity, if the runner has spoken. */
147
+ export declare function runnerOf(records: readonly TaskRecord[]): {
148
+ readonly pid: number;
149
+ readonly startedAt: string;
150
+ } | undefined;