@frockbot/kernel-do 0.3.41 → 0.3.43

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.41",
3
+ "version": "0.3.43",
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.41",
16
- "@frockbot/kernel-contracts": "0.3.41",
15
+ "@frockbot/kernel-composition": "0.3.43",
16
+ "@frockbot/kernel-contracts": "0.3.43",
17
17
  "cordis": "4.0.0-rc.8"
18
18
  },
19
19
  "devDependencies": {
package/src/authority.ts CHANGED
@@ -10,7 +10,10 @@ import {
10
10
  type SessionEvent,
11
11
  } from "@frockbot/kernel-contracts";
12
12
  import type { CompositionGenerationV1 } from "@frockbot/kernel-composition/generation";
13
- import { DurableCompositionStore } from "./composition-store.js";
13
+ import {
14
+ DurableCompositionStore,
15
+ decodeCompositionPinV1,
16
+ } from "./composition-store.js";
14
17
  import { DurableCompositionFailureLog } from "./composition-failures.js";
15
18
  import {
16
19
  boundedRunFailureV1,
@@ -22,11 +25,13 @@ import {
22
25
  storedRunRecordV2,
23
26
  storedRunSubagentRoleV1,
24
27
  storedRunTurnTypeV1,
28
+ unreadableStoredRunV1,
25
29
  type BotNotificationIntent,
26
30
  type BotTurnCommand,
27
31
  type BotTurnCompletion,
28
32
  type StoredRunCodecV1,
29
33
  type StoredRunV1,
34
+ type UnreadableStoredRunV1,
30
35
  } from "./run-records.js";
31
36
  import {
32
37
  completeStoredRun,
@@ -67,6 +72,7 @@ import {
67
72
  } from "./conversations.js";
68
73
  import {
69
74
  ACTIVE_RUN_KEY,
75
+ COMPOSITION_CURRENT_KEY,
70
76
  CONVERSATION_INDEX_KEY,
71
77
  CONVERSATION_KEY,
72
78
  MAX_LISTED_CONVERSATIONS,
@@ -235,6 +241,14 @@ function runWasDiscardedV1(
235
241
  return Boolean(run?.stopRequestedAt || run?.supersededAt);
236
242
  }
237
243
 
244
+ /**
245
+ * One run as the display-only read boundary sees it: either the decoded
246
+ * record, or the bounded identity of a record that could not be decoded.
247
+ */
248
+ export type DisplayRunReadV1<Snapshot> =
249
+ | { readonly readable: true; readonly run: StoredRunV1<Snapshot> }
250
+ | { readonly readable: false; readonly run: UnreadableStoredRunV1 };
251
+
238
252
  export interface BotDurableAuthorityOptions<Snapshot> {
239
253
  state: DurableObjectState;
240
254
  codec: StoredRunCodecV1<Snapshot>;
@@ -400,6 +414,14 @@ export class BotDurableAuthority<Snapshot> {
400
414
  * Makes the durably queued run the active one, recomputing the history it
401
415
  * starts from: the Turn it waited behind appended events, and the queued
402
416
  * Turn's model request derives from everything that is durable now.
417
+ *
418
+ * The Composition pin is recomputed here for the same reason. A queued Turn
419
+ * is admitted but has not started, and "an in-flight Turn keeps its pinned
420
+ * implementation" is about a Turn that is running, not one that is waiting.
421
+ * The Turn ahead of it can author a Package or follow a deployment while it
422
+ * waits, and pinning what the pointer said at admission ran the queued Turn
423
+ * without the member that was just added — the tool the Bot had told the
424
+ * person it had built.
403
425
  */
404
426
  private async promoteQueuedRun(runId: string): Promise<
405
427
  | "not-queued"
@@ -449,9 +471,18 @@ export class BotDurableAuthority<Snapshot> {
449
471
  // repair applies before this Turn starts on it.
450
472
  const repaired = repairedSessionLogV1(run.sessionId, storedEvents);
451
473
  const latestEvents = repaired ?? storedEvents;
474
+ // The pointer is materialized at admission, so it is there; a Bot whose
475
+ // record somehow is not keeps the generation it was admitted under
476
+ // rather than failing a Turn it is owed.
477
+ const pointer = await transaction.get<unknown>(COMPOSITION_CURRENT_KEY);
478
+ const compositionGenerationId =
479
+ pointer === undefined
480
+ ? run.compositionGenerationId
481
+ : decodeCompositionPinV1(pointer).generationId;
452
482
  const promoted = this.codec.require({
453
483
  ...run,
454
484
  phase: "admitted",
485
+ compositionGenerationId,
455
486
  previousEventCount: latestEvents.length,
456
487
  ...storedRunEventFieldsV2(latestEvents.length, []),
457
488
  } satisfies StoredRunV1<Snapshot>);
@@ -1227,6 +1258,61 @@ export class BotDurableAuthority<Snapshot> {
1227
1258
  return this.readRunFrom(this.ctx.storage, runId, "display");
1228
1259
  }
1229
1260
 
1261
+ /**
1262
+ * The record alone, for display, never throwing on a record it cannot read.
1263
+ *
1264
+ * The transcript is the one reader that must survive a bad row. Execution
1265
+ * and recovery stay strict — they act on the record, and acting on a record
1266
+ * nobody can decode is how a Turn gets settled twice — but a read that only
1267
+ * draws the conversation owes the person the other forty Turns. An
1268
+ * undecodable record comes back as {@link UnreadableStoredRunV1}: the run id
1269
+ * from the lookup key plus whatever scraped strings are safe, which is
1270
+ * enough to render exactly one degraded row.
1271
+ */
1272
+ async readRunHeaderForDisplay(
1273
+ runId: string,
1274
+ ): Promise<DisplayRunReadV1<Snapshot> | undefined> {
1275
+ const raw = await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`);
1276
+ if (raw === undefined) return undefined;
1277
+ try {
1278
+ return { readable: true, run: this.codec.require(raw) };
1279
+ } catch {
1280
+ return { readable: false, run: unreadableStoredRunV1(runId, raw) };
1281
+ }
1282
+ }
1283
+
1284
+ /**
1285
+ * The journal behind a display header, degrading rather than throwing.
1286
+ *
1287
+ * Takes the header the caller already read rather than the run id: a
1288
+ * transcript page reads one record per candidate and hydrates only the ones
1289
+ * it keeps, and re-reading the record here would put that read back.
1290
+ */
1291
+ async hydrateRunForDisplay(
1292
+ header: DisplayRunReadV1<Snapshot>,
1293
+ ): Promise<DisplayRunReadV1<Snapshot>> {
1294
+ if (!header.readable) return header;
1295
+ try {
1296
+ return {
1297
+ readable: true,
1298
+ run: await this.hydrateRun(this.ctx.storage, header.run, "display"),
1299
+ };
1300
+ } catch {
1301
+ return {
1302
+ readable: false,
1303
+ run: unreadableStoredRunV1(header.run.runId, header.run),
1304
+ };
1305
+ }
1306
+ }
1307
+
1308
+ /** {@link readRunHeaderForDisplay} with its journal hydrated. */
1309
+ async readStoredRunForDisplayOrDegraded(
1310
+ runId: string,
1311
+ ): Promise<DisplayRunReadV1<Snapshot> | undefined> {
1312
+ const header = await this.readRunHeaderForDisplay(runId);
1313
+ return header ? this.hydrateRunForDisplay(header) : undefined;
1314
+ }
1315
+
1230
1316
  private async readRunFrom(
1231
1317
  storage: SessionEventLogStorage,
1232
1318
  runId: string,
@@ -1235,7 +1321,16 @@ export class BotDurableAuthority<Snapshot> {
1235
1321
  const run = this.codec.optional(
1236
1322
  await storage.get<unknown>(`${RUN_PREFIX}${runId}`),
1237
1323
  );
1238
- if (!run?.eventRange) return run;
1324
+ if (!run) return undefined;
1325
+ return this.hydrateRun(storage, run, fidelity);
1326
+ }
1327
+
1328
+ private async hydrateRun(
1329
+ storage: SessionEventLogStorage,
1330
+ run: StoredRunV1<Snapshot>,
1331
+ fidelity: "exact" | "display",
1332
+ ): Promise<StoredRunV1<Snapshot>> {
1333
+ if (!run.eventRange) return run;
1239
1334
  const log = new SessionEventLog(storage);
1240
1335
  const events =
1241
1336
  fidelity === "display"
@@ -3,6 +3,9 @@ import {
3
3
  bootstrapGeneration,
4
4
  compositionArtifactSetHashV1,
5
5
  compositionGenerationIdV1,
6
+ CompositionPinConflictError,
7
+ COMPOSITION_PIN_ATTEMPTS_V1,
8
+ pinCompositionWithRetryV1,
6
9
  type CompositionGenerationV1,
7
10
  type CompositionMemberV1,
8
11
  } from "@frockbot/kernel-composition/generation";
@@ -506,6 +509,123 @@ describe("Bot Durable Object Composition records", () => {
506
509
  });
507
510
  });
508
511
 
512
+ describe("a pinning proposal compares and swaps the pointer", () => {
513
+ test("a proposal derived from the generation the pointer still names wins", async () => {
514
+ const storage = new MemoryStorage();
515
+ const store = createStore(storage);
516
+ const parent = await store.current();
517
+ const next = await grownSuccessor(parent, "2026-09-01T00:00:00.000Z");
518
+
519
+ await store.propose(next, {
520
+ pin: true,
521
+ expectedCurrentGenerationId: parent.generationId,
522
+ });
523
+
524
+ expect((await store.current()).generationId).toBe(next.generationId);
525
+ });
526
+
527
+ test("a proposal derived from a pointer that has since moved is refused whole", async () => {
528
+ const storage = new MemoryStorage();
529
+ const store = createStore(storage);
530
+ // What the losing writer snapshotted before it yielded.
531
+ const stale = await store.current();
532
+ // What landed while it was yielded: a Package the Bot authored.
533
+ const authored = await grownSuccessor(stale, "2026-09-01T00:00:00.000Z");
534
+ await store.propose(authored, {
535
+ pin: true,
536
+ expectedCurrentGenerationId: stale.generationId,
537
+ });
538
+
539
+ const derivedFromStale = await successor(stale, "2026-09-01T00:00:01.000Z");
540
+ await expect(
541
+ store.propose(derivedFromStale, {
542
+ pin: true,
543
+ expectedCurrentGenerationId: stale.generationId,
544
+ }),
545
+ ).rejects.toBeInstanceOf(CompositionPinConflictError);
546
+
547
+ // The pointer still names the winner, and the loser wrote nothing at all:
548
+ // no generation record, no index entry, so no retention quota was spent on
549
+ // a proposal that never applied.
550
+ expect((await store.current()).generationId).toBe(authored.generationId);
551
+ expect(await store.read(derivedFromStale.generationId)).toBeUndefined();
552
+ expect(await store.retainedCount()).toBe(2);
553
+ // And the authored member is still there — the whole point of refusing.
554
+ expect(
555
+ (await store.current()).members.map((member) => member.packageId),
556
+ ).toEqual(["bot-authored-greeter", "shell"]);
557
+ });
558
+
559
+ test("the loser re-derives from the winner and keeps both members", async () => {
560
+ const storage = new MemoryStorage();
561
+ const store = createStore(storage);
562
+ const stale = await store.current();
563
+ const authored = await grownSuccessor(stale, "2026-09-01T00:00:00.000Z");
564
+ await store.propose(authored, {
565
+ pin: true,
566
+ expectedCurrentGenerationId: stale.generationId,
567
+ });
568
+
569
+ let derivedFrom = stale;
570
+ let attempts = 0;
571
+ const pinned = await pinCompositionWithRetryV1(async () => {
572
+ attempts += 1;
573
+ // A first attempt that derives from the snapshot it took before it
574
+ // yielded, and a retry that re-reads — which is the merge.
575
+ const parent = attempts === 1 ? stale : await store.current();
576
+ derivedFrom = parent;
577
+ const generation = await successor(
578
+ parent,
579
+ `2026-09-01T00:00:0${attempts}.000Z`,
580
+ );
581
+ await store.propose(generation, {
582
+ pin: true,
583
+ expectedCurrentGenerationId: parent.generationId,
584
+ });
585
+ return generation;
586
+ });
587
+
588
+ expect(attempts).toBe(2);
589
+ expect(derivedFrom.generationId).toBe(authored.generationId);
590
+ expect((await store.current()).generationId).toBe(pinned.generationId);
591
+ expect(pinned.members.map((member) => member.packageId)).toEqual([
592
+ "bot-authored-greeter",
593
+ "shell",
594
+ ]);
595
+ });
596
+
597
+ test("gives up after a bounded number of losses rather than spinning", async () => {
598
+ const storage = new MemoryStorage();
599
+ const store = createStore(storage);
600
+ const stale = await store.current();
601
+ let attempts = 0;
602
+
603
+ await expect(
604
+ pinCompositionWithRetryV1(async () => {
605
+ attempts += 1;
606
+ // Someone else always wins: every attempt derives from a pointer that
607
+ // has already moved by the time it proposes.
608
+ const winner = await successor(
609
+ await store.current(),
610
+ `2026-09-0${attempts}T00:00:00.000Z`,
611
+ );
612
+ await store.propose(winner, { pin: true });
613
+ const generation = await successor(
614
+ stale,
615
+ `2026-09-0${attempts}T00:00:01.000Z`,
616
+ );
617
+ await store.propose(generation, {
618
+ pin: true,
619
+ expectedCurrentGenerationId: stale.generationId,
620
+ });
621
+ return generation;
622
+ }),
623
+ ).rejects.toBeInstanceOf(CompositionPinConflictError);
624
+
625
+ expect(attempts).toBe(COMPOSITION_PIN_ATTEMPTS_V1);
626
+ });
627
+ });
628
+
509
629
  interface TurnProbe {
510
630
  authority: BotDurableAuthority<undefined>;
511
631
  store: DurableCompositionStore;
@@ -8,6 +8,7 @@ import { decodeCompositionFailureV1 } from "@frockbot/kernel-composition/activat
8
8
  import {
9
9
  assertCompositionArtifactSetHashV1,
10
10
  compositionGenerationIdV1,
11
+ CompositionPinConflictError,
11
12
  type CompositionGenerationV1,
12
13
  type CompositionOriginV1,
13
14
  type CompositionStore,
@@ -151,7 +152,7 @@ export class DurableCompositionStore implements CompositionStore {
151
152
 
152
153
  async propose(
153
154
  generation: CompositionGenerationV1,
154
- options: { pin?: boolean } = {},
155
+ options: { pin?: boolean; expectedCurrentGenerationId?: string } = {},
155
156
  ): Promise<void> {
156
157
  const proposed = decodeCompositionGenerationV1(generation);
157
158
  if (proposed.status !== "pending") {
@@ -164,6 +165,23 @@ export class DurableCompositionStore implements CompositionStore {
164
165
  await this.ctx.storage.transaction(async (transaction) => {
165
166
  const bootstrap = await this.bootstrapGeneration(transaction);
166
167
  this.assertRequiredCoreSet(bootstrap, proposed);
168
+ // Compare-and-swap before anything is written: a proposal derived from a
169
+ // pointer that has since moved would drop whatever the winner added, so
170
+ // it is refused whole rather than merged blind. Nothing has been put yet,
171
+ // so the caller re-derives from the pointer it lost to and tries again.
172
+ if (options.pin && options.expectedCurrentGenerationId !== undefined) {
173
+ const pointer = await transaction.get<unknown>(COMPOSITION_CURRENT_KEY);
174
+ const currentId =
175
+ pointer === undefined
176
+ ? undefined
177
+ : decodeCompositionPinV1(pointer).generationId;
178
+ if (currentId !== options.expectedCurrentGenerationId) {
179
+ throw new CompositionPinConflictError(
180
+ options.expectedCurrentGenerationId,
181
+ currentId,
182
+ );
183
+ }
184
+ }
167
185
  const key = compositionGenerationKey(proposed.generationId);
168
186
  if ((await transaction.get<unknown>(key)) !== undefined) {
169
187
  throw new Error(
@@ -980,3 +980,60 @@ export interface BotTurnCompletion {
980
980
  events: SessionEvent[];
981
981
  notification?: BotNotificationIntent;
982
982
  }
983
+
984
+ /**
985
+ * The little that can be trusted about a run record nobody can decode.
986
+ *
987
+ * A transcript read is display-only: it never resumes, settles or recovers a
988
+ * Turn, so it does not need the record to be valid — it needs enough to draw
989
+ * one row saying which Turn could not be read. The run id comes from the
990
+ * admission index key, which is authority; everything else is scraped from the
991
+ * raw value and kept only where it is plainly a safe, bounded string, so a
992
+ * record corrupt in any other field still yields a renderable row.
993
+ */
994
+ export interface UnreadableStoredRunV1 {
995
+ readonly runId: string;
996
+ readonly sessionId?: string;
997
+ readonly acceptedAt?: string;
998
+ readonly input?: string;
999
+ readonly admission?: { readonly turnType?: TurnTypeV1 };
1000
+ }
1001
+
1002
+ /** Scrape a record that failed to decode down to {@link UnreadableStoredRunV1}. */
1003
+ export function unreadableStoredRunV1(
1004
+ runId: string,
1005
+ raw: unknown,
1006
+ ): UnreadableStoredRunV1 {
1007
+ const candidate =
1008
+ raw && typeof raw === "object" && !Array.isArray(raw)
1009
+ ? (raw as Record<string, unknown>)
1010
+ : {};
1011
+ const admission =
1012
+ candidate.admission &&
1013
+ typeof candidate.admission === "object" &&
1014
+ !Array.isArray(candidate.admission)
1015
+ ? (candidate.admission as Record<string, unknown>)
1016
+ : undefined;
1017
+ let turnType: TurnTypeV1 | undefined;
1018
+ try {
1019
+ if (admission?.turnType !== undefined) {
1020
+ turnType = decodeTurnTypeV1(admission.turnType);
1021
+ }
1022
+ } catch {
1023
+ turnType = undefined;
1024
+ }
1025
+ return {
1026
+ runId,
1027
+ ...(boundedString(candidate.sessionId, 257)
1028
+ ? { sessionId: candidate.sessionId }
1029
+ : {}),
1030
+ ...(boundedString(candidate.acceptedAt, 64) &&
1031
+ Number.isFinite(Date.parse(candidate.acceptedAt))
1032
+ ? { acceptedAt: candidate.acceptedAt }
1033
+ : {}),
1034
+ ...(boundedString(candidate.input, 32_000)
1035
+ ? { input: candidate.input }
1036
+ : {}),
1037
+ ...(turnType ? { admission: { turnType } } : {}),
1038
+ };
1039
+ }