@frockbot/kernel-do 0.0.0 → 0.1.1
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 +25 -6
- package/src/authority.ts +989 -0
- package/src/composition-failures.test.ts +405 -0
- package/src/composition-failures.ts +108 -0
- package/src/composition-store.test.ts +497 -0
- package/src/composition-store.ts +473 -0
- package/src/index.ts +10 -0
- package/src/memory-storage.fixture.ts +61 -0
- package/src/run-records.ts +624 -0
- package/src/run-recovery.ts +163 -0
- package/src/run-terminal.ts +220 -0
- package/src/storage-keys.ts +129 -0
- package/src/turn-admission.test.ts +450 -0
- package/src/turn-errors.ts +33 -0
- package/src/workspace-generations.test.ts +228 -0
- package/src/workspace-generations.ts +189 -0
- package/src/workspace-sync-effects.ts +103 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
bootstrapGeneration,
|
|
4
|
+
compositionArtifactSetHashV1,
|
|
5
|
+
compositionGenerationIdV1,
|
|
6
|
+
type CompositionGenerationV1,
|
|
7
|
+
type CompositionMemberV1,
|
|
8
|
+
} from "@frockbot/kernel-composition/generation";
|
|
9
|
+
import {
|
|
10
|
+
BotDurableAuthority,
|
|
11
|
+
type BotDurableAuthorityHooks,
|
|
12
|
+
type BotTurnExecutionInput,
|
|
13
|
+
} from "./authority.ts";
|
|
14
|
+
import { DurableCompositionStore } from "./composition-store.ts";
|
|
15
|
+
import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.ts";
|
|
16
|
+
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
17
|
+
|
|
18
|
+
function bootstrap(createdAt: string): Promise<CompositionGenerationV1> {
|
|
19
|
+
return bootstrapGeneration(
|
|
20
|
+
[
|
|
21
|
+
{
|
|
22
|
+
packageId: "shell",
|
|
23
|
+
specifier: "@frockbot/plugin-shell",
|
|
24
|
+
version: "0.0.1",
|
|
25
|
+
manifest: { id: "shell", version: "0.0.1" },
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
{ createdAt },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function successor(
|
|
33
|
+
parent: CompositionGenerationV1,
|
|
34
|
+
createdAt: string,
|
|
35
|
+
): Promise<CompositionGenerationV1> {
|
|
36
|
+
return {
|
|
37
|
+
...parent,
|
|
38
|
+
generationId: `${createdAt}:${parent.artifactSetHash.slice(0, 16)}`,
|
|
39
|
+
parentGenerationId: parent.generationId,
|
|
40
|
+
createdAt,
|
|
41
|
+
origin: { kind: "user-install", userId: "user-1" },
|
|
42
|
+
status: "pending",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createStore(storage: MemoryStorage, now?: () => Date) {
|
|
47
|
+
return new DurableCompositionStore({
|
|
48
|
+
state: { storage } as unknown as DurableObjectState,
|
|
49
|
+
bootstrap: () => bootstrap("2026-08-31T00:00:00.000Z"),
|
|
50
|
+
...(now ? { now } : {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const authoredMember: CompositionMemberV1 = {
|
|
55
|
+
packageId: "bot-authored-greeter",
|
|
56
|
+
specifier: "bot:greeter",
|
|
57
|
+
version: "0.0.1",
|
|
58
|
+
manifestHash: "a".repeat(64),
|
|
59
|
+
provenance: {
|
|
60
|
+
kind: "bot",
|
|
61
|
+
packageId: "bot-authored-greeter",
|
|
62
|
+
version: "0.0.1",
|
|
63
|
+
botId: "primary",
|
|
64
|
+
sessionId: "user-1:primary",
|
|
65
|
+
turnId: "turn-1",
|
|
66
|
+
runId: "run-1",
|
|
67
|
+
authoredAt: "2026-08-31T12:00:00.000Z",
|
|
68
|
+
},
|
|
69
|
+
artifact: {
|
|
70
|
+
contentHash: "b".repeat(64),
|
|
71
|
+
size: 512,
|
|
72
|
+
mediaType: "application/javascript",
|
|
73
|
+
bundlerVersion: "worker-bundler@0.2.3",
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** A successor that actually changes the member set, so a revert is visible. */
|
|
78
|
+
async function grownSuccessor(
|
|
79
|
+
parent: CompositionGenerationV1,
|
|
80
|
+
createdAt: string,
|
|
81
|
+
): Promise<CompositionGenerationV1> {
|
|
82
|
+
const members = [...parent.members, authoredMember].sort((left, right) =>
|
|
83
|
+
left.packageId.localeCompare(right.packageId),
|
|
84
|
+
);
|
|
85
|
+
const artifactSetHash = await compositionArtifactSetHashV1(members);
|
|
86
|
+
return {
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
generationId: compositionGenerationIdV1(createdAt, artifactSetHash),
|
|
89
|
+
artifactSetHash,
|
|
90
|
+
parentGenerationId: parent.generationId,
|
|
91
|
+
createdAt,
|
|
92
|
+
origin: {
|
|
93
|
+
kind: "bot-authored",
|
|
94
|
+
runId: "run-1",
|
|
95
|
+
sessionId: "user-1:primary",
|
|
96
|
+
turnId: "turn-1",
|
|
97
|
+
},
|
|
98
|
+
members,
|
|
99
|
+
status: "pending",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("Bot Durable Object Composition records", () => {
|
|
104
|
+
test("materializes the bootstrap generation once, idempotently", async () => {
|
|
105
|
+
const storage = new MemoryStorage();
|
|
106
|
+
const store = createStore(storage);
|
|
107
|
+
|
|
108
|
+
const first = await store.materialize();
|
|
109
|
+
const second = await store.materialize();
|
|
110
|
+
|
|
111
|
+
expect(second).toEqual(first);
|
|
112
|
+
expect(storage.values.get("composition:current")).toEqual(first);
|
|
113
|
+
expect(storage.values.get("composition:last-known-good")).toBe(
|
|
114
|
+
first.generationId,
|
|
115
|
+
);
|
|
116
|
+
const current = await store.current();
|
|
117
|
+
expect(current.status).toBe("active");
|
|
118
|
+
expect(current.origin).toEqual({ kind: "bootstrap" });
|
|
119
|
+
expect(await store.lastKnownGood()).toEqual(current);
|
|
120
|
+
expect([...storage.values.keys()].sort()).toEqual([
|
|
121
|
+
"composition:current",
|
|
122
|
+
`composition:generation:${first.generationId}`,
|
|
123
|
+
`composition:index:2026-08-31T00:00:00.000Z:${first.generationId}`,
|
|
124
|
+
"composition:last-known-good",
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("committing a proposal supersedes the generation it replaces", async () => {
|
|
129
|
+
const storage = new MemoryStorage();
|
|
130
|
+
const store = createStore(storage);
|
|
131
|
+
const parent = await store.current();
|
|
132
|
+
const next = await successor(parent, "2026-09-01T00:00:00.000Z");
|
|
133
|
+
|
|
134
|
+
await store.propose(next);
|
|
135
|
+
expect((await store.read(next.generationId))?.status).toBe("pending");
|
|
136
|
+
expect((await store.current()).generationId).toBe(parent.generationId);
|
|
137
|
+
|
|
138
|
+
await store.commit(next.generationId);
|
|
139
|
+
|
|
140
|
+
expect((await store.current()).generationId).toBe(next.generationId);
|
|
141
|
+
expect((await store.read(parent.generationId))?.status).toBe("superseded");
|
|
142
|
+
expect((await store.lastKnownGood()).generationId).toBe(next.generationId);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("rejects unknown, duplicate, and non-pending proposals", async () => {
|
|
146
|
+
const storage = new MemoryStorage();
|
|
147
|
+
const store = createStore(storage);
|
|
148
|
+
const parent = await store.current();
|
|
149
|
+
const next = await successor(parent, "2026-09-01T00:00:00.000Z");
|
|
150
|
+
|
|
151
|
+
await expect(store.commit("missing")).rejects.toThrow(
|
|
152
|
+
'composition generation "missing" is unknown',
|
|
153
|
+
);
|
|
154
|
+
await expect(store.propose({ ...next, status: "active" })).rejects.toThrow(
|
|
155
|
+
"must be proposed as pending",
|
|
156
|
+
);
|
|
157
|
+
await expect(
|
|
158
|
+
store.propose({ ...next, artifactSetHash: "e".repeat(64) }),
|
|
159
|
+
).rejects.toThrow("mismatched artifact set hash");
|
|
160
|
+
await store.propose(next);
|
|
161
|
+
await expect(store.propose(next)).rejects.toThrow("already exists");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("reverting records a new pending generation with the target's members", async () => {
|
|
165
|
+
const storage = new MemoryStorage();
|
|
166
|
+
const store = createStore(
|
|
167
|
+
storage,
|
|
168
|
+
() => new Date("2026-09-05T00:00:00.000Z"),
|
|
169
|
+
);
|
|
170
|
+
const bootstrapped = await store.current();
|
|
171
|
+
const authored = await grownSuccessor(
|
|
172
|
+
bootstrapped,
|
|
173
|
+
"2026-09-01T00:00:00.000Z",
|
|
174
|
+
);
|
|
175
|
+
await store.propose(authored);
|
|
176
|
+
await store.commit(authored.generationId);
|
|
177
|
+
|
|
178
|
+
const reverted = await store.revert(bootstrapped.generationId, {
|
|
179
|
+
kind: "revert",
|
|
180
|
+
revertsTo: bootstrapped.generationId,
|
|
181
|
+
userId: "user-1",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(reverted.generationId).not.toBe(bootstrapped.generationId);
|
|
185
|
+
expect(reverted.status).toBe("pending");
|
|
186
|
+
expect(reverted.parentGenerationId).toBe(authored.generationId);
|
|
187
|
+
expect(reverted.members).toEqual(bootstrapped.members);
|
|
188
|
+
expect(reverted.artifactSetHash).toBe(bootstrapped.artifactSetHash);
|
|
189
|
+
expect(reverted.createdAt).toBe("2026-09-05T00:00:00.000Z");
|
|
190
|
+
expect(reverted.origin).toEqual({
|
|
191
|
+
kind: "revert",
|
|
192
|
+
revertsTo: bootstrapped.generationId,
|
|
193
|
+
userId: "user-1",
|
|
194
|
+
});
|
|
195
|
+
// The reverted-to generation is a record: reverting never mutates it.
|
|
196
|
+
expect((await store.read(bootstrapped.generationId))?.status).toBe(
|
|
197
|
+
"superseded",
|
|
198
|
+
);
|
|
199
|
+
// The revert is pinned: the pointer names it now, so the next admitted
|
|
200
|
+
// Turn mounts and commits it. Without the pin the pointer would keep
|
|
201
|
+
// naming the generation the revert replaces and nothing would change.
|
|
202
|
+
expect((await store.current()).generationId).toBe(reverted.generationId);
|
|
203
|
+
expect((await store.read(reverted.generationId))?.status).toBe("pending");
|
|
204
|
+
expect((await store.read(reverted.generationId))?.members).toEqual(
|
|
205
|
+
bootstrapped.members,
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("the reverted generation activates only when it is committed", async () => {
|
|
210
|
+
const storage = new MemoryStorage();
|
|
211
|
+
const store = createStore(
|
|
212
|
+
storage,
|
|
213
|
+
() => new Date("2026-09-05T00:00:00.000Z"),
|
|
214
|
+
);
|
|
215
|
+
const bootstrapped = await store.current();
|
|
216
|
+
const authored = await grownSuccessor(
|
|
217
|
+
bootstrapped,
|
|
218
|
+
"2026-09-01T00:00:00.000Z",
|
|
219
|
+
);
|
|
220
|
+
await store.propose(authored);
|
|
221
|
+
await store.commit(authored.generationId);
|
|
222
|
+
|
|
223
|
+
const reverted = await store.revert(bootstrapped.generationId, {
|
|
224
|
+
kind: "revert",
|
|
225
|
+
revertsTo: bootstrapped.generationId,
|
|
226
|
+
userId: "user-1",
|
|
227
|
+
});
|
|
228
|
+
await store.commit(reverted.generationId);
|
|
229
|
+
|
|
230
|
+
const current = await store.current();
|
|
231
|
+
expect(current.generationId).toBe(reverted.generationId);
|
|
232
|
+
expect(current.members).toEqual(bootstrapped.members);
|
|
233
|
+
expect((await store.read(authored.generationId))?.status).toBe(
|
|
234
|
+
"superseded",
|
|
235
|
+
);
|
|
236
|
+
expect(
|
|
237
|
+
(await store.list({ limit: 10 })).generations.map(
|
|
238
|
+
(entry) => entry.generationId,
|
|
239
|
+
),
|
|
240
|
+
).toEqual([
|
|
241
|
+
reverted.generationId,
|
|
242
|
+
authored.generationId,
|
|
243
|
+
bootstrapped.generationId,
|
|
244
|
+
]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("refuses an unknown target and the generation that is already current", async () => {
|
|
248
|
+
const storage = new MemoryStorage();
|
|
249
|
+
const store = createStore(
|
|
250
|
+
storage,
|
|
251
|
+
() => new Date("2026-09-05T00:00:00.000Z"),
|
|
252
|
+
);
|
|
253
|
+
const bootstrapped = await store.current();
|
|
254
|
+
|
|
255
|
+
await expect(
|
|
256
|
+
store.revert("missing", {
|
|
257
|
+
kind: "revert",
|
|
258
|
+
revertsTo: "missing",
|
|
259
|
+
userId: "user-1",
|
|
260
|
+
}),
|
|
261
|
+
).rejects.toThrow('composition generation "missing" is unknown');
|
|
262
|
+
await expect(
|
|
263
|
+
store.revert(bootstrapped.generationId, {
|
|
264
|
+
kind: "revert",
|
|
265
|
+
revertsTo: bootstrapped.generationId,
|
|
266
|
+
userId: "user-1",
|
|
267
|
+
}),
|
|
268
|
+
).rejects.toThrow("is already current");
|
|
269
|
+
await expect(
|
|
270
|
+
store.revert(bootstrapped.generationId, {
|
|
271
|
+
kind: "revert",
|
|
272
|
+
revertsTo: "some-other-generation",
|
|
273
|
+
userId: "user-1",
|
|
274
|
+
}),
|
|
275
|
+
).rejects.toThrow("does not name its target");
|
|
276
|
+
expect([...storage.values.keys()]).toHaveLength(4);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("quarantine with no last known good record falls back to the bootstrap", async () => {
|
|
280
|
+
const storage = new MemoryStorage();
|
|
281
|
+
const store = createStore(
|
|
282
|
+
storage,
|
|
283
|
+
() => new Date("2026-09-05T00:00:00.000Z"),
|
|
284
|
+
);
|
|
285
|
+
const bootstrapped = await store.current();
|
|
286
|
+
const authored = await grownSuccessor(
|
|
287
|
+
bootstrapped,
|
|
288
|
+
"2026-09-01T00:00:00.000Z",
|
|
289
|
+
);
|
|
290
|
+
await store.propose(authored);
|
|
291
|
+
await store.commit(authored.generationId);
|
|
292
|
+
const broken = await successor(authored, "2026-09-02T00:00:00.000Z");
|
|
293
|
+
await store.propose(broken, { pin: true });
|
|
294
|
+
// The last known good record is gone. Without a fallback the pointer would
|
|
295
|
+
// keep naming the quarantined generation and every later Turn would throw.
|
|
296
|
+
storage.values.delete(`composition:generation:${authored.generationId}`);
|
|
297
|
+
|
|
298
|
+
await store.fail(broken.generationId, { quarantined: true });
|
|
299
|
+
|
|
300
|
+
expect((await store.current()).generationId).toBe(
|
|
301
|
+
bootstrapped.generationId,
|
|
302
|
+
);
|
|
303
|
+
expect((await store.lastKnownGood()).generationId).toBe(
|
|
304
|
+
bootstrapped.generationId,
|
|
305
|
+
);
|
|
306
|
+
expect((await store.read(broken.generationId))?.status).toBe("quarantined");
|
|
307
|
+
// The fallback is itself a recorded, visible failure.
|
|
308
|
+
const failures = [...storage.values.entries()].filter(([key]) =>
|
|
309
|
+
key.startsWith(`composition:failure:${authored.generationId}:`),
|
|
310
|
+
);
|
|
311
|
+
expect(failures).toHaveLength(1);
|
|
312
|
+
expect(failures[0]?.[1]).toMatchObject({
|
|
313
|
+
generationId: authored.generationId,
|
|
314
|
+
attempt: 1,
|
|
315
|
+
phase: "resolve",
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("lists generations newest first and paginates by cursor", async () => {
|
|
320
|
+
const storage = new MemoryStorage();
|
|
321
|
+
const store = createStore(storage);
|
|
322
|
+
const parent = await store.current();
|
|
323
|
+
const created = [parent.generationId];
|
|
324
|
+
for (const day of ["01", "02", "03", "04"]) {
|
|
325
|
+
const next = await successor(parent, `2026-09-${day}T00:00:00.000Z`);
|
|
326
|
+
await store.propose(next);
|
|
327
|
+
created.push(next.generationId);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const first = await store.list({ limit: 2 });
|
|
331
|
+
expect(first.generations.map((entry) => entry.generationId)).toEqual([
|
|
332
|
+
created[4],
|
|
333
|
+
created[3],
|
|
334
|
+
]);
|
|
335
|
+
expect(first.cursor).toBeDefined();
|
|
336
|
+
|
|
337
|
+
const second = await store.list({ limit: 2, cursor: first.cursor });
|
|
338
|
+
expect(second.generations.map((entry) => entry.generationId)).toEqual([
|
|
339
|
+
created[2],
|
|
340
|
+
created[1],
|
|
341
|
+
]);
|
|
342
|
+
|
|
343
|
+
const last = await store.list({ limit: 2, cursor: second.cursor });
|
|
344
|
+
expect(last.generations.map((entry) => entry.generationId)).toEqual([
|
|
345
|
+
created[0],
|
|
346
|
+
]);
|
|
347
|
+
expect(last.cursor).toBeUndefined();
|
|
348
|
+
await expect(store.list({ limit: 0 })).rejects.toThrow("positive integer");
|
|
349
|
+
await expect(
|
|
350
|
+
store.list({ limit: 2, cursor: "run-index:x" }),
|
|
351
|
+
).rejects.toThrow("cursor is invalid");
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
interface TurnProbe {
|
|
356
|
+
authority: BotDurableAuthority<undefined>;
|
|
357
|
+
store: DurableCompositionStore;
|
|
358
|
+
storage: MemoryStorage;
|
|
359
|
+
observed: BotTurnExecutionInput<undefined>[];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function createAuthority(
|
|
363
|
+
storage: MemoryStorage,
|
|
364
|
+
executeTurn?: BotDurableAuthorityHooks<undefined>["executeTurn"],
|
|
365
|
+
): TurnProbe {
|
|
366
|
+
const observed: BotTurnExecutionInput<undefined>[] = [];
|
|
367
|
+
const authority = new BotDurableAuthority<undefined>({
|
|
368
|
+
state: { storage } as unknown as DurableObjectState,
|
|
369
|
+
codec: createStoredRunCodecV1<undefined>({
|
|
370
|
+
decodeRunId: (value) => value as string,
|
|
371
|
+
decodeConfigurationSnapshot: () => undefined,
|
|
372
|
+
}),
|
|
373
|
+
hooks: {
|
|
374
|
+
resolveAdmissionSnapshot: () => Promise.resolve(undefined),
|
|
375
|
+
bootstrapComposition: () => bootstrap("2026-08-31T00:00:00.000Z"),
|
|
376
|
+
admittedSnapshot: () => Promise.resolve(undefined),
|
|
377
|
+
executeTurn: (input) => {
|
|
378
|
+
observed.push(input);
|
|
379
|
+
return (
|
|
380
|
+
executeTurn?.(input) ??
|
|
381
|
+
Promise.resolve({
|
|
382
|
+
runId: input.command.runId,
|
|
383
|
+
text: "ok",
|
|
384
|
+
events: [],
|
|
385
|
+
})
|
|
386
|
+
);
|
|
387
|
+
},
|
|
388
|
+
notification: () => undefined,
|
|
389
|
+
scheduledDeadlines: () => Promise.resolve([]),
|
|
390
|
+
scheduledWorkInFlight: () => false,
|
|
391
|
+
deferScheduledWork: () => Promise.resolve(),
|
|
392
|
+
settleScheduledWork: () => Promise.resolve(),
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
return { authority, store: authority.composition, storage, observed };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function command(runId: string) {
|
|
399
|
+
return {
|
|
400
|
+
userId: "user-1",
|
|
401
|
+
botId: "primary",
|
|
402
|
+
runId,
|
|
403
|
+
sessionId: "user-1:primary",
|
|
404
|
+
acceptedAt: "2026-08-31T01:00:00.000Z",
|
|
405
|
+
text: "hello",
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
describe("Composition pinned at admission", () => {
|
|
410
|
+
test("an admitted Turn records the current generation id", async () => {
|
|
411
|
+
const storage = new MemoryStorage();
|
|
412
|
+
const probe = createAuthority(storage);
|
|
413
|
+
const current = await probe.store.current();
|
|
414
|
+
|
|
415
|
+
await probe.authority.run(command("run-1"));
|
|
416
|
+
|
|
417
|
+
const stored = storage.values.get("run:run-1") as StoredRunV1<undefined>;
|
|
418
|
+
expect(stored.compositionGenerationId).toBe(current.generationId);
|
|
419
|
+
expect(probe.observed[0]?.compositionGenerationId).toBe(
|
|
420
|
+
current.generationId,
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("committing a generation mid-Turn does not move the in-flight Turn", async () => {
|
|
425
|
+
const storage = new MemoryStorage();
|
|
426
|
+
let pinned: string | undefined;
|
|
427
|
+
let committed: string | undefined;
|
|
428
|
+
const probe = createAuthority(storage, async (input) => {
|
|
429
|
+
pinned = input.compositionGenerationId;
|
|
430
|
+
const parent = await probe.store.current();
|
|
431
|
+
const next = await successor(parent, "2026-09-01T00:00:00.000Z");
|
|
432
|
+
await probe.store.propose(next);
|
|
433
|
+
await probe.store.commit(next.generationId);
|
|
434
|
+
committed = next.generationId;
|
|
435
|
+
return { runId: input.command.runId, text: "ok", events: [] };
|
|
436
|
+
});
|
|
437
|
+
const admitted = await probe.store.current();
|
|
438
|
+
|
|
439
|
+
await probe.authority.run(command("run-1"));
|
|
440
|
+
|
|
441
|
+
expect(pinned).toBe(admitted.generationId);
|
|
442
|
+
expect(committed).not.toBe(admitted.generationId);
|
|
443
|
+
const stored = storage.values.get("run:run-1") as StoredRunV1<undefined>;
|
|
444
|
+
expect(stored.compositionGenerationId).toBe(admitted.generationId);
|
|
445
|
+
expect((await probe.store.current()).generationId).toBe(committed!);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("the next admitted Turn pins the newly committed generation", async () => {
|
|
449
|
+
const storage = new MemoryStorage();
|
|
450
|
+
const probe = createAuthority(storage);
|
|
451
|
+
const parent = await probe.store.current();
|
|
452
|
+
await probe.authority.run(command("run-1"));
|
|
453
|
+
const next = await successor(parent, "2026-09-01T00:00:00.000Z");
|
|
454
|
+
await probe.store.propose(next);
|
|
455
|
+
await probe.store.commit(next.generationId);
|
|
456
|
+
|
|
457
|
+
await probe.authority.run({ ...command("run-2"), text: "again" });
|
|
458
|
+
|
|
459
|
+
expect(
|
|
460
|
+
(storage.values.get("run:run-1") as StoredRunV1<undefined>)
|
|
461
|
+
.compositionGenerationId,
|
|
462
|
+
).toBe(parent.generationId);
|
|
463
|
+
expect(
|
|
464
|
+
(storage.values.get("run:run-2") as StoredRunV1<undefined>)
|
|
465
|
+
.compositionGenerationId,
|
|
466
|
+
).toBe(next.generationId);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("recovery reads the pin from the stored run rather than recomputing it", async () => {
|
|
470
|
+
const storage = new MemoryStorage();
|
|
471
|
+
const probe = createAuthority(storage);
|
|
472
|
+
const parent = await probe.store.current();
|
|
473
|
+
await probe.authority.run(command("run-1"));
|
|
474
|
+
const next = await successor(parent, "2026-09-01T00:00:00.000Z");
|
|
475
|
+
await probe.store.propose(next);
|
|
476
|
+
await probe.store.commit(next.generationId);
|
|
477
|
+
|
|
478
|
+
// A Turn interrupted after admission: the durable record still holds the pin.
|
|
479
|
+
const stored = storage.values.get("run:run-1") as StoredRunV1<undefined>;
|
|
480
|
+
storage.values.set("run:run-1", {
|
|
481
|
+
...stored,
|
|
482
|
+
status: "running",
|
|
483
|
+
phase: "executing",
|
|
484
|
+
responseText: undefined,
|
|
485
|
+
events: [],
|
|
486
|
+
});
|
|
487
|
+
storage.values.set("active-run", "run-1");
|
|
488
|
+
storage.values.set("identity", { userId: "user-1", botId: "primary" });
|
|
489
|
+
storage.values.set("latest-events", []);
|
|
490
|
+
const resumed = createAuthority(storage);
|
|
491
|
+
await resumed.authority.recoverActiveRun();
|
|
492
|
+
|
|
493
|
+
expect(resumed.observed.at(-1)?.compositionGenerationId).toBe(
|
|
494
|
+
parent.generationId,
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
});
|