@frockbot/kernel-do 0.3.15 → 0.3.17
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 +6 -1
- package/src/applets.ts +3 -1
- package/src/authority.ts +319 -159
- package/src/conversations.test.ts +7 -9
- package/src/index.ts +1 -0
- package/src/model-timeout-settlement.test.ts +8 -8
- package/src/run-liveness.test.ts +2 -1
- package/src/run-records.ts +142 -6
- package/src/run-recovery.ts +2 -1
- package/src/run-terminal-open-turn.test.ts +9 -4
- package/src/run-terminal.ts +61 -44
- package/src/session-event-log.test.ts +267 -0
- package/src/session-event-log.ts +731 -0
- package/src/session-log-size.test.ts +250 -0
- package/src/storage-keys.ts +12 -0
- package/src/turn-admission.test.ts +54 -9
- package/src/turn-supersede.test.ts +161 -8
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
bootstrapGeneration,
|
|
4
|
+
type CompositionGenerationV1,
|
|
5
|
+
} from "@frockbot/kernel-composition/generation";
|
|
6
|
+
import { Session, type SessionEvent } from "@frockbot/kernel-contracts";
|
|
7
|
+
import {
|
|
8
|
+
BotDurableAuthority,
|
|
9
|
+
type BotDurableAuthorityHooks,
|
|
10
|
+
} from "./authority.ts";
|
|
11
|
+
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
12
|
+
import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.ts";
|
|
13
|
+
|
|
14
|
+
const SQLITE_VALUE_LIMIT_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
const LARGE_REQUEST_BYTES = 80_000;
|
|
16
|
+
const STEPS = 30;
|
|
17
|
+
const SESSION_ID = "user-1:primary";
|
|
18
|
+
|
|
19
|
+
const codec = createStoredRunCodecV1<undefined>({
|
|
20
|
+
decodeRunId: (value) => String(value),
|
|
21
|
+
decodeConfigurationSnapshot: () => undefined,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** A storage double that enforces Durable Object SQLite's per-value ceiling. */
|
|
25
|
+
class SqliteLimitedMemoryStorage extends MemoryStorage {
|
|
26
|
+
readonly rejectedKeys: string[] = [];
|
|
27
|
+
|
|
28
|
+
override put(
|
|
29
|
+
key: string | Record<string, unknown>,
|
|
30
|
+
value?: unknown,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const entries =
|
|
33
|
+
typeof key === "string" ? [[key, value] as const] : Object.entries(key);
|
|
34
|
+
for (const [entryKey, entry] of entries) {
|
|
35
|
+
const bytes = new TextEncoder().encode(JSON.stringify(entry)).byteLength;
|
|
36
|
+
if (bytes > SQLITE_VALUE_LIMIT_BYTES) {
|
|
37
|
+
this.rejectedKeys.push(entryKey);
|
|
38
|
+
throw new Error("string or blob too big: SQLITE_TOOBIG");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return super.put(key, value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override async transaction<T>(
|
|
45
|
+
callback: (storage: MemoryStorage) => Promise<T>,
|
|
46
|
+
): Promise<T> {
|
|
47
|
+
const before = structuredClone([...this.values.entries()]);
|
|
48
|
+
try {
|
|
49
|
+
return await callback(this);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
this.values.clear();
|
|
52
|
+
for (const [key, value] of before) this.values.set(key, value);
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bootstrap(): Promise<CompositionGenerationV1> {
|
|
59
|
+
return bootstrapGeneration(
|
|
60
|
+
[
|
|
61
|
+
{
|
|
62
|
+
packageId: "shell",
|
|
63
|
+
specifier: "@frockbot/plugin-shell",
|
|
64
|
+
version: "0.0.1",
|
|
65
|
+
manifest: { id: "shell", version: "0.0.1" },
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
{ createdAt: "2026-09-04T00:00:00.000Z" },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function legacyEvents(): SessionEvent[] {
|
|
73
|
+
const session = new Session(SESSION_ID, () => {});
|
|
74
|
+
session.appendBatch([
|
|
75
|
+
{ type: "turn/start", turn: 1 },
|
|
76
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
77
|
+
{
|
|
78
|
+
type: "user/message",
|
|
79
|
+
turn: 1,
|
|
80
|
+
step: 1,
|
|
81
|
+
messageId: "legacy-message",
|
|
82
|
+
text: "Earlier work",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: "model/request",
|
|
86
|
+
turn: 1,
|
|
87
|
+
step: 1,
|
|
88
|
+
request: {
|
|
89
|
+
requestId: "legacy-request",
|
|
90
|
+
provider: "fake",
|
|
91
|
+
model: "large-context",
|
|
92
|
+
system: "p".repeat(1_900_000),
|
|
93
|
+
messages: [{ role: "user", content: "Earlier work" }],
|
|
94
|
+
tools: [],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: "assistant/message",
|
|
99
|
+
turn: 1,
|
|
100
|
+
step: 1,
|
|
101
|
+
requestId: "legacy-request",
|
|
102
|
+
text: "Done earlier.",
|
|
103
|
+
toolCalls: [],
|
|
104
|
+
},
|
|
105
|
+
{ type: "step/end", turn: 1, step: 1, outcome: "completed" },
|
|
106
|
+
{ type: "turn/end", turn: 1, outcome: "completed" },
|
|
107
|
+
]);
|
|
108
|
+
return [...session.events];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function legacyRun(events: SessionEvent[]): StoredRunV1<undefined> {
|
|
112
|
+
return {
|
|
113
|
+
runId: "legacy-run",
|
|
114
|
+
commandFingerprint: "legacy-fingerprint",
|
|
115
|
+
sessionId: SESSION_ID,
|
|
116
|
+
acceptedAt: "2026-09-04T00:00:00.000Z",
|
|
117
|
+
input: "Earlier work",
|
|
118
|
+
events,
|
|
119
|
+
effectAdmissions: [],
|
|
120
|
+
status: "completed",
|
|
121
|
+
responseText: "Done earlier.",
|
|
122
|
+
phase: "executing",
|
|
123
|
+
compositionGenerationId: "legacy-generation",
|
|
124
|
+
configurationSnapshot: undefined,
|
|
125
|
+
previousEventCount: 0,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createAuthority(
|
|
130
|
+
storage: SqliteLimitedMemoryStorage,
|
|
131
|
+
): BotDurableAuthority<undefined> {
|
|
132
|
+
const hooks: BotDurableAuthorityHooks<undefined> = {
|
|
133
|
+
resolveAdmissionSnapshot: () => Promise.resolve(undefined),
|
|
134
|
+
bootstrapComposition: () => bootstrap(),
|
|
135
|
+
admittedSnapshot: () => Promise.resolve(undefined),
|
|
136
|
+
executeTurn: async (input) => {
|
|
137
|
+
let seq = input.previousEvents.length;
|
|
138
|
+
const events: SessionEvent[] = [];
|
|
139
|
+
const persist = async (
|
|
140
|
+
batch: Array<Omit<SessionEvent, "seq" | "timestamp">>,
|
|
141
|
+
) => {
|
|
142
|
+
const stamped = batch.map(
|
|
143
|
+
(event) =>
|
|
144
|
+
({
|
|
145
|
+
...event,
|
|
146
|
+
seq: seq++,
|
|
147
|
+
timestamp: "2026-09-04T00:01:00.000Z",
|
|
148
|
+
}) as SessionEvent,
|
|
149
|
+
);
|
|
150
|
+
events.push(...stamped);
|
|
151
|
+
await input.persistSessionEvents(input.command.sessionId, stamped);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
await persist([{ type: "turn/start", turn: 2 } as never]);
|
|
155
|
+
for (let step = 1; step <= STEPS; step += 1) {
|
|
156
|
+
await persist([
|
|
157
|
+
{ type: "step/start", turn: 2, step } as never,
|
|
158
|
+
...(step === 1
|
|
159
|
+
? [
|
|
160
|
+
{
|
|
161
|
+
type: "user/message",
|
|
162
|
+
turn: 2,
|
|
163
|
+
step,
|
|
164
|
+
messageId: "message-2",
|
|
165
|
+
text: input.command.text,
|
|
166
|
+
} as never,
|
|
167
|
+
]
|
|
168
|
+
: []),
|
|
169
|
+
{
|
|
170
|
+
type: "model/request",
|
|
171
|
+
turn: 2,
|
|
172
|
+
step,
|
|
173
|
+
request: {
|
|
174
|
+
requestId: `request-${step}`,
|
|
175
|
+
provider: "fake",
|
|
176
|
+
model: "large-context",
|
|
177
|
+
system: "s".repeat(LARGE_REQUEST_BYTES),
|
|
178
|
+
messages: [{ role: "user", content: input.command.text }],
|
|
179
|
+
tools: [],
|
|
180
|
+
},
|
|
181
|
+
} as never,
|
|
182
|
+
{
|
|
183
|
+
type: "assistant/message",
|
|
184
|
+
turn: 2,
|
|
185
|
+
step,
|
|
186
|
+
requestId: `request-${step}`,
|
|
187
|
+
text: step === STEPS ? "All done." : "",
|
|
188
|
+
toolCalls: [],
|
|
189
|
+
} as never,
|
|
190
|
+
{ type: "step/end", turn: 2, step, outcome: "completed" } as never,
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
await persist([
|
|
194
|
+
{ type: "turn/end", turn: 2, outcome: "completed" } as never,
|
|
195
|
+
]);
|
|
196
|
+
return { runId: input.command.runId, text: "All done.", events };
|
|
197
|
+
},
|
|
198
|
+
notification: () => undefined,
|
|
199
|
+
scheduledDeadlines: () => Promise.resolve([]),
|
|
200
|
+
scheduledWorkInFlight: () => false,
|
|
201
|
+
deferScheduledWork: () => Promise.resolve(),
|
|
202
|
+
settleScheduledWork: () => Promise.resolve(),
|
|
203
|
+
};
|
|
204
|
+
return new BotDurableAuthority<undefined>({
|
|
205
|
+
state: { storage } as unknown as DurableObjectState,
|
|
206
|
+
codec,
|
|
207
|
+
hooks,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
describe("a long Turn on a legacy near-limit Session", () => {
|
|
212
|
+
test("migrates to bounded values and completes thirty large model steps", async () => {
|
|
213
|
+
const storage = new SqliteLimitedMemoryStorage();
|
|
214
|
+
const previous = legacyEvents();
|
|
215
|
+
const legacyBytes = new TextEncoder().encode(
|
|
216
|
+
JSON.stringify(previous),
|
|
217
|
+
).byteLength;
|
|
218
|
+
expect(legacyBytes).toBeGreaterThan(1_900_000);
|
|
219
|
+
expect(legacyBytes).toBeLessThan(SQLITE_VALUE_LIMIT_BYTES);
|
|
220
|
+
// Seed through the backing map because these are values an older deploy
|
|
221
|
+
// already wrote. Every write made by the code under test is size-checked.
|
|
222
|
+
storage.values.set("latest-events", structuredClone(previous));
|
|
223
|
+
storage.values.set("run:legacy-run", structuredClone(legacyRun(previous)));
|
|
224
|
+
|
|
225
|
+
const completion = await createAuthority(storage).run({
|
|
226
|
+
userId: "user-1",
|
|
227
|
+
botId: "primary",
|
|
228
|
+
runId: "large-run",
|
|
229
|
+
sessionId: SESSION_ID,
|
|
230
|
+
acceptedAt: "2026-09-04T00:01:00.000Z",
|
|
231
|
+
text: "Keep going",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const stored = codec.require(storage.values.get("run:large-run"));
|
|
235
|
+
expect(storage.rejectedKeys).not.toContain("run:large-run");
|
|
236
|
+
expect(stored.failure ?? "").not.toContain("SQLITE_TOOBIG");
|
|
237
|
+
expect(completion.text).toBe("All done.");
|
|
238
|
+
expect(
|
|
239
|
+
completion.events.filter((event) => event.type === "model/request"),
|
|
240
|
+
).toHaveLength(STEPS);
|
|
241
|
+
expect(storage.values.has("latest-events")).toBe(false);
|
|
242
|
+
expect(
|
|
243
|
+
[...storage.values.values()].every(
|
|
244
|
+
(value) =>
|
|
245
|
+
new TextEncoder().encode(JSON.stringify(value)).byteLength <=
|
|
246
|
+
SQLITE_VALUE_LIMIT_BYTES,
|
|
247
|
+
),
|
|
248
|
+
).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|
package/src/storage-keys.ts
CHANGED
|
@@ -16,7 +16,15 @@ export const ACTIVE_RUN_KEY = "active-run";
|
|
|
16
16
|
* working through a backlog of things the User has already replaced.
|
|
17
17
|
*/
|
|
18
18
|
export const PENDING_RUN_KEY = "pending-run";
|
|
19
|
+
/** Agent-lane Turns wait FIFO behind conversational work. */
|
|
20
|
+
export const PENDING_AGENT_RUN_PREFIX = "pending-agent-run:";
|
|
21
|
+
/** A Bot cannot accumulate an unbounded cross-Bot inbox. */
|
|
22
|
+
export const MAX_PENDING_AGENT_RUNS_V1 = 32;
|
|
23
|
+
/** Legacy pre-ADR-0033 Session value, read only for transparent migration. */
|
|
19
24
|
export const LATEST_EVENTS_KEY = "latest-events";
|
|
25
|
+
export const SESSION_EVENT_LOG_INDEX_PREFIX = "session-events:index:";
|
|
26
|
+
export const SESSION_EVENT_LOG_PAGE_PREFIX = "session-events:page:";
|
|
27
|
+
export const SESSION_EVENT_PAYLOAD_PREFIX = "session-events:payload:";
|
|
20
28
|
export const IDENTITY_KEY = "identity";
|
|
21
29
|
export const NOTIFICATION_PREFIX = "notification:";
|
|
22
30
|
export const COMPOSITION_CURRENT_KEY = "composition:current";
|
|
@@ -80,6 +88,10 @@ export function runIndexKey(acceptedAt: string, runId: string): string {
|
|
|
80
88
|
return `${RUN_INDEX_PREFIX}${acceptedAt}:${runId}`;
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
export function pendingAgentRunKey(acceptedAt: string, runId: string): string {
|
|
92
|
+
return `${PENDING_AGENT_RUN_PREFIX}${acceptedAt}:${runId}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
export function compositionGenerationKey(generationId: string): string {
|
|
84
96
|
return `${COMPOSITION_GENERATION_PREFIX}${generationId}`;
|
|
85
97
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type BotTurnExecutionInput,
|
|
11
11
|
} from "./authority.ts";
|
|
12
12
|
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
13
|
+
import { SessionEventLog } from "./session-event-log.ts";
|
|
13
14
|
import {
|
|
14
15
|
botTurnCommandFingerprintV1,
|
|
15
16
|
createStoredRunCodecV1,
|
|
@@ -36,6 +37,13 @@ const SUBAGENT_ORIGIN: StoredRunOriginV1 = {
|
|
|
36
37
|
parentRunId: "run-parent",
|
|
37
38
|
};
|
|
38
39
|
|
|
40
|
+
const BOT_ORIGIN: StoredRunOriginV1 = {
|
|
41
|
+
kind: "bot",
|
|
42
|
+
fromBotId: "researcher",
|
|
43
|
+
fromBotName: "Researcher",
|
|
44
|
+
messageId: "agent-message-1",
|
|
45
|
+
};
|
|
46
|
+
|
|
39
47
|
const codec = createStoredRunCodecV1<undefined>({
|
|
40
48
|
decodeRunId: (value) => value as string,
|
|
41
49
|
decodeConfigurationSnapshot: () => undefined,
|
|
@@ -157,6 +165,22 @@ describe("the admission record names what produced the Turn", () => {
|
|
|
157
165
|
expect(codec.require(structuredClone(decoded))).toEqual(decoded);
|
|
158
166
|
});
|
|
159
167
|
|
|
168
|
+
test("round-trips an agent Turn and its sending Bot", () => {
|
|
169
|
+
const decoded = codec.require(
|
|
170
|
+
legacyRun({
|
|
171
|
+
admission: {
|
|
172
|
+
schemaVersion: 1,
|
|
173
|
+
turnType: "agent",
|
|
174
|
+
origin: BOT_ORIGIN,
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(decoded.admission?.origin).toEqual(BOT_ORIGIN);
|
|
180
|
+
expect(storedRunTurnTypeV1(decoded)).toBe("agent");
|
|
181
|
+
expect(codec.require(structuredClone(decoded))).toEqual(decoded);
|
|
182
|
+
});
|
|
183
|
+
|
|
160
184
|
test("each origin kind has its own exact fields, and cannot borrow another's", () => {
|
|
161
185
|
const withOrigin = (origin: unknown) =>
|
|
162
186
|
legacyRun({
|
|
@@ -359,9 +383,14 @@ function command(runId: string, turnType?: TurnTypeV1) {
|
|
|
359
383
|
};
|
|
360
384
|
}
|
|
361
385
|
|
|
362
|
-
function admittedEvent(storage: MemoryStorage, runId: string) {
|
|
386
|
+
async function admittedEvent(storage: MemoryStorage, runId: string) {
|
|
363
387
|
const run = storage.values.get(`run:${runId}`) as StoredRunV1<undefined>;
|
|
364
|
-
|
|
388
|
+
const events = await new SessionEventLog(storage).readRange(
|
|
389
|
+
run.sessionId,
|
|
390
|
+
run.eventRange!.startSeq,
|
|
391
|
+
run.eventRange!.endSeq,
|
|
392
|
+
);
|
|
393
|
+
return events.find((event) => event.type === "turn/admission");
|
|
365
394
|
}
|
|
366
395
|
|
|
367
396
|
describe("an admitted Turn re-mounts on its recorded turn type", () => {
|
|
@@ -389,11 +418,27 @@ describe("an admitted Turn re-mounts on its recorded turn type", () => {
|
|
|
389
418
|
turnType: "automation",
|
|
390
419
|
});
|
|
391
420
|
expect(probe.observed[0]?.command.turnType).toBe("automation");
|
|
392
|
-
expect(admittedEvent(storage, "run-1")).toMatchObject({
|
|
421
|
+
expect(await admittedEvent(storage, "run-1")).toMatchObject({
|
|
393
422
|
turnType: "automation",
|
|
394
423
|
});
|
|
395
424
|
});
|
|
396
425
|
|
|
426
|
+
test("an agent Turn defaults to the agent admission lane", async () => {
|
|
427
|
+
const storage = new MemoryStorage();
|
|
428
|
+
const probe = createAuthority(storage);
|
|
429
|
+
|
|
430
|
+
await probe.authority.run(command("run-agent", "agent"));
|
|
431
|
+
|
|
432
|
+
const stored = storage.values.get(
|
|
433
|
+
"run:run-agent",
|
|
434
|
+
) as StoredRunV1<undefined>;
|
|
435
|
+
expect(stored.admission).toEqual({
|
|
436
|
+
schemaVersion: 1,
|
|
437
|
+
turnType: "agent",
|
|
438
|
+
});
|
|
439
|
+
expect(probe.observed[0]?.command.turnType).toBe("agent");
|
|
440
|
+
});
|
|
441
|
+
|
|
397
442
|
test("after eviction the resumed run re-mounts on the recorded type", async () => {
|
|
398
443
|
const storage = new MemoryStorage();
|
|
399
444
|
const probe = createAuthority(storage);
|
|
@@ -407,17 +452,17 @@ describe("an admitted Turn re-mounts on its recorded turn type", () => {
|
|
|
407
452
|
status: "running",
|
|
408
453
|
phase: "executing",
|
|
409
454
|
responseText: undefined,
|
|
410
|
-
|
|
455
|
+
eventRange: { startSeq: 0, endSeq: 0 },
|
|
411
456
|
});
|
|
412
457
|
storage.values.set("active-run", "run-1");
|
|
413
458
|
storage.values.set("identity", { userId: "user-1", botId: "primary" });
|
|
414
|
-
storage.
|
|
459
|
+
await new SessionEventLog(storage).rewrite("user-1:primary", []);
|
|
415
460
|
|
|
416
461
|
const resumed = createAuthority(storage);
|
|
417
462
|
await resumed.authority.recoverActiveRun();
|
|
418
463
|
|
|
419
464
|
expect(resumed.observed.at(-1)?.command.turnType).toBe("automation");
|
|
420
|
-
expect(admittedEvent(storage, "run-1")).toMatchObject({
|
|
465
|
+
expect(await admittedEvent(storage, "run-1")).toMatchObject({
|
|
421
466
|
turnType: "automation",
|
|
422
467
|
});
|
|
423
468
|
});
|
|
@@ -433,17 +478,17 @@ describe("an admitted Turn re-mounts on its recorded turn type", () => {
|
|
|
433
478
|
status: "running",
|
|
434
479
|
phase: "executing",
|
|
435
480
|
responseText: undefined,
|
|
436
|
-
|
|
481
|
+
eventRange: { startSeq: 0, endSeq: 0 },
|
|
437
482
|
});
|
|
438
483
|
storage.values.set("active-run", "run-1");
|
|
439
484
|
storage.values.set("identity", { userId: "user-1", botId: "primary" });
|
|
440
|
-
storage.
|
|
485
|
+
await new SessionEventLog(storage).rewrite("user-1:primary", []);
|
|
441
486
|
|
|
442
487
|
const resumed = createAuthority(storage);
|
|
443
488
|
await resumed.authority.recoverActiveRun();
|
|
444
489
|
|
|
445
490
|
expect(resumed.observed.at(-1)?.command.turnType).toBe("chat");
|
|
446
|
-
expect(admittedEvent(storage, "run-1")).toMatchObject({
|
|
491
|
+
expect(await admittedEvent(storage, "run-1")).toMatchObject({
|
|
447
492
|
turnType: "chat",
|
|
448
493
|
});
|
|
449
494
|
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type OwnedBotTurnCommand,
|
|
16
16
|
} from "./authority.ts";
|
|
17
17
|
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
18
|
+
import { SessionEventLog } from "./session-event-log.ts";
|
|
18
19
|
import {
|
|
19
20
|
BotTurnReconciliationRequiredError,
|
|
20
21
|
BotTurnRecoveryRequiredError,
|
|
@@ -24,6 +25,10 @@ import {
|
|
|
24
25
|
storedRunLaneV1,
|
|
25
26
|
type StoredRunV1,
|
|
26
27
|
} from "./run-records.ts";
|
|
28
|
+
import {
|
|
29
|
+
MAX_PENDING_AGENT_RUNS_V1,
|
|
30
|
+
pendingAgentRunKey,
|
|
31
|
+
} from "./storage-keys.ts";
|
|
27
32
|
|
|
28
33
|
const codec = createStoredRunCodecV1<undefined>({
|
|
29
34
|
decodeRunId: (value) => value as string,
|
|
@@ -92,8 +97,13 @@ interface Deferred<T> {
|
|
|
92
97
|
* requests never reach a Durable Object in the same microtask, and the tests
|
|
93
98
|
* that send two messages are describing two requests.
|
|
94
99
|
*/
|
|
95
|
-
function admitted(): Promise<void> {
|
|
96
|
-
|
|
100
|
+
async function admitted(): Promise<void> {
|
|
101
|
+
// Admission now also hashes any legacy event payloads it migrates. Let the
|
|
102
|
+
// Web Crypto promises and the Durable Object transaction both drain before
|
|
103
|
+
// inspecting the durable queue.
|
|
104
|
+
for (let turn = 0; turn < 8; turn += 1) {
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
106
|
+
}
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
function deferred<T>(): Deferred<T> {
|
|
@@ -357,7 +367,9 @@ describe("a user message supersedes the running Turn", () => {
|
|
|
357
367
|
probe.handle("run-2").finish();
|
|
358
368
|
const result = await second;
|
|
359
369
|
|
|
360
|
-
const superseded =
|
|
370
|
+
const superseded = await probe.authority.readRun("run-1");
|
|
371
|
+
expect(superseded).toBeDefined();
|
|
372
|
+
if (superseded === undefined) throw new Error("run-1 was not stored");
|
|
361
373
|
expect(superseded.status).toBe("superseded");
|
|
362
374
|
expect(superseded.supersededBy).toBe("run-2");
|
|
363
375
|
expect(turnEndOf(superseded)).toMatchObject({
|
|
@@ -604,6 +616,150 @@ describe("a background admission never supersedes", () => {
|
|
|
604
616
|
});
|
|
605
617
|
});
|
|
606
618
|
|
|
619
|
+
describe("the agent lane", () => {
|
|
620
|
+
test("queues behind the active Turn without superseding it", async () => {
|
|
621
|
+
const storage = new MemoryStorage();
|
|
622
|
+
const probe = createAuthority(storage);
|
|
623
|
+
const first = probe.authority.run(command("run-1", "person"));
|
|
624
|
+
await probe.handle("run-1").started;
|
|
625
|
+
|
|
626
|
+
const agent = probe.authority.run(
|
|
627
|
+
command("run-agent", "question", {
|
|
628
|
+
turnType: "agent",
|
|
629
|
+
origin: {
|
|
630
|
+
kind: "bot",
|
|
631
|
+
fromBotId: "researcher",
|
|
632
|
+
fromBotName: "Researcher",
|
|
633
|
+
messageId: "message-1",
|
|
634
|
+
},
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
await admitted();
|
|
638
|
+
expect(probe.interrupts).toEqual([]);
|
|
639
|
+
expect(storedRun(storage, "run-agent")).toMatchObject({
|
|
640
|
+
phase: "queued",
|
|
641
|
+
admission: { turnType: "agent" },
|
|
642
|
+
});
|
|
643
|
+
expect(storedRunLaneV1(storedRun(storage, "run-agent"))).toBe("agent");
|
|
644
|
+
|
|
645
|
+
probe.handle("run-1").finish();
|
|
646
|
+
await first;
|
|
647
|
+
await probe.handle("run-agent").started;
|
|
648
|
+
probe.handle("run-agent").finish();
|
|
649
|
+
expect(await agent).toMatchObject({ text: "done: question" });
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
test("runs queued agent Turns FIFO", async () => {
|
|
653
|
+
const storage = new MemoryStorage();
|
|
654
|
+
const probe = createAuthority(storage);
|
|
655
|
+
const active = probe.authority.run(command("run-1", "person"));
|
|
656
|
+
await probe.handle("run-1").started;
|
|
657
|
+
|
|
658
|
+
const firstAgent = probe.authority.run(
|
|
659
|
+
command("run-agent-1", "first agent", { turnType: "agent" }),
|
|
660
|
+
);
|
|
661
|
+
await admitted();
|
|
662
|
+
const secondAgent = probe.authority.run(
|
|
663
|
+
command("run-agent-2", "second agent", { turnType: "agent" }),
|
|
664
|
+
);
|
|
665
|
+
await admitted();
|
|
666
|
+
|
|
667
|
+
probe.handle("run-1").finish();
|
|
668
|
+
await active;
|
|
669
|
+
await probe.handle("run-agent-1").started;
|
|
670
|
+
expect(probe.observed.map(({ command }) => command.runId)).toEqual([
|
|
671
|
+
"run-1",
|
|
672
|
+
"run-agent-1",
|
|
673
|
+
]);
|
|
674
|
+
probe.handle("run-agent-1").finish();
|
|
675
|
+
await firstAgent;
|
|
676
|
+
|
|
677
|
+
await probe.handle("run-agent-2").started;
|
|
678
|
+
probe.handle("run-agent-2").finish();
|
|
679
|
+
await secondAgent;
|
|
680
|
+
expect(probe.observed.map(({ command }) => command.runId)).toEqual([
|
|
681
|
+
"run-1",
|
|
682
|
+
"run-agent-1",
|
|
683
|
+
"run-agent-2",
|
|
684
|
+
]);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("gives a queued User Turn priority over queued agent work", async () => {
|
|
688
|
+
const storage = new MemoryStorage();
|
|
689
|
+
const probe = createAuthority(storage);
|
|
690
|
+
const active = probe.authority.run(command("run-1", "person"));
|
|
691
|
+
await probe.handle("run-1").started;
|
|
692
|
+
const agent = probe.authority.run(
|
|
693
|
+
command("run-agent", "agent", { turnType: "agent" }),
|
|
694
|
+
);
|
|
695
|
+
await admitted();
|
|
696
|
+
|
|
697
|
+
const user = probe.authority.run(
|
|
698
|
+
command("run-user", "next message", {
|
|
699
|
+
lane: "user",
|
|
700
|
+
supersedes: { runId: "run-1" },
|
|
701
|
+
}),
|
|
702
|
+
);
|
|
703
|
+
await active;
|
|
704
|
+
await probe.handle("run-user").started;
|
|
705
|
+
expect(probe.observed.map(({ command }) => command.runId)).toEqual([
|
|
706
|
+
"run-1",
|
|
707
|
+
"run-user",
|
|
708
|
+
]);
|
|
709
|
+
probe.handle("run-user").finish();
|
|
710
|
+
await user;
|
|
711
|
+
|
|
712
|
+
await probe.handle("run-agent").started;
|
|
713
|
+
probe.handle("run-agent").finish();
|
|
714
|
+
await agent;
|
|
715
|
+
expect(probe.observed.map(({ command }) => command.runId)).toEqual([
|
|
716
|
+
"run-1",
|
|
717
|
+
"run-user",
|
|
718
|
+
"run-agent",
|
|
719
|
+
]);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("refuses agent admission while the active Turn awaits reconciliation", async () => {
|
|
723
|
+
const storage = new MemoryStorage();
|
|
724
|
+
const probe = createAuthority(storage, { parkOnRelease: () => true });
|
|
725
|
+
const active = probe.authority.run(command("run-1", "person"));
|
|
726
|
+
await probe.handle("run-1").started;
|
|
727
|
+
probe.handle("run-1").finish();
|
|
728
|
+
await active.catch(() => undefined);
|
|
729
|
+
|
|
730
|
+
await expect(
|
|
731
|
+
probe.authority.run(command("run-agent", "agent", { turnType: "agent" })),
|
|
732
|
+
).rejects.toThrow(/cannot admit agent work.*requires reconciliation/);
|
|
733
|
+
expect(storage.values.has("run:run-agent")).toBe(false);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("refuses admission when the Bot's bounded agent queue is full", async () => {
|
|
737
|
+
const storage = new MemoryStorage();
|
|
738
|
+
const probe = createAuthority(storage);
|
|
739
|
+
const first = probe.authority.run(command("run-1", "person"));
|
|
740
|
+
await probe.handle("run-1").started;
|
|
741
|
+
for (let index = 0; index < MAX_PENDING_AGENT_RUNS_V1; index += 1) {
|
|
742
|
+
const runId = `queued-${index}`;
|
|
743
|
+
storage.values.set(
|
|
744
|
+
pendingAgentRunKey(
|
|
745
|
+
new Date(Date.UTC(2026, 8, 3, 1, 0, index)).toISOString(),
|
|
746
|
+
runId,
|
|
747
|
+
),
|
|
748
|
+
runId,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
await expect(
|
|
753
|
+
probe.authority.run(
|
|
754
|
+
command("run-past-bound", "question", { turnType: "agent" }),
|
|
755
|
+
),
|
|
756
|
+
).rejects.toThrow(/agent queue is full \(32 Turns\)/);
|
|
757
|
+
|
|
758
|
+
probe.handle("run-1").finish();
|
|
759
|
+
await first;
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
607
763
|
describe("eviction between the two Turns", () => {
|
|
608
764
|
/**
|
|
609
765
|
* Exactly what the object holds at the moment between the superseded Turn
|
|
@@ -681,10 +837,7 @@ describe("a durable log left inside a Turn", () => {
|
|
|
681
837
|
probe.handle("run-1").finish();
|
|
682
838
|
await run;
|
|
683
839
|
|
|
684
|
-
const events = storage.
|
|
685
|
-
type: string;
|
|
686
|
-
turn?: number;
|
|
687
|
-
}>;
|
|
840
|
+
const events = await new SessionEventLog(storage).read("user-1:primary");
|
|
688
841
|
// The orphaned Turn is closed, so the new one starts.
|
|
689
842
|
expect(events[2]).toMatchObject({
|
|
690
843
|
type: "turn/end",
|
|
@@ -738,7 +891,7 @@ describe("a durable log left inside a Turn", () => {
|
|
|
738
891
|
probe.handle("run-1").finish();
|
|
739
892
|
await run;
|
|
740
893
|
|
|
741
|
-
const events = storage.
|
|
894
|
+
const events = await new SessionEventLog(storage).read("user-1:primary");
|
|
742
895
|
// Turn 1 is closed where it was abandoned, not after the Turns that
|
|
743
896
|
// followed it, and the log is resequenced around the insertion.
|
|
744
897
|
expect(
|