@frockbot/kernel-do 0.3.9 → 0.3.10

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.9",
3
+ "version": "0.3.10",
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.9",
16
- "@frockbot/kernel-contracts": "0.3.9",
15
+ "@frockbot/kernel-composition": "0.3.10",
16
+ "@frockbot/kernel-contracts": "0.3.10",
17
17
  "cordis": "4.0.0-rc.8"
18
18
  },
19
19
  "devDependencies": {
@@ -39,7 +39,15 @@ class MemoryStorage {
39
39
  return Promise.resolve();
40
40
  }
41
41
 
42
- delete(key: string): Promise<boolean> {
42
+ delete(key: string | string[]): Promise<boolean | number> {
43
+ if (Array.isArray(key)) {
44
+ return Promise.resolve(
45
+ key.reduce(
46
+ (count, entry) => count + (this.values.delete(entry) ? 1 : 0),
47
+ 0,
48
+ ),
49
+ );
50
+ }
43
51
  return Promise.resolve(this.values.delete(key));
44
52
  }
45
53
 
@@ -390,6 +398,94 @@ describe("fail-closed Composition activation", () => {
390
398
  expect(await failures.list(broken.generationId)).toHaveLength(1);
391
399
  });
392
400
 
401
+ test("three failed repairs quarantine, even though each one is a new generation", async () => {
402
+ // The safeguard was dead code in the path a real user takes. The model
403
+ // never retries a failed generation: it authors a *new* one, which
404
+ // supersedes the failed one at attempt 1, so the per-generation counter
405
+ // never reached three and the Composition just grew one dead generation
406
+ // per repair attempt.
407
+ const { store, failures, storage, lastKnownGood } = await fixture();
408
+ const attempts: CompositionGenerationV1[] = [];
409
+ for (const minute of [1, 2, 3]) {
410
+ const generation = await authored(
411
+ lastKnownGood,
412
+ `2026-09-01T00:0${minute}:00.000Z`,
413
+ );
414
+ await store.propose(generation, { pin: true });
415
+ attempts.push(generation);
416
+ const activation = await activateCompositionV1({
417
+ generationId: generation.generationId,
418
+ store: activationStore(store),
419
+ failures,
420
+ host: brokenHost(generation.generationId, "mount"),
421
+ signal: new AbortController().signal,
422
+ });
423
+ expect(activation.status).toBe("failed-closed");
424
+ if (activation.status !== "failed-closed") return;
425
+ // Each new generation is on its own first attempt...
426
+ expect(activation.failure?.attempt).toBe(1);
427
+ // ...but the Bot's streak is what earns the quarantine.
428
+ expect(activation.quarantined).toBe(minute === 3);
429
+ }
430
+
431
+ const third = attempts[2]!;
432
+ expect((await store.read(third.generationId))?.status).toBe("quarantined");
433
+ expect(
434
+ storage.values.get(compositionQuarantineKey(third.generationId)),
435
+ ).toBeDefined();
436
+ // The two earlier attempts stay `failed`: a quarantine is a decision about
437
+ // the generation that earned it, not a verdict on its history.
438
+ expect((await store.read(attempts[0]!.generationId))?.status).toBe(
439
+ "failed",
440
+ );
441
+ expect((await store.read(attempts[1]!.generationId))?.status).toBe(
442
+ "failed",
443
+ );
444
+ });
445
+
446
+ test("a generation that activates clears the streak, so the next failure starts over", async () => {
447
+ const { store, failures, lastKnownGood } = await fixture();
448
+ for (const minute of [1, 2]) {
449
+ const generation = await authored(
450
+ lastKnownGood,
451
+ `2026-09-01T00:0${minute}:00.000Z`,
452
+ );
453
+ await store.propose(generation, { pin: true });
454
+ await activateCompositionV1({
455
+ generationId: generation.generationId,
456
+ store: activationStore(store),
457
+ failures,
458
+ host: brokenHost(generation.generationId, "mount"),
459
+ signal: new AbortController().signal,
460
+ });
461
+ }
462
+
463
+ const working = await authored(lastKnownGood, "2026-09-01T00:03:00.000Z");
464
+ await store.propose(working, { pin: true });
465
+ const activated = await activateCompositionV1({
466
+ generationId: working.generationId,
467
+ store: activationStore(store),
468
+ failures,
469
+ host: { mount: (generation) => Promise.resolve(mounted(generation)) },
470
+ signal: new AbortController().signal,
471
+ });
472
+ expect(activated.status).toBe("activated");
473
+
474
+ const next = await authored(lastKnownGood, "2026-09-01T00:04:00.000Z");
475
+ await store.propose(next, { pin: true });
476
+ const activation = await activateCompositionV1({
477
+ generationId: next.generationId,
478
+ store: activationStore(store),
479
+ failures,
480
+ host: brokenHost(next.generationId, "mount"),
481
+ signal: new AbortController().signal,
482
+ });
483
+
484
+ expect(activation.status).toBe("failed-closed");
485
+ if (activation.status !== "failed-closed") return;
486
+ expect(activation.quarantined).toBe(false);
487
+ });
488
+
393
489
  test("a last known good that will not mount has nothing to fail into", async () => {
394
490
  const { store, failures, lastKnownGood } = await fixture();
395
491
  await expect(
@@ -14,6 +14,7 @@ import {
14
14
  type CompositionQuarantineV1,
15
15
  } from "@frockbot/kernel-composition/activation";
16
16
  import {
17
+ COMPOSITION_FAILURE_STREAK_KEY,
17
18
  compositionFailureCountKey,
18
19
  compositionFailureKey,
19
20
  compositionFailurePrefix,
@@ -34,8 +35,11 @@ export interface DurableCompositionFailureLogOptions {
34
35
  * `composition:failure-count:<generationId>`, and
35
36
  * `composition:quarantine:<generationId>`.
36
37
  *
37
- * Quarantine is per generation, never per Bot: a later, unrelated generation is
38
- * unaffected by an earlier one being quarantined.
38
+ * Quarantine is *marked* per generation, never per Bot: a generation an earlier
39
+ * one's quarantine does not implicate is unaffected, and lifting a quarantine
40
+ * is a decision about one generation. What earns a quarantine is either
41
+ * counter reaching the threshold — this generation's own retries, or the Bot's
42
+ * consecutive failures across however many generations the repairs minted.
39
43
  */
40
44
  export class DurableCompositionFailureLog implements CompositionFailureLog {
41
45
  private readonly ctx: DurableObjectState;
@@ -60,10 +64,20 @@ export class DurableCompositionFailureLog implements CompositionFailureLog {
60
64
  )) ?? 0;
61
65
  const attempt = previous + 1;
62
66
  const recorded = decodeCompositionFailureV1({ ...failure, attempt });
63
- const quarantined = attempt >= this.threshold;
67
+ // Two counters, one threshold. `attempt` is this generation's own
68
+ // retries; `streak` is the Bot's consecutive failures however many
69
+ // generations they are spread over. Only the second one ever moves when
70
+ // the model repairs by authoring a *new* generation, which is what it
71
+ // always does — so without it the safeguard never fired and the
72
+ // Composition grew one dead generation per repair attempt.
73
+ const streak =
74
+ ((await transaction.get<number>(COMPOSITION_FAILURE_STREAK_KEY)) ?? 0) +
75
+ 1;
76
+ const quarantined = attempt >= this.threshold || streak >= this.threshold;
64
77
  const writes: Record<string, unknown> = {
65
78
  [compositionFailureKey(generationId, attempt)]: recorded,
66
79
  [compositionFailureCountKey(generationId)]: attempt,
80
+ [COMPOSITION_FAILURE_STREAK_KEY]: streak,
67
81
  };
68
82
  if (quarantined) {
69
83
  writes[compositionQuarantineKey(generationId)] =
@@ -71,7 +85,9 @@ export class DurableCompositionFailureLog implements CompositionFailureLog {
71
85
  generationId,
72
86
  quarantinedAt: this.now().toISOString(),
73
87
  reason: recorded.message,
74
- failures: attempt,
88
+ // The count that earned it, which is the streak whenever the
89
+ // repairs were spread over generations.
90
+ failures: Math.max(attempt, streak),
75
91
  });
76
92
  }
77
93
  await transaction.put(writes);
@@ -103,6 +119,9 @@ export class DurableCompositionFailureLog implements CompositionFailureLog {
103
119
  * recorded failures survive: they are the repair history a User reads.
104
120
  */
105
121
  async clear(generationId: string): Promise<void> {
106
- await this.ctx.storage.delete(compositionFailureCountKey(generationId));
122
+ await this.ctx.storage.delete([
123
+ compositionFailureCountKey(generationId),
124
+ COMPOSITION_FAILURE_STREAK_KEY,
125
+ ]);
107
126
  }
108
127
  }
@@ -109,6 +109,19 @@ export function compositionFailureCountKey(generationId: string): string {
109
109
  return `${COMPOSITION_FAILURE_COUNT_PREFIX}${generationId}`;
110
110
  }
111
111
 
112
+ /**
113
+ * The Bot's own consecutive-failure streak, across generations.
114
+ *
115
+ * The per-generation counter alone left quarantine as dead code in the path a
116
+ * real user takes: the model's natural repair is to *author a new generation*,
117
+ * which supersedes the failed one at attempt 1, so no generation ever reached
118
+ * three. Every repair attempt then added one more dead generation, forever.
119
+ * This key counts the Bot's consecutive activation failures however many
120
+ * generations they are spread over, and a generation that finally activates
121
+ * clears it.
122
+ */
123
+ export const COMPOSITION_FAILURE_STREAK_KEY = "composition:failure-streak";
124
+
112
125
  export function compositionQuarantineKey(generationId: string): string {
113
126
  return `${COMPOSITION_QUARANTINE_PREFIX}${generationId}`;
114
127
  }