@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 +3 -3
- package/src/composition-failures.test.ts +136 -1
- package/src/composition-failures.ts +55 -5
- package/src/storage-keys.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-do",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
16
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
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
|
|
38
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
153
|
+
await this.ctx.storage.delete([
|
|
154
|
+
compositionFailureCountKey(generationId),
|
|
155
|
+
COMPOSITION_FAILURE_STREAK_KEY,
|
|
156
|
+
]);
|
|
107
157
|
}
|
|
108
158
|
}
|
package/src/storage-keys.ts
CHANGED
|
@@ -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
|
}
|