@frockbot/kernel-do 0.3.41 → 0.3.42
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 +22 -1
- package/src/composition-store.test.ts +120 -0
- package/src/composition-store.ts +19 -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.42",
|
|
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.42",
|
|
16
|
+
"@frockbot/kernel-contracts": "0.3.42",
|
|
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 {
|
|
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,
|
|
@@ -67,6 +70,7 @@ import {
|
|
|
67
70
|
} from "./conversations.js";
|
|
68
71
|
import {
|
|
69
72
|
ACTIVE_RUN_KEY,
|
|
73
|
+
COMPOSITION_CURRENT_KEY,
|
|
70
74
|
CONVERSATION_INDEX_KEY,
|
|
71
75
|
CONVERSATION_KEY,
|
|
72
76
|
MAX_LISTED_CONVERSATIONS,
|
|
@@ -400,6 +404,14 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
400
404
|
* Makes the durably queued run the active one, recomputing the history it
|
|
401
405
|
* starts from: the Turn it waited behind appended events, and the queued
|
|
402
406
|
* Turn's model request derives from everything that is durable now.
|
|
407
|
+
*
|
|
408
|
+
* The Composition pin is recomputed here for the same reason. A queued Turn
|
|
409
|
+
* is admitted but has not started, and "an in-flight Turn keeps its pinned
|
|
410
|
+
* implementation" is about a Turn that is running, not one that is waiting.
|
|
411
|
+
* The Turn ahead of it can author a Package or follow a deployment while it
|
|
412
|
+
* waits, and pinning what the pointer said at admission ran the queued Turn
|
|
413
|
+
* without the member that was just added — the tool the Bot had told the
|
|
414
|
+
* person it had built.
|
|
403
415
|
*/
|
|
404
416
|
private async promoteQueuedRun(runId: string): Promise<
|
|
405
417
|
| "not-queued"
|
|
@@ -449,9 +461,18 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
449
461
|
// repair applies before this Turn starts on it.
|
|
450
462
|
const repaired = repairedSessionLogV1(run.sessionId, storedEvents);
|
|
451
463
|
const latestEvents = repaired ?? storedEvents;
|
|
464
|
+
// The pointer is materialized at admission, so it is there; a Bot whose
|
|
465
|
+
// record somehow is not keeps the generation it was admitted under
|
|
466
|
+
// rather than failing a Turn it is owed.
|
|
467
|
+
const pointer = await transaction.get<unknown>(COMPOSITION_CURRENT_KEY);
|
|
468
|
+
const compositionGenerationId =
|
|
469
|
+
pointer === undefined
|
|
470
|
+
? run.compositionGenerationId
|
|
471
|
+
: decodeCompositionPinV1(pointer).generationId;
|
|
452
472
|
const promoted = this.codec.require({
|
|
453
473
|
...run,
|
|
454
474
|
phase: "admitted",
|
|
475
|
+
compositionGenerationId,
|
|
455
476
|
previousEventCount: latestEvents.length,
|
|
456
477
|
...storedRunEventFieldsV2(latestEvents.length, []),
|
|
457
478
|
} satisfies StoredRunV1<Snapshot>);
|
|
@@ -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;
|
package/src/composition-store.ts
CHANGED
|
@@ -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(
|