@vincemakes/kiso-runtime 0.45.2 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,438 @@
1
+ /**
2
+ * ADR-0058 §6 — the TaskManager: one per session, the only door to a
3
+ * session's tasks.
4
+ *
5
+ * It owns the task directory (`<store root>/<session>.tasks/<id>/`, holding
6
+ * `journal.jsonl` and `output.log`), allocates ids (`t1`, `t2`, … — stable
7
+ * across restarts, read off the directory), writes the kiso-side records
8
+ * durably, and classifies every task from its journal plus a verified
9
+ * runner identity. It never spawns a process itself: a BACKEND does (the
10
+ * process backend lives with the shell, in tools-node), and any number of
11
+ * hosts reach tasks through this one manager — never by searching for a
12
+ * child process themselves.
13
+ *
14
+ * Two ways in: `start` — a runner owns the command (background work, it
15
+ * survives kiso); `adopt` — a foreground shell command promoted past its
16
+ * wait, which the kiso process that spawned it keeps owning. An adopted
17
+ * task's runner IS that kiso process: if kiso crashes the journal reads
18
+ * `unknown`, never ended, never re-run (ADR-0058 3b, D1).
19
+ *
20
+ * What it does not do (later steps): tell the model (3c), or decide whether
21
+ * an idle session wakes (3c).
22
+ */
23
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { appendRecord, fsyncDir, readRecords, runnerOf, verdictOf } from "./journal.js";
26
+ /** The name a task's end (or its runner's vanishing, or a start that never
27
+ * ran) has for the model — as a notice says it, and as a claim records it. */
28
+ export function endTransitionOf(task) {
29
+ const s = task.state;
30
+ if (s.kind === "ended") {
31
+ if (task.stoppedBy !== undefined)
32
+ return "stopped";
33
+ return s.exitCode === 0 && s.error === undefined ? "exited" : "failed";
34
+ }
35
+ if (s.kind === "unknown")
36
+ return "unknown";
37
+ if (s.kind === "not_run")
38
+ return "failed";
39
+ return null;
40
+ }
41
+ const JOURNAL = "journal.jsonl";
42
+ const OUTPUT = "output.log";
43
+ const ID = /^t(\d+)$/;
44
+ export class TaskManager {
45
+ root;
46
+ #backend;
47
+ #listeners = new Set();
48
+ #pollMs;
49
+ #identifyEveryMs;
50
+ /** id → the last identity verdict for its runner (pid + start time). */
51
+ #identity = new Map();
52
+ #timer = null;
53
+ /** id → the last observed kind ("ready" for a running task that has signalled) */
54
+ #seen = new Map();
55
+ /** id → how to stop an adopted task this process owns */
56
+ #local = new Map();
57
+ /** executionId → a running foreground command that can be detached (3e) */
58
+ #detachable = new Map();
59
+ /** id → how many `awaitSettled` calls wait on it: the watcher skips it */
60
+ #waiting = new Map();
61
+ constructor(options) {
62
+ this.root = options.root;
63
+ this.#backend = options.backend;
64
+ if (options.onTransition !== undefined)
65
+ this.#listeners.add(options.onTransition);
66
+ this.#pollMs = options.pollMs ?? 500;
67
+ this.#identifyEveryMs = options.identifyEveryMs ?? 5_000;
68
+ }
69
+ /** A runner's identity (Windows P6): "gone" is final — that pid and
70
+ * start time never come back; any other verdict is reused for
71
+ * `identifyEveryMs`. `fresh` always checks (a stop must never signal a
72
+ * pid that is someone else's now). */
73
+ #identityOf(id, runner, fresh = false) {
74
+ const key = `${runner.pid}:${runner.startedAt}`;
75
+ const cached = this.#identity.get(id);
76
+ if (cached !== undefined && cached.key === key && (cached.verdict === "gone" || (!fresh && Date.now() - cached.at < this.#identifyEveryMs)))
77
+ return cached.verdict;
78
+ const verdict = this.#backend.identify(runner.pid, runner.startedAt);
79
+ this.#identity.set(id, { key, verdict, at: Date.now() });
80
+ return verdict;
81
+ }
82
+ /** Hear every transition (delivery, the terminal, telemetry — any number
83
+ * of them). Returns the unsubscribe. */
84
+ subscribe(listener) {
85
+ this.#listeners.add(listener);
86
+ return () => void this.#listeners.delete(listener);
87
+ }
88
+ /** A new task directory, durable before anything is recorded in it: the
89
+ * session's task root (and its entry in the store directory), then the
90
+ * task's own directory. */
91
+ #newTaskDir() {
92
+ if (!existsSync(this.root)) {
93
+ mkdirSync(this.root, { recursive: true });
94
+ fsyncDir(dirname(this.root));
95
+ }
96
+ const id = this.#nextId();
97
+ const dir = join(this.root, id);
98
+ mkdirSync(dir);
99
+ fsyncDir(this.root);
100
+ return { id, dir };
101
+ }
102
+ /** Start a task. `planned` is durable before the runner is spawned. */
103
+ async start(options) {
104
+ const { id, dir } = this.#newTaskDir();
105
+ const exec = options.exec?.(dir);
106
+ appendRecord(join(dir, JOURNAL), {
107
+ type: "planned",
108
+ ts: Date.now(),
109
+ taskId: id,
110
+ backend: "process",
111
+ command: options.command,
112
+ cwd: options.cwd,
113
+ profile: options.profile ?? "oneshot",
114
+ ...(options.executionId !== undefined ? { executionId: options.executionId } : {}),
115
+ ...(options.readyWhen !== undefined ? { readyWhen: options.readyWhen } : {}),
116
+ ...(exec !== undefined ? { launch: { kind: "exec", file: exec.file, args: [...exec.args] } } : {}),
117
+ ...(options.agent !== undefined ? { agent: options.agent } : {}),
118
+ });
119
+ // the journal's own directory entry: without it, a power loss could
120
+ // erase a `planned` whose runner was spawned — "no planned ⇒ never
121
+ // spawned" holds only once this is durable
122
+ fsyncDir(dir);
123
+ await this.#backend.spawn({ dir, env: options.env ?? process.env });
124
+ this.#seen.set(id, "starting");
125
+ this.observe();
126
+ return this.get(id);
127
+ }
128
+ /** A running foreground command becomes a task this process owns:
129
+ * `planned`, the owner's identity and `command_started` are durable
130
+ * before this returns. The command is already running; nothing is
131
+ * spawned. */
132
+ adopt(options) {
133
+ const { id, dir } = this.#newTaskDir();
134
+ const journal = join(dir, JOURNAL);
135
+ appendRecord(journal, {
136
+ type: "planned",
137
+ ts: Date.now(),
138
+ taskId: id,
139
+ backend: "foreground",
140
+ command: options.command,
141
+ cwd: options.cwd,
142
+ profile: options.readyWhen !== undefined ? "service" : "oneshot",
143
+ ...(options.executionId !== undefined ? { executionId: options.executionId } : {}),
144
+ ...(options.readyWhen !== undefined ? { readyWhen: options.readyWhen } : {}),
145
+ });
146
+ appendRecord(journal, { type: "runner_started", ts: Date.now(), pid: options.runner.pid, startedAt: options.runner.startedAt });
147
+ appendRecord(journal, { type: "command_started", ts: Date.now() });
148
+ fsyncDir(dir);
149
+ if (options.ready === true && options.readyWhen !== undefined) {
150
+ appendRecord(journal, { type: "ready", ts: Date.now(), match: options.readyWhen });
151
+ // the promotion's own result says "ready —": that call reports it
152
+ if (options.executionId !== undefined)
153
+ appendRecord(journal, { type: "result_claimed", ts: Date.now(), transition: "ready", executionId: options.executionId });
154
+ }
155
+ this.#local.set(id, options.stop);
156
+ this.#seen.set(id, options.ready === true ? "ready" : "running");
157
+ this.observe();
158
+ let readySent = options.ready === true;
159
+ return {
160
+ id,
161
+ outputPath: join(dir, OUTPUT),
162
+ ready: (match) => {
163
+ if (readySent)
164
+ return;
165
+ readySent = true;
166
+ appendRecord(journal, { type: "ready", ts: Date.now(), match });
167
+ },
168
+ ended: (exitCode, signal) => {
169
+ if (!this.#local.delete(id))
170
+ return;
171
+ appendRecord(journal, { type: "terminal", ts: Date.now(), exitCode, signal });
172
+ },
173
+ unconfirmed: (pids) => {
174
+ if (!this.#local.delete(id))
175
+ return;
176
+ appendRecord(journal, { type: "stop_unconfirmed", ts: Date.now(), pids });
177
+ },
178
+ };
179
+ }
180
+ /** Every task of the session, oldest first. */
181
+ list() {
182
+ return this.#ids().map((id) => this.#info(id));
183
+ }
184
+ get(id) {
185
+ return ID.test(id) && existsSync(join(this.root, id, JOURNAL)) ? this.#info(id) : undefined;
186
+ }
187
+ /** Stop a live task: `stop_requested` is durable first — the runner
188
+ * watches its journal for it — then a VERIFIED runner is also signalled.
189
+ * An unverifiable one is never signalled (its pid may be a stranger's
190
+ * now); the record alone reaches it if it is ours. False when there is
191
+ * nothing that could be stopped. */
192
+ stop(id, by) {
193
+ if (!ID.test(id))
194
+ return false;
195
+ const records = readRecords(join(this.root, id, JOURNAL));
196
+ if (records.some((r) => r.type === "terminal"))
197
+ return false;
198
+ const planned = records.find((r) => r.type === "planned");
199
+ if (planned?.backend === "foreground") {
200
+ // its runner is a kiso process: never signalled — the owner stops
201
+ // the command, and only the owner can
202
+ const stopper = this.#local.get(id);
203
+ if (stopper === undefined)
204
+ return false;
205
+ appendRecord(join(this.root, id, JOURNAL), { type: "stop_requested", ts: Date.now(), by });
206
+ stopper();
207
+ return true;
208
+ }
209
+ const runner = runnerOf(records);
210
+ if (runner === undefined)
211
+ return false;
212
+ const identity = this.#identityOf(id, runner, true);
213
+ if (identity === "gone")
214
+ return false;
215
+ appendRecord(join(this.root, id, JOURNAL), { type: "stop_requested", ts: Date.now(), by });
216
+ if (identity === "verified")
217
+ this.#backend.signalStop(runner.pid);
218
+ return true;
219
+ }
220
+ /** ADR-0058 Amendment 7: wait up to `ms` for task `id` to reach `until`
221
+ * — "end" (ended, unknown, never run) or "ready" (ready, or any end) —
222
+ * on behalf of a tool call whose result will report it. While it waits
223
+ * the watcher never announces this task. What it observes is CLAIMED
224
+ * for `executionId` — the caller's own execution: `result_claimed` is
225
+ * durable first, then the transition counts as seen, so it is never
226
+ * announced. A timeout, an abort, no execution id, an agent task (its
227
+ * end belongs to its group, 3d) or a claim that cannot be written
228
+ * claim nothing: the transition is announced as before. */
229
+ async awaitSettled(id, until, ms, opts = {}) {
230
+ if (this.get(id) === undefined)
231
+ throw new Error(`no task ${id} in this session`);
232
+ this.#waiting.set(id, (this.#waiting.get(id) ?? 0) + 1);
233
+ let aborted = opts.signal?.aborted === true;
234
+ let wake = () => { };
235
+ const onAbort = () => {
236
+ aborted = true;
237
+ wake();
238
+ };
239
+ opts.signal?.addEventListener("abort", onAbort);
240
+ try {
241
+ const deadline = Date.now() + ms;
242
+ for (;;) {
243
+ const info = this.#info(id);
244
+ const reached = endTransitionOf(info) ?? (until === "ready" && info.state.kind === "running" && info.state.ready ? "ready" : null);
245
+ if (reached !== null)
246
+ return { info, settled: true, claimed: this.#claim(info, reached, opts.executionId) };
247
+ const left = deadline - Date.now();
248
+ if (aborted || left <= 0)
249
+ return { info, settled: false, claimed: false };
250
+ await new Promise((resolve) => {
251
+ const timer = setTimeout(resolve, Math.min(100, left));
252
+ wake = () => {
253
+ clearTimeout(timer);
254
+ resolve();
255
+ };
256
+ });
257
+ }
258
+ }
259
+ finally {
260
+ opts.signal?.removeEventListener("abort", onAbort);
261
+ const n = (this.#waiting.get(id) ?? 1) - 1;
262
+ if (n > 0)
263
+ this.#waiting.set(id, n);
264
+ else
265
+ this.#waiting.delete(id);
266
+ }
267
+ }
268
+ /** The frozen order: the claim durable, THEN seen — an append that fails
269
+ * leaves the transition unseen, to be announced (a duplicate is the
270
+ * lesser evil next to a lost transition). */
271
+ #claim(info, transition, executionId) {
272
+ if (executionId === undefined || info.agent !== undefined)
273
+ return false;
274
+ try {
275
+ appendRecord(join(this.root, info.id, JOURNAL), { type: "result_claimed", ts: Date.now(), transition, executionId });
276
+ }
277
+ catch {
278
+ return false;
279
+ }
280
+ this.#seen.set(info.id, this.#kindOf(info));
281
+ return true;
282
+ }
283
+ /** 3e: a running foreground command that can become a task now —
284
+ * registered by its owner while it runs; returns the unregister. Every
285
+ * detach goes through here (ADR-0058 §6): the person's key, a steer. */
286
+ registerDetachable(executionId, detachable) {
287
+ this.#detachable.set(executionId, detachable);
288
+ return () => {
289
+ if (this.#detachable.get(executionId) === detachable)
290
+ this.#detachable.delete(executionId);
291
+ };
292
+ }
293
+ /** Detach one registered execution. It is unregistered BEFORE it is
294
+ * asked, so a second detach — two steers, a key racing a pending
295
+ * auto-detach — is a no-op: one promotion, one task. */
296
+ detach(executionId, by) {
297
+ const d = this.#detachable.get(executionId);
298
+ if (d === undefined)
299
+ return false;
300
+ this.#detachable.delete(executionId);
301
+ d.detach(by);
302
+ return true;
303
+ }
304
+ /** Detach every registered execution; the ids that were detached. */
305
+ detachAll(by) {
306
+ return [...this.#detachable.keys()].filter((id) => this.detach(id, by));
307
+ }
308
+ /** What can be detached now, with when each started. */
309
+ detachable() {
310
+ return [...this.#detachable].map(([executionId, d]) => ({ executionId, startedAt: d.startedAt }));
311
+ }
312
+ /** A clean exit: stop every live task — or, `which: "moved"`, only the
313
+ * ones this process owns (promoted foreground commands; a runner's task
314
+ * is left running) — and wait for their terminals, up to `graceMs`.
315
+ * Returns the ids still without a terminal: a stop REQUESTED, never
316
+ * confirmed (3e). */
317
+ async stopAll(by = "exit", graceMs = 8_000, which = "all") {
318
+ // every task not ended is asked; a corrupt journal is skipped here
319
+ // (it fails loudly where it is read on purpose) — an exit still exits
320
+ const kindOf = (id) => {
321
+ try {
322
+ return this.#info(id).state.kind;
323
+ }
324
+ catch {
325
+ return "corrupt";
326
+ }
327
+ };
328
+ const moved = (id) => {
329
+ try {
330
+ return this.#info(id).backend === "foreground";
331
+ }
332
+ catch {
333
+ return false;
334
+ }
335
+ };
336
+ const live = this.#ids().filter((id) => ["running", "starting", "unknown"].includes(kindOf(id)) && (which === "all" || moved(id)));
337
+ const asked = live.filter((id) => this.stop(id, by));
338
+ const deadline = Date.now() + graceMs;
339
+ let left = asked;
340
+ while (left.length > 0 && Date.now() < deadline) {
341
+ await new Promise((r) => setTimeout(r, 100));
342
+ left = left.filter((id) => kindOf(id) !== "ended");
343
+ }
344
+ return left;
345
+ }
346
+ /** Start watching: every task is classified now (no transition for what
347
+ * it already is), and live ones are re-read until they settle. */
348
+ observe() {
349
+ for (const id of this.#ids()) {
350
+ if (this.#seen.has(id))
351
+ continue;
352
+ try {
353
+ this.#seen.set(id, this.#kindOf(this.#info(id)));
354
+ }
355
+ catch {
356
+ // a corrupt journal: left unclassified, and read loudly on purpose
357
+ }
358
+ }
359
+ if (this.#timer !== null)
360
+ return;
361
+ this.#timer = setInterval(() => this.#poll(), this.#pollMs);
362
+ this.#timer.unref?.();
363
+ }
364
+ /** Stop watching. Tasks keep running. */
365
+ close() {
366
+ if (this.#timer !== null)
367
+ clearInterval(this.#timer);
368
+ this.#timer = null;
369
+ }
370
+ #poll() {
371
+ for (const id of this.#ids()) {
372
+ // a tool call is waiting on it: what it sees, its result reports
373
+ if (this.#waiting.has(id))
374
+ continue;
375
+ const before = this.#seen.get(id);
376
+ if (before === "ended" || before === "unknown" || before === "not_run")
377
+ continue;
378
+ let info;
379
+ try {
380
+ info = this.#info(id);
381
+ }
382
+ catch {
383
+ continue; // a corrupt journal: reported where it is read, never here in the background
384
+ }
385
+ const now = this.#kindOf(info);
386
+ if (now === before)
387
+ continue;
388
+ this.#seen.set(id, now);
389
+ const transition = now === "ready" ? "ready" : now === "ended" ? "ended" : now === "unknown" ? "unknown" : now === "not_run" ? "not_run" : null;
390
+ if (transition !== null)
391
+ for (const listener of this.#listeners)
392
+ listener(info, transition);
393
+ }
394
+ }
395
+ #kindOf(info) {
396
+ return info.state.kind === "running" && info.state.ready ? "ready" : info.state.kind;
397
+ }
398
+ #info(id) {
399
+ const records = readRecords(join(this.root, id, JOURNAL));
400
+ const planned = records.find((r) => r.type === "planned");
401
+ const runner = runnerOf(records);
402
+ const terminal = records.find((r) => r.type === "terminal");
403
+ // an adopted task this very process still owns needs no identity
404
+ // check: the owner is here, holding the child; and an ended task's
405
+ // verdict is its terminal — identity cannot change it (Windows P6)
406
+ const verified = this.#local.has(id) || (terminal === undefined && runner !== undefined && this.#identityOf(id, runner) === "verified");
407
+ const stop = records.find((r) => r.type === "stop_requested");
408
+ const claims = records
409
+ .filter((r) => r.type === "result_claimed")
410
+ .map((r) => ({ transition: r.transition, executionId: r.executionId }));
411
+ return {
412
+ id,
413
+ command: planned?.command ?? "",
414
+ profile: planned?.profile ?? "oneshot",
415
+ backend: planned?.backend ?? "process",
416
+ ...(planned?.executionId !== undefined ? { executionId: planned.executionId } : {}),
417
+ ...(stop !== undefined ? { stoppedBy: stop.by } : {}),
418
+ ...(planned?.agent !== undefined ? { agent: planned.agent } : {}),
419
+ state: verdictOf(records, verified),
420
+ outputPath: join(this.root, id, OUTPUT),
421
+ startedAt: planned?.ts ?? 0,
422
+ ...(terminal !== undefined ? { endedAt: terminal.ts } : {}),
423
+ ...(claims.length > 0 ? { claims } : {}),
424
+ };
425
+ }
426
+ #ids() {
427
+ if (!existsSync(this.root))
428
+ return [];
429
+ return readdirSync(this.root)
430
+ .filter((name) => ID.test(name))
431
+ .sort((a, b) => Number(a.slice(1)) - Number(b.slice(1)));
432
+ }
433
+ #nextId() {
434
+ const ids = this.#ids();
435
+ const last = ids.length === 0 ? 0 : Number(ids.at(-1).slice(1));
436
+ return `t${last + 1}`;
437
+ }
438
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.45.2",
3
+ "version": "0.46.0",
4
4
  "description": "kiso runtime \u2014 durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,12 +25,12 @@
25
25
  "test": "vitest run"
26
26
  },
27
27
  "dependencies": {
28
- "@vincemakes/kiso-core": "0.45.2"
28
+ "@vincemakes/kiso-core": "0.46.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@vincemakes/kiso-provider-anthropic": "0.45.2",
32
- "@vincemakes/kiso-provider-openai": "0.45.2",
33
- "@vincemakes/kiso-provider-openai-responses": "0.45.2"
31
+ "@vincemakes/kiso-provider-anthropic": "0.46.0",
32
+ "@vincemakes/kiso-provider-openai": "0.46.0",
33
+ "@vincemakes/kiso-provider-openai-responses": "0.46.0"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "@vincemakes/kiso-provider-anthropic": {
@@ -44,7 +44,7 @@
44
44
  }
45
45
  },
46
46
  "devDependencies": {
47
- "@vincemakes/kiso-evals": "0.45.2",
47
+ "@vincemakes/kiso-evals": "0.46.0",
48
48
  "@types/node": "^26.1.2",
49
49
  "typescript": "^5.7.2",
50
50
  "vitest": "^3.0.0"