@vincemakes/kiso-runtime 0.45.3 → 0.46.1
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/agent.d.ts +6 -0
- package/dist/agent.js +5 -1
- package/dist/internal.d.ts +2 -0
- package/dist/internal.js +4 -0
- package/dist/profile.js +13 -7
- package/dist/run.d.ts +22 -3
- package/dist/run.js +74 -22
- package/dist/session.d.ts +19 -0
- package/dist/session.js +66 -5
- package/dist/store.js +14 -4
- package/dist/tasks/delivery.d.ts +82 -0
- package/dist/tasks/delivery.js +482 -0
- package/dist/tasks/journal.d.ts +150 -0
- package/dist/tasks/journal.js +124 -0
- package/dist/tasks/manager.d.ts +219 -0
- package/dist/tasks/manager.js +438 -0
- package/package.json +6 -6
|
@@ -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;
|