@frockbot/kernel-do 0.3.29 → 0.3.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-do",
3
- "version": "0.3.29",
3
+ "version": "0.3.31",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,8 +12,8 @@
12
12
  "typecheck": "tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "dependencies": {
15
- "@frockbot/kernel-composition": "0.3.29",
16
- "@frockbot/kernel-contracts": "0.3.29",
15
+ "@frockbot/kernel-composition": "0.3.31",
16
+ "@frockbot/kernel-contracts": "0.3.31",
17
17
  "cordis": "4.0.0-rc.8"
18
18
  },
19
19
  "devDependencies": {
package/src/authority.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  completeStoredRun,
32
32
  type TerminalPackageRecords,
33
33
  type SupersededPackageRecords,
34
+ type FailedRunNotification,
34
35
  failStoredRun,
35
36
  requireStoredRunReconciliation,
36
37
  } from "./run-terminal.js";
@@ -134,6 +135,24 @@ export interface BotDurableAuthorityHooks<Snapshot> {
134
135
  snapshot: Snapshot,
135
136
  result: BotTurnCompletion,
136
137
  ): BotNotificationIntent | undefined;
138
+ /**
139
+ * Notification policy for a Turn that ended `failed`, given the settings the
140
+ * Turn was admitted under and its settled record. `undefined` records none.
141
+ *
142
+ * The snapshot comes off the run itself rather than from a fresh read: a
143
+ * failure settles on paths — recovery after a restart, the stale-run repair —
144
+ * where nothing else has the settings to hand, and `configurationSnapshot` is
145
+ * the durable copy of exactly the ones this Turn ran under.
146
+ */
147
+ failureNotification?(
148
+ snapshot: Snapshot,
149
+ failed: {
150
+ runId: string;
151
+ /** The stored diagnostic. Never shown to a person as it stands. */
152
+ failure: string;
153
+ events: readonly SessionEvent[];
154
+ },
155
+ ): BotNotificationIntent | undefined;
137
156
  /**
138
157
  * Package records written in the same transaction that settles a Turn, given
139
158
  * the settled run and the admission-index cursor it was admitted under. The
@@ -1349,6 +1368,7 @@ export class BotDurableAuthority<Snapshot> {
1349
1368
  run.events,
1350
1369
  STALE_RUNNING_RUN_FAILURE_V1,
1351
1370
  this.supersededPackageRecords(),
1371
+ this.failedRunNotification(),
1352
1372
  );
1353
1373
  await this.refreshRecoveryAlarm(transaction);
1354
1374
  });
@@ -1823,6 +1843,21 @@ export class BotDurableAuthority<Snapshot> {
1823
1843
  }
1824
1844
 
1825
1845
  /** The Package's superseded-record hook, or `undefined` when it has none. */
1846
+ /**
1847
+ * The Package's failed-Turn notification hook, bound to the run's own
1848
+ * durable snapshot. Absent when the Package contributes none.
1849
+ */
1850
+ private failedRunNotification(): FailedRunNotification<Snapshot> | undefined {
1851
+ const hook = this.hooks.failureNotification;
1852
+ if (!hook) return undefined;
1853
+ return (run) =>
1854
+ hook.call(this.hooks, run.configurationSnapshot, {
1855
+ runId: run.runId,
1856
+ failure: run.failure ?? "",
1857
+ events: run.events,
1858
+ });
1859
+ }
1860
+
1826
1861
  private supersededPackageRecords():
1827
1862
  SupersededPackageRecords<Snapshot> | undefined {
1828
1863
  const hook = this.hooks.supersededRecords;
@@ -1890,6 +1925,7 @@ export class BotDurableAuthority<Snapshot> {
1890
1925
  events,
1891
1926
  boundedRunFailureV1(failure),
1892
1927
  this.supersededPackageRecords(),
1928
+ this.failedRunNotification(),
1893
1929
  );
1894
1930
  await this.refreshRecoveryAlarm(transaction);
1895
1931
  });
@@ -2108,6 +2144,7 @@ export class BotDurableAuthority<Snapshot> {
2108
2144
  events,
2109
2145
  plan.failure,
2110
2146
  this.supersededPackageRecords(),
2147
+ this.failedRunNotification(),
2111
2148
  );
2112
2149
  await this.refreshRecoveryAlarm(transaction);
2113
2150
  return undefined;
@@ -0,0 +1,227 @@
1
+ // A Turn that ends `failed` owes the person who was waiting on it the same
2
+ // kind of notice a completed one gives them. Before this, only a completed
3
+ // Turn recorded a notification intent, so a deadline, a provider outage or a
4
+ // restart reached nobody who was not still looking at that conversation.
5
+ import { describe, expect, test } from "bun:test";
6
+ import { Session, type SessionEvent } from "@frockbot/kernel-contracts";
7
+ import { MemoryStorage } from "./memory-storage.fixture.ts";
8
+ import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.ts";
9
+ import { failStoredRun } from "./run-terminal.ts";
10
+
11
+ interface Snapshot {
12
+ name: string;
13
+ notify: boolean;
14
+ }
15
+
16
+ const codec = createStoredRunCodecV1<Snapshot>({
17
+ decodeRunId: (value) => String(value),
18
+ decodeConfigurationSnapshot: (value) => value as Snapshot,
19
+ });
20
+
21
+ const SESSION_ID = "user-1:primary";
22
+
23
+ const KEYS = {
24
+ run: "run:run-1",
25
+ activeRun: "active-run",
26
+ latestEvents: "latest-events",
27
+ notificationPrefix: "notification:",
28
+ };
29
+
30
+ /** The Package policy under test: it reads the run's own durable snapshot. */
31
+ function failureNotification(run: StoredRunV1<Snapshot>) {
32
+ if (!run.configurationSnapshot.notify) return undefined;
33
+ return {
34
+ notificationId: `run-failed-${run.runId}`,
35
+ runId: run.runId,
36
+ createdAt: "2026-09-05T00:00:00.000Z",
37
+ title: `${run.configurationSnapshot.name} couldn't finish`,
38
+ body: "This Bot couldn't finish its reply. Try again.",
39
+ };
40
+ }
41
+
42
+ function journal(): SessionEvent[] {
43
+ const events: SessionEvent[] = [];
44
+ const session = new Session(SESSION_ID, (envelope) => {
45
+ events.push(envelope.event);
46
+ });
47
+ session.appendBatch([
48
+ { type: "turn/start", turn: 1 },
49
+ { type: "step/start", turn: 1, step: 1 },
50
+ {
51
+ type: "user/message",
52
+ turn: 1,
53
+ step: 1,
54
+ messageId: "message-1",
55
+ text: "hello",
56
+ },
57
+ ]);
58
+ return events;
59
+ }
60
+
61
+ function storedRun(
62
+ events: SessionEvent[],
63
+ intent: Partial<StoredRunV1<Snapshot>>,
64
+ ): StoredRunV1<Snapshot> {
65
+ return {
66
+ runId: "run-1",
67
+ commandFingerprint: "fingerprint-1",
68
+ sessionId: SESSION_ID,
69
+ acceptedAt: "2026-09-05T00:00:00.000Z",
70
+ input: "hello",
71
+ events,
72
+ effectAdmissions: [],
73
+ status: "running",
74
+ phase: "executing",
75
+ compositionGenerationId: "generation-1",
76
+ configurationSnapshot: { name: "Bob", notify: true },
77
+ previousEventCount: 0,
78
+ ...intent,
79
+ };
80
+ }
81
+
82
+ async function settledStorage(
83
+ intent: Partial<StoredRunV1<Snapshot>> = {},
84
+ ): Promise<{ storage: MemoryStorage; events: SessionEvent[] }> {
85
+ const storage = new MemoryStorage();
86
+ const events = journal();
87
+ await storage.put({
88
+ [KEYS.activeRun]: "run-1",
89
+ [KEYS.run]: storedRun(events, intent),
90
+ [KEYS.latestEvents]: events,
91
+ });
92
+ return { storage, events };
93
+ }
94
+
95
+ function notifications(storage: MemoryStorage): unknown[] {
96
+ return [...storage.values.entries()]
97
+ .filter(([key]) => key.startsWith(KEYS.notificationPrefix))
98
+ .map(([, value]) => value);
99
+ }
100
+
101
+ describe("the notification a failed Turn records", () => {
102
+ test("is composed from the snapshot the Turn was admitted under", async () => {
103
+ const { storage, events } = await settledStorage();
104
+
105
+ await failStoredRun(
106
+ codec,
107
+ storage,
108
+ KEYS,
109
+ "run-1",
110
+ [],
111
+ events,
112
+ "Bot turn ended with outcome model-error: Model request failed (401)",
113
+ undefined,
114
+ failureNotification,
115
+ );
116
+
117
+ expect(notifications(storage)).toEqual([
118
+ {
119
+ notificationId: "run-failed-run-1",
120
+ runId: "run-1",
121
+ createdAt: "2026-09-05T00:00:00.000Z",
122
+ title: "Bob couldn't finish",
123
+ body: "This Bot couldn't finish its reply. Try again.",
124
+ },
125
+ ]);
126
+ });
127
+
128
+ test("is not recorded when the Bot's own policy declines it", async () => {
129
+ const { storage, events } = await settledStorage({
130
+ configurationSnapshot: { name: "Bob", notify: false },
131
+ });
132
+
133
+ await failStoredRun(
134
+ codec,
135
+ storage,
136
+ KEYS,
137
+ "run-1",
138
+ [],
139
+ events,
140
+ "Bot turn ended with outcome model-error",
141
+ undefined,
142
+ failureNotification,
143
+ );
144
+
145
+ expect(notifications(storage)).toEqual([]);
146
+ });
147
+
148
+ test("is written once, however often the run is settled again", async () => {
149
+ const { storage, events } = await settledStorage();
150
+
151
+ await failStoredRun(
152
+ codec,
153
+ storage,
154
+ KEYS,
155
+ "run-1",
156
+ [],
157
+ events,
158
+ "the service restarted",
159
+ undefined,
160
+ failureNotification,
161
+ );
162
+ // What acknowledging it does. A recovery pass over the same run must not
163
+ // bring it back — the person has already read it.
164
+ storage.values.delete(`${KEYS.notificationPrefix}run-failed-run-1`);
165
+
166
+ await failStoredRun(
167
+ codec,
168
+ storage,
169
+ KEYS,
170
+ "run-1",
171
+ [],
172
+ events,
173
+ "the service restarted",
174
+ undefined,
175
+ failureNotification,
176
+ );
177
+
178
+ expect(notifications(storage)).toEqual([]);
179
+ });
180
+
181
+ test("is not recorded for a Turn a later message replaced", async () => {
182
+ const { storage, events } = await settledStorage({
183
+ supersededAt: "2026-09-05T00:01:00.000Z",
184
+ supersededBy: "run-2",
185
+ });
186
+
187
+ await failStoredRun(
188
+ codec,
189
+ storage,
190
+ KEYS,
191
+ "run-1",
192
+ [],
193
+ events,
194
+ "Bot turn ended with outcome model-error",
195
+ undefined,
196
+ failureNotification,
197
+ );
198
+
199
+ expect((storage.values.get(KEYS.run) as StoredRunV1<Snapshot>).status).toBe(
200
+ "superseded",
201
+ );
202
+ expect(notifications(storage)).toEqual([]);
203
+ });
204
+
205
+ test("is not recorded for a Turn the person stopped", async () => {
206
+ const { storage, events } = await settledStorage({
207
+ stopRequestedAt: "2026-09-05T00:01:00.000Z",
208
+ });
209
+
210
+ await failStoredRun(
211
+ codec,
212
+ storage,
213
+ KEYS,
214
+ "run-1",
215
+ [],
216
+ events,
217
+ "Bot turn ended with outcome model-error",
218
+ undefined,
219
+ failureNotification,
220
+ );
221
+
222
+ expect((storage.values.get(KEYS.run) as StoredRunV1<Snapshot>).status).toBe(
223
+ "cancelled",
224
+ );
225
+ expect(notifications(storage)).toEqual([]);
226
+ });
227
+ });
@@ -4,6 +4,7 @@ import {
4
4
  type SessionEvent,
5
5
  } from "@frockbot/kernel-contracts";
6
6
  import type {
7
+ BotNotificationIntent,
7
8
  BotTurnCompletion,
8
9
  StoredRunCodecV1,
9
10
  StoredRunV1,
@@ -132,6 +133,26 @@ export type SupersededPackageRecords<Snapshot> = (input: {
132
133
  read<T>(key: string): Promise<T | undefined>;
133
134
  }) => Promise<Record<string, unknown>>;
134
135
 
136
+ /**
137
+ * The notification a Turn that ended `failed` owes the person who was waiting
138
+ * on it, decided by the Package that owns notification content.
139
+ *
140
+ * A completed Turn already tells them — "Bob replied", with what it said —
141
+ * through the completion's own intent. A failed Turn had none, so the only
142
+ * person who ever learned was the one still looking at that conversation. This
143
+ * is the same seam for the other outcome: the kernel hands over the settled
144
+ * record, whose `configurationSnapshot` is the durable copy of the settings
145
+ * the Turn was admitted under, and writes back whatever intent comes out
146
+ * without reading it.
147
+ *
148
+ * It is consulted only on the transition into `failed`, so a replay or a
149
+ * recovery pass over a run that already settled writes nothing — an
150
+ * acknowledged notification stays acknowledged.
151
+ */
152
+ export type FailedRunNotification<Snapshot> = (
153
+ run: StoredRunV1<Snapshot>,
154
+ ) => BotNotificationIntent | undefined;
155
+
135
156
  /**
136
157
  * Settles a superseded run as terminal `superseded` and clears its active
137
158
  * marker. Like a cancelled run it produces no response text, no failure, and
@@ -325,12 +346,17 @@ export async function failStoredRun<Snapshot>(
325
346
  events: readonly SessionEvent[],
326
347
  failure: string,
327
348
  supersededRecords?: SupersededPackageRecords<Snapshot>,
349
+ failureNotification?: FailedRunNotification<Snapshot>,
328
350
  ): Promise<
329
351
  "failed" | "cancelled" | "superseded" | "preserved-completion" | "missing"
330
352
  > {
331
353
  const run = await hydratedRun(codec, storage, keys.run);
332
354
  if (!run) return "missing";
333
355
  if (run.status === "completed") return "preserved-completion";
356
+ // Whether this settlement is the one that fails the run. A run already
357
+ // `failed` can be settled again — recovery and the stale-run repair both
358
+ // re-enter — and the second pass owes nobody a second notification.
359
+ const alreadyFailed = run.status === "failed";
334
360
  // A stopped run never becomes `failed`: Stop is the durable outcome.
335
361
  if (run.stopRequestedAt) {
336
362
  return cancelStoredRun(codec, storage, keys, runId, previous, events);
@@ -357,8 +383,16 @@ export async function failStoredRun<Snapshot>(
357
383
  phase: run.phase === "reconciliation-required" ? "executing" : run.phase,
358
384
  failure,
359
385
  } satisfies StoredRunV1<Snapshot>);
386
+ const records: Record<string, unknown> = {
387
+ [keys.run]: structuredClone(storedRunRecordV2(failed)),
388
+ };
389
+ const intent = alreadyFailed ? undefined : failureNotification?.(failed);
390
+ if (intent) {
391
+ records[`${keys.notificationPrefix}${intent.notificationId}`] =
392
+ structuredClone(intent);
393
+ }
360
394
  await new SessionEventLog(storage).rewrite(run.sessionId, latestEvents);
361
- await storage.put({ [keys.run]: structuredClone(storedRunRecordV2(failed)) });
395
+ await storage.put(records);
362
396
  if ((await storage.get<string>(keys.activeRun)) === runId) {
363
397
  await storage.delete(keys.activeRun);
364
398
  }