@frockbot/kernel-do 0.3.28 → 0.3.30
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 +3 -3
- package/src/applets.ts +1 -1
- package/src/authority.ts +37 -0
- package/src/run-terminal-failure-notification.test.ts +227 -0
- package/src/run-terminal.ts +35 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-do",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.30",
|
|
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.
|
|
16
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
15
|
+
"@frockbot/kernel-composition": "0.3.30",
|
|
16
|
+
"@frockbot/kernel-contracts": "0.3.30",
|
|
17
17
|
"cordis": "4.0.0-rc.8"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
package/src/applets.ts
CHANGED
|
@@ -69,7 +69,7 @@ export const APPLET_MOUNT_INPUT_KEY = "applet:mount-input";
|
|
|
69
69
|
* Applet Durable Object: the open activation trial, if one is in flight.
|
|
70
70
|
*
|
|
71
71
|
* An activation is a commit boundary the kernel owns, not a mount the candidate
|
|
72
|
-
* is trusted to survive (ADR
|
|
72
|
+
* is trusted to survive (ADR 0041). While this key exists the facet's storage is
|
|
73
73
|
* provisional: a byte copy of it is parked in the rollback facet, and whatever
|
|
74
74
|
* reads the Applet next either finds the trial committed — the key deleted — or
|
|
75
75
|
* rolls it back before answering. That is what makes an interrupted publish
|
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
|
+
});
|
package/src/run-terminal.ts
CHANGED
|
@@ -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(
|
|
395
|
+
await storage.put(records);
|
|
362
396
|
if ((await storage.get<string>(keys.activeRun)) === runId) {
|
|
363
397
|
await storage.delete(keys.activeRun);
|
|
364
398
|
}
|