@frockbot/kernel-do 0.3.6 → 0.3.7

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.6",
3
+ "version": "0.3.7",
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.6",
16
- "@frockbot/kernel-contracts": "0.3.6",
15
+ "@frockbot/kernel-composition": "0.3.7",
16
+ "@frockbot/kernel-contracts": "0.3.7",
17
17
  "cordis": "4.0.0-rc.8"
18
18
  },
19
19
  "devDependencies": {
package/src/authority.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  latestModelRequestJournalState,
36
36
  planBotRunRecovery,
37
37
  type ProviderReconcilesV1,
38
- repairOrphanedOpenTurnV1,
38
+ repairedSessionLogV1,
39
39
  unresolvedModelRequestFailure,
40
40
  } from "./run-recovery.js";
41
41
  import {
@@ -43,8 +43,20 @@ import {
43
43
  BotTurnRecoveryRequiredError,
44
44
  BotTurnRefusedError,
45
45
  } from "./turn-errors.js";
46
+ import {
47
+ botConversationBaseSessionIdV1,
48
+ conversationSessionIdV1,
49
+ decodeConversationRecordV1,
50
+ decodeStoredConversationV1,
51
+ firstConversationV1,
52
+ type ConversationRecordV1,
53
+ type StoredConversationV1,
54
+ } from "./conversations.js";
46
55
  import {
47
56
  ACTIVE_RUN_KEY,
57
+ CONVERSATION_INDEX_KEY,
58
+ CONVERSATION_KEY,
59
+ MAX_LISTED_CONVERSATIONS,
48
60
  PENDING_RUN_KEY,
49
61
  IDENTITY_KEY,
50
62
  LATEST_EVENTS_KEY,
@@ -220,7 +232,8 @@ export class BotDurableAuthority<Snapshot> {
220
232
  });
221
233
  }
222
234
 
223
- async run(command: OwnedBotTurnCommand): Promise<BotTurnCompletion> {
235
+ async run(input: OwnedBotTurnCommand): Promise<BotTurnCompletion> {
236
+ const command = await this.conversationScopedCommand(input);
224
237
  await this.assertMatchingRunCommand(command);
225
238
  // Recovering whatever this object was left holding must never decide the
226
239
  // fate of a new command. `recoverActiveRun` executes the *previous* Turn
@@ -368,9 +381,15 @@ export class BotDurableAuthority<Snapshot> {
368
381
  if (await transaction.get<string>(ACTIVE_RUN_KEY)) {
369
382
  return "blocked" as const;
370
383
  }
371
- const latestEvents = (
384
+ const storedEvents = (
372
385
  (await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
373
386
  ).map(decodeSessionEvent);
387
+ // A queued Turn was admitted while another was executing, so admission
388
+ // could not repair the log: something was still entitled to close that
389
+ // Turn. Here the active-run marker is gone and nothing is, so the same
390
+ // repair applies before this Turn starts on it.
391
+ const repaired = repairedSessionLogV1(run.sessionId, storedEvents);
392
+ const latestEvents = repaired ?? storedEvents;
374
393
  const promoted = this.codec.require({
375
394
  ...run,
376
395
  phase: "admitted",
@@ -379,6 +398,13 @@ export class BotDurableAuthority<Snapshot> {
379
398
  await transaction.put({
380
399
  [key]: structuredClone(promoted),
381
400
  [ACTIVE_RUN_KEY]: runId,
401
+ ...(repaired
402
+ ? {
403
+ [LATEST_EVENTS_KEY]: structuredClone(
404
+ repaired.map(decodeSessionEvent),
405
+ ),
406
+ }
407
+ : {}),
382
408
  });
383
409
  await transaction.delete(PENDING_RUN_KEY);
384
410
  await this.refreshRecoveryAlarm(transaction);
@@ -863,6 +889,158 @@ export class BotDurableAuthority<Snapshot> {
863
889
  return this.ctx.storage.get<string>(ACTIVE_RUN_KEY);
864
890
  }
865
891
 
892
+ /** The conversation this Bot's chat Session is on. */
893
+ async readConversation(): Promise<StoredConversationV1> {
894
+ return (
895
+ decodeStoredConversationV1(
896
+ await this.ctx.storage.get<unknown>(CONVERSATION_KEY),
897
+ ) ?? firstConversationV1(new Date().toISOString())
898
+ );
899
+ }
900
+
901
+ /**
902
+ * The conversations this Bot has had, newest first, the current one included.
903
+ *
904
+ * Ended conversations are listed from a bounded index rather than
905
+ * reconstructed from the run log: the run index is paged and a conversation
906
+ * with no surviving runs is still a conversation the User had.
907
+ */
908
+ async listConversations(
909
+ identity?: BotIdentity,
910
+ ): Promise<ConversationRecordV1[]> {
911
+ const known =
912
+ identity ?? (await this.ctx.storage.get<BotIdentity>(IDENTITY_KEY));
913
+ if (!known) return [];
914
+ const base = botConversationBaseSessionIdV1(known);
915
+ const current = await this.readConversation();
916
+ const ended = (
917
+ (await this.ctx.storage.get<unknown[]>(CONVERSATION_INDEX_KEY)) ?? []
918
+ ).flatMap((entry) => {
919
+ try {
920
+ return [decodeConversationRecordV1(entry)];
921
+ } catch {
922
+ // One unreadable record is skipped, never a list that throws: a
923
+ // conversation you cannot name must not hide the ones you can.
924
+ return [];
925
+ }
926
+ });
927
+ return [
928
+ {
929
+ schemaVersion: 1 as const,
930
+ sessionId: conversationSessionIdV1(base, current.ordinal),
931
+ ordinal: current.ordinal,
932
+ startedAt: current.startedAt,
933
+ },
934
+ ...ended,
935
+ ].sort((left, right) => right.ordinal - left.ordinal);
936
+ }
937
+
938
+ /**
939
+ * The Session id this Bot's chat Turns are recording right now, or
940
+ * `undefined` before the object has admitted anything and learned its
941
+ * identity. A reader that has to say which Turns are "this conversation"
942
+ * asks here rather than reconstructing the id.
943
+ */
944
+ async readConversationSessionId(): Promise<string | undefined> {
945
+ const identity = await this.ctx.storage.get<BotIdentity>(IDENTITY_KEY);
946
+ if (!identity) return undefined;
947
+ const conversation = await this.readConversation();
948
+ return conversationSessionIdV1(
949
+ botConversationBaseSessionIdV1(identity),
950
+ conversation.ordinal,
951
+ );
952
+ }
953
+
954
+ /**
955
+ * Ends the current conversation and starts the next one.
956
+ *
957
+ * The durable event log the next Turn derives its request from is emptied,
958
+ * so history stops growing without bound; the runs of the conversation just
959
+ * ended keep their events and their Session id and stay readable. Refused
960
+ * while a Turn is admitted: the log a running Turn is appending to is not
961
+ * something a click may pull out from under it.
962
+ */
963
+ async startConversation(
964
+ identity: BotIdentity,
965
+ ): Promise<ConversationRecordV1> {
966
+ await this.assertIdentity(identity);
967
+ await this.recoverActiveRun();
968
+ const base = botConversationBaseSessionIdV1(identity);
969
+ return this.ctx.storage.transaction(async (transaction) => {
970
+ const active = await transaction.get<string>(ACTIVE_RUN_KEY);
971
+ const pending = await transaction.get<string>(PENDING_RUN_KEY);
972
+ if (active || pending) {
973
+ throw new Error(
974
+ "This Bot is still working on a Turn. Wait for it to finish, then start a new conversation.",
975
+ );
976
+ }
977
+ const current =
978
+ decodeStoredConversationV1(
979
+ await transaction.get<unknown>(CONVERSATION_KEY),
980
+ ) ?? firstConversationV1(new Date().toISOString());
981
+ const endedAt = new Date().toISOString();
982
+ const ended = (
983
+ (await transaction.get<unknown[]>(CONVERSATION_INDEX_KEY)) ?? []
984
+ ).flatMap((entry) => {
985
+ try {
986
+ return [decodeConversationRecordV1(entry)];
987
+ } catch {
988
+ return [];
989
+ }
990
+ });
991
+ const next: StoredConversationV1 = {
992
+ schemaVersion: 1,
993
+ ordinal: current.ordinal + 1,
994
+ startedAt: endedAt,
995
+ };
996
+ await transaction.put({
997
+ [CONVERSATION_KEY]: next,
998
+ [CONVERSATION_INDEX_KEY]: [
999
+ {
1000
+ schemaVersion: 1 as const,
1001
+ sessionId: conversationSessionIdV1(base, current.ordinal),
1002
+ ordinal: current.ordinal,
1003
+ startedAt: current.startedAt,
1004
+ endedAt,
1005
+ },
1006
+ ...ended,
1007
+ ]
1008
+ .sort((left, right) => right.ordinal - left.ordinal)
1009
+ .slice(0, MAX_LISTED_CONVERSATIONS),
1010
+ // The next Turn derives its messages from an empty log. Nothing is
1011
+ // deleted: `run:<id>` still holds every event of every Turn.
1012
+ [LATEST_EVENTS_KEY]: [],
1013
+ });
1014
+ return {
1015
+ schemaVersion: 1 as const,
1016
+ sessionId: conversationSessionIdV1(base, next.ordinal),
1017
+ ordinal: next.ordinal,
1018
+ startedAt: next.startedAt,
1019
+ };
1020
+ });
1021
+ }
1022
+
1023
+ /**
1024
+ * The command as this object's durable conversation state addresses it.
1025
+ *
1026
+ * A client names the Bot's conversational Session by its base id and knows
1027
+ * nothing about conversations; which conversation that is, is durable state
1028
+ * here. Every other Session id — a Routine's `routine:<id>`, a subagent's —
1029
+ * is left exactly as its producer wrote it.
1030
+ */
1031
+ private async conversationScopedCommand(
1032
+ command: OwnedBotTurnCommand,
1033
+ ): Promise<OwnedBotTurnCommand> {
1034
+ const base = botConversationBaseSessionIdV1(command);
1035
+ if (command.sessionId !== base) return command;
1036
+ const conversation = await this.readConversation();
1037
+ if (conversation.ordinal <= 1) return command;
1038
+ return {
1039
+ ...command,
1040
+ sessionId: conversationSessionIdV1(base, conversation.ordinal),
1041
+ };
1042
+ }
1043
+
866
1044
  /** Durable run record, unchecked against its lookup key. */
867
1045
  async readStoredRun(
868
1046
  runId: string,
@@ -1072,11 +1250,18 @@ export class BotDurableAuthority<Snapshot> {
1072
1250
  const stillOwned =
1073
1251
  activeRun?.status === "running" ||
1074
1252
  activeRun?.status === "reconciliation-required";
1075
- const repairs = stillOwned
1076
- ? []
1077
- : repairOrphanedOpenTurnV1(command.sessionId, storedEvents);
1078
- const latestEvents = [...storedEvents, ...repairs];
1079
- if (repairs.length > 0) {
1253
+ //
1254
+ // The repair rewrites the whole log rather than appending to it: by the
1255
+ // time anyone notices, the abandoned Turn is usually no longer the last
1256
+ // thing in the log. Each refused message journals its own `turn/start`
1257
+ // before it assembles the request that discovers the breakage, and its
1258
+ // `finally` writes the matching `turn/end`, so the log ends closed with
1259
+ // the abandoned Turn still open behind it. Appending cannot close that.
1260
+ const repaired = stillOwned
1261
+ ? undefined
1262
+ : repairedSessionLogV1(command.sessionId, storedEvents);
1263
+ const latestEvents = repaired ?? storedEvents;
1264
+ if (repaired) {
1080
1265
  await transaction.put(
1081
1266
  LATEST_EVENTS_KEY,
1082
1267
  structuredClone(latestEvents.map(decodeSessionEvent)),
@@ -0,0 +1,159 @@
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
+ type BotDurableAuthorityHooks,
10
+ } from "./authority.ts";
11
+ import {
12
+ conversationSessionIdV1,
13
+ isConversationSessionIdV1,
14
+ } from "./conversations.ts";
15
+ import { MemoryStorage } from "./memory-storage.fixture.ts";
16
+ import { createStoredRunCodecV1 } from "./run-records.ts";
17
+
18
+ const codec = createStoredRunCodecV1<undefined>({
19
+ decodeRunId: (value) => value as string,
20
+ decodeConfigurationSnapshot: () => undefined,
21
+ });
22
+
23
+ const IDENTITY = { userId: "user-1", botId: "primary" };
24
+
25
+ function bootstrap(): Promise<CompositionGenerationV1> {
26
+ return bootstrapGeneration(
27
+ [
28
+ {
29
+ packageId: "shell",
30
+ specifier: "@frockbot/plugin-shell",
31
+ version: "0.0.1",
32
+ manifest: { id: "shell", version: "0.0.1" },
33
+ },
34
+ ],
35
+ { createdAt: "2026-08-31T00:00:00.000Z" },
36
+ );
37
+ }
38
+
39
+ function createAuthority(storage: MemoryStorage) {
40
+ const sessions: string[] = [];
41
+ const hooks: BotDurableAuthorityHooks<undefined> = {
42
+ resolveAdmissionSnapshot: () => Promise.resolve(undefined),
43
+ bootstrapComposition: () => bootstrap(),
44
+ admittedSnapshot: () => Promise.resolve(undefined),
45
+ executeTurn: async (input) => {
46
+ sessions.push(input.command.sessionId);
47
+ const events: SessionEvent[] = [
48
+ {
49
+ type: "turn/admission",
50
+ seq: input.previousEvents.length,
51
+ timestamp: "2026-08-31T01:00:01.000Z",
52
+ turn: input.previousEvents.length + 1,
53
+ turnType: "chat",
54
+ },
55
+ ];
56
+ await input.persistSessionEvents(input.command.sessionId, events);
57
+ return { runId: input.command.runId, text: "ok", events };
58
+ },
59
+ notification: () => undefined,
60
+ scheduledDeadlines: () => Promise.resolve([]),
61
+ scheduledWorkInFlight: () => false,
62
+ deferScheduledWork: () => Promise.resolve(),
63
+ settleScheduledWork: () => Promise.resolve(),
64
+ };
65
+ return {
66
+ authority: new BotDurableAuthority<undefined>({
67
+ state: { storage } as unknown as DurableObjectState,
68
+ codec,
69
+ hooks,
70
+ }),
71
+ sessions,
72
+ };
73
+ }
74
+
75
+ function command(runId: string) {
76
+ return {
77
+ ...IDENTITY,
78
+ runId,
79
+ sessionId: "user-1:primary",
80
+ acceptedAt: `2026-08-31T01:00:0${runId.slice(-1)}.000Z`,
81
+ text: `message ${runId}`,
82
+ };
83
+ }
84
+
85
+ describe("a Bot's Session id names the conversation it is on", () => {
86
+ test("the first conversation is the bare Session id", () => {
87
+ expect(conversationSessionIdV1("user-1:primary", 1)).toBe("user-1:primary");
88
+ expect(conversationSessionIdV1("user-1:primary", 3)).toBe(
89
+ "user-1:primary#3",
90
+ );
91
+ });
92
+
93
+ test("only a Bot's own conversations match its base id", () => {
94
+ const base = "user-1:primary";
95
+ expect(isConversationSessionIdV1(base, base)).toBe(true);
96
+ expect(isConversationSessionIdV1(base, `${base}#2`)).toBe(true);
97
+ expect(isConversationSessionIdV1(base, "routine:morning")).toBe(false);
98
+ expect(isConversationSessionIdV1(base, `${base}#0`)).toBe(false);
99
+ expect(isConversationSessionIdV1(base, `${base}#x`)).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe("starting a new conversation", () => {
104
+ test("empties the log the next Turn derives from and keeps the old Turns", async () => {
105
+ const storage = new MemoryStorage();
106
+ const probe = createAuthority(storage);
107
+
108
+ await probe.authority.run(command("run-1"));
109
+ expect((storage.values.get("latest-events") as SessionEvent[]).length).toBe(
110
+ 1,
111
+ );
112
+
113
+ const started = await probe.authority.startConversation(IDENTITY);
114
+ expect(started.ordinal).toBe(2);
115
+ expect(started.sessionId).toBe("user-1:primary#2");
116
+ // The unbounded log is the bug: the next Turn starts from nothing.
117
+ expect(storage.values.get("latest-events")).toEqual([]);
118
+ // The conversation just ended is still on disk, Turn for Turn.
119
+ expect(
120
+ (storage.values.get("run:run-1") as { events: SessionEvent[] }).events
121
+ .length,
122
+ ).toBe(1);
123
+
124
+ await probe.authority.run(command("run-2"));
125
+ // The new Turn ran in the new Session, and saw none of the old history.
126
+ expect(probe.sessions).toEqual(["user-1:primary", "user-1:primary#2"]);
127
+ expect(
128
+ (storage.values.get("run:run-2") as { previousEventCount: number })
129
+ .previousEventCount,
130
+ ).toBe(0);
131
+ });
132
+
133
+ test("lists the conversations the Bot has had, newest first", async () => {
134
+ const storage = new MemoryStorage();
135
+ const probe = createAuthority(storage);
136
+
137
+ await probe.authority.run(command("run-1"));
138
+ await probe.authority.startConversation(IDENTITY);
139
+ await probe.authority.run(command("run-2"));
140
+
141
+ const conversations = await probe.authority.listConversations(IDENTITY);
142
+ expect(conversations.map((entry) => entry.sessionId)).toEqual([
143
+ "user-1:primary#2",
144
+ "user-1:primary",
145
+ ]);
146
+ expect(conversations[0]?.endedAt).toBeUndefined();
147
+ expect(conversations[1]?.endedAt).toBeString();
148
+ });
149
+
150
+ test("is refused while a Turn is still admitted", async () => {
151
+ const storage = new MemoryStorage();
152
+ const probe = createAuthority(storage);
153
+ storage.values.set("active-run", "run-9");
154
+
155
+ await expect(probe.authority.startConversation(IDENTITY)).rejects.toThrow(
156
+ /still working on a Turn/,
157
+ );
158
+ });
159
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Conversations: how one Bot has more than one chat Session over its life.
3
+ *
4
+ * A Bot's conversational Session id was `<userId>:<botId>` forever, so its
5
+ * durable event log only ever grew and there was no way to put a conversation
6
+ * down and start another. A conversation numbers that Session: the first is
7
+ * the bare id, so nothing already stored changes, and each one after it is the
8
+ * same id with `#<ordinal>` appended.
9
+ *
10
+ * Starting a new conversation is a durable boundary, not a deletion. The event
11
+ * log the next Turn derives its model request from is empty again; every Turn
12
+ * of every earlier conversation stays in the run index under the Session id it
13
+ * recorded, so the earlier conversation is still readable.
14
+ */
15
+
16
+ /** The conversation a Bot's chat Session is currently on. */
17
+ export interface StoredConversationV1 {
18
+ schemaVersion: 1;
19
+ /** 1 is the Session every Bot starts on and the one already on disk. */
20
+ ordinal: number;
21
+ startedAt: string;
22
+ }
23
+
24
+ /** One conversation a Bot has had, current or ended. */
25
+ export interface ConversationRecordV1 {
26
+ schemaVersion: 1;
27
+ /** The Session id its Turns recorded. */
28
+ sessionId: string;
29
+ ordinal: number;
30
+ startedAt: string;
31
+ /** Absent while this is the conversation the Bot is on. */
32
+ endedAt?: string;
33
+ }
34
+
35
+ const MAX_CONVERSATION_ORDINAL = 1_000_000;
36
+
37
+ /** The conversation a Bot with nothing stored is on. */
38
+ export function firstConversationV1(startedAt: string): StoredConversationV1 {
39
+ return { schemaVersion: 1, ordinal: 1, startedAt };
40
+ }
41
+
42
+ export function decodeStoredConversationV1(
43
+ input: unknown,
44
+ ): StoredConversationV1 | undefined {
45
+ if (input === undefined || input === null) return undefined;
46
+ if (typeof input !== "object") {
47
+ throw new Error("stored conversation is invalid");
48
+ }
49
+ const value = input as Record<string, unknown>;
50
+ if (value.schemaVersion !== 1) {
51
+ throw new Error("stored conversation.schemaVersion is invalid");
52
+ }
53
+ if (
54
+ typeof value.ordinal !== "number" ||
55
+ !Number.isSafeInteger(value.ordinal) ||
56
+ value.ordinal < 1 ||
57
+ value.ordinal > MAX_CONVERSATION_ORDINAL
58
+ ) {
59
+ throw new Error("stored conversation.ordinal is invalid");
60
+ }
61
+ if (typeof value.startedAt !== "string" || value.startedAt.length === 0) {
62
+ throw new Error("stored conversation.startedAt is invalid");
63
+ }
64
+ return {
65
+ schemaVersion: 1,
66
+ ordinal: value.ordinal,
67
+ startedAt: value.startedAt,
68
+ };
69
+ }
70
+
71
+ export function decodeConversationRecordV1(
72
+ input: unknown,
73
+ ): ConversationRecordV1 {
74
+ if (typeof input !== "object" || input === null) {
75
+ throw new Error("conversation record is invalid");
76
+ }
77
+ const value = input as Record<string, unknown>;
78
+ const stored = decodeStoredConversationV1({
79
+ schemaVersion: value.schemaVersion,
80
+ ordinal: value.ordinal,
81
+ startedAt: value.startedAt,
82
+ })!;
83
+ if (typeof value.sessionId !== "string" || value.sessionId.length === 0) {
84
+ throw new Error("conversation record.sessionId is invalid");
85
+ }
86
+ if (value.endedAt !== undefined && typeof value.endedAt !== "string") {
87
+ throw new Error("conversation record.endedAt is invalid");
88
+ }
89
+ return {
90
+ ...stored,
91
+ sessionId: value.sessionId,
92
+ ...(value.endedAt ? { endedAt: value.endedAt as string } : {}),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * The Session id a Bot's chat Turns record while it is on this conversation.
98
+ *
99
+ * The first conversation is the bare id on purpose: every Session already
100
+ * stored is conversation 1, so nothing has to be migrated for it to be one.
101
+ */
102
+ export function conversationSessionIdV1(base: string, ordinal: number): string {
103
+ return ordinal <= 1 ? base : `${base}#${ordinal}`;
104
+ }
105
+
106
+ /** The Session id a Bot's conversational Turns are addressed to. */
107
+ export function botConversationBaseSessionIdV1(identity: {
108
+ userId: string;
109
+ botId: string;
110
+ }): string {
111
+ return `${identity.userId}:${identity.botId}`;
112
+ }
113
+
114
+ /**
115
+ * True when this Session id names a conversation of that Bot — the bare base
116
+ * id or the base id with an ordinal. A Routine firing's `routine:<id>` and a
117
+ * subagent's Session are deliberately not conversations and never match.
118
+ */
119
+ export function isConversationSessionIdV1(
120
+ base: string,
121
+ sessionId: string,
122
+ ): boolean {
123
+ if (sessionId === base) return true;
124
+ if (!sessionId.startsWith(`${base}#`)) return false;
125
+ return /^[1-9][0-9]{0,6}$/.test(sessionId.slice(base.length + 1));
126
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./authority.js";
2
2
  export * from "./applets.js";
3
3
  export * from "./composition-failures.js";
4
+ export * from "./conversations.js";
4
5
  export * from "./composition-store.js";
5
6
  export * from "./run-records.js";
6
7
  export * from "./run-recovery.js";
@@ -266,6 +266,76 @@ export function repairOrphanedOpenTurnV1(
266
266
  }
267
267
  }
268
268
 
269
+ /**
270
+ * True when *any* Turn in the log was never closed — including one buried
271
+ * behind later Turns that are themselves well formed.
272
+ *
273
+ * `hasOrphanedOpenTurnV1` only sees a log that *ends* inside a Turn, and that
274
+ * is the shape a wedged Bot stops having after its very first retry. The Agent
275
+ * loop journals `turn/start` durably and only then assembles the request, so
276
+ * the Turn that discovers the invariant is broken has already written its own
277
+ * `turn/start`, and its `finally` writes a matching `turn/end` carrying the
278
+ * validation message. The log that comes out of that ends closed — with the
279
+ * abandoned Turn still open several events back — so the trailing-open test
280
+ * says there is nothing to repair, and every later message fails the same way.
281
+ */
282
+ export function hasUnclosedTurnV1(events: readonly SessionEvent[]): boolean {
283
+ let openTurn: number | undefined;
284
+ for (const event of events) {
285
+ if (event.type === "turn/start") {
286
+ if (openTurn !== undefined) return true;
287
+ openTurn = event.turn;
288
+ }
289
+ if (event.type === "turn/end" && event.turn === openTurn) {
290
+ openTurn = undefined;
291
+ }
292
+ }
293
+ return openTurn !== undefined;
294
+ }
295
+
296
+ /**
297
+ * The whole durable log with every abandoned Turn closed, or `undefined` when
298
+ * there is nothing to repair or the log cannot be repaired without inventing
299
+ * history.
300
+ *
301
+ * A Turn left open in the middle of the log cannot be closed by appending:
302
+ * `turn/end` for it would land after the Turns that followed, and the log
303
+ * would still read as "turn N started while turn N-1 is open". So the repair
304
+ * *rewrites* the log, inserting the closing events at the point the Turn was
305
+ * abandoned and resequencing what follows. The inserted events are the ones
306
+ * the interrupted-run repair already writes — every unresolved tool occurrence
307
+ * closed as `interrupted`, then `step/end`, then `turn/end` with outcome
308
+ * `interrupted` — so a Turn nobody finished reads as one nobody finished.
309
+ *
310
+ * Only called where nothing is entitled to write those ends: at admission and
311
+ * promotion with no run executing, and at settlement, where the run that owned
312
+ * the Turn has just stopped.
313
+ */
314
+ export function repairedSessionLogV1(
315
+ sessionId: string,
316
+ latest: readonly SessionEvent[],
317
+ ): SessionEvent[] | undefined {
318
+ if (!hasUnclosedTurnV1(latest)) return undefined;
319
+ let repaired: SessionEvent[] = [];
320
+ const closeOpenTurn = (): boolean => {
321
+ if (repaired.length === 0 || !hasOrphanedOpenTurnV1(repaired)) return true;
322
+ try {
323
+ const session = new Session(sessionId, () => {}, repaired);
324
+ session.reconcileInterrupted();
325
+ repaired = [...session.events];
326
+ } catch {
327
+ return false;
328
+ }
329
+ return !hasOrphanedOpenTurnV1(repaired);
330
+ };
331
+ for (const event of latest) {
332
+ if (event.type === "turn/start" && !closeOpenTurn()) return undefined;
333
+ repaired.push({ ...event, seq: repaired.length });
334
+ }
335
+ if (!closeOpenTurn()) return undefined;
336
+ return repaired;
337
+ }
338
+
269
339
  export function eventsForFailedRun(
270
340
  durableRun: { events: SessionEvent[] } | undefined,
271
341
  error: unknown,
@@ -8,6 +8,7 @@ import type {
8
8
  StoredRunCodecV1,
9
9
  StoredRunV1,
10
10
  } from "./run-records.js";
11
+ import { repairedSessionLogV1 } from "./run-recovery.js";
11
12
 
12
13
  /**
13
14
  * The events a terminal settlement commits, with any Turn they were left
@@ -44,9 +45,15 @@ function settledEventsV1(
44
45
  } catch {
45
46
  repairs = [];
46
47
  }
48
+ const settled = [...latest, ...repairs];
49
+ // A Turn abandoned earlier in the log cannot be closed by appending, and a
50
+ // settlement that only appends leaves it open forever. The run's own events
51
+ // are committed as they stand — that record is this run's account, not the
52
+ // conversation's — while the forward log is repaired in place so the next
53
+ // Turn starts on a log that reads as a complete history.
47
54
  return {
48
55
  events: [...decoded, ...repairs],
49
- latestEvents: [...latest, ...repairs],
56
+ latestEvents: repairedSessionLogV1(sessionId, settled) ?? settled,
50
57
  };
51
58
  }
52
59
 
@@ -135,3 +135,18 @@ export function storedRunAdmissionFences(input: unknown): string[] {
135
135
  export function workspaceSyncEffectKey(effectId: string): string {
136
136
  return `${WORKSPACE_SYNC_EFFECT_PREFIX}${effectId}`;
137
137
  }
138
+
139
+ /**
140
+ * The conversation the Bot's chat Session is currently on.
141
+ *
142
+ * One Bot has one conversational Session at a time. Starting a new
143
+ * conversation ends the current one and begins the next: the durable event log
144
+ * the next Turn derives its request from is empty again, while every Turn the
145
+ * earlier conversations recorded stays durable and readable under its own
146
+ * Session id.
147
+ */
148
+ export const CONVERSATION_KEY = "conversation";
149
+ /** The conversations this Bot has already ended, newest last. */
150
+ export const CONVERSATION_INDEX_KEY = "conversation-index";
151
+ /** How many ended conversations stay listable. Older ones drop off the list. */
152
+ export const MAX_LISTED_CONVERSATIONS = 64;
@@ -3,7 +3,10 @@ import {
3
3
  bootstrapGeneration,
4
4
  type CompositionGenerationV1,
5
5
  } from "@frockbot/kernel-composition/generation";
6
- import type { SessionEvent } from "@frockbot/kernel-contracts";
6
+ import {
7
+ type SessionEvent,
8
+ validateToolOccurrenceJournal,
9
+ } from "@frockbot/kernel-contracts";
7
10
  import {
8
11
  BotDurableAuthority,
9
12
  SUPERSEDED_TURN_REASON_V1,
@@ -690,6 +693,87 @@ describe("a durable log left inside a Turn", () => {
690
693
  });
691
694
  expect(storedRun(storage, "run-1").status).toBe("completed");
692
695
  });
696
+
697
+ test("is repaired even once refused Turns have been logged behind it", async () => {
698
+ const storage = new MemoryStorage();
699
+ // What production actually holds on a Bot wedged before the repair
700
+ // existed. The Agent loop journals `turn/start` durably and only then
701
+ // assembles the request that discovers turn 1 is still open, so every
702
+ // refused message left a *complete* Turn of its own behind the abandoned
703
+ // one — and the log stopped ending inside a Turn. The trailing-open test
704
+ // then said there was nothing to repair, so admission repaired nothing and
705
+ // the next message failed exactly the same way, forever.
706
+ storage.values.set("latest-events", [
707
+ {
708
+ type: "session/created",
709
+ createdAt: "2026-09-03T00:00:00.000Z",
710
+ seq: 0,
711
+ timestamp: "2026-09-03T00:00:00.000Z",
712
+ },
713
+ {
714
+ type: "turn/start",
715
+ turn: 1,
716
+ seq: 1,
717
+ timestamp: "2026-09-03T00:00:01.000Z",
718
+ },
719
+ {
720
+ type: "turn/start",
721
+ turn: 2,
722
+ seq: 2,
723
+ timestamp: "2026-09-03T00:00:02.000Z",
724
+ },
725
+ {
726
+ type: "turn/end",
727
+ turn: 2,
728
+ outcome: "model-error",
729
+ reason: "turn 2 started while turn 1 is open",
730
+ seq: 3,
731
+ timestamp: "2026-09-03T00:00:03.000Z",
732
+ },
733
+ ]);
734
+ const probe = createAuthority(storage);
735
+
736
+ const run = probe.authority.run(command("run-1", "hello"));
737
+ await probe.handle("run-1").started;
738
+ probe.handle("run-1").finish();
739
+ await run;
740
+
741
+ const events = storage.values.get("latest-events") as SessionEvent[];
742
+ // Turn 1 is closed where it was abandoned, not after the Turns that
743
+ // followed it, and the log is resequenced around the insertion.
744
+ expect(
745
+ events
746
+ .slice(0, 5)
747
+ .map((event) =>
748
+ event.type === "turn/start" || event.type === "turn/end"
749
+ ? `${event.type}:${event.turn}`
750
+ : event.type,
751
+ ),
752
+ ).toEqual([
753
+ "session/created",
754
+ "turn/start:1",
755
+ "turn/end:1",
756
+ "turn/start:2",
757
+ "turn/end:2",
758
+ ]);
759
+ expect(events[2]).toMatchObject({
760
+ type: "turn/end",
761
+ turn: 1,
762
+ outcome: "interrupted",
763
+ });
764
+ expect(events.map((event) => event.seq)).toEqual(
765
+ events.map((_event, index) => index),
766
+ );
767
+ // The repaired history is one the invariant accepts, which is what the
768
+ // refused Turns were failing on. (The stub Agent below numbers its own
769
+ // Turn rather than reading `nextTurn`, so only the repaired prefix is the
770
+ // subject here.)
771
+ expect(() =>
772
+ validateToolOccurrenceJournal(events.slice(0, 5)),
773
+ ).not.toThrow();
774
+ expect(storedRun(storage, "run-1").status).toBe("completed");
775
+ expect(storedRun(storage, "run-1").previousEventCount).toBe(5);
776
+ });
693
777
  });
694
778
 
695
779
  describe("a failing recovery of an older Turn", () => {