@frockbot/kernel-do 0.3.1 → 0.3.3
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.test.ts +285 -0
- package/src/applets.ts +672 -0
- package/src/authority.ts +468 -13
- package/src/index.ts +1 -0
- package/src/memory-storage.fixture.ts +14 -1
- package/src/run-records.ts +132 -4
- package/src/run-terminal.ts +109 -2
- package/src/storage-keys.ts +8 -0
- package/src/turn-supersede.test.ts +578 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
bootstrapGeneration,
|
|
4
|
+
type CompositionGenerationV1,
|
|
5
|
+
} from "@frockbot/kernel-composition/generation";
|
|
6
|
+
import type { SessionEvent } from "@frockbot/kernel-contracts";
|
|
7
|
+
import {
|
|
8
|
+
BotDurableAuthority,
|
|
9
|
+
SUPERSEDED_TURN_REASON_V1,
|
|
10
|
+
type BotDurableAuthorityHooks,
|
|
11
|
+
type BotTurnExecutionInput,
|
|
12
|
+
type OwnedBotTurnCommand,
|
|
13
|
+
} from "./authority.ts";
|
|
14
|
+
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
15
|
+
import {
|
|
16
|
+
createStoredRunCodecV1,
|
|
17
|
+
storedRunLaneV1,
|
|
18
|
+
type StoredRunV1,
|
|
19
|
+
} from "./run-records.ts";
|
|
20
|
+
|
|
21
|
+
const codec = createStoredRunCodecV1<undefined>({
|
|
22
|
+
decodeRunId: (value) => value as string,
|
|
23
|
+
decodeConfigurationSnapshot: () => undefined,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function bootstrap(): Promise<CompositionGenerationV1> {
|
|
27
|
+
return bootstrapGeneration(
|
|
28
|
+
[
|
|
29
|
+
{
|
|
30
|
+
packageId: "shell",
|
|
31
|
+
specifier: "@frockbot/plugin-shell",
|
|
32
|
+
version: "0.0.1",
|
|
33
|
+
manifest: { id: "shell", version: "0.0.1" },
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
{ createdAt: "2026-09-03T00:00:00.000Z" },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const identity = { userId: "user-1", botId: "primary" };
|
|
41
|
+
|
|
42
|
+
let clock = 0;
|
|
43
|
+
|
|
44
|
+
function command(
|
|
45
|
+
runId: string,
|
|
46
|
+
text: string,
|
|
47
|
+
extra: Partial<OwnedBotTurnCommand> = {},
|
|
48
|
+
): OwnedBotTurnCommand {
|
|
49
|
+
clock += 1;
|
|
50
|
+
return {
|
|
51
|
+
...identity,
|
|
52
|
+
runId,
|
|
53
|
+
sessionId: "user-1:primary",
|
|
54
|
+
acceptedAt: new Date(Date.UTC(2026, 8, 3, 0, 0, clock)).toISOString(),
|
|
55
|
+
text,
|
|
56
|
+
...extra,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One Turn the test is holding open inside `executeTurn`. */
|
|
61
|
+
interface TurnHandle {
|
|
62
|
+
/** Resolves once the Turn has journaled its opening events and blocked. */
|
|
63
|
+
started: Promise<void>;
|
|
64
|
+
/** Lets the Turn finish normally. */
|
|
65
|
+
finish(): void;
|
|
66
|
+
/** Ends the Turn the way an interrupted Agent loop does. */
|
|
67
|
+
interrupt(reason: string): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Probe {
|
|
71
|
+
authority: BotDurableAuthority<undefined>;
|
|
72
|
+
observed: BotTurnExecutionInput<undefined>[];
|
|
73
|
+
interrupts: { runId: string; reason: string }[];
|
|
74
|
+
supersededRecordRuns: string[];
|
|
75
|
+
handle(runId: string): TurnHandle;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Deferred<T> {
|
|
79
|
+
promise: Promise<T>;
|
|
80
|
+
resolve(value: T): void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Lets the object finish what a command already set in motion. Two HTTP
|
|
85
|
+
* requests never reach a Durable Object in the same microtask, and the tests
|
|
86
|
+
* that send two messages are describing two requests.
|
|
87
|
+
*/
|
|
88
|
+
function admitted(): Promise<void> {
|
|
89
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function deferred<T>(): Deferred<T> {
|
|
93
|
+
let resolve!: (value: T) => void;
|
|
94
|
+
const promise = new Promise<T>((settle) => {
|
|
95
|
+
resolve = settle;
|
|
96
|
+
});
|
|
97
|
+
return { promise, resolve };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* An authority whose Package holds every Turn open until the test releases it,
|
|
102
|
+
* and whose Turns end the way the Agent loop's do when they are cancelled: a
|
|
103
|
+
* `turn/end` naming the opaque reason the caller passed, then a failure.
|
|
104
|
+
*
|
|
105
|
+
* `dispatch` decides whether a Turn journals a `model/request` before it
|
|
106
|
+
* blocks. That event is the whole of what makes a Turn interruptible: a Turn
|
|
107
|
+
* that has not reached its first durable checkpoint is left to finish.
|
|
108
|
+
*/
|
|
109
|
+
function createAuthority(
|
|
110
|
+
storage: MemoryStorage,
|
|
111
|
+
options: { dispatch?(runId: string): boolean } = {},
|
|
112
|
+
): Probe {
|
|
113
|
+
const observed: BotTurnExecutionInput<undefined>[] = [];
|
|
114
|
+
const interrupts: { runId: string; reason: string }[] = [];
|
|
115
|
+
const supersededRecordRuns: string[] = [];
|
|
116
|
+
const handles = new Map<
|
|
117
|
+
string,
|
|
118
|
+
{
|
|
119
|
+
started: Deferred<void>;
|
|
120
|
+
settled: Deferred<{ interrupted?: string }>;
|
|
121
|
+
}
|
|
122
|
+
>();
|
|
123
|
+
const handleFor = (runId: string) => {
|
|
124
|
+
const existing = handles.get(runId);
|
|
125
|
+
if (existing) return existing;
|
|
126
|
+
const created = {
|
|
127
|
+
started: deferred<void>(),
|
|
128
|
+
settled: deferred<{ interrupted?: string }>(),
|
|
129
|
+
};
|
|
130
|
+
handles.set(runId, created);
|
|
131
|
+
return created;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const hooks: BotDurableAuthorityHooks<undefined> = {
|
|
135
|
+
resolveAdmissionSnapshot: () => Promise.resolve(undefined),
|
|
136
|
+
bootstrapComposition: () => bootstrap(),
|
|
137
|
+
admittedSnapshot: () => Promise.resolve(undefined),
|
|
138
|
+
executeTurn: async (input) => {
|
|
139
|
+
observed.push(input);
|
|
140
|
+
const runId = input.command.runId;
|
|
141
|
+
const turn = observed.length;
|
|
142
|
+
const handle = handleFor(runId);
|
|
143
|
+
let seq = input.previousEvents.length;
|
|
144
|
+
const appended: SessionEvent[] = [];
|
|
145
|
+
const persist = async (
|
|
146
|
+
...events: Omit<SessionEvent, "seq" | "timestamp">[]
|
|
147
|
+
) => {
|
|
148
|
+
const stamped = events.map(
|
|
149
|
+
(event) =>
|
|
150
|
+
({
|
|
151
|
+
...event,
|
|
152
|
+
seq: seq++,
|
|
153
|
+
timestamp: "2026-09-03T00:00:10.000Z",
|
|
154
|
+
}) as SessionEvent,
|
|
155
|
+
);
|
|
156
|
+
appended.push(...stamped);
|
|
157
|
+
await input.persistSessionEvents(input.command.sessionId, stamped);
|
|
158
|
+
};
|
|
159
|
+
await persist(
|
|
160
|
+
{ type: "turn/start", turn } as never,
|
|
161
|
+
{
|
|
162
|
+
type: "user/message",
|
|
163
|
+
turn,
|
|
164
|
+
step: 1,
|
|
165
|
+
messageId: `m-${runId}`,
|
|
166
|
+
text: input.command.text,
|
|
167
|
+
} as never,
|
|
168
|
+
);
|
|
169
|
+
if (options.dispatch?.(runId) ?? true) {
|
|
170
|
+
await persist(
|
|
171
|
+
{
|
|
172
|
+
type: "model/request",
|
|
173
|
+
turn,
|
|
174
|
+
step: 1,
|
|
175
|
+
request: {
|
|
176
|
+
requestId: `request-${runId}`,
|
|
177
|
+
provider: "foundation",
|
|
178
|
+
model: "foundation-model",
|
|
179
|
+
system: "system",
|
|
180
|
+
messages: [{ role: "user", content: input.command.text }],
|
|
181
|
+
tools: [],
|
|
182
|
+
},
|
|
183
|
+
} as never,
|
|
184
|
+
{
|
|
185
|
+
type: "assistant/message",
|
|
186
|
+
turn,
|
|
187
|
+
step: 1,
|
|
188
|
+
requestId: `request-${runId}`,
|
|
189
|
+
text: `working on ${input.command.text}`,
|
|
190
|
+
toolCalls: [],
|
|
191
|
+
} as never,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
handle.started.resolve();
|
|
195
|
+
const outcome = await handle.settled.promise;
|
|
196
|
+
if (outcome.interrupted !== undefined) {
|
|
197
|
+
await persist({
|
|
198
|
+
type: "turn/end",
|
|
199
|
+
turn,
|
|
200
|
+
outcome: "cancelled",
|
|
201
|
+
reason: outcome.interrupted,
|
|
202
|
+
} as never);
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Bot turn ended with outcome cancelled: ${outcome.interrupted}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
await persist({ type: "turn/end", turn, outcome: "completed" } as never);
|
|
208
|
+
return { runId, text: `done: ${input.command.text}`, events: appended };
|
|
209
|
+
},
|
|
210
|
+
notification: () => undefined,
|
|
211
|
+
scheduledDeadlines: () => Promise.resolve([]),
|
|
212
|
+
scheduledWorkInFlight: () => false,
|
|
213
|
+
deferScheduledWork: () => Promise.resolve(),
|
|
214
|
+
settleScheduledWork: () => Promise.resolve(),
|
|
215
|
+
interruptTurn: (runId, reason) => {
|
|
216
|
+
interrupts.push({ runId, reason });
|
|
217
|
+
handleFor(runId).settled.resolve({ interrupted: reason });
|
|
218
|
+
},
|
|
219
|
+
supersededRecords: ({ run }) => {
|
|
220
|
+
supersededRecordRuns.push(run.runId);
|
|
221
|
+
return Promise.resolve({
|
|
222
|
+
[`superseded-note:${run.runId}`]: { runId: run.runId },
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
authority: new BotDurableAuthority<undefined>({
|
|
229
|
+
state: { storage } as unknown as DurableObjectState,
|
|
230
|
+
codec,
|
|
231
|
+
hooks,
|
|
232
|
+
}),
|
|
233
|
+
observed,
|
|
234
|
+
interrupts,
|
|
235
|
+
supersededRecordRuns,
|
|
236
|
+
handle: (runId) => {
|
|
237
|
+
const handle = handleFor(runId);
|
|
238
|
+
return {
|
|
239
|
+
started: handle.started.promise,
|
|
240
|
+
finish: () => handle.settled.resolve({}),
|
|
241
|
+
interrupt: (reason) => handle.settled.resolve({ interrupted: reason }),
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function storedRun(
|
|
248
|
+
storage: MemoryStorage,
|
|
249
|
+
runId: string,
|
|
250
|
+
): StoredRunV1<undefined> {
|
|
251
|
+
return codec.require(storage.values.get(`run:${runId}`));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function turnEndOf(run: StoredRunV1<undefined>) {
|
|
255
|
+
return run.events.findLast((event) => event.type === "turn/end");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
describe("a user message supersedes the running Turn", () => {
|
|
259
|
+
test("the running Turn terminalizes superseded and the new one runs", async () => {
|
|
260
|
+
const storage = new MemoryStorage();
|
|
261
|
+
const probe = createAuthority(storage);
|
|
262
|
+
|
|
263
|
+
const first = probe.authority.run(command("run-1", "first"));
|
|
264
|
+
await probe.handle("run-1").started;
|
|
265
|
+
|
|
266
|
+
const second = probe.authority.run(
|
|
267
|
+
command("run-2", "second", {
|
|
268
|
+
lane: "user",
|
|
269
|
+
supersedes: { runId: "run-1" },
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
// The new Turn is durable before anything is acknowledged, and it is
|
|
273
|
+
// waiting rather than running.
|
|
274
|
+
await probe.handle("run-2").started.then(
|
|
275
|
+
() => undefined,
|
|
276
|
+
() => undefined,
|
|
277
|
+
);
|
|
278
|
+
await first;
|
|
279
|
+
probe.handle("run-2").finish();
|
|
280
|
+
const result = await second;
|
|
281
|
+
|
|
282
|
+
const superseded = storedRun(storage, "run-1");
|
|
283
|
+
expect(superseded.status).toBe("superseded");
|
|
284
|
+
expect(superseded.supersededBy).toBe("run-2");
|
|
285
|
+
expect(turnEndOf(superseded)).toMatchObject({
|
|
286
|
+
outcome: "cancelled",
|
|
287
|
+
reason: SUPERSEDED_TURN_REASON_V1,
|
|
288
|
+
});
|
|
289
|
+
expect(probe.interrupts).toEqual([
|
|
290
|
+
{ runId: "run-1", reason: SUPERSEDED_TURN_REASON_V1 },
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
const replacement = storedRun(storage, "run-2");
|
|
294
|
+
expect(replacement.status).toBe("completed");
|
|
295
|
+
expect(replacement.input).toBe("second");
|
|
296
|
+
expect(result.text).toBe("done: second");
|
|
297
|
+
expect(storage.values.get("active-run")).toBeUndefined();
|
|
298
|
+
expect(storage.values.get("pending-run")).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("the superseded Turn's history is what the next Turn starts from", async () => {
|
|
302
|
+
const storage = new MemoryStorage();
|
|
303
|
+
const probe = createAuthority(storage);
|
|
304
|
+
|
|
305
|
+
const first = probe.authority.run(command("run-1", "first"));
|
|
306
|
+
await probe.handle("run-1").started;
|
|
307
|
+
const second = probe.authority.run(
|
|
308
|
+
command("run-2", "second", {
|
|
309
|
+
lane: "user",
|
|
310
|
+
supersedes: { runId: "run-1" },
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
await first;
|
|
314
|
+
probe.handle("run-2").finish();
|
|
315
|
+
await second;
|
|
316
|
+
|
|
317
|
+
// The replacement was handed everything the superseded Turn made durable:
|
|
318
|
+
// what it said, and the fact that it ended cancelled and why.
|
|
319
|
+
const replacementInput = probe.observed.find(
|
|
320
|
+
(input) => input.command.runId === "run-2",
|
|
321
|
+
);
|
|
322
|
+
if (!replacementInput) throw new Error("the replacement Turn never ran");
|
|
323
|
+
const kinds = replacementInput.previousEvents.map((event) => event.type);
|
|
324
|
+
expect(kinds).toContain("assistant/message");
|
|
325
|
+
expect(kinds).toContain("turn/end");
|
|
326
|
+
// And it starts *after* them: `previousEventCount` is recomputed when the
|
|
327
|
+
// queued Turn is promoted, not when it was admitted.
|
|
328
|
+
expect(storedRun(storage, "run-2").previousEventCount).toBe(
|
|
329
|
+
replacementInput.previousEvents.length,
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("the superseded settlement writes the Package's durable note", async () => {
|
|
334
|
+
const storage = new MemoryStorage();
|
|
335
|
+
const probe = createAuthority(storage);
|
|
336
|
+
|
|
337
|
+
const first = probe.authority.run(command("run-1", "first"));
|
|
338
|
+
await probe.handle("run-1").started;
|
|
339
|
+
const second = probe.authority.run(
|
|
340
|
+
command("run-2", "second", {
|
|
341
|
+
lane: "user",
|
|
342
|
+
supersedes: { runId: "run-1" },
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
await first;
|
|
346
|
+
probe.handle("run-2").finish();
|
|
347
|
+
await second;
|
|
348
|
+
|
|
349
|
+
expect(probe.supersededRecordRuns).toEqual(["run-1"]);
|
|
350
|
+
expect(storage.values.get("superseded-note:run-1")).toEqual({
|
|
351
|
+
runId: "run-1",
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("a Turn that has not dispatched a model request is left alone", () => {
|
|
357
|
+
test("the new message queues and runs after it", async () => {
|
|
358
|
+
const storage = new MemoryStorage();
|
|
359
|
+
const probe = createAuthority(storage, { dispatch: () => false });
|
|
360
|
+
|
|
361
|
+
const first = probe.authority.run(command("run-1", "first"));
|
|
362
|
+
await probe.handle("run-1").started;
|
|
363
|
+
|
|
364
|
+
const second = probe.authority.run(
|
|
365
|
+
command("run-2", "second", {
|
|
366
|
+
lane: "user",
|
|
367
|
+
supersedes: { runId: "run-1" },
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
await admitted();
|
|
371
|
+
// No interrupt was signalled: there is no durable checkpoint to lose.
|
|
372
|
+
expect(probe.interrupts).toEqual([]);
|
|
373
|
+
expect(storage.values.get("pending-run")).toBe("run-2");
|
|
374
|
+
expect(storedRun(storage, "run-1").supersededAt).toBeUndefined();
|
|
375
|
+
|
|
376
|
+
probe.handle("run-1").finish();
|
|
377
|
+
expect(await first).toMatchObject({ text: "done: first" });
|
|
378
|
+
probe.handle("run-2").finish();
|
|
379
|
+
expect(await second).toMatchObject({ text: "done: second" });
|
|
380
|
+
|
|
381
|
+
expect(storedRun(storage, "run-1").status).toBe("completed");
|
|
382
|
+
expect(storedRun(storage, "run-2").status).toBe("completed");
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe("several messages in quick succession", () => {
|
|
387
|
+
test("each earlier Turn is terminal, the last one runs, order is kept", async () => {
|
|
388
|
+
const storage = new MemoryStorage();
|
|
389
|
+
const probe = createAuthority(storage);
|
|
390
|
+
|
|
391
|
+
const first = probe.authority.run(command("run-1", "one"));
|
|
392
|
+
await probe.handle("run-1").started;
|
|
393
|
+
const second = probe.authority.run(
|
|
394
|
+
command("run-2", "two", {
|
|
395
|
+
lane: "user",
|
|
396
|
+
supersedes: { runId: "run-1" },
|
|
397
|
+
}),
|
|
398
|
+
);
|
|
399
|
+
// Sent before the object has finished admitting the one before it. The
|
|
400
|
+
// admissions still serialize, so the last message wins.
|
|
401
|
+
const third = probe.authority.run(
|
|
402
|
+
command("run-3", "three", {
|
|
403
|
+
lane: "user",
|
|
404
|
+
supersedes: { runId: "run-2" },
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
await admitted();
|
|
408
|
+
await first;
|
|
409
|
+
await second;
|
|
410
|
+
probe.handle("run-3").finish();
|
|
411
|
+
await third;
|
|
412
|
+
|
|
413
|
+
expect(storedRun(storage, "run-1").status).toBe("superseded");
|
|
414
|
+
// The one that never started is terminal too, and appended no event.
|
|
415
|
+
const skipped = storedRun(storage, "run-2");
|
|
416
|
+
expect(skipped.status).toBe("superseded");
|
|
417
|
+
expect(skipped.supersededBy).toBe("run-3");
|
|
418
|
+
expect(skipped.events).toEqual([]);
|
|
419
|
+
expect(storedRun(storage, "run-3").status).toBe("completed");
|
|
420
|
+
// Only the two Turns that ran ever reached the Package, in order.
|
|
421
|
+
expect(probe.observed.map((input) => input.command.text)).toEqual([
|
|
422
|
+
"one",
|
|
423
|
+
"three",
|
|
424
|
+
]);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe("supersede intent that names no run", () => {
|
|
429
|
+
test("still replaces whatever is active", async () => {
|
|
430
|
+
const storage = new MemoryStorage();
|
|
431
|
+
const probe = createAuthority(storage);
|
|
432
|
+
|
|
433
|
+
const first = probe.authority.run(command("run-1", "first"));
|
|
434
|
+
await probe.handle("run-1").started;
|
|
435
|
+
|
|
436
|
+
// The composer sent before it had observed its own run — a person typing
|
|
437
|
+
// faster than the client polls. The intent is there; the provenance is
|
|
438
|
+
// not, and the Bot supersedes whatever is actually active regardless.
|
|
439
|
+
const second = probe.authority.run(
|
|
440
|
+
command("run-2", "second", { lane: "user", supersedes: {} }),
|
|
441
|
+
);
|
|
442
|
+
await first;
|
|
443
|
+
probe.handle("run-2").finish();
|
|
444
|
+
await second;
|
|
445
|
+
|
|
446
|
+
expect(storedRun(storage, "run-1").status).toBe("superseded");
|
|
447
|
+
expect(storedRun(storage, "run-1").supersededBy).toBe("run-2");
|
|
448
|
+
expect(storedRun(storage, "run-2").status).toBe("completed");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("a replayed command replays and never interrupts a second Turn", async () => {
|
|
452
|
+
const storage = new MemoryStorage();
|
|
453
|
+
const probe = createAuthority(storage);
|
|
454
|
+
const superseding = command("run-2", "second", {
|
|
455
|
+
lane: "user",
|
|
456
|
+
supersedes: {},
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const first = probe.authority.run(command("run-1", "first"));
|
|
460
|
+
await probe.handle("run-1").started;
|
|
461
|
+
const second = probe.authority.run(superseding);
|
|
462
|
+
await first;
|
|
463
|
+
probe.handle("run-2").finish();
|
|
464
|
+
await second;
|
|
465
|
+
|
|
466
|
+
// The same command again — a retried POST. The intent is in its
|
|
467
|
+
// fingerprint, so this is the same command, and a replay reads back the
|
|
468
|
+
// Turn it already produced rather than interrupting the one now running.
|
|
469
|
+
const third = probe.authority.run(command("run-3", "third"));
|
|
470
|
+
await probe.handle("run-3").started;
|
|
471
|
+
const replay = await probe.authority.run(superseding);
|
|
472
|
+
expect(replay.runId).toBe("run-2");
|
|
473
|
+
expect(storedRun(storage, "run-3").supersededAt).toBeUndefined();
|
|
474
|
+
|
|
475
|
+
probe.handle("run-3").finish();
|
|
476
|
+
await third;
|
|
477
|
+
expect(storedRun(storage, "run-3").status).toBe("completed");
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe("a background admission never supersedes", () => {
|
|
482
|
+
test("it is refused exactly as a second command always was", async () => {
|
|
483
|
+
const storage = new MemoryStorage();
|
|
484
|
+
const probe = createAuthority(storage);
|
|
485
|
+
|
|
486
|
+
const first = probe.authority.run(command("run-1", "first"));
|
|
487
|
+
await probe.handle("run-1").started;
|
|
488
|
+
|
|
489
|
+
await expect(
|
|
490
|
+
probe.authority.run(
|
|
491
|
+
command("run-2", "firing", {
|
|
492
|
+
turnType: "automation",
|
|
493
|
+
supersedes: { runId: "run-1" },
|
|
494
|
+
}),
|
|
495
|
+
),
|
|
496
|
+
).rejects.toThrow(/bot already has an active run/);
|
|
497
|
+
// And a user-lane command with no supersede intent is refused too: an
|
|
498
|
+
// interrupt is explicit or it does not happen.
|
|
499
|
+
await expect(
|
|
500
|
+
probe.authority.run(command("run-3", "second")),
|
|
501
|
+
).rejects.toThrow(/bot already has an active run/);
|
|
502
|
+
|
|
503
|
+
probe.handle("run-1").finish();
|
|
504
|
+
await first;
|
|
505
|
+
expect(probe.interrupts).toEqual([]);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("the lane a Turn was admitted on is durable", async () => {
|
|
509
|
+
const storage = new MemoryStorage();
|
|
510
|
+
const probe = createAuthority(storage);
|
|
511
|
+
const first = probe.authority.run(command("run-1", "first"));
|
|
512
|
+
await probe.handle("run-1").started;
|
|
513
|
+
probe.handle("run-1").finish();
|
|
514
|
+
await first;
|
|
515
|
+
|
|
516
|
+
// A chat Turn's lane is what its recorded turn type already says, so no
|
|
517
|
+
// stored byte changed to carry it.
|
|
518
|
+
const chat = storedRun(storage, "run-1");
|
|
519
|
+
expect(chat.admission).toBeUndefined();
|
|
520
|
+
expect(storedRunLaneV1(chat)).toBe("user");
|
|
521
|
+
expect(
|
|
522
|
+
storedRunLaneV1({
|
|
523
|
+
admission: { schemaVersion: 1, turnType: "automation" },
|
|
524
|
+
}),
|
|
525
|
+
).toBe("background");
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
describe("eviction between the two Turns", () => {
|
|
530
|
+
/**
|
|
531
|
+
* Exactly what the object holds at the moment between the superseded Turn
|
|
532
|
+
* terminalizing and the queued one starting: no active run, a queued run
|
|
533
|
+
* record, and the pending marker naming it. Nothing else survives an
|
|
534
|
+
* eviction, so nothing else is given to the object that comes back.
|
|
535
|
+
*/
|
|
536
|
+
async function evictedAfterSupersede(): Promise<MemoryStorage> {
|
|
537
|
+
const storage = new MemoryStorage();
|
|
538
|
+
const probe = createAuthority(storage);
|
|
539
|
+
const first = probe.authority.run(command("run-1", "first"));
|
|
540
|
+
await probe.handle("run-1").started;
|
|
541
|
+
const second = probe.authority.run(
|
|
542
|
+
command("run-2", "second", {
|
|
543
|
+
lane: "user",
|
|
544
|
+
supersedes: { runId: "run-1" },
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
547
|
+
await first.catch(() => undefined);
|
|
548
|
+
// The caller that was waiting for the queued Turn is gone with the object.
|
|
549
|
+
second.catch(() => undefined);
|
|
550
|
+
const evicted = new MemoryStorage();
|
|
551
|
+
for (const [key, value] of storage.values) {
|
|
552
|
+
evicted.values.set(key, structuredClone(value));
|
|
553
|
+
}
|
|
554
|
+
return evicted;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
test("a reconstructed object starts the queued Turn exactly once", async () => {
|
|
558
|
+
const storage = await evictedAfterSupersede();
|
|
559
|
+
expect(storage.values.get("pending-run")).toBe("run-2");
|
|
560
|
+
expect(storage.values.get("active-run")).toBeUndefined();
|
|
561
|
+
expect(storedRun(storage, "run-1").status).toBe("superseded");
|
|
562
|
+
expect(storedRun(storage, "run-2").phase).toBe("queued");
|
|
563
|
+
|
|
564
|
+
const restarted = createAuthority(storage);
|
|
565
|
+
const resumed = restarted.authority.recoverActiveRun();
|
|
566
|
+
await restarted.handle("run-2").started;
|
|
567
|
+
restarted.handle("run-2").finish();
|
|
568
|
+
await resumed;
|
|
569
|
+
|
|
570
|
+
expect(restarted.observed.map((input) => input.command.runId)).toEqual([
|
|
571
|
+
"run-2",
|
|
572
|
+
]);
|
|
573
|
+
expect(storedRun(storage, "run-2").status).toBe("completed");
|
|
574
|
+
// A second recovery pass starts nothing: the queue is empty.
|
|
575
|
+
await restarted.authority.recoverActiveRun();
|
|
576
|
+
expect(restarted.observed).toHaveLength(1);
|
|
577
|
+
});
|
|
578
|
+
});
|