@frockbot/kernel-do 0.3.2 → 0.3.4

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.
@@ -45,8 +45,21 @@ export class MemoryStorage {
45
45
  );
46
46
  }
47
47
 
48
+ /**
49
+ * Transactions run one at a time, as a Durable Object's do. Two admissions
50
+ * that arrive together must not both read "no run is pending" and both
51
+ * write themselves into the slot, and a fixture that let them would prove
52
+ * the opposite of what the tests are for.
53
+ */
54
+ #serialized: Promise<unknown> = Promise.resolve();
55
+
48
56
  transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
49
- return callback(this);
57
+ const next = this.#serialized.then(
58
+ () => callback(this),
59
+ () => callback(this),
60
+ );
61
+ this.#serialized = next.catch(() => undefined);
62
+ return next;
50
63
  }
51
64
 
52
65
  setAlarm(scheduledTime: number): Promise<void> {
@@ -22,7 +22,34 @@ export interface StoredRunCodecV1<Snapshot> {
22
22
  }
23
23
 
24
24
  export type StoredRunStatus =
25
- "running" | "completed" | "failed" | "cancelled" | "reconciliation-required";
25
+ | "running"
26
+ | "completed"
27
+ | "failed"
28
+ | "cancelled"
29
+ | "superseded"
30
+ | "reconciliation-required";
31
+
32
+ /**
33
+ * The admission lane a Turn was accepted on.
34
+ *
35
+ * A `user` admission is a person speaking to the Bot and may supersede
36
+ * whatever is running; a `background` admission — a Routine firing, a subagent
37
+ * dispatch — never supersedes and waits. The lane is durable because the
38
+ * decision to interrupt is made in the admission transaction and has to
39
+ * survive eviction alongside the run it interrupted.
40
+ */
41
+ export type RunLaneV1 = "user" | "background";
42
+
43
+ const RUN_LANES_V1: readonly RunLaneV1[] = ["user", "background"];
44
+
45
+ /**
46
+ * The lane a turn type belongs to when its record names none. Chat is the
47
+ * conversation, so it is the User's lane; every other turn type is work the
48
+ * Bot started for itself.
49
+ */
50
+ export function defaultRunLaneV1(turnType: TurnTypeV1): RunLaneV1 {
51
+ return turnType === "chat" ? "user" : "background";
52
+ }
26
53
 
27
54
  export type StoredEffectAdmissionOutcome = "admitted" | "fenced";
28
55
 
@@ -80,6 +107,13 @@ const STORED_RUN_ORIGIN_TRIGGERS: readonly StoredRunTriggerV1[] = [
80
107
  export interface StoredRunAdmissionV1 {
81
108
  schemaVersion: 1;
82
109
  turnType: TurnTypeV1;
110
+ /**
111
+ * The lane this Turn was admitted on. Absent means the lane its turn type
112
+ * defaults to, so no producer writing today's lanes changes a stored byte;
113
+ * a later lane that is not a turn type's default — bot-to-bot messaging's
114
+ * `agent` lane — names itself here.
115
+ */
116
+ lane?: RunLaneV1;
83
117
  /**
84
118
  * The subagent role the Turn was admitted under, when it had one. Recorded
85
119
  * for the same reason the turn type is: recovery after eviction has to
@@ -90,7 +124,7 @@ export interface StoredRunAdmissionV1 {
90
124
  }
91
125
 
92
126
  export type StoredRunPhase =
93
- "admitted" | "executing" | "reconciliation-required";
127
+ "queued" | "admitted" | "executing" | "reconciliation-required";
94
128
 
95
129
  export interface StoredRunV1<Snapshot = unknown> {
96
130
  runId: string;
@@ -106,6 +140,15 @@ export interface StoredRunV1<Snapshot = unknown> {
106
140
  phase: StoredRunPhase;
107
141
  /** Durable Stop intent; orthogonal to status and phase. */
108
142
  stopRequestedAt?: string;
143
+ /**
144
+ * Durable supersede intent: a later user-lane admission has taken this
145
+ * Turn's place. Orthogonal to status and phase exactly as Stop is, and read
146
+ * the same way — every new effect is fenced from the instant it is
147
+ * recorded, and the settlement that follows is terminal `superseded`.
148
+ */
149
+ supersededAt?: string;
150
+ /** The run whose admission superseded this one. */
151
+ supersededBy?: string;
109
152
  /** The Composition generation pinned in the same transaction that admitted the run. */
110
153
  compositionGenerationId: string;
111
154
  configurationSnapshot: Snapshot;
@@ -137,6 +180,15 @@ export function storedRunTurnTypeV1(run: {
137
180
  return run.admission?.turnType ?? "chat";
138
181
  }
139
182
 
183
+ /** The lane a stored run was admitted on. */
184
+ export function storedRunLaneV1(run: {
185
+ admission?: StoredRunAdmissionV1;
186
+ }): RunLaneV1 {
187
+ return (
188
+ run.admission?.lane ?? defaultRunLaneV1(run.admission?.turnType ?? "chat")
189
+ );
190
+ }
191
+
140
192
  /**
141
193
  * The `admission` field a Turn records — nothing at all for a chat Turn with
142
194
  * no recorded origin, so no stored bytes change for the Turn every producer
@@ -146,14 +198,25 @@ export function storedRunAdmissionV1(
146
198
  turnType: TurnTypeV1 | undefined,
147
199
  origin?: StoredRunOriginV1,
148
200
  subagentRole?: string,
201
+ lane?: RunLaneV1,
149
202
  ): { admission?: StoredRunAdmissionV1 } {
150
203
  const admitted = turnType ?? "chat";
151
- if (admitted === "chat" && origin === undefined && subagentRole === undefined)
204
+ // A lane that is already the turn type's default is not written: it says
205
+ // nothing the record does not, and writing it would change the bytes of the
206
+ // Turn every producer writes today.
207
+ const named = lane && lane !== defaultRunLaneV1(admitted) ? lane : undefined;
208
+ if (
209
+ admitted === "chat" &&
210
+ origin === undefined &&
211
+ subagentRole === undefined &&
212
+ named === undefined
213
+ )
152
214
  return {};
153
215
  return {
154
216
  admission: {
155
217
  schemaVersion: 1,
156
218
  turnType: admitted,
219
+ ...(named ? { lane: named } : {}),
157
220
  ...(subagentRole ? { subagentRole } : {}),
158
221
  ...(origin ? { origin } : {}),
159
222
  },
@@ -168,9 +231,11 @@ const STORED_RUN_STATUSES: readonly StoredRunStatus[] = [
168
231
  "completed",
169
232
  "failed",
170
233
  "cancelled",
234
+ "superseded",
171
235
  "reconciliation-required",
172
236
  ];
173
237
  const STORED_RUN_PHASES: readonly StoredRunPhase[] = [
238
+ "queued",
174
239
  "admitted",
175
240
  "executing",
176
241
  "reconciliation-required",
@@ -193,6 +258,8 @@ const STORED_RUN_OPTIONAL_KEYS = [
193
258
  "responseText",
194
259
  "failure",
195
260
  "stopRequestedAt",
261
+ "supersededAt",
262
+ "supersededBy",
196
263
  "admission",
197
264
  "directTool",
198
265
  ] as const;
@@ -333,6 +400,7 @@ function decodeStoredRunAdmission(
333
400
  const allowed = new Set([
334
401
  "schemaVersion",
335
402
  "turnType",
403
+ "lane",
336
404
  "subagentRole",
337
405
  "origin",
338
406
  ]);
@@ -352,6 +420,13 @@ function decodeStoredRunAdmission(
352
420
  } catch {
353
421
  throw new Error(`run "${runId}" has an invalid admission turn type`);
354
422
  }
423
+ const lane =
424
+ candidate.lane === undefined
425
+ ? undefined
426
+ : RUN_LANES_V1.find((value) => value === candidate.lane);
427
+ if (candidate.lane !== undefined && !lane) {
428
+ throw new Error(`run "${runId}" has an invalid admission lane`);
429
+ }
355
430
  if (
356
431
  candidate.subagentRole !== undefined &&
357
432
  (typeof candidate.subagentRole !== "string" ||
@@ -363,6 +438,7 @@ function decodeStoredRunAdmission(
363
438
  return {
364
439
  schemaVersion: 1,
365
440
  turnType,
441
+ ...(lane === undefined ? {} : { lane }),
366
442
  ...(candidate.subagentRole === undefined
367
443
  ? {}
368
444
  : { subagentRole: candidate.subagentRole as string }),
@@ -521,6 +597,25 @@ function requireStoredRunRecordV1<Snapshot>(
521
597
  ) {
522
598
  throw new Error(`run "${runId}" has invalid stopRequestedAt`);
523
599
  }
600
+ if (
601
+ candidate.supersededAt !== undefined &&
602
+ (!boundedString(candidate.supersededAt, 64) ||
603
+ !Number.isFinite(Date.parse(candidate.supersededAt as string)))
604
+ ) {
605
+ throw new Error(`run "${runId}" has invalid supersededAt`);
606
+ }
607
+ if (
608
+ candidate.supersededBy !== undefined &&
609
+ !boundedString(candidate.supersededBy, 128)
610
+ ) {
611
+ throw new Error(`run "${runId}" has invalid supersededBy`);
612
+ }
613
+ if (
614
+ candidate.supersededBy !== undefined &&
615
+ candidate.supersededAt === undefined
616
+ ) {
617
+ throw new Error(`run "${runId}" names a superseder with no supersede time`);
618
+ }
524
619
  if (
525
620
  status === "completed"
526
621
  ? candidate.responseText === undefined || candidate.failure !== undefined
@@ -538,6 +633,9 @@ function requireStoredRunRecordV1<Snapshot>(
538
633
  if (status === "cancelled" && candidate.stopRequestedAt === undefined) {
539
634
  throw new Error(`run "${runId}" has no durable stop intent`);
540
635
  }
636
+ if (status === "superseded" && candidate.supersededAt === undefined) {
637
+ throw new Error(`run "${runId}" has no durable supersede intent`);
638
+ }
541
639
  if (
542
640
  (status === "reconciliation-required") !==
543
641
  (phase === "reconciliation-required")
@@ -566,6 +664,12 @@ function requireStoredRunRecordV1<Snapshot>(
566
664
  ...(candidate.stopRequestedAt === undefined
567
665
  ? {}
568
666
  : { stopRequestedAt: candidate.stopRequestedAt as string }),
667
+ ...(candidate.supersededAt === undefined
668
+ ? {}
669
+ : { supersededAt: candidate.supersededAt as string }),
670
+ ...(candidate.supersededBy === undefined
671
+ ? {}
672
+ : { supersededBy: candidate.supersededBy as string }),
569
673
  ...(candidate.admission === undefined
570
674
  ? {}
571
675
  : { admission: decodeStoredRunAdmission(candidate.admission, runId) }),
@@ -602,6 +706,25 @@ export interface BotTurnCommand {
602
706
  */
603
707
  skills?: SkillRefV1[];
604
708
  directTool?: DirectToolCommandV1;
709
+ /**
710
+ * The lane this command asks to be admitted on. Absent means the lane its
711
+ * turn type defaults to.
712
+ */
713
+ lane?: RunLaneV1;
714
+ /**
715
+ * The explicit intent to replace whatever is running with this command. A
716
+ * user-lane command that carries it is the authenticated cancellation of the
717
+ * active Turn: that run terminalizes `superseded` and this one takes its
718
+ * place. Without it a second command is refused exactly as it always was.
719
+ *
720
+ * `runId` is provenance — the run the sender had observed, which may already
721
+ * be stale — and never the target. Its absence means the sender had observed
722
+ * no run at all, which is a race rather than a different intention, so it
723
+ * supersedes just the same. The whole field is part of the command
724
+ * fingerprint, so a replayed command replays and never interrupts a second
725
+ * Turn.
726
+ */
727
+ supersedes?: { runId?: string };
605
728
  }
606
729
 
607
730
  /**
@@ -616,12 +739,15 @@ export function botTurnCommandFingerprintV1(
616
739
  ): string {
617
740
  const turnType = command.turnType ?? "chat";
618
741
  const skills = command.skills ?? [];
742
+ const lane = command.lane ?? defaultRunLaneV1(turnType);
619
743
  if (
620
744
  turnType !== "chat" ||
621
745
  command.origin !== undefined ||
622
746
  command.subagentRole !== undefined ||
623
747
  skills.length > 0 ||
624
- command.directTool !== undefined
748
+ command.directTool !== undefined ||
749
+ lane !== defaultRunLaneV1(turnType) ||
750
+ command.supersedes !== undefined
625
751
  ) {
626
752
  return `bot-turn-command-v2:${JSON.stringify({
627
753
  userId: command.userId,
@@ -629,8 +755,10 @@ export function botTurnCommandFingerprintV1(
629
755
  sessionId: command.sessionId,
630
756
  text: command.text,
631
757
  turnType,
758
+ ...(lane === defaultRunLaneV1(turnType) ? {} : { lane }),
632
759
  ...(command.subagentRole ? { subagentRole: command.subagentRole } : {}),
633
760
  ...(command.origin ? { origin: command.origin } : {}),
761
+ ...(command.supersedes ? { supersedes: command.supersedes } : {}),
634
762
  ...(skills.length > 0 ? { skills: skills.map(formatSkillRefV1) } : {}),
635
763
  ...(command.directTool ? { directTool: command.directTool } : {}),
636
764
  })}`;
@@ -50,6 +50,83 @@ function assertPackageRecordKeys(
50
50
  }
51
51
  }
52
52
 
53
+ /**
54
+ * Records a Package writes in the transaction that settles a Turn as
55
+ * `superseded`. Same shape and same rule as `TerminalPackageRecords`: the
56
+ * kernel writes opaque keys and holds none of the policy that produced them.
57
+ * It is a separate seam because a superseded Turn is not a completed one — the
58
+ * Package that owns the conversation wants to leave the *next* Turn a durable
59
+ * note, not advance the records a finished Turn advances.
60
+ */
61
+ export type SupersededPackageRecords<Snapshot> = (input: {
62
+ run: StoredRunV1<Snapshot>;
63
+ read<T>(key: string): Promise<T | undefined>;
64
+ }) => Promise<Record<string, unknown>>;
65
+
66
+ /**
67
+ * Settles a superseded run as terminal `superseded` and clears its active
68
+ * marker. Like a cancelled run it produces no response text, no failure, and
69
+ * no notification: the Turn that replaced it is what the User is watching.
70
+ */
71
+ export async function supersedeStoredRun<Snapshot>(
72
+ codec: StoredRunCodecV1<Snapshot>,
73
+ storage: RunTerminalStorage,
74
+ keys: RunTerminalKeys,
75
+ runId: string,
76
+ previous: readonly SessionEvent[],
77
+ events: readonly SessionEvent[],
78
+ packageRecords?: SupersededPackageRecords<Snapshot>,
79
+ ): Promise<"superseded"> {
80
+ const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
81
+ if (!stored) throw new Error(`run "${runId}" was not accepted`);
82
+ const run = codec.require(stored);
83
+ if (!run.supersededAt) {
84
+ throw new Error(`run "${runId}" has no durable supersede intent`);
85
+ }
86
+ const decodedEvents = events.map(decodeSessionEvent);
87
+ const { responseText: _text, failure: _failure, ...settled } = run;
88
+ // A run superseded while still queued never started, never appended an
89
+ // event, and never spoke: it settles as a record on its own and leaves both
90
+ // the session log and the running Turn exactly where they were.
91
+ const queued = settled.phase === "queued";
92
+ const superseded = codec.require({
93
+ ...settled,
94
+ events: queued ? [] : decodedEvents,
95
+ status: "superseded",
96
+ phase:
97
+ settled.phase === "reconciliation-required"
98
+ ? "executing"
99
+ : settled.phase === "queued"
100
+ ? "admitted"
101
+ : settled.phase,
102
+ } satisfies StoredRunV1<Snapshot>);
103
+ const records: Record<string, unknown> = {
104
+ [keys.run]: structuredClone(superseded),
105
+ ...(queued
106
+ ? {}
107
+ : {
108
+ [keys.latestEvents]: structuredClone(
109
+ [...previous, ...decodedEvents].map(decodeSessionEvent),
110
+ ),
111
+ }),
112
+ };
113
+ if (packageRecords && !queued) {
114
+ const contributed = await packageRecords({
115
+ run: superseded,
116
+ read: <T>(key: string) => storage.get<T>(key),
117
+ });
118
+ assertPackageRecordKeys(contributed, keys);
119
+ for (const [key, value] of Object.entries(contributed)) {
120
+ records[key] = structuredClone(value);
121
+ }
122
+ }
123
+ await storage.put(records);
124
+ if ((await storage.get<string>(keys.activeRun)) === runId) {
125
+ await storage.delete(keys.activeRun);
126
+ }
127
+ return "superseded";
128
+ }
129
+
53
130
  export async function completeStoredRun<Snapshot>(
54
131
  codec: StoredRunCodecV1<Snapshot>,
55
132
  storage: RunTerminalStorage,
@@ -58,7 +135,8 @@ export async function completeStoredRun<Snapshot>(
58
135
  previous: readonly SessionEvent[],
59
136
  result: BotTurnCompletion,
60
137
  packageRecords?: TerminalPackageRecords<Snapshot>,
61
- ): Promise<"completed" | "cancelled"> {
138
+ supersededRecords?: SupersededPackageRecords<Snapshot>,
139
+ ): Promise<"completed" | "cancelled" | "superseded"> {
62
140
  const activeRunId = await storage.get<string>(keys.activeRun);
63
141
  if (activeRunId !== runId) throw new Error(`run "${runId}" is not active`);
64
142
  const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
@@ -66,6 +144,20 @@ export async function completeStoredRun<Snapshot>(
66
144
  const run = codec.require(stored);
67
145
  const events = result.events.map(decodeSessionEvent);
68
146
  const latestEvents = [...previous, ...events].map(decodeSessionEvent);
147
+ // Stop outranks supersede: the User asked for this Turn to stop, and a
148
+ // message that arrived after that does not turn their cancellation into
149
+ // something else.
150
+ if (run.supersededAt && !run.stopRequestedAt) {
151
+ return supersedeStoredRun(
152
+ codec,
153
+ storage,
154
+ keys,
155
+ runId,
156
+ previous,
157
+ result.events,
158
+ supersededRecords,
159
+ );
160
+ }
69
161
  // Durable Stop intent recorded before this settlement wins: the run becomes
70
162
  // terminal `cancelled` with no response text, failure, or notification.
71
163
  if (run.stopRequestedAt) {
@@ -162,7 +254,10 @@ export async function failStoredRun<Snapshot>(
162
254
  previous: readonly SessionEvent[],
163
255
  events: readonly SessionEvent[],
164
256
  failure: string,
165
- ): Promise<"failed" | "cancelled" | "preserved-completion" | "missing"> {
257
+ supersededRecords?: SupersededPackageRecords<Snapshot>,
258
+ ): Promise<
259
+ "failed" | "cancelled" | "superseded" | "preserved-completion" | "missing"
260
+ > {
166
261
  const stored = await storage.get<StoredRunV1<Snapshot>>(keys.run);
167
262
  if (!stored) return "missing";
168
263
  const run = codec.require(stored);
@@ -171,6 +266,18 @@ export async function failStoredRun<Snapshot>(
171
266
  if (run.stopRequestedAt) {
172
267
  return cancelStoredRun(codec, storage, keys, runId, previous, events);
173
268
  }
269
+ // Nor does a superseded one. The Turn that replaced it is the outcome.
270
+ if (run.supersededAt) {
271
+ return supersedeStoredRun(
272
+ codec,
273
+ storage,
274
+ keys,
275
+ runId,
276
+ previous,
277
+ events,
278
+ supersededRecords,
279
+ );
280
+ }
174
281
  const decodedEvents = events.map(decodeSessionEvent);
175
282
  const latestEvents = [...previous, ...decodedEvents].map(decodeSessionEvent);
176
283
  const failed = codec.require({
@@ -8,6 +8,14 @@ export const RUN_ADMISSION_FENCE_PREFIX = "run-admission-fence:";
8
8
  export const RUN_ADMISSION_FENCE_INDEX_KEY = "run-admission-fences";
9
9
  export const MAX_RUN_ADMISSION_FENCES = 256;
10
10
  export const ACTIVE_RUN_KEY = "active-run";
11
+ /**
12
+ * The one admitted user-lane Turn waiting for the object to become free.
13
+ *
14
+ * A single slot, not a queue: a second user message supersedes the first
15
+ * waiting one exactly as it supersedes a running one, so the Bot is never
16
+ * working through a backlog of things the User has already replaced.
17
+ */
18
+ export const PENDING_RUN_KEY = "pending-run";
11
19
  export const LATEST_EVENTS_KEY = "latest-events";
12
20
  export const IDENTITY_KEY = "identity";
13
21
  export const NOTIFICATION_PREFIX = "notification:";