@frockbot/kernel-do 0.3.11 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-do",
3
- "version": "0.3.11",
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.11",
16
- "@frockbot/kernel-contracts": "0.3.11",
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
- throw new Error(
1050
- "This Bot is still working on a Turn. Wait for it to finish, then start a new conversation.",
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;
@@ -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
- await expect(probe.authority.startConversation(IDENTITY)).rejects.toThrow(
156
- /still working on a Turn/,
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
  });
@@ -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
+ }
@@ -35,7 +35,7 @@ const request: UnstampedEvent = {
35
35
  request: {
36
36
  requestId: "request-1",
37
37
  provider: "flock-ai",
38
- model: "@flock/auto",
38
+ model: "@frock/auto",
39
39
  system: "Be concise.",
40
40
  messages: [{ role: "user", content: "hello" }],
41
41
  tools: [],
@@ -63,7 +63,7 @@ function interruptedJournal(): SessionEvent[] {
63
63
  request: {
64
64
  requestId: "request-1",
65
65
  provider: "flock-ai",
66
- model: "@flock/auto",
66
+ model: "@frock/auto",
67
67
  system: "",
68
68
  messages: [],
69
69
  tools: [],