@parcha/agentrun-kernel 0.0.0-stage → 0.1.0-beta.29

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +6 -0
  4. package/README.md +52 -2
  5. package/dist/delegate.d.ts +87 -0
  6. package/dist/delegate.js +143 -0
  7. package/dist/errors.d.ts +36 -0
  8. package/dist/errors.js +37 -0
  9. package/dist/host/alive.d.ts +2 -0
  10. package/dist/host/alive.js +28 -0
  11. package/dist/host/box-slot.d.ts +26 -0
  12. package/dist/host/box-slot.js +112 -0
  13. package/dist/host/box.d.ts +104 -0
  14. package/dist/host/box.js +16 -0
  15. package/dist/host/egress-proxy.d.ts +45 -0
  16. package/dist/host/egress-proxy.js +416 -0
  17. package/dist/host/egress-table.d.ts +24 -0
  18. package/dist/host/egress-table.js +165 -0
  19. package/dist/host/index.d.ts +16 -0
  20. package/dist/host/index.js +12 -0
  21. package/dist/host/overhead.d.ts +53 -0
  22. package/dist/host/overhead.js +75 -0
  23. package/dist/host/parent-death.d.ts +15 -0
  24. package/dist/host/parent-death.js +42 -0
  25. package/dist/host/parent-watch.d.ts +1 -0
  26. package/dist/host/parent-watch.js +38 -0
  27. package/dist/host/pinned-binary.d.ts +6 -0
  28. package/dist/host/pinned-binary.js +48 -0
  29. package/dist/host/proxy.d.ts +179 -0
  30. package/dist/host/proxy.js +629 -0
  31. package/dist/host/serial.d.ts +3 -0
  32. package/dist/host/serial.js +10 -0
  33. package/dist/index.d.ts +11 -0
  34. package/dist/index.js +6 -0
  35. package/dist/kernel/blobs.d.ts +16 -0
  36. package/dist/kernel/blobs.js +59 -0
  37. package/dist/kernel/drive.d.ts +35 -0
  38. package/dist/kernel/drive.js +202 -0
  39. package/dist/kernel/errors.d.ts +27 -0
  40. package/dist/kernel/errors.js +42 -0
  41. package/dist/kernel/follow-up.d.ts +67 -0
  42. package/dist/kernel/follow-up.js +146 -0
  43. package/dist/kernel/halt.d.ts +11 -0
  44. package/dist/kernel/halt.js +17 -0
  45. package/dist/kernel/index.d.ts +8 -0
  46. package/dist/kernel/index.js +5 -0
  47. package/dist/kernel/types.d.ts +120 -0
  48. package/dist/kernel/types.js +1 -0
  49. package/dist/process/builtins.d.ts +142 -0
  50. package/dist/process/builtins.js +207 -0
  51. package/dist/process/inbox.d.ts +29 -0
  52. package/dist/process/inbox.js +52 -0
  53. package/dist/process/index.d.ts +9 -0
  54. package/dist/process/index.js +11 -0
  55. package/dist/process/journal.d.ts +111 -0
  56. package/dist/process/journal.js +232 -0
  57. package/dist/process/records.d.ts +143 -0
  58. package/dist/process/records.js +142 -0
  59. package/dist/process/recovery.d.ts +76 -0
  60. package/dist/process/recovery.js +139 -0
  61. package/dist/process/settle.d.ts +11 -0
  62. package/dist/process/settle.js +20 -0
  63. package/dist/process/tail.d.ts +20 -0
  64. package/dist/process/tail.js +53 -0
  65. package/dist/process/tools.d.ts +105 -0
  66. package/dist/process/tools.js +120 -0
  67. package/dist/process/zod.d.ts +32 -0
  68. package/dist/process/zod.js +109 -0
  69. package/dist/runner.d.ts +154 -0
  70. package/dist/runner.js +547 -0
  71. package/dist/schema-check-cases.d.ts +7 -0
  72. package/dist/schema-check-cases.js +73 -0
  73. package/dist/schema-check.d.ts +3 -0
  74. package/dist/schema-check.js +88 -0
  75. package/dist/skill-files.d.ts +23 -0
  76. package/dist/skill-files.js +136 -0
  77. package/dist/testing.d.ts +91 -0
  78. package/dist/testing.js +196 -0
  79. package/dist/types.d.ts +237 -0
  80. package/dist/types.js +10 -0
  81. package/package.json +74 -4
  82. package/vendor/durable-harness/UPSTREAM.json +67 -0
@@ -0,0 +1,16 @@
1
+ import type { KernelWorld } from "./types.js";
2
+ export declare const BLOB_RECORD = "kernel.blob";
3
+ /** Characters per piece. */
4
+ export declare const BLOB_PIECE_CHARS: number;
5
+ /** Where a session's blobs are, for messages: the session's own records. */
6
+ export declare const blobStreamName: (runId: string) => string;
7
+ export declare function sha256Hex(content: string): Promise<string>;
8
+ /** Write `text` as the blob `hash` (its sha256; a caller that has it already passes it), in pieces, durably before it
9
+ * resolves. A hash that is not the text's is refused. */
10
+ export declare function writeBlob(world: KernelWorld, runId: string, text: string, options?: {
11
+ hash?: string;
12
+ pieceChars?: number;
13
+ }): Promise<string>;
14
+ /** Every complete blob of the session, by hash: the pieces of one layout (a hash and its piece count), joined, and
15
+ * only when they hash to that name. */
16
+ export declare function readBlobs(world: KernelWorld, runId: string): Promise<Map<string, string>>;
@@ -0,0 +1,59 @@
1
+ // Records too large to keep inline, as content-addressed pieces in the session's own records (kind `kernel.blob`), by
2
+ // the names the copied journal calls. A blob is named by its content's sha256, and counts only once all the pieces of
3
+ // one write are there and join to text with that hash: a write again under another piece size is a layout of its own,
4
+ // never mixed with the first. Written for AgentRun.
5
+ import { createHash } from "node:crypto";
6
+ import { SessionFenced } from "../errors.js";
7
+ import { Restart } from "./errors.js";
8
+ export const BLOB_RECORD = "kernel.blob";
9
+ /** Characters per piece. */
10
+ export const BLOB_PIECE_CHARS = 2 * 1024 * 1024;
11
+ /** Where a session's blobs are, for messages: the session's own records. */
12
+ export const blobStreamName = (runId) => `${runId} (${BLOB_RECORD} records)`;
13
+ export async function sha256Hex(content) {
14
+ return createHash("sha256").update(content).digest("hex");
15
+ }
16
+ /** Write `text` as the blob `hash` (its sha256; a caller that has it already passes it), in pieces, durably before it
17
+ * resolves. A hash that is not the text's is refused. */
18
+ export async function writeBlob(world, runId, text, options = {}) {
19
+ const hash = await sha256Hex(text);
20
+ if (options.hash !== undefined && options.hash !== hash)
21
+ throw new TypeError("agentrun-kernel: a blob's hash must be its text's sha256");
22
+ const size = options.pieceChars ?? BLOB_PIECE_CHARS;
23
+ const count = Math.max(1, Math.ceil(text.length / size));
24
+ const records = Array.from({ length: count }, (_, piece) => ({ kind: BLOB_RECORD, data: { hash, piece, count, text: text.slice(piece * size, (piece + 1) * size) } }));
25
+ try {
26
+ await world.store.append(records);
27
+ }
28
+ catch (error) {
29
+ // A newer drive holds the session, as for an effect's own writes.
30
+ if (error instanceof SessionFenced)
31
+ throw new Restart(`agentrun-kernel: ${error.message}`);
32
+ throw error;
33
+ }
34
+ return hash;
35
+ }
36
+ /** Every complete blob of the session, by hash: the pieces of one layout (a hash and its piece count), joined, and
37
+ * only when they hash to that name. */
38
+ export async function readBlobs(world, runId) {
39
+ const layouts = new Map();
40
+ for (const record of await world.store.read()) {
41
+ if (record.kind !== BLOB_RECORD)
42
+ continue;
43
+ const { hash, piece, count, text } = record.data;
44
+ const layout = `${hash}/${count}`;
45
+ const blob = layouts.get(layout) ?? { hash, count, parts: new Map() };
46
+ if (!blob.parts.has(piece))
47
+ blob.parts.set(piece, text);
48
+ layouts.set(layout, blob);
49
+ }
50
+ const out = new Map();
51
+ for (const blob of layouts.values()) {
52
+ if (out.has(blob.hash) || blob.parts.size !== blob.count)
53
+ continue;
54
+ const text = Array.from({ length: blob.count }, (_, i) => blob.parts.get(i) ?? "").join("");
55
+ if ((await sha256Hex(text)) === blob.hash)
56
+ out.set(blob.hash, text);
57
+ }
58
+ return out;
59
+ }
@@ -0,0 +1,35 @@
1
+ import type { SessionRecord, SessionWriter } from "../types.js";
2
+ import type { DriveContext, EffectIO, HostToolIO, SessionState, Submission } from "./types.js";
3
+ export declare const KERNEL_RECORDS: {
4
+ readonly submission: "kernel.submission";
5
+ readonly start: "kernel.start";
6
+ readonly end: "kernel.end";
7
+ readonly retry: "kernel.retry";
8
+ };
9
+ /** What an interrupted `never` effect's error says. */
10
+ export declare const INTERRUPTED_TEXT = "interrupted: the process running it stopped before it reported an outcome; it may or may not have taken effect";
11
+ /** The state a session's records fold to. */
12
+ export declare function foldSession(runId: string, records: readonly SessionRecord[]): SessionState;
13
+ export declare function pendingSubmissions(state: SessionState): Submission[];
14
+ export declare function hostToolIO(io: EffectIO, ctx: DriveContext, overrides?: {
15
+ signal?: AbortSignal;
16
+ progress?: (chunk: unknown) => void;
17
+ }): HostToolIO;
18
+ export interface OpenDriveOptions {
19
+ /** The session's writer (`SessionStore.open`): the drive writes only through it, and its key is the session's. */
20
+ writer: SessionWriter;
21
+ signal: AbortSignal;
22
+ config?: unknown;
23
+ attempt?: number;
24
+ deadline?: number;
25
+ live?: (event: unknown) => void;
26
+ halt?: (reason: unknown) => void;
27
+ }
28
+ export interface Drive extends DriveContext {
29
+ /** Record new input for the session (the door's accepted submission, or a withdraw). Resolves once it is durable. */
30
+ submit(submission: Omit<Submission, "receivedAt"> & {
31
+ receivedAt?: Date;
32
+ }): Promise<void>;
33
+ }
34
+ /** Open the session's drive: fold its records and resolve whatever a dead process left open. */
35
+ export declare function openDrive(options: OpenDriveOptions): Promise<Drive>;
@@ -0,0 +1,202 @@
1
+ // A session's drive over its records: the kernel the copied harness code runs on. Written for AgentRun.
2
+ //
3
+ // An effect is an intent (`kernel.start`), then at most one outcome (`kernel.end`), both durable before the code that
4
+ // follows them goes on: a completed or failed key is answered from its records and never run again. One drive holds a
5
+ // session at a time, and the store enforces it: a drive writes through the session's writer, which a newer open of the
6
+ // session fences, so a stale drive's next write is refused and it gets a `Restart`, never a result. So when a drive
7
+ // opens, an intent with no outcome was cut off by the process that wrote it: a `safe` one is released to run again (`kernel.retry`), a `never` one is recorded failed and interrupted
8
+ // ("it may or may not have taken effect") and is never run again. Input is a `kernel.submission` record; an effect that
9
+ // names submissions in `placed` takes them out of `pending()` at its intent, and a withdraw written before that wins:
10
+ // that placement places nothing and its effect throws `Restart`.
11
+ import { SessionFenced } from "../errors.js";
12
+ import { EffectFailure, EffectInterrupted, Fatal, Restart } from "./errors.js";
13
+ export const KERNEL_RECORDS = {
14
+ submission: "kernel.submission",
15
+ start: "kernel.start",
16
+ end: "kernel.end",
17
+ retry: "kernel.retry",
18
+ };
19
+ /** What an interrupted `never` effect's error says. */
20
+ export const INTERRUPTED_TEXT = "interrupted: the process running it stopped before it reported an outcome; it may or may not have taken effect";
21
+ /** The state a session's records fold to. */
22
+ export function foldSession(runId, records) {
23
+ const state = { runId, status: "running", submissions: [], placed: new Set(), withdrawn: new Set(), effects: new Map(), effectOrder: [] };
24
+ const byStep = new Map();
25
+ for (const record of records) {
26
+ if (record.kind === KERNEL_RECORDS.submission) {
27
+ const data = record.data;
28
+ if (state.submissions.some((s) => s.id === data.id))
29
+ continue;
30
+ state.submissions.push({ ...data, receivedAt: new Date(data.receivedAt) });
31
+ // A withdraw lands only while its target is not placed yet.
32
+ if (data.kind === "withdraw" && data.target && !state.placed.has(data.target))
33
+ state.withdrawn.add(data.target);
34
+ }
35
+ else if (record.kind === KERNEL_RECORDS.start) {
36
+ const data = record.data;
37
+ const existing = state.effects.get(data.key);
38
+ // A key's first intent wins; a later one is the same key run again after a release.
39
+ if (existing && existing.status !== "pending")
40
+ continue;
41
+ // A placement a withdraw beat places nothing, not even its other submissions: the effect that wrote it is
42
+ // refused (`Restart`), and the next drive places what is still pending.
43
+ const placed = data.placed.some((id) => state.withdrawn.has(id)) ? [] : data.placed;
44
+ const effect = { key: data.key, stepId: data.stepId, name: data.name, replay: data.replay, input: data.input, status: "running", attempt: data.attempt, startedAt: new Date(data.startedAt), placed };
45
+ if (existing)
46
+ Object.assign(existing, effect, { output: undefined, error: undefined });
47
+ else {
48
+ state.effects.set(data.key, effect);
49
+ state.effectOrder.push(effect);
50
+ }
51
+ byStep.set(data.stepId, existing ?? effect);
52
+ for (const id of placed)
53
+ state.placed.add(id);
54
+ }
55
+ else if (record.kind === KERNEL_RECORDS.end) {
56
+ const data = record.data;
57
+ const effect = byStep.get(data.stepId);
58
+ // An outcome applies only to an effect that is running under that step.
59
+ if (!effect || effect.status !== "running" || effect.stepId !== data.stepId)
60
+ continue;
61
+ effect.status = data.status;
62
+ if (data.status === "completed")
63
+ effect.output = data.output;
64
+ else
65
+ effect.error = data.error ?? { message: "failed" };
66
+ }
67
+ else if (record.kind === KERNEL_RECORDS.retry) {
68
+ const effect = byStep.get(record.data.stepId);
69
+ if (effect && effect.status === "running")
70
+ effect.status = "pending";
71
+ }
72
+ }
73
+ return state;
74
+ }
75
+ export function pendingSubmissions(state) {
76
+ return state.submissions.filter((s) => s.kind !== "withdraw" && !state.placed.has(s.id) && !state.withdrawn.has(s.id));
77
+ }
78
+ export function hostToolIO(io, ctx, overrides = {}) {
79
+ return { signal: overrides.signal ?? io.signal, progress: overrides.progress ?? io.progress, ctx, stepId: io.stepId };
80
+ }
81
+ /** Open the session's drive: fold its records and resolve whatever a dead process left open. */
82
+ export async function openDrive(options) {
83
+ const { writer } = options;
84
+ const key = writer.key;
85
+ let records = await writer.read();
86
+ let state = foldSession(key, records);
87
+ const appendNow = async (written) => {
88
+ try {
89
+ await writer.append(written);
90
+ }
91
+ catch (error) {
92
+ // A newer drive holds the session: this one must rebuild from the log, and nothing it was writing counts.
93
+ if (error instanceof SessionFenced)
94
+ throw new Restart(`agentrun-kernel: ${error.message}`);
95
+ throw error;
96
+ }
97
+ records = [...records, ...written];
98
+ state = foldSession(key, records);
99
+ };
100
+ // Appends go to the writer one at a time, in call order, so the state folds records in the order the log holds
101
+ // them even when a submit's input and an effect's intent are written at once and a writer resolves them out of order.
102
+ let queue = Promise.resolve();
103
+ const append = (written) => {
104
+ const run = queue.then(() => appendNow(written));
105
+ queue = run.catch(() => undefined);
106
+ return run;
107
+ };
108
+ // One drive holds the session: an intent with no outcome belongs to a process that is gone.
109
+ const abandoned = state.effectOrder.filter((e) => e.status === "running");
110
+ if (abandoned.length) {
111
+ await append(abandoned.map((e) => e.replay === "safe"
112
+ ? { kind: KERNEL_RECORDS.retry, data: { stepId: e.stepId } }
113
+ : { kind: KERNEL_RECORDS.end, data: { stepId: e.stepId, status: "failed", error: { message: INTERRUPTED_TEXT, interrupted: true } } }));
114
+ }
115
+ const controller = new AbortController();
116
+ const signal = AbortSignal.any([options.signal, controller.signal]);
117
+ const hints = new Set();
118
+ // Keys this drive has begun and not finished, reserved before the first await: two calls issued together cannot both
119
+ // pass the checks below and dispatch one effect twice.
120
+ const inflight = new Set();
121
+ const drive = {
122
+ runId: key,
123
+ // The copied code's own writes (a journal's blobs) go through the same queue as the drive's.
124
+ world: { store: { key: writer.key, read: () => writer.read(), append: (written) => append([...written]) } },
125
+ config: options.config,
126
+ attempt: options.attempt ?? 0,
127
+ ...(options.deadline === undefined ? {} : { deadline: options.deadline }),
128
+ signal,
129
+ state: () => state,
130
+ isMemoized: (k) => {
131
+ const status = state.effects.get(k)?.status;
132
+ return status === "completed" || status === "failed";
133
+ },
134
+ pending: () => pendingSubmissions(state),
135
+ // No read: this drive is the session's only writer, so its records are the whole log, and a read that resolved
136
+ // after a newer append would put older state back.
137
+ async refresh() {
138
+ state = foldSession(key, records);
139
+ },
140
+ live: (event) => options.live?.(event),
141
+ onHint(listener) {
142
+ hints.add(listener);
143
+ return () => { hints.delete(listener); };
144
+ },
145
+ halt(reason) {
146
+ if (!controller.signal.aborted)
147
+ controller.abort(reason);
148
+ options.halt?.(reason);
149
+ },
150
+ async submit(submission) {
151
+ const { receivedAt, ...rest } = submission;
152
+ await append([{ kind: KERNEL_RECORDS.submission, data: { ...rest, receivedAt: (receivedAt ?? new Date()).toISOString() } }]);
153
+ for (const listener of [...hints])
154
+ listener({ eventType: "hook_received" });
155
+ },
156
+ async effect(k, effectOptions, fn) {
157
+ const known = state.effects.get(k);
158
+ if (known?.status === "completed")
159
+ return known.output;
160
+ if (known?.status === "failed")
161
+ throw new EffectFailure(known.error ?? { message: "failed" });
162
+ if (known?.status === "running" || inflight.has(k))
163
+ throw new Fatal(`agentrun-kernel: effect ${k} is already running in this drive`);
164
+ if (signal.aborted)
165
+ throw signal.reason;
166
+ const placing = effectOptions.placed ?? [];
167
+ // A withdraw already in the log beat this placement: refuse before writing anything, so no intent is left for
168
+ // recovery to report. One appended but not folded yet is caught after the intent, below.
169
+ if (placing.some((id) => state.withdrawn.has(id)))
170
+ throw new Restart(`agentrun-kernel: effect ${k} would place a withdrawn submission; the drive must be rebuilt`);
171
+ inflight.add(k);
172
+ try {
173
+ const attempt = known ? known.attempt + 1 : 0;
174
+ const stepId = `${k}#${attempt}`;
175
+ await append([{ kind: KERNEL_RECORDS.start, data: { key: k, stepId, name: effectOptions.name, replay: effectOptions.replay, input: effectOptions.input, placed: placing, attempt, startedAt: new Date().toISOString() } }]);
176
+ // A withdraw appended while this intent was being written beat the placement: what the caller would send next
177
+ // is not what the log placed, so it must rebuild instead of running.
178
+ if (placing.length && state.effects.get(k)?.placed.length !== placing.length)
179
+ throw new Restart(`agentrun-kernel: effect ${k} placed a withdrawn submission; the drive must be rebuilt`);
180
+ const partial = [];
181
+ let output;
182
+ try {
183
+ output = await fn({ signal, stepId, progress: (chunk) => { partial.push(chunk); } });
184
+ }
185
+ catch (error) {
186
+ // The drive is stopping, or must be rebuilt: no outcome is written; the next drive resolves the intent.
187
+ if (signal.aborted || error instanceof Restart || error instanceof Fatal)
188
+ throw error;
189
+ const failed = { message: String(error?.message ?? error), ...(error instanceof EffectInterrupted ? { interrupted: true } : {}), ...(partial.length ? { partial } : {}) };
190
+ await append([{ kind: KERNEL_RECORDS.end, data: { stepId, status: "failed", error: failed } }]);
191
+ throw new EffectFailure(failed);
192
+ }
193
+ await append([{ kind: KERNEL_RECORDS.end, data: { stepId, status: "completed", output: (output === undefined ? null : output) } }]);
194
+ return output;
195
+ }
196
+ finally {
197
+ inflight.delete(k);
198
+ }
199
+ },
200
+ };
201
+ return drive;
202
+ }
@@ -0,0 +1,27 @@
1
+ import type { EffectError } from "./types.js";
2
+ /** An effect failed; `error` is what its records say. A memoized failure is thrown again as this. */
3
+ export declare class EffectFailure extends Error {
4
+ readonly error: EffectError;
5
+ constructor(error: EffectError);
6
+ }
7
+ /** An effect was stopped before it reported its outcome. */
8
+ export declare class EffectInterrupted extends Error {
9
+ constructor(message: string);
10
+ }
11
+ /** Rebuild the drive from the session's records, in this process. */
12
+ export declare class Restart extends Error {
13
+ constructor(reason: string);
14
+ }
15
+ /** The session's records cannot be used as they are (a missing blob): no rebuild helps. */
16
+ export declare class Fatal extends Error {
17
+ constructor(message: string);
18
+ }
19
+ /** The harness cannot run this turn (no sandbox, a version mismatch, a cap reached); the turn ends with this text. */
20
+ export declare class Unavailable extends Error {
21
+ readonly code: string;
22
+ readonly hint?: string;
23
+ constructor(message: string, options?: {
24
+ code?: string;
25
+ hint?: string;
26
+ });
27
+ }
@@ -0,0 +1,42 @@
1
+ /** An effect failed; `error` is what its records say. A memoized failure is thrown again as this. */
2
+ export class EffectFailure extends Error {
3
+ error;
4
+ constructor(error) {
5
+ super(error.message);
6
+ this.error = error;
7
+ this.name = "EffectFailure";
8
+ }
9
+ }
10
+ /** An effect was stopped before it reported its outcome. */
11
+ export class EffectInterrupted extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "EffectInterrupted";
15
+ }
16
+ }
17
+ /** Rebuild the drive from the session's records, in this process. */
18
+ export class Restart extends Error {
19
+ constructor(reason) {
20
+ super(`restart: ${reason}`);
21
+ this.name = "Restart";
22
+ }
23
+ }
24
+ /** The session's records cannot be used as they are (a missing blob): no rebuild helps. */
25
+ export class Fatal extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "Fatal";
29
+ }
30
+ }
31
+ /** The harness cannot run this turn (no sandbox, a version mismatch, a cap reached); the turn ends with this text. */
32
+ export class Unavailable extends Error {
33
+ code;
34
+ hint;
35
+ constructor(message, options = {}) {
36
+ super(message);
37
+ this.name = "Unavailable";
38
+ this.code = options.code ?? "unavailable";
39
+ if (options.hint !== undefined)
40
+ this.hint = options.hint;
41
+ }
42
+ }
@@ -0,0 +1,67 @@
1
+ import type { SessionState as AdapterSessionState, SessionStore, TurnStatus } from "../types.js";
2
+ import { foldSession } from "./drive.js";
3
+ /** The effect's key in the follow-up's own session. */
4
+ export declare const FOLLOW_UP_EFFECT = "follow-up";
5
+ /** What a request the reserve refuses says, first. */
6
+ export declare const FOLLOW_UP_BUDGET_REACHED = "follow_up_budget_reached";
7
+ /** What a request asked for after the follow-up was cancelled says, first. */
8
+ export declare const FOLLOW_UP_CANCELLED = "follow_up_cancelled";
9
+ /** The follow-up's own session key, beside the session it follows up. */
10
+ export declare const followUpKey: (session: string, name: string) => string;
11
+ /** The session as a follow-up drives it: an adapter's session, and how a turn a crash cut off is closed unsent. */
12
+ export interface FollowUpTarget {
13
+ readonly state: AdapterSessionState;
14
+ /** Send `text` as a new turn, its submission under `id`. */
15
+ send(text: string, options?: {
16
+ id?: string;
17
+ }): Promise<void>;
18
+ turnEnd(): Promise<{
19
+ status: TurnStatus;
20
+ error?: string;
21
+ }>;
22
+ /** Record the open turn as ended, interrupted, and send nothing. It withdraws only the submission it is named
23
+ * (`opening`), while that is unplaced and the oldest pending. */
24
+ settleOpenTurn(options?: {
25
+ opening?: string;
26
+ }): Promise<void>;
27
+ /** Stop the turn in flight. */
28
+ interrupt(): Promise<void>;
29
+ }
30
+ export interface FollowUpOptions {
31
+ store: SessionStore;
32
+ /** The key of the session it follows up. */
33
+ session: string;
34
+ /** The follow-up's name: one follow-up per name per session. */
35
+ name: string;
36
+ prompt: string;
37
+ /** The most its paid requests may cost, in dollars, counted from its first request. */
38
+ reserveUsd: number;
39
+ /** A request's worst case, held for each request in flight and the one asked for. Required: a host with no worst case
40
+ * passes 0, and the reserve may then be passed by the one request admitted last. */
41
+ worstUsd: number;
42
+ }
43
+ export type FollowUpResult = {
44
+ status: "done";
45
+ } | {
46
+ status: "interrupted";
47
+ } | {
48
+ status: "failed";
49
+ error: string;
50
+ };
51
+ export interface FollowUp {
52
+ readonly key: string;
53
+ /** Ahead of the host's own check on each paid request: undefined outside the follow-up's turn. */
54
+ beforeRequest(request: {
55
+ spentUsd: number | null;
56
+ inFlight: readonly unknown[];
57
+ }): undefined | {
58
+ refuse: string;
59
+ };
60
+ /** Sends the follow-up once, or answers from its record. */
61
+ run(target: FollowUpTarget, signal: AbortSignal): Promise<FollowUpResult>;
62
+ }
63
+ export type FollowUpState = "none" | "running" | "done" | "interrupted" | "failed";
64
+ /** The follow-up's state in its records, as written: `running` is one a process that died left without an outcome. A
65
+ * host reads it before it resumes an open turn: a turn of a `running` or `interrupted` follow-up is settled instead. */
66
+ export declare function followUpState(records: Parameters<typeof foldSession>[1]): FollowUpState;
67
+ export declare function followUpOnce(options: FollowUpOptions): FollowUp;
@@ -0,0 +1,146 @@
1
+ import { foldSession, openDrive } from "./drive.js";
2
+ import { EffectFailure, EffectInterrupted } from "./errors.js";
3
+ /** The effect's key in the follow-up's own session. */
4
+ export const FOLLOW_UP_EFFECT = "follow-up";
5
+ /** What a request the reserve refuses says, first. */
6
+ export const FOLLOW_UP_BUDGET_REACHED = "follow_up_budget_reached";
7
+ /** What a request asked for after the follow-up was cancelled says, first. */
8
+ export const FOLLOW_UP_CANCELLED = "follow_up_cancelled";
9
+ /** The effect that records the follow-up's own cut turn settled: no later run settles another turn. */
10
+ const SETTLED_EFFECT = "follow-up.settled";
11
+ /** The run of each follow-up key in this process: a second run waits for it, never fences it. */
12
+ const runs = new WeakMap();
13
+ /** The follow-up's own session key, beside the session it follows up. */
14
+ export const followUpKey = (session, name) => `${session}:follow-up:${name}`;
15
+ /** The follow-up's state in its records, as written: `running` is one a process that died left without an outcome. A
16
+ * host reads it before it resumes an open turn: a turn of a `running` or `interrupted` follow-up is settled instead. */
17
+ export function followUpState(records) {
18
+ const effect = foldSession("", records).effects.get(FOLLOW_UP_EFFECT);
19
+ if (!effect)
20
+ return "none";
21
+ if (effect.status === "completed")
22
+ return "done";
23
+ if (effect.status === "failed")
24
+ return effect.error?.interrupted ? "interrupted" : "failed";
25
+ return "running";
26
+ }
27
+ export function followUpOnce(options) {
28
+ const { store, prompt, reserveUsd } = options;
29
+ const { worstUsd } = options;
30
+ if (typeof reserveUsd !== "number" || !Number.isFinite(reserveUsd) || reserveUsd <= 0)
31
+ throw new Error("reserveUsd must be a number of dollars above 0");
32
+ if (typeof worstUsd !== "number" || !Number.isFinite(worstUsd) || worstUsd < 0)
33
+ throw new Error("worstUsd must be given, a number of dollars, 0 or more");
34
+ const key = followUpKey(options.session, options.name);
35
+ // The id of the prompt's submission in the session: the follow-up's own, so a later run names it to the settle and a
36
+ // settle never withdraws input that is not the follow-up's.
37
+ const submission = `follow-up:${key}`;
38
+ let active = false;
39
+ let signalNow;
40
+ // The session's spentUsd when the follow-up's first request asks: the reserve counts from there. Whatever the session
41
+ // books after that counts against the reserve too: a task request whose cost was booked late (REQUEST_COST_LATE), and
42
+ // a harness's own background requests during the follow-up's turn (a CLI's small-model calls). Both refuse the
43
+ // follow-up sooner, never let it spend more.
44
+ let start;
45
+ const refuse = (why) => ({ refuse: `${FOLLOW_UP_BUDGET_REACHED}: ${why}` });
46
+ return {
47
+ key,
48
+ beforeRequest({ spentUsd, inFlight }) {
49
+ if (!active)
50
+ return undefined;
51
+ if (signalNow?.aborted)
52
+ return { refuse: `${FOLLOW_UP_CANCELLED}: the follow-up was cancelled; the request was not sent` };
53
+ if (spentUsd === null)
54
+ return refuse("what the session has spent is unknown, so the follow-up's reserve cannot be kept; the request was not sent");
55
+ start ??= spentUsd;
56
+ const used = spentUsd - start;
57
+ if (used + worstUsd * (inFlight.length + 1) > reserveUsd)
58
+ return refuse(`$${used.toFixed(6)} spent of a $${reserveUsd} reserve, and the next request could cost up to $${worstUsd}; the request was not sent`);
59
+ return undefined;
60
+ },
61
+ run(target, signal) {
62
+ const byKey = runs.get(store) ?? new Map();
63
+ runs.set(store, byKey);
64
+ const prior = byKey.get(key) ?? Promise.resolve();
65
+ // Waiting its turn, a run still hears its own signal: cancelled, it stops waiting and leaves the run before it alone.
66
+ const turn = new Promise((resolve, reject) => {
67
+ if (signal.aborted)
68
+ return reject(signal.reason);
69
+ const onAbort = () => reject(signal.reason);
70
+ signal.addEventListener("abort", onAbort, { once: true });
71
+ void prior.then(() => {
72
+ signal.removeEventListener("abort", onAbort);
73
+ resolve();
74
+ });
75
+ });
76
+ const run = turn.then(() => runOnce(target, signal));
77
+ // The next run of the key waits for the one before this, whether this one ran or gave up waiting.
78
+ const settled = prior.then(() => run.catch(() => undefined));
79
+ byKey.set(key, settled);
80
+ void settled.then(() => byKey.get(key) === settled && byKey.delete(key));
81
+ return run;
82
+ },
83
+ };
84
+ async function runOnce(target, signal) {
85
+ const drive = await openDrive({ writer: await store.open(key), signal });
86
+ const known = drive.state().effects.get(FOLLOW_UP_EFFECT);
87
+ if (known?.status === "completed")
88
+ return { status: "done" };
89
+ if (known?.status === "failed") {
90
+ if (!known.error?.interrupted)
91
+ return { status: "failed", error: known.error?.message ?? "failed" };
92
+ // Its own turn may still be open in the session: closed unsent, never resumed, and once. An open turn after that
93
+ // is another's, and is left alone.
94
+ if (drive.state().effects.get(SETTLED_EFFECT)?.status !== "completed") {
95
+ if (target.state === "open_turn")
96
+ await target.settleOpenTurn({ opening: submission });
97
+ await drive.effect(SETTLED_EFFECT, { name: SETTLED_EFFECT, replay: "safe", input: null }, async () => null);
98
+ }
99
+ return { status: "interrupted" };
100
+ }
101
+ if (target.state === "open_turn")
102
+ throw new Error("the session has an open turn that is not this follow-up's: recover it before the follow-up");
103
+ try {
104
+ await drive.effect(FOLLOW_UP_EFFECT, { name: options.name, replay: "never", input: { prompt } }, async () => {
105
+ if (signal.aborted)
106
+ throw signal.reason;
107
+ active = true;
108
+ signalNow = signal;
109
+ // A cancel stops the turn and ends the run at once: no outcome is written, so the next process reads the
110
+ // follow-up interrupted, and a turn that ends after it never answers done.
111
+ let onAbort = () => { };
112
+ const aborted = new Promise((_, reject) => {
113
+ onAbort = () => {
114
+ void target.interrupt().catch(() => undefined);
115
+ reject(signal.reason);
116
+ };
117
+ signal.addEventListener("abort", onAbort, { once: true });
118
+ });
119
+ aborted.catch(() => undefined);
120
+ try {
121
+ // A send the session refuses because it holds the id already cannot strand the follow-up: the effect is
122
+ // `never`, so a crash after the submission landed is recovered interrupted, and the prompt is never sent again.
123
+ await Promise.race([target.send(prompt, { id: submission }), aborted]);
124
+ const end = await Promise.race([target.turnEnd(), aborted]);
125
+ if (signal.aborted)
126
+ throw signal.reason;
127
+ if (end.status === "interrupted")
128
+ throw new EffectInterrupted(end.error ?? "the follow-up's turn was interrupted");
129
+ if (end.status !== "completed")
130
+ throw new Error(end.error ?? "the follow-up's turn failed");
131
+ return null;
132
+ }
133
+ finally {
134
+ active = false;
135
+ signal.removeEventListener("abort", onAbort);
136
+ }
137
+ });
138
+ return { status: "done" };
139
+ }
140
+ catch (error) {
141
+ if (error instanceof EffectFailure)
142
+ return error.error.interrupted ? { status: "interrupted" } : { status: "failed", error: error.error.message };
143
+ throw error;
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,11 @@
1
+ import type { TurnStatus } from "../types.js";
2
+ /** The session's own stop of its drive: told apart from every other halt by identity, never by its text. */
3
+ export declare class SessionHalt extends Error {
4
+ readonly kind: "interrupted" | "closed";
5
+ constructor(kind: "interrupted" | "closed");
6
+ }
7
+ /** The outcome of a turn whose drive `signal` was aborted, the session's host being `host`. */
8
+ export declare function abortedOutcome(signal: AbortSignal, host?: AbortSignal): {
9
+ status: TurnStatus;
10
+ error?: string;
11
+ };
@@ -0,0 +1,17 @@
1
+ /** The session's own stop of its drive: told apart from every other halt by identity, never by its text. */
2
+ export class SessionHalt extends Error {
3
+ kind;
4
+ constructor(kind) {
5
+ super(kind);
6
+ this.kind = kind;
7
+ this.name = "SessionHalt";
8
+ }
9
+ }
10
+ /** The outcome of a turn whose drive `signal` was aborted, the session's host being `host`. */
11
+ export function abortedOutcome(signal, host) {
12
+ const reason = signal.reason;
13
+ if (reason instanceof SessionHalt || host?.aborted)
14
+ return { status: "interrupted" };
15
+ const message = reason instanceof Error ? reason.message : reason === undefined ? "the turn was halted, with no reason given" : String(reason);
16
+ return { status: "failed", error: message.slice(0, 500) };
17
+ }
@@ -0,0 +1,8 @@
1
+ export type { DriveContext, EffectError, EffectIO, EffectOptions, EffectRecord, EffectStatus, HostTool, HostToolIO, ToolOutput, KernelWorld, ReplayPolicy, SessionState, Submission, SubmissionKind } from "./types.js";
2
+ export { EffectFailure, EffectInterrupted, Fatal, Restart, Unavailable } from "./errors.js";
3
+ export { abortedOutcome, SessionHalt } from "./halt.js";
4
+ export { BLOB_PIECE_CHARS, BLOB_RECORD, blobStreamName, readBlobs, sha256Hex, writeBlob } from "./blobs.js";
5
+ export { foldSession, hostToolIO, INTERRUPTED_TEXT, KERNEL_RECORDS, openDrive, pendingSubmissions } from "./drive.js";
6
+ export type { Drive, OpenDriveOptions } from "./drive.js";
7
+ export { FOLLOW_UP_BUDGET_REACHED, FOLLOW_UP_CANCELLED, FOLLOW_UP_EFFECT, followUpKey, followUpOnce, followUpState } from "./follow-up.js";
8
+ export type { FollowUp, FollowUpOptions, FollowUpResult, FollowUpState, FollowUpTarget } from "./follow-up.js";
@@ -0,0 +1,5 @@
1
+ export { EffectFailure, EffectInterrupted, Fatal, Restart, Unavailable } from "./errors.js";
2
+ export { abortedOutcome, SessionHalt } from "./halt.js";
3
+ export { BLOB_PIECE_CHARS, BLOB_RECORD, blobStreamName, readBlobs, sha256Hex, writeBlob } from "./blobs.js";
4
+ export { foldSession, hostToolIO, INTERRUPTED_TEXT, KERNEL_RECORDS, openDrive, pendingSubmissions } from "./drive.js";
5
+ export { FOLLOW_UP_BUDGET_REACHED, FOLLOW_UP_CANCELLED, FOLLOW_UP_EFFECT, followUpKey, followUpOnce, followUpState } from "./follow-up.js";