@frockbot/kernel-do 0.3.10 → 0.3.12
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 +81 -3
- package/src/composition-failures.test.ts +39 -0
- package/src/composition-failures.ts +35 -4
- package/src/conversations.test.ts +12 -3
- package/src/conversations.ts +37 -0
- package/src/index.ts +1 -0
- package/src/run-liveness.test.ts +299 -0
- package/src/run-liveness.ts +113 -0
- package/src/run-recovery.test.ts +1 -1
- package/src/run-terminal-open-turn.test.ts +1 -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.12",
|
|
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.12",
|
|
16
|
+
"@frockbot/kernel-contracts": "0.3.12",
|
|
17
17
|
"cordis": "4.0.0-rc.8"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
package/src/authority.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
repairedSessionLogV1,
|
|
40
40
|
unresolvedModelRequestFailure,
|
|
41
41
|
} from "./run-recovery.js";
|
|
42
|
+
import { runLivenessV1, STALE_RUNNING_RUN_FAILURE_V1 } from "./run-liveness.js";
|
|
42
43
|
import {
|
|
43
44
|
BotTurnReconciliationRequiredError,
|
|
44
45
|
BotTurnRecoveryRequiredError,
|
|
@@ -46,6 +47,7 @@ import {
|
|
|
46
47
|
} from "./turn-errors.js";
|
|
47
48
|
import {
|
|
48
49
|
botConversationBaseSessionIdV1,
|
|
50
|
+
ConversationBusyError,
|
|
49
51
|
conversationSessionIdV1,
|
|
50
52
|
decodeConversationRecordV1,
|
|
51
53
|
decodeStoredConversationV1,
|
|
@@ -1046,9 +1048,10 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1046
1048
|
const active = await transaction.get<string>(ACTIVE_RUN_KEY);
|
|
1047
1049
|
const pending = await transaction.get<string>(PENDING_RUN_KEY);
|
|
1048
1050
|
if (active || pending) {
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1051
|
+
// A typed refusal, not a bare Error: the Durable Object boundary turns
|
|
1052
|
+
// this one case into a 409 value rather than letting it escape the
|
|
1053
|
+
// object's entry frame as an uncaught exception.
|
|
1054
|
+
throw new ConversationBusyError();
|
|
1052
1055
|
}
|
|
1053
1056
|
const current =
|
|
1054
1057
|
decodeStoredConversationV1(
|
|
@@ -1135,6 +1138,81 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1135
1138
|
return run;
|
|
1136
1139
|
}
|
|
1137
1140
|
|
|
1141
|
+
/**
|
|
1142
|
+
* Whether a run is still working, settling its record when it is not.
|
|
1143
|
+
*
|
|
1144
|
+
* This is the only honest answer to "is this Bot busy", and both readers that
|
|
1145
|
+
* ask — the sidebar's activity ring and the transcript's running Turn — go
|
|
1146
|
+
* through here. `status === "running"` alone is a claim the record makes and
|
|
1147
|
+
* nothing renews: a Turn that died mid-answer never wrote its own
|
|
1148
|
+
* settlement, so idle Bots wore a pulsing ring for hours.
|
|
1149
|
+
* {@link runLivenessV1} holds the rule; this adds the two things a pure rule
|
|
1150
|
+
* cannot have.
|
|
1151
|
+
*
|
|
1152
|
+
* The first is the fence. A run this object is executing right now is alive
|
|
1153
|
+
* by direct observation, whatever the durable record and the log look like
|
|
1154
|
+
* mid-flush, and it is never judged or touched. The object is
|
|
1155
|
+
* single-threaded, so `executingRunId` is exact for the run in this isolate,
|
|
1156
|
+
* and a run executing in some *other* isolate cannot be at issue: the durable
|
|
1157
|
+
* `active-run` marker admits one Turn at a time, and a record older than the
|
|
1158
|
+
* Turn deadline is past the point where any isolate is still holding it.
|
|
1159
|
+
*
|
|
1160
|
+
* The second is the repair. A read that finds a dead record settles it rather
|
|
1161
|
+
* than merely hiding it, so the ring goes out for every other reader too and
|
|
1162
|
+
* the next message inherits a closed Turn instead of repairing one. The
|
|
1163
|
+
* settlement is `failStoredRun`, exactly as recovery's is, which closes the
|
|
1164
|
+
* open Turn in the log on the way and routes a run carrying a durable Stop or
|
|
1165
|
+
* supersede intent to the outcome that intent already decided. It is
|
|
1166
|
+
* idempotent — a second caller finds a terminal record and settles nothing —
|
|
1167
|
+
* and the run-record write it commits is what publishes the `runs`
|
|
1168
|
+
* invalidation the watching clients re-read on.
|
|
1169
|
+
*/
|
|
1170
|
+
async resolveRunWorking(runId: string | undefined): Promise<boolean> {
|
|
1171
|
+
if (runId === undefined) return false;
|
|
1172
|
+
if (runId === this.executingRunId) return true;
|
|
1173
|
+
const run = await this.readRun(runId);
|
|
1174
|
+
if (!run || run.status !== "running") return false;
|
|
1175
|
+
const sessionEvents = (
|
|
1176
|
+
(await this.ctx.storage.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
|
|
1177
|
+
).map(decodeSessionEvent);
|
|
1178
|
+
if (runLivenessV1({ run, sessionEvents }).working) return true;
|
|
1179
|
+
await this.settleStaleRun(runId);
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Settles one run whose record says `running` and whose Turn is over.
|
|
1185
|
+
*
|
|
1186
|
+
* The verdict is taken again inside the transaction, against the record and
|
|
1187
|
+
* the log as they are committed there, so a Turn that settled itself between
|
|
1188
|
+
* the read above and this write is left exactly as it settled — and so is one
|
|
1189
|
+
* that started executing in this object in the meantime.
|
|
1190
|
+
*/
|
|
1191
|
+
private async settleStaleRun(runId: string): Promise<void> {
|
|
1192
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
1193
|
+
if (runId === this.executingRunId) return;
|
|
1194
|
+
const run = this.codec.optional(
|
|
1195
|
+
await transaction.get<unknown>(`${RUN_PREFIX}${runId}`),
|
|
1196
|
+
);
|
|
1197
|
+
if (!run || run.runId !== runId || run.status !== "running") return;
|
|
1198
|
+
const latest = (
|
|
1199
|
+
(await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
|
|
1200
|
+
).map(decodeSessionEvent);
|
|
1201
|
+
if (runLivenessV1({ run, sessionEvents: latest }).working) return;
|
|
1202
|
+
await failStoredRun(
|
|
1203
|
+
this.codec,
|
|
1204
|
+
transaction,
|
|
1205
|
+
this.terminalKeys(runId),
|
|
1206
|
+
runId,
|
|
1207
|
+
latest.slice(0, run.previousEventCount),
|
|
1208
|
+
run.events,
|
|
1209
|
+
STALE_RUNNING_RUN_FAILURE_V1,
|
|
1210
|
+
this.supersededPackageRecords(),
|
|
1211
|
+
);
|
|
1212
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1138
1216
|
/** Reverse-ordered admission index page: `[cursor, runId]` entries. */
|
|
1139
1217
|
async listRunIndex(query: {
|
|
1140
1218
|
limit: number;
|
|
@@ -486,6 +486,45 @@ describe("fail-closed Composition activation", () => {
|
|
|
486
486
|
expect(activation.quarantined).toBe(false);
|
|
487
487
|
});
|
|
488
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
|
+
|
|
489
528
|
test("a last known good that will not mount has nothing to fail into", async () => {
|
|
490
529
|
const { store, failures, lastKnownGood } = await fixture();
|
|
491
530
|
await expect(
|
|
@@ -27,8 +27,23 @@ export interface DurableCompositionFailureLogOptions {
|
|
|
27
27
|
now?(): Date;
|
|
28
28
|
/** Consecutive failures that quarantine a generation. Defaults to three. */
|
|
29
29
|
threshold?: number;
|
|
30
|
+
/** How long the Bot-wide streak stays alive. Defaults to one hour. */
|
|
31
|
+
streakWindowMs?: number;
|
|
30
32
|
}
|
|
31
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
|
+
|
|
32
47
|
/**
|
|
33
48
|
* `CompositionFailureLog` over the Bot object's prefixed keys:
|
|
34
49
|
* `composition:failure:<generationId>:<attempt>`,
|
|
@@ -45,11 +60,14 @@ export class DurableCompositionFailureLog implements CompositionFailureLog {
|
|
|
45
60
|
private readonly ctx: DurableObjectState;
|
|
46
61
|
private readonly now: () => Date;
|
|
47
62
|
private readonly threshold: number;
|
|
63
|
+
private readonly streakWindowMs: number;
|
|
48
64
|
|
|
49
65
|
constructor(options: DurableCompositionFailureLogOptions) {
|
|
50
66
|
this.ctx = options.state;
|
|
51
67
|
this.now = options.now ?? (() => new Date());
|
|
52
68
|
this.threshold = options.threshold ?? COMPOSITION_QUARANTINE_THRESHOLD;
|
|
69
|
+
this.streakWindowMs =
|
|
70
|
+
options.streakWindowMs ?? COMPOSITION_FAILURE_STREAK_WINDOW_MS;
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
/** The attempt number is assigned here, inside the counter's transaction. */
|
|
@@ -70,14 +88,27 @@ export class DurableCompositionFailureLog implements CompositionFailureLog {
|
|
|
70
88
|
// the model repairs by authoring a *new* generation, which is what it
|
|
71
89
|
// always does — so without it the safeguard never fired and the
|
|
72
90
|
// Composition grew one dead generation per repair attempt.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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;
|
|
76
104
|
const quarantined = attempt >= this.threshold || streak >= this.threshold;
|
|
77
105
|
const writes: Record<string, unknown> = {
|
|
78
106
|
[compositionFailureKey(generationId, attempt)]: recorded,
|
|
79
107
|
[compositionFailureCountKey(generationId)]: attempt,
|
|
80
|
-
[COMPOSITION_FAILURE_STREAK_KEY]:
|
|
108
|
+
[COMPOSITION_FAILURE_STREAK_KEY]: {
|
|
109
|
+
count: streak,
|
|
110
|
+
at: at.toISOString(),
|
|
111
|
+
} satisfies CompositionFailureStreakV1,
|
|
81
112
|
};
|
|
82
113
|
if (quarantined) {
|
|
83
114
|
writes[compositionQuarantineKey(generationId)] =
|
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
type BotDurableAuthorityHooks,
|
|
10
10
|
} from "./authority.ts";
|
|
11
11
|
import {
|
|
12
|
+
ConversationBusyError,
|
|
12
13
|
conversationSessionIdV1,
|
|
14
|
+
isConversationBusyV1,
|
|
13
15
|
isConversationSessionIdV1,
|
|
14
16
|
} from "./conversations.ts";
|
|
15
17
|
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
@@ -152,8 +154,15 @@ describe("starting a new conversation", () => {
|
|
|
152
154
|
const probe = createAuthority(storage);
|
|
153
155
|
storage.values.set("active-run", "run-9");
|
|
154
156
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
// Typed, not a bare Error: the Durable Object boundary keys on the name to
|
|
158
|
+
// turn this one case into a 409 value rather than letting it escape the
|
|
159
|
+
// object's entry frame as an uncaught exception.
|
|
160
|
+
const refusal = await probe.authority
|
|
161
|
+
.startConversation(IDENTITY)
|
|
162
|
+
.then(() => undefined)
|
|
163
|
+
.catch((error: unknown) => error);
|
|
164
|
+
expect(isConversationBusyV1(refusal)).toBe(true);
|
|
165
|
+
expect(refusal).toBeInstanceOf(ConversationBusyError);
|
|
166
|
+
expect((refusal as Error).message).toMatch(/still working on a Turn/);
|
|
158
167
|
});
|
|
159
168
|
});
|
package/src/conversations.ts
CHANGED
|
@@ -124,3 +124,40 @@ export function isConversationSessionIdV1(
|
|
|
124
124
|
if (!sessionId.startsWith(`${base}#`)) return false;
|
|
125
125
|
return /^[1-9][0-9]{0,6}$/.test(sessionId.slice(base.length + 1));
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What a person is told when a new conversation is refused.
|
|
130
|
+
*
|
|
131
|
+
* The Turn that is running owns the event log the next Turn derives its
|
|
132
|
+
* request from, so a click may not pull it out from under it. That is a "not
|
|
133
|
+
* now", not a fault, and the sentence says what to do about it.
|
|
134
|
+
*/
|
|
135
|
+
export const CONVERSATION_BUSY_MESSAGE_V1 =
|
|
136
|
+
"This Bot is still working on a Turn. Wait for it to finish, then start a new conversation.";
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A refusal, told apart from a genuine failure.
|
|
140
|
+
*
|
|
141
|
+
* It exists so the Durable Object boundary can turn this one case into a value
|
|
142
|
+
* — a 409 the composer already understands — instead of letting it escape as
|
|
143
|
+
* an uncaught exception. An exception that crosses a DO's entry frame is
|
|
144
|
+
* logged by workerd as `Uncaught Error`, and the isolate that logs it has been
|
|
145
|
+
* seen to go down with a broken pipe immediately afterwards; in production the
|
|
146
|
+
* same sequence is a 500 where a 409 belonged.
|
|
147
|
+
*/
|
|
148
|
+
export class ConversationBusyError extends Error {
|
|
149
|
+
override readonly name = "ConversationBusyError";
|
|
150
|
+
constructor(message = CONVERSATION_BUSY_MESSAGE_V1) {
|
|
151
|
+
super(message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether this is the "still working on a Turn" refusal, across bundles. */
|
|
156
|
+
export function isConversationBusyV1(error: unknown): boolean {
|
|
157
|
+
return (
|
|
158
|
+
typeof error === "object" &&
|
|
159
|
+
error !== null &&
|
|
160
|
+
"name" in error &&
|
|
161
|
+
(error as { name?: unknown }).name === "ConversationBusyError"
|
|
162
|
+
);
|
|
163
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./composition-failures.js";
|
|
|
4
4
|
export * from "./conversations.js";
|
|
5
5
|
export * from "./composition-store.js";
|
|
6
6
|
export * from "./run-records.js";
|
|
7
|
+
export * from "./run-liveness.js";
|
|
7
8
|
export * from "./run-recovery.js";
|
|
8
9
|
export * from "./run-terminal.js";
|
|
9
10
|
export * from "./storage-keys.js";
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// An idle Bot wearing the activity ring.
|
|
2
|
+
//
|
|
3
|
+
// The sidebar's `working` flag was `readRun(newest).status === "running"`, and
|
|
4
|
+
// nothing ever renews that field: a Turn that died mid-answer — a Worker torn
|
|
5
|
+
// down, one of the "turn N started while turn N-1 is open" wedges — leaves a
|
|
6
|
+
// record saying `running` for ever. Production had Bots that had been quiet for
|
|
7
|
+
// hours pulsing as though they were mid-sentence.
|
|
8
|
+
//
|
|
9
|
+
// Liveness is three conditions, not one, and a reader that finds a record
|
|
10
|
+
// failing them settles it rather than merely declining to draw a ring.
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
bootstrapGeneration,
|
|
14
|
+
type CompositionGenerationV1,
|
|
15
|
+
} from "@frockbot/kernel-composition/generation";
|
|
16
|
+
import {
|
|
17
|
+
type SessionEvent,
|
|
18
|
+
TURN_DEADLINE_MS_V1,
|
|
19
|
+
} from "@frockbot/kernel-contracts";
|
|
20
|
+
import {
|
|
21
|
+
BotDurableAuthority,
|
|
22
|
+
type BotDurableAuthorityHooks,
|
|
23
|
+
} from "./authority.ts";
|
|
24
|
+
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
25
|
+
import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.ts";
|
|
26
|
+
import {
|
|
27
|
+
runLivenessV1,
|
|
28
|
+
STALE_RUNNING_RUN_FAILURE_V1,
|
|
29
|
+
STALE_RUNNING_RUN_GRACE_MS_V1,
|
|
30
|
+
} from "./run-liveness.ts";
|
|
31
|
+
import {
|
|
32
|
+
ACTIVE_RUN_KEY,
|
|
33
|
+
IDENTITY_KEY,
|
|
34
|
+
LATEST_EVENTS_KEY,
|
|
35
|
+
RUN_PREFIX,
|
|
36
|
+
runIndexKey,
|
|
37
|
+
} from "./storage-keys.ts";
|
|
38
|
+
|
|
39
|
+
const NOW = Date.UTC(2026, 8, 3, 12, 0, 0);
|
|
40
|
+
const ACCEPTED_AT = new Date(NOW - 1000).toISOString();
|
|
41
|
+
|
|
42
|
+
function event(
|
|
43
|
+
seq: number,
|
|
44
|
+
type: SessionEvent["type"],
|
|
45
|
+
extra: Record<string, unknown> = {},
|
|
46
|
+
): SessionEvent {
|
|
47
|
+
return {
|
|
48
|
+
type,
|
|
49
|
+
seq,
|
|
50
|
+
timestamp: new Date(NOW - 1000 + seq).toISOString(),
|
|
51
|
+
...extra,
|
|
52
|
+
} as SessionEvent;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The events one Turn writes while it runs, opening the Turn and no more. */
|
|
56
|
+
const openTurn: SessionEvent[] = [
|
|
57
|
+
event(0, "turn/start", { turn: 1 }),
|
|
58
|
+
event(1, "step/start", { turn: 1, step: 1 }),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** The same log, with the ending somebody else wrote for it. */
|
|
62
|
+
const closedTurn: SessionEvent[] = [
|
|
63
|
+
...openTurn,
|
|
64
|
+
event(2, "step/end", { turn: 1, step: 1, outcome: "interrupted" }),
|
|
65
|
+
event(3, "turn/end", { turn: 1, outcome: "interrupted" }),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function run(
|
|
69
|
+
overrides: Partial<StoredRunV1<undefined>> = {},
|
|
70
|
+
): StoredRunV1<undefined> {
|
|
71
|
+
return {
|
|
72
|
+
runId: "run-1",
|
|
73
|
+
commandFingerprint: "fingerprint",
|
|
74
|
+
sessionId: "user-1:primary",
|
|
75
|
+
acceptedAt: ACCEPTED_AT,
|
|
76
|
+
input: "hello",
|
|
77
|
+
events: openTurn,
|
|
78
|
+
effectAdmissions: [],
|
|
79
|
+
status: "running",
|
|
80
|
+
phase: "executing",
|
|
81
|
+
compositionGenerationId: "generation-1",
|
|
82
|
+
configurationSnapshot: undefined,
|
|
83
|
+
previousEventCount: 0,
|
|
84
|
+
...overrides,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("whether a run marked running is working", () => {
|
|
89
|
+
test("a Turn admitted a moment ago is working", () => {
|
|
90
|
+
expect(
|
|
91
|
+
runLivenessV1({ run: run(), sessionEvents: openTurn, now: NOW }),
|
|
92
|
+
).toEqual({ working: true, stale: false });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a settled run is not working, and owes no repair", () => {
|
|
96
|
+
expect(
|
|
97
|
+
runLivenessV1({
|
|
98
|
+
run: run({ status: "completed" }),
|
|
99
|
+
sessionEvents: closedTurn,
|
|
100
|
+
now: NOW,
|
|
101
|
+
}),
|
|
102
|
+
).toEqual({ working: false, stale: false });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("one still inside the deadline is left alone", () => {
|
|
106
|
+
expect(
|
|
107
|
+
runLivenessV1({
|
|
108
|
+
run: run(),
|
|
109
|
+
sessionEvents: openTurn,
|
|
110
|
+
now: NOW + TURN_DEADLINE_MS_V1 - 1000,
|
|
111
|
+
}),
|
|
112
|
+
).toEqual({ working: true, stale: false });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("the grace covers the unwind after the deadline fires", () => {
|
|
116
|
+
expect(
|
|
117
|
+
runLivenessV1({
|
|
118
|
+
run: run(),
|
|
119
|
+
sessionEvents: openTurn,
|
|
120
|
+
now: NOW + TURN_DEADLINE_MS_V1 + STALE_RUNNING_RUN_GRACE_MS_V1 - 1000,
|
|
121
|
+
}).working,
|
|
122
|
+
).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("one past the deadline and its grace is stale", () => {
|
|
126
|
+
expect(
|
|
127
|
+
runLivenessV1({
|
|
128
|
+
run: run(),
|
|
129
|
+
sessionEvents: openTurn,
|
|
130
|
+
now: NOW + TURN_DEADLINE_MS_V1 + STALE_RUNNING_RUN_GRACE_MS_V1 + 1000,
|
|
131
|
+
}),
|
|
132
|
+
).toEqual({ working: false, stale: true, reason: "deadline" });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("one whose Turn the log already closed is stale, however fresh", () => {
|
|
136
|
+
expect(
|
|
137
|
+
runLivenessV1({ run: run(), sessionEvents: closedTurn, now: NOW }),
|
|
138
|
+
).toEqual({ working: false, stale: true, reason: "turn-closed" });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("an earlier Turn's ending says nothing about this one", () => {
|
|
142
|
+
// The log ends closed because the *previous* Turn closed it, and this run
|
|
143
|
+
// has not journaled its own `turn/start` yet.
|
|
144
|
+
expect(
|
|
145
|
+
runLivenessV1({
|
|
146
|
+
run: run({ events: [], previousEventCount: closedTurn.length }),
|
|
147
|
+
sessionEvents: closedTurn,
|
|
148
|
+
now: NOW,
|
|
149
|
+
}),
|
|
150
|
+
).toEqual({ working: true, stale: false });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("an unreadable admission time is not evidence of death", () => {
|
|
154
|
+
expect(
|
|
155
|
+
runLivenessV1({
|
|
156
|
+
run: run({ acceptedAt: "not a timestamp" }),
|
|
157
|
+
sessionEvents: openTurn,
|
|
158
|
+
now: NOW + TURN_DEADLINE_MS_V1 * 100,
|
|
159
|
+
}).stale,
|
|
160
|
+
).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const codec = createStoredRunCodecV1<undefined>({
|
|
165
|
+
decodeRunId: (value) => value as string,
|
|
166
|
+
decodeConfigurationSnapshot: () => undefined,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
function bootstrap(): Promise<CompositionGenerationV1> {
|
|
170
|
+
return bootstrapGeneration(
|
|
171
|
+
[
|
|
172
|
+
{
|
|
173
|
+
packageId: "shell",
|
|
174
|
+
specifier: "@frockbot/plugin-shell",
|
|
175
|
+
version: "0.0.1",
|
|
176
|
+
manifest: { id: "shell", version: "0.0.1" },
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
{ createdAt: "2026-09-03T00:00:00.000Z" },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const hooks: BotDurableAuthorityHooks<undefined> = {
|
|
184
|
+
resolveAdmissionSnapshot: () => Promise.resolve(undefined),
|
|
185
|
+
bootstrapComposition: () => bootstrap(),
|
|
186
|
+
admittedSnapshot: () => Promise.resolve(undefined),
|
|
187
|
+
executeTurn: () => Promise.reject(new Error("no Turn should execute here")),
|
|
188
|
+
notification: () => undefined,
|
|
189
|
+
scheduledDeadlines: () => Promise.resolve([]),
|
|
190
|
+
scheduledWorkInFlight: () => false,
|
|
191
|
+
deferScheduledWork: () => Promise.resolve(),
|
|
192
|
+
settleScheduledWork: () => Promise.resolve(),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A Bot left holding exactly what production was left holding: a record that
|
|
197
|
+
* says `running`, an `active-run` marker pointing at it, and a durable log with
|
|
198
|
+
* the Turn it opened.
|
|
199
|
+
*/
|
|
200
|
+
async function seed(input: {
|
|
201
|
+
acceptedAt: string;
|
|
202
|
+
events: SessionEvent[];
|
|
203
|
+
log: SessionEvent[];
|
|
204
|
+
}): Promise<{
|
|
205
|
+
storage: MemoryStorage;
|
|
206
|
+
authority: BotDurableAuthority<undefined>;
|
|
207
|
+
}> {
|
|
208
|
+
const storage = new MemoryStorage();
|
|
209
|
+
const stored = run({ acceptedAt: input.acceptedAt, events: input.events });
|
|
210
|
+
await storage.put({
|
|
211
|
+
[`${RUN_PREFIX}${stored.runId}`]: stored,
|
|
212
|
+
[runIndexKey(stored.acceptedAt, stored.runId)]: stored.runId,
|
|
213
|
+
[ACTIVE_RUN_KEY]: stored.runId,
|
|
214
|
+
[LATEST_EVENTS_KEY]: input.log,
|
|
215
|
+
[IDENTITY_KEY]: { userId: "user-1", botId: "primary" },
|
|
216
|
+
});
|
|
217
|
+
const authority = new BotDurableAuthority<undefined>({
|
|
218
|
+
state: { storage } as unknown as DurableObjectState,
|
|
219
|
+
codec,
|
|
220
|
+
hooks,
|
|
221
|
+
});
|
|
222
|
+
return { storage, authority };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const longAgo = new Date(
|
|
226
|
+
Date.now() - TURN_DEADLINE_MS_V1 - STALE_RUNNING_RUN_GRACE_MS_V1 - 60_000,
|
|
227
|
+
).toISOString();
|
|
228
|
+
|
|
229
|
+
describe("the read that repairs what it finds", () => {
|
|
230
|
+
test("reports a fresh Turn as working and touches nothing", async () => {
|
|
231
|
+
const { storage, authority } = await seed({
|
|
232
|
+
acceptedAt: new Date().toISOString(),
|
|
233
|
+
events: openTurn,
|
|
234
|
+
log: openTurn,
|
|
235
|
+
});
|
|
236
|
+
expect(await authority.resolveRunWorking("run-1")).toBe(true);
|
|
237
|
+
expect(
|
|
238
|
+
(await storage.get<StoredRunV1<undefined>>(`${RUN_PREFIX}run-1`))?.status,
|
|
239
|
+
).toBe("running");
|
|
240
|
+
expect(await storage.get<string>(ACTIVE_RUN_KEY)).toBe("run-1");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("settles one that outlived the Turn deadline", async () => {
|
|
244
|
+
const { storage, authority } = await seed({
|
|
245
|
+
acceptedAt: longAgo,
|
|
246
|
+
events: openTurn,
|
|
247
|
+
log: openTurn,
|
|
248
|
+
});
|
|
249
|
+
expect(await authority.resolveRunWorking("run-1")).toBe(false);
|
|
250
|
+
const settled = await storage.get<StoredRunV1<undefined>>(
|
|
251
|
+
`${RUN_PREFIX}run-1`,
|
|
252
|
+
);
|
|
253
|
+
expect(settled?.status).toBe("failed");
|
|
254
|
+
expect(settled?.failure).toBe(STALE_RUNNING_RUN_FAILURE_V1);
|
|
255
|
+
// The Bot is free: nothing holds the object, and the next Turn admits
|
|
256
|
+
// against a log that reads as a complete history.
|
|
257
|
+
expect(await storage.get<string>(ACTIVE_RUN_KEY)).toBeUndefined();
|
|
258
|
+
const log = (await storage.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? [];
|
|
259
|
+
expect(log.some((entry) => entry.type === "turn/end")).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("settles one whose Turn the log already closed", async () => {
|
|
263
|
+
const { storage, authority } = await seed({
|
|
264
|
+
acceptedAt: new Date().toISOString(),
|
|
265
|
+
events: openTurn,
|
|
266
|
+
log: closedTurn,
|
|
267
|
+
});
|
|
268
|
+
expect(await authority.resolveRunWorking("run-1")).toBe(false);
|
|
269
|
+
expect(
|
|
270
|
+
(await storage.get<StoredRunV1<undefined>>(`${RUN_PREFIX}run-1`))?.status,
|
|
271
|
+
).toBe("failed");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("is idempotent: a second read settles nothing and still says no ring", async () => {
|
|
275
|
+
const { storage, authority } = await seed({
|
|
276
|
+
acceptedAt: longAgo,
|
|
277
|
+
events: openTurn,
|
|
278
|
+
log: openTurn,
|
|
279
|
+
});
|
|
280
|
+
expect(await authority.resolveRunWorking("run-1")).toBe(false);
|
|
281
|
+
const first = await storage.get<StoredRunV1<undefined>>(
|
|
282
|
+
`${RUN_PREFIX}run-1`,
|
|
283
|
+
);
|
|
284
|
+
expect(await authority.resolveRunWorking("run-1")).toBe(false);
|
|
285
|
+
expect(
|
|
286
|
+
await storage.get<StoredRunV1<undefined>>(`${RUN_PREFIX}run-1`),
|
|
287
|
+
).toEqual(first!);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("no run is no ring", async () => {
|
|
291
|
+
const { authority } = await seed({
|
|
292
|
+
acceptedAt: longAgo,
|
|
293
|
+
events: openTurn,
|
|
294
|
+
log: openTurn,
|
|
295
|
+
});
|
|
296
|
+
expect(await authority.resolveRunWorking(undefined)).toBe(false);
|
|
297
|
+
expect(await authority.resolveRunWorking("run-missing")).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SessionEvent,
|
|
3
|
+
TURN_DEADLINE_MS_V1,
|
|
4
|
+
} from "@frockbot/kernel-contracts";
|
|
5
|
+
import type { StoredRunV1 } from "./run-records.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How long past the Turn deadline a `running` record is still given the
|
|
9
|
+
* benefit of the doubt.
|
|
10
|
+
*
|
|
11
|
+
* The deadline is enforced by a timer inside the loop, and the settlement that
|
|
12
|
+
* follows it is a durable write on the far side of an abort: there is a real
|
|
13
|
+
* interval in which a Turn is legitimately still finishing after its clock ran
|
|
14
|
+
* out. A minute is far longer than that unwind takes and far shorter than the
|
|
15
|
+
* hours an abandoned record has been claiming to work.
|
|
16
|
+
*/
|
|
17
|
+
export const STALE_RUNNING_RUN_GRACE_MS_V1 = 60_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a run settled by this rule records, in the register ADR 0028 settles an
|
|
21
|
+
* unretrievable Turn in: what happened, and what to do about it.
|
|
22
|
+
*/
|
|
23
|
+
export const STALE_RUNNING_RUN_FAILURE_V1 =
|
|
24
|
+
"This Turn stopped without finishing and was settled when nothing was left to finish it. Try sending it again.";
|
|
25
|
+
|
|
26
|
+
export interface RunLivenessV1 {
|
|
27
|
+
/** Whether the run may be shown as working — the activity ring's whole rule. */
|
|
28
|
+
readonly working: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the record claims to be running and demonstrably is not, so the
|
|
31
|
+
* reader that asked owes it a terminal settlement.
|
|
32
|
+
*/
|
|
33
|
+
readonly stale: boolean;
|
|
34
|
+
/** Why it is stale, for the failure a settlement records. Absent when it is not. */
|
|
35
|
+
readonly reason?: "deadline" | "turn-closed";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NOT_RUNNING: RunLivenessV1 = { working: false, stale: false };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The seq of the last `turn/start` this run wrote, or `undefined` when it has
|
|
42
|
+
* not opened a Turn in the durable log yet.
|
|
43
|
+
*/
|
|
44
|
+
function openedTurnSeqV1(events: readonly SessionEvent[]): number | undefined {
|
|
45
|
+
let seq: number | undefined;
|
|
46
|
+
for (const event of events) {
|
|
47
|
+
if (event.type === "turn/start") seq = event.seq;
|
|
48
|
+
}
|
|
49
|
+
return seq;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a run is honestly still working.
|
|
54
|
+
*
|
|
55
|
+
* `status === "running"` was the whole test, and it is not one: a record is
|
|
56
|
+
* only ever moved off `running` by the settlement its own Turn performs, so
|
|
57
|
+
* every way a Turn can stop without settling — a Worker torn down mid-answer,
|
|
58
|
+
* the "turn N started while turn N-1 is open" wedges — left a record that says
|
|
59
|
+
* `running` for ever. The sidebar drew an activity ring off that field, so
|
|
60
|
+
* Bots that had been idle for hours pulsed as though they were mid-sentence.
|
|
61
|
+
*
|
|
62
|
+
* Three conditions, all of which must hold:
|
|
63
|
+
*
|
|
64
|
+
* - the record says `running`, which is necessary and was mistaken for
|
|
65
|
+
* sufficient;
|
|
66
|
+
* - it has not outlived {@link TURN_DEADLINE_MS_V1} plus
|
|
67
|
+
* {@link STALE_RUNNING_RUN_GRACE_MS_V1}, because the loop stops waiting at
|
|
68
|
+
* the deadline and a record older than that cannot be a Turn anybody is
|
|
69
|
+
* still running;
|
|
70
|
+
* - the durable session log does not already close the Turn it opened. A
|
|
71
|
+
* `turn/end` at or after this run's own `turn/start` means something has
|
|
72
|
+
* already written the Turn's ending — the admission repair, usually — and a
|
|
73
|
+
* record still saying `running` behind a closed Turn is a leftover, not work.
|
|
74
|
+
*
|
|
75
|
+
* A run that has not written its `turn/start` yet is judged on the deadline
|
|
76
|
+
* alone: there is no Turn in the log to call closed, and the previous Turn's
|
|
77
|
+
* `turn/end` says nothing about this one.
|
|
78
|
+
*
|
|
79
|
+
* Pure, and deliberately so: it is consulted on a read path, on a settlement
|
|
80
|
+
* path, and in tests, and all three have to reach the same verdict.
|
|
81
|
+
*/
|
|
82
|
+
export function runLivenessV1(input: {
|
|
83
|
+
run:
|
|
84
|
+
Pick<StoredRunV1<unknown>, "status" | "acceptedAt" | "events"> | undefined;
|
|
85
|
+
/** The Bot's durable session log, as the run's own events sit inside it. */
|
|
86
|
+
sessionEvents: readonly SessionEvent[];
|
|
87
|
+
now?: number;
|
|
88
|
+
deadlineMs?: number;
|
|
89
|
+
graceMs?: number;
|
|
90
|
+
}): RunLivenessV1 {
|
|
91
|
+
const run = input.run;
|
|
92
|
+
if (!run || run.status !== "running") return NOT_RUNNING;
|
|
93
|
+
const now = input.now ?? Date.now();
|
|
94
|
+
const deadline =
|
|
95
|
+
(input.deadlineMs ?? TURN_DEADLINE_MS_V1) +
|
|
96
|
+
(input.graceMs ?? STALE_RUNNING_RUN_GRACE_MS_V1);
|
|
97
|
+
const acceptedAt = Date.parse(run.acceptedAt);
|
|
98
|
+
// An unparseable timestamp is not evidence of death. The record is left
|
|
99
|
+
// alone rather than settled on a number nobody can read.
|
|
100
|
+
if (Number.isFinite(acceptedAt) && now - acceptedAt > deadline) {
|
|
101
|
+
return { working: false, stale: true, reason: "deadline" };
|
|
102
|
+
}
|
|
103
|
+
const opened = openedTurnSeqV1(run.events);
|
|
104
|
+
if (
|
|
105
|
+
opened !== undefined &&
|
|
106
|
+
input.sessionEvents.some(
|
|
107
|
+
(event) => event.type === "turn/end" && event.seq >= opened,
|
|
108
|
+
)
|
|
109
|
+
) {
|
|
110
|
+
return { working: false, stale: true, reason: "turn-closed" };
|
|
111
|
+
}
|
|
112
|
+
return { working: true, stale: false };
|
|
113
|
+
}
|
package/src/run-recovery.test.ts
CHANGED