@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,124 @@
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
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, writeSync } from "node:fs";
20
+ /** Append one record and fsync it before returning — the write-ahead step. */
21
+ export function appendRecord(file, record) {
22
+ const fd = openSync(file, "a");
23
+ try {
24
+ writeSync(fd, `${JSON.stringify(record)}\n`);
25
+ if (process.platform === "win32")
26
+ syncFile(file);
27
+ else
28
+ fsyncSync(fd);
29
+ }
30
+ finally {
31
+ closeSync(fd);
32
+ }
33
+ }
34
+ /** Windows: an append handle cannot be flushed (FlushFileBuffers needs
35
+ * write access, which append mode drops), and the append must stay an
36
+ * append (the runner and the manager both write the journal) — so the
37
+ * flush goes through a read-write handle of its own. */
38
+ export function syncFile(file) {
39
+ const fd = openSync(file, "r+");
40
+ try {
41
+ fsyncSync(fd);
42
+ }
43
+ finally {
44
+ closeSync(fd);
45
+ }
46
+ }
47
+ /** fsync a directory, so a file created in it survives a power loss too. */
48
+ export function fsyncDir(dir) {
49
+ // Windows opens no directory as a file, so the entry is not flushed
50
+ // here: a process crash loses nothing, but the entry's power-loss
51
+ // ordering is not the guarantee POSIX's directory fsync gives (the
52
+ // Windows durability contract is P6's to state)
53
+ if (process.platform === "win32")
54
+ return;
55
+ const fd = openSync(dir, "r");
56
+ try {
57
+ fsyncSync(fd);
58
+ }
59
+ finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ /** A journal that is not a sequence of records with at most a torn tail. */
64
+ export class TaskJournalCorruptError extends Error {
65
+ file;
66
+ line;
67
+ constructor(file, line) {
68
+ super(`task journal ${file} is corrupt at line ${line} — a record follows an unreadable one`);
69
+ this.name = "TaskJournalCorruptError";
70
+ this.file = file;
71
+ this.line = line;
72
+ }
73
+ }
74
+ /** Every complete record, in order. A torn LAST line (a write cut by a
75
+ * crash) is not a record: it is dropped, never guessed at. An unreadable
76
+ * line with a record after it is corruption, and it fails loudly — the
77
+ * verdicts are inferences from what is present, so nothing is skipped. */
78
+ export function readRecords(file) {
79
+ if (!existsSync(file))
80
+ return [];
81
+ const out = [];
82
+ const lines = readFileSync(file, "utf8").split("\n");
83
+ for (let i = 0; i < lines.length; i++) {
84
+ const line = lines[i];
85
+ if (line.trim() === "")
86
+ continue;
87
+ try {
88
+ out.push(JSON.parse(line));
89
+ }
90
+ catch {
91
+ if (lines.slice(i + 1).some((l) => l.trim() !== ""))
92
+ throw new TaskJournalCorruptError(file, i + 1);
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ /** How long a `planned` without a runner may wait for its runner to speak
98
+ * before it counts as never run — the runner's first act is its record. */
99
+ export const RUNNER_START_WINDOW_MS = 30_000;
100
+ export function verdictOf(records, runnerAlive, now = Date.now()) {
101
+ const has = (type) => records.some((r) => r.type === type);
102
+ const terminal = records.find((r) => r.type === "terminal");
103
+ if (terminal !== undefined)
104
+ return {
105
+ kind: "ended",
106
+ exitCode: terminal.exitCode,
107
+ signal: terminal.signal,
108
+ stopped: has("stop_requested"),
109
+ ...(terminal.error !== undefined ? { error: terminal.error } : {}),
110
+ };
111
+ const planned = records.find((r) => r.type === "planned");
112
+ if (planned === undefined)
113
+ return { kind: "not_run" };
114
+ if (has("command_started"))
115
+ return runnerAlive ? { kind: "running", ready: has("ready") } : { kind: "unknown" };
116
+ if (has("runner_started"))
117
+ return runnerAlive ? { kind: "starting" } : { kind: "not_run" };
118
+ return now - planned.ts < RUNNER_START_WINDOW_MS ? { kind: "starting" } : { kind: "not_run" };
119
+ }
120
+ /** The recorded runner identity, if the runner has spoken. */
121
+ export function runnerOf(records) {
122
+ const r = records.find((x) => x.type === "runner_started");
123
+ return r === undefined ? undefined : { pid: r.pid, startedAt: r.startedAt };
124
+ }
@@ -0,0 +1,219 @@
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 type { AbortSignalLike } from "@vincemakes/kiso-core";
24
+ import { type ClaimedTransition, type TaskAgent, type TaskProfile, type TaskState } from "./journal.js";
25
+ /** Whether a recorded runner is still that process (ADR-0058 §6):
26
+ * "verified" — the pid is live AND its OS start time is the recorded one;
27
+ * "gone" — no such process, or another process holds the pid;
28
+ * "unverifiable" — the pid is live but whose it is cannot be told (no
29
+ * start time recorded, or none readable now). Only "verified" is the
30
+ * runner: "when identity cannot be verified, the verdict is the gone row". */
31
+ export type RunnerIdentity = "verified" | "gone" | "unverifiable";
32
+ /** A backend runs one kind of task. The manager has already made `planned`
33
+ * durable; the backend spawns the runner, which records itself. */
34
+ export interface TaskBackend {
35
+ /** Spawn the runner for the task in `dir`; resolve once `runner_started`
36
+ * is on disk (reject if the runner never speaks). */
37
+ spawn(spec: {
38
+ readonly dir: string;
39
+ readonly env: Readonly<Record<string, string | undefined>>;
40
+ }): Promise<void>;
41
+ identify(pid: number, startedAt: string): RunnerIdentity;
42
+ /** Signal a VERIFIED runner to stop (a sooner path than the journal's
43
+ * `stop_requested`, which the runner watches; a no-op where signals
44
+ * would kill the runner outright). */
45
+ signalStop(pid: number): void;
46
+ }
47
+ export interface TaskStartOptions {
48
+ readonly command: string;
49
+ readonly cwd: string;
50
+ readonly profile?: TaskProfile;
51
+ /** The runner's environment. The shell's own policy (stripped
52
+ * credentials) is applied by the caller; nothing of it is written down. */
53
+ readonly env?: Readonly<Record<string, string | undefined>>;
54
+ /** The tool invocation that started the task, when one did. */
55
+ readonly executionId?: string;
56
+ /** A literal substring of the output that means "ready" (ADR-0058 §3). */
57
+ readonly readyWhen?: string;
58
+ /** ADR-0058 3d: start `file` with exactly `args` — no shell; `command`
59
+ * is then the label. Given the task's directory, before it is planned. */
60
+ readonly exec?: (dir: string) => {
61
+ readonly file: string;
62
+ readonly args: readonly string[];
63
+ };
64
+ /** ADR-0058 §5: the task is a child agent (its role and session). */
65
+ readonly agent?: TaskAgent;
66
+ }
67
+ export interface TaskInfo {
68
+ readonly id: string;
69
+ readonly command: string;
70
+ readonly profile: TaskProfile;
71
+ /** "process": a runner owns it; "foreground": a promoted command its kiso owns. */
72
+ readonly backend: "process" | "foreground";
73
+ /** The tool invocation that started it, when one did. */
74
+ readonly executionId?: string;
75
+ /** Who asked for its stop, when someone did. */
76
+ readonly stoppedBy?: "person" | "model" | "exit";
77
+ /** An agent task's role and child session (ADR-0058 §5). */
78
+ readonly agent?: TaskAgent;
79
+ readonly state: TaskState;
80
+ readonly outputPath: string;
81
+ readonly startedAt: number;
82
+ readonly endedAt?: number;
83
+ /** ADR-0058 Amendment 7: the transitions a tool execution took over —
84
+ * its result reports them, so they are never noticed. */
85
+ readonly claims?: readonly {
86
+ readonly transition: ClaimedTransition;
87
+ readonly executionId: string;
88
+ }[];
89
+ }
90
+ /** The name a task's end (or its runner's vanishing, or a start that never
91
+ * ran) has for the model — as a notice says it, and as a claim records it. */
92
+ export declare function endTransitionOf(task: TaskInfo): Exclude<ClaimedTransition, "ready"> | null;
93
+ /** What `awaitSettled` saw: settled — the target reached (or any end);
94
+ * claimed — taken over for the calling execution, so never announced. */
95
+ export interface TaskSettled {
96
+ readonly info: TaskInfo;
97
+ readonly settled: boolean;
98
+ readonly claimed: boolean;
99
+ }
100
+ export interface TaskAdoptOptions {
101
+ readonly command: string;
102
+ readonly cwd: string;
103
+ /** The tool invocation that ran the command. */
104
+ readonly executionId?: string;
105
+ readonly readyWhen?: string;
106
+ /** The process that owns the command from now on — the caller itself:
107
+ * its pid and OS start time ("" when it cannot be read). */
108
+ readonly runner: {
109
+ readonly pid: number;
110
+ readonly startedAt: string;
111
+ };
112
+ /** True when the ready line is what promoted it. */
113
+ readonly ready?: boolean;
114
+ /** Stop the command. The owner then reports `ended` or `unconfirmed`. */
115
+ readonly stop: () => void;
116
+ }
117
+ /** The owner's side of an adopted task: every record it writes is durable. */
118
+ export interface AdoptedTask {
119
+ readonly id: string;
120
+ readonly outputPath: string;
121
+ ready(match: string): void;
122
+ ended(exitCode: number | null, signal: string | null): void;
123
+ /** A stop left these pids alive: no terminal — the task stays `unknown`. */
124
+ unconfirmed(pids: readonly number[]): void;
125
+ }
126
+ /** Who moved a running foreground command to the background (3e). */
127
+ export type DetachBy = "person" | "steer";
128
+ /** A running foreground execution that can become a task now (ADR-0058
129
+ * §2 ways 3 and 4; 3e). Its owner registers it while it runs. */
130
+ export interface Detachable {
131
+ /** When the command started (ms since the epoch): a steer detaches
132
+ * only a command old enough (ADR-0057 §5; 3e D1). */
133
+ readonly startedAt: number;
134
+ /** Promote it now — the owner's own promotion (3b), with the reason. */
135
+ detach(by: DetachBy): void;
136
+ }
137
+ /** What changed, reported once each: the ready signal, the end, the runner
138
+ * vanishing without an end, or a start that never ran. */
139
+ export type TaskTransition = "ready" | "ended" | "unknown" | "not_run";
140
+ export type TaskListener = (task: TaskInfo, transition: TaskTransition) => void;
141
+ export interface TaskManagerOptions {
142
+ /** `<store root>/<session>.tasks` */
143
+ readonly root: string;
144
+ readonly backend: TaskBackend;
145
+ /** A first listener — the same as `subscribe` right after construction. */
146
+ readonly onTransition?: TaskListener;
147
+ /** How often live tasks are re-read. Default 500 ms. */
148
+ readonly pollMs?: number;
149
+ /** How long a live runner's identity is reused before it is checked
150
+ * again. Default 5 s. Each check starts a process (`ps`; a PowerShell
151
+ * on win32), so it is not repeated on every read; the journal still is,
152
+ * so an end is seen at once. The cost: a runner that dies WITHOUT a
153
+ * terminal keeps reading as `running` for up to this long after it
154
+ * died (its cached verdict is still "verified"), then `unknown`. */
155
+ readonly identifyEveryMs?: number;
156
+ }
157
+ export declare class TaskManager {
158
+ #private;
159
+ readonly root: string;
160
+ constructor(options: TaskManagerOptions);
161
+ /** Hear every transition (delivery, the terminal, telemetry — any number
162
+ * of them). Returns the unsubscribe. */
163
+ subscribe(listener: TaskListener): () => void;
164
+ /** Start a task. `planned` is durable before the runner is spawned. */
165
+ start(options: TaskStartOptions): Promise<TaskInfo>;
166
+ /** A running foreground command becomes a task this process owns:
167
+ * `planned`, the owner's identity and `command_started` are durable
168
+ * before this returns. The command is already running; nothing is
169
+ * spawned. */
170
+ adopt(options: TaskAdoptOptions): AdoptedTask;
171
+ /** Every task of the session, oldest first. */
172
+ list(): TaskInfo[];
173
+ get(id: string): TaskInfo | undefined;
174
+ /** Stop a live task: `stop_requested` is durable first — the runner
175
+ * watches its journal for it — then a VERIFIED runner is also signalled.
176
+ * An unverifiable one is never signalled (its pid may be a stranger's
177
+ * now); the record alone reaches it if it is ours. False when there is
178
+ * nothing that could be stopped. */
179
+ stop(id: string, by: "person" | "model" | "exit"): boolean;
180
+ /** ADR-0058 Amendment 7: wait up to `ms` for task `id` to reach `until`
181
+ * — "end" (ended, unknown, never run) or "ready" (ready, or any end) —
182
+ * on behalf of a tool call whose result will report it. While it waits
183
+ * the watcher never announces this task. What it observes is CLAIMED
184
+ * for `executionId` — the caller's own execution: `result_claimed` is
185
+ * durable first, then the transition counts as seen, so it is never
186
+ * announced. A timeout, an abort, no execution id, an agent task (its
187
+ * end belongs to its group, 3d) or a claim that cannot be written
188
+ * claim nothing: the transition is announced as before. */
189
+ awaitSettled(id: string, until: "end" | "ready", ms: number, opts?: {
190
+ readonly executionId?: string;
191
+ readonly signal?: AbortSignalLike;
192
+ }): Promise<TaskSettled>;
193
+ /** 3e: a running foreground command that can become a task now —
194
+ * registered by its owner while it runs; returns the unregister. Every
195
+ * detach goes through here (ADR-0058 §6): the person's key, a steer. */
196
+ registerDetachable(executionId: string, detachable: Detachable): () => void;
197
+ /** Detach one registered execution. It is unregistered BEFORE it is
198
+ * asked, so a second detach — two steers, a key racing a pending
199
+ * auto-detach — is a no-op: one promotion, one task. */
200
+ detach(executionId: string, by: DetachBy): boolean;
201
+ /** Detach every registered execution; the ids that were detached. */
202
+ detachAll(by: DetachBy): string[];
203
+ /** What can be detached now, with when each started. */
204
+ detachable(): {
205
+ readonly executionId: string;
206
+ readonly startedAt: number;
207
+ }[];
208
+ /** A clean exit: stop every live task — or, `which: "moved"`, only the
209
+ * ones this process owns (promoted foreground commands; a runner's task
210
+ * is left running) — and wait for their terminals, up to `graceMs`.
211
+ * Returns the ids still without a terminal: a stop REQUESTED, never
212
+ * confirmed (3e). */
213
+ stopAll(by?: "person" | "exit", graceMs?: number, which?: "all" | "moved"): Promise<string[]>;
214
+ /** Start watching: every task is classified now (no transition for what
215
+ * it already is), and live ones are re-read until they settle. */
216
+ observe(): void;
217
+ /** Stop watching. Tasks keep running. */
218
+ close(): void;
219
+ }