@frockbot/kernel-do 0.3.5 → 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 +3 -3
- package/src/authority.ts +234 -10
- package/src/conversations.test.ts +159 -0
- package/src/conversations.ts +126 -0
- package/src/index.ts +1 -0
- package/src/run-records.ts +9 -1
- package/src/run-recovery.test.ts +120 -12
- package/src/run-recovery.ts +116 -2
- package/src/run-terminal-open-turn.test.ts +203 -0
- package/src/run-terminal.ts +58 -8
- package/src/storage-keys.ts +15 -0
- package/src/turn-admission.test.ts +38 -0
- package/src/turn-supersede.test.ts +85 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-do",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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.
|
|
16
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
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
|
@@ -34,7 +34,8 @@ import {
|
|
|
34
34
|
eventsForFailedRun,
|
|
35
35
|
latestModelRequestJournalState,
|
|
36
36
|
planBotRunRecovery,
|
|
37
|
-
|
|
37
|
+
type ProviderReconcilesV1,
|
|
38
|
+
repairedSessionLogV1,
|
|
38
39
|
unresolvedModelRequestFailure,
|
|
39
40
|
} from "./run-recovery.js";
|
|
40
41
|
import {
|
|
@@ -42,8 +43,20 @@ import {
|
|
|
42
43
|
BotTurnRecoveryRequiredError,
|
|
43
44
|
BotTurnRefusedError,
|
|
44
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";
|
|
45
55
|
import {
|
|
46
56
|
ACTIVE_RUN_KEY,
|
|
57
|
+
CONVERSATION_INDEX_KEY,
|
|
58
|
+
CONVERSATION_KEY,
|
|
59
|
+
MAX_LISTED_CONVERSATIONS,
|
|
47
60
|
PENDING_RUN_KEY,
|
|
48
61
|
IDENTITY_KEY,
|
|
49
62
|
LATEST_EVENTS_KEY,
|
|
@@ -144,6 +157,16 @@ export interface BotDurableAuthorityHooks<Snapshot> {
|
|
|
144
157
|
run: StoredRunV1<Snapshot>;
|
|
145
158
|
read<T>(key: string): Promise<T | undefined>;
|
|
146
159
|
}): Promise<Record<string, unknown>>;
|
|
160
|
+
/**
|
|
161
|
+
* Whether the named provider can be asked what happened to a model request
|
|
162
|
+
* it never answered (ADR 0028).
|
|
163
|
+
*
|
|
164
|
+
* Synchronous and pure, because it is consulted inside the recovery
|
|
165
|
+
* transaction: it answers from what the deployment knows about a provider
|
|
166
|
+
* Package, never by reaching one. Absent means every provider reconciles,
|
|
167
|
+
* which is the behaviour that predates the ADR.
|
|
168
|
+
*/
|
|
169
|
+
providerReconciles?: ProviderReconcilesV1;
|
|
147
170
|
}
|
|
148
171
|
|
|
149
172
|
/** What a `turn/end` records when a later user message took a Turn's place. */
|
|
@@ -209,7 +232,8 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
209
232
|
});
|
|
210
233
|
}
|
|
211
234
|
|
|
212
|
-
async run(
|
|
235
|
+
async run(input: OwnedBotTurnCommand): Promise<BotTurnCompletion> {
|
|
236
|
+
const command = await this.conversationScopedCommand(input);
|
|
213
237
|
await this.assertMatchingRunCommand(command);
|
|
214
238
|
// Recovering whatever this object was left holding must never decide the
|
|
215
239
|
// fate of a new command. `recoverActiveRun` executes the *previous* Turn
|
|
@@ -357,9 +381,15 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
357
381
|
if (await transaction.get<string>(ACTIVE_RUN_KEY)) {
|
|
358
382
|
return "blocked" as const;
|
|
359
383
|
}
|
|
360
|
-
const
|
|
384
|
+
const storedEvents = (
|
|
361
385
|
(await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
|
|
362
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;
|
|
363
393
|
const promoted = this.codec.require({
|
|
364
394
|
...run,
|
|
365
395
|
phase: "admitted",
|
|
@@ -368,6 +398,13 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
368
398
|
await transaction.put({
|
|
369
399
|
[key]: structuredClone(promoted),
|
|
370
400
|
[ACTIVE_RUN_KEY]: runId,
|
|
401
|
+
...(repaired
|
|
402
|
+
? {
|
|
403
|
+
[LATEST_EVENTS_KEY]: structuredClone(
|
|
404
|
+
repaired.map(decodeSessionEvent),
|
|
405
|
+
),
|
|
406
|
+
}
|
|
407
|
+
: {}),
|
|
371
408
|
});
|
|
372
409
|
await transaction.delete(PENDING_RUN_KEY);
|
|
373
410
|
await this.refreshRecoveryAlarm(transaction);
|
|
@@ -852,6 +889,158 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
852
889
|
return this.ctx.storage.get<string>(ACTIVE_RUN_KEY);
|
|
853
890
|
}
|
|
854
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
|
+
|
|
855
1044
|
/** Durable run record, unchecked against its lookup key. */
|
|
856
1045
|
async readStoredRun(
|
|
857
1046
|
runId: string,
|
|
@@ -1044,11 +1233,35 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1044
1233
|
// N-1 is open". Nothing owned that repair, because the run that would
|
|
1045
1234
|
// have closed it is already terminal, so admission does: with nothing
|
|
1046
1235
|
// executing, an open Turn is one nobody is going to finish.
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1236
|
+
//
|
|
1237
|
+
// The pointer alone is not the test. A Bot can hold an `active-run` id
|
|
1238
|
+
// whose record is already terminal — a settlement that landed while the
|
|
1239
|
+
// pointer clear did not, a supersede whose Turn ended between the two
|
|
1240
|
+
// writes — and gating the repair on the pointer left exactly those Bots
|
|
1241
|
+
// wedged. What matters is whether anything is still entitled to write
|
|
1242
|
+
// that Turn's end: a `running` record is, and so is a
|
|
1243
|
+
// `reconciliation-required` one, whose Turn is held open on purpose
|
|
1244
|
+
// until its outcome is retrieved. Nothing else is.
|
|
1245
|
+
const activeRun = activeRunId
|
|
1246
|
+
? this.codec.optional(
|
|
1247
|
+
await transaction.get<unknown>(`${RUN_PREFIX}${activeRunId}`),
|
|
1248
|
+
)
|
|
1249
|
+
: undefined;
|
|
1250
|
+
const stillOwned =
|
|
1251
|
+
activeRun?.status === "running" ||
|
|
1252
|
+
activeRun?.status === "reconciliation-required";
|
|
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) {
|
|
1052
1265
|
await transaction.put(
|
|
1053
1266
|
LATEST_EVENTS_KEY,
|
|
1054
1267
|
structuredClone(latestEvents.map(decodeSessionEvent)),
|
|
@@ -1418,7 +1631,12 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1418
1631
|
const latest = (
|
|
1419
1632
|
(await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
|
|
1420
1633
|
).map(decodeSessionEvent);
|
|
1421
|
-
const plan = planBotRunRecovery(
|
|
1634
|
+
const plan = planBotRunRecovery(
|
|
1635
|
+
run,
|
|
1636
|
+
latest,
|
|
1637
|
+
this.codec,
|
|
1638
|
+
this.hooks.providerReconciles ?? (() => true),
|
|
1639
|
+
);
|
|
1422
1640
|
if (plan.kind === "complete") {
|
|
1423
1641
|
const result = {
|
|
1424
1642
|
runId: run.runId,
|
|
@@ -1443,13 +1661,19 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1443
1661
|
return undefined;
|
|
1444
1662
|
}
|
|
1445
1663
|
if (plan.kind === "fail") {
|
|
1664
|
+
// The repairs matter when the failure is ADR 0028's: they close the
|
|
1665
|
+
// tool occurrences the restart left open, so the settled run's journal
|
|
1666
|
+
// is a complete account rather than one that stops mid-sentence twice.
|
|
1667
|
+
const events = plan.repairs
|
|
1668
|
+
? [...run.events, ...plan.repairs]
|
|
1669
|
+
: run.events;
|
|
1446
1670
|
await failStoredRun(
|
|
1447
1671
|
this.codec,
|
|
1448
1672
|
transaction,
|
|
1449
1673
|
this.terminalKeys(run.runId),
|
|
1450
1674
|
run.runId,
|
|
1451
1675
|
latest.slice(0, run.previousEventCount),
|
|
1452
|
-
|
|
1676
|
+
events,
|
|
1453
1677
|
plan.failure,
|
|
1454
1678
|
this.supersededPackageRecords(),
|
|
1455
1679
|
);
|
|
@@ -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
package/src/run-records.ts
CHANGED
|
@@ -758,7 +758,15 @@ export function botTurnCommandFingerprintV1(
|
|
|
758
758
|
...(lane === defaultRunLaneV1(turnType) ? {} : { lane }),
|
|
759
759
|
...(command.subagentRole ? { subagentRole: command.subagentRole } : {}),
|
|
760
760
|
...(command.origin ? { origin: command.origin } : {}),
|
|
761
|
-
|
|
761
|
+
// The *intent* is part of the command's identity, exactly as ADR 0024
|
|
762
|
+
// requires: a replay of a command that carried no supersede can never
|
|
763
|
+
// become one that interrupts a second Turn. The provenance is not. A
|
|
764
|
+
// client retrying the same send — same commandId, same text — names
|
|
765
|
+
// whichever run it happened to have observed by then, and that is a fact
|
|
766
|
+
// about its polling, not about what the person asked for. Hashing it
|
|
767
|
+
// turned an ordinary retry into "this idempotency key was reused for a
|
|
768
|
+
// different command" and refused the send.
|
|
769
|
+
...(command.supersedes ? { supersedes: true } : {}),
|
|
762
770
|
...(skills.length > 0 ? { skills: skills.map(formatSkillRefV1) } : {}),
|
|
763
771
|
...(command.directTool ? { directTool: command.directTool } : {}),
|
|
764
772
|
})}`;
|