@frockbot/kernel-do 0.3.9 → 0.3.11

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.11",
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.11",
16
+ "@frockbot/kernel-contracts": "0.3.11",
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,133 @@ 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
+
489
+ test("the streak expires, so unrelated failures far apart are separate incidents", async () => {
490
+ // Three repair attempts are one sitting. Three failures months apart are
491
+ // three incidents, and quarantining the third would strand a Bot for
492
+ // something it had already recovered from twice.
493
+ const storage = new MemoryStorage();
494
+ const state = { storage } as unknown as DurableObjectState;
495
+ const store = new DurableCompositionStore({ state, bootstrap });
496
+ let clock = new Date("2026-09-01T00:00:00.000Z");
497
+ const failures = new DurableCompositionFailureLog({
498
+ state,
499
+ now: () => clock,
500
+ streakWindowMs: 60 * 60 * 1000,
501
+ });
502
+ const lastKnownGood = await store.current();
503
+
504
+ const outcomes: boolean[] = [];
505
+ for (const day of [1, 2, 3]) {
506
+ clock = new Date(`2026-09-0${day}T00:00:00.000Z`);
507
+ const generation = await authored(
508
+ lastKnownGood,
509
+ `2026-09-0${day}T00:00:00.000Z`,
510
+ );
511
+ await store.propose(generation, { pin: true });
512
+ const activation = await activateCompositionV1({
513
+ generationId: generation.generationId,
514
+ store: activationStore(store),
515
+ failures,
516
+ host: brokenHost(generation.generationId, "mount"),
517
+ signal: new AbortController().signal,
518
+ now: () => clock,
519
+ });
520
+ if (activation.status !== "failed-closed") throw new Error("expected");
521
+ outcomes.push(activation.quarantined);
522
+ }
523
+
524
+ // A day apart each time: never a streak, so never a quarantine.
525
+ expect(outcomes).toEqual([false, false, false]);
526
+ });
527
+
393
528
  test("a last known good that will not mount has nothing to fail into", async () => {
394
529
  const { store, failures, lastKnownGood } = await fixture();
395
530
  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,
@@ -26,26 +27,47 @@ export interface DurableCompositionFailureLogOptions {
26
27
  now?(): Date;
27
28
  /** Consecutive failures that quarantine a generation. Defaults to three. */
28
29
  threshold?: number;
30
+ /** How long the Bot-wide streak stays alive. Defaults to one hour. */
31
+ streakWindowMs?: number;
29
32
  }
30
33
 
34
+ /** The Bot's live failure streak: how many, and when the last one landed. */
35
+ interface CompositionFailureStreakV1 {
36
+ count: number;
37
+ at: string;
38
+ }
39
+
40
+ /**
41
+ * One sitting. Repair attempts arrive minutes apart, so an hour is long enough
42
+ * to span a Bot's whole attempt to fix itself and short enough that yesterday's
43
+ * unrelated failure does not count towards today's quarantine.
44
+ */
45
+ const COMPOSITION_FAILURE_STREAK_WINDOW_MS = 60 * 60 * 1000;
46
+
31
47
  /**
32
48
  * `CompositionFailureLog` over the Bot object's prefixed keys:
33
49
  * `composition:failure:<generationId>:<attempt>`,
34
50
  * `composition:failure-count:<generationId>`, and
35
51
  * `composition:quarantine:<generationId>`.
36
52
  *
37
- * Quarantine is per generation, never per Bot: a later, unrelated generation is
38
- * unaffected by an earlier one being quarantined.
53
+ * Quarantine is *marked* per generation, never per Bot: a generation an earlier
54
+ * one's quarantine does not implicate is unaffected, and lifting a quarantine
55
+ * is a decision about one generation. What earns a quarantine is either
56
+ * counter reaching the threshold — this generation's own retries, or the Bot's
57
+ * consecutive failures across however many generations the repairs minted.
39
58
  */
40
59
  export class DurableCompositionFailureLog implements CompositionFailureLog {
41
60
  private readonly ctx: DurableObjectState;
42
61
  private readonly now: () => Date;
43
62
  private readonly threshold: number;
63
+ private readonly streakWindowMs: number;
44
64
 
45
65
  constructor(options: DurableCompositionFailureLogOptions) {
46
66
  this.ctx = options.state;
47
67
  this.now = options.now ?? (() => new Date());
48
68
  this.threshold = options.threshold ?? COMPOSITION_QUARANTINE_THRESHOLD;
69
+ this.streakWindowMs =
70
+ options.streakWindowMs ?? COMPOSITION_FAILURE_STREAK_WINDOW_MS;
49
71
  }
50
72
 
51
73
  /** The attempt number is assigned here, inside the counter's transaction. */
@@ -60,10 +82,33 @@ export class DurableCompositionFailureLog implements CompositionFailureLog {
60
82
  )) ?? 0;
61
83
  const attempt = previous + 1;
62
84
  const recorded = decodeCompositionFailureV1({ ...failure, attempt });
63
- const quarantined = attempt >= this.threshold;
85
+ // Two counters, one threshold. `attempt` is this generation's own
86
+ // retries; `streak` is the Bot's consecutive failures however many
87
+ // generations they are spread over. Only the second one ever moves when
88
+ // the model repairs by authoring a *new* generation, which is what it
89
+ // always does — so without it the safeguard never fired and the
90
+ // Composition grew one dead generation per repair attempt.
91
+ //
92
+ // The streak is bounded in time as well as reset by success. Three
93
+ // repair attempts are one sitting; three unrelated failures months apart
94
+ // are three separate incidents, and quarantining the third would strand
95
+ // a Bot for something it had already recovered from twice.
96
+ const at = this.now();
97
+ const stored = await transaction.get<CompositionFailureStreakV1>(
98
+ COMPOSITION_FAILURE_STREAK_KEY,
99
+ );
100
+ const continues =
101
+ stored !== undefined &&
102
+ at.getTime() - Date.parse(stored.at) <= this.streakWindowMs;
103
+ const streak = (continues ? stored.count : 0) + 1;
104
+ const quarantined = attempt >= this.threshold || streak >= this.threshold;
64
105
  const writes: Record<string, unknown> = {
65
106
  [compositionFailureKey(generationId, attempt)]: recorded,
66
107
  [compositionFailureCountKey(generationId)]: attempt,
108
+ [COMPOSITION_FAILURE_STREAK_KEY]: {
109
+ count: streak,
110
+ at: at.toISOString(),
111
+ } satisfies CompositionFailureStreakV1,
67
112
  };
68
113
  if (quarantined) {
69
114
  writes[compositionQuarantineKey(generationId)] =
@@ -71,7 +116,9 @@ export class DurableCompositionFailureLog implements CompositionFailureLog {
71
116
  generationId,
72
117
  quarantinedAt: this.now().toISOString(),
73
118
  reason: recorded.message,
74
- failures: attempt,
119
+ // The count that earned it, which is the streak whenever the
120
+ // repairs were spread over generations.
121
+ failures: Math.max(attempt, streak),
75
122
  });
76
123
  }
77
124
  await transaction.put(writes);
@@ -103,6 +150,9 @@ export class DurableCompositionFailureLog implements CompositionFailureLog {
103
150
  * recorded failures survive: they are the repair history a User reads.
104
151
  */
105
152
  async clear(generationId: string): Promise<void> {
106
- await this.ctx.storage.delete(compositionFailureCountKey(generationId));
153
+ await this.ctx.storage.delete([
154
+ compositionFailureCountKey(generationId),
155
+ COMPOSITION_FAILURE_STREAK_KEY,
156
+ ]);
107
157
  }
108
158
  }
@@ -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
  }