@frockbot/kernel-do 0.3.4 → 0.3.6

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.4",
3
+ "version": "0.3.6",
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.4",
16
- "@frockbot/kernel-contracts": "0.3.4",
15
+ "@frockbot/kernel-composition": "0.3.6",
16
+ "@frockbot/kernel-contracts": "0.3.6",
17
17
  "cordis": "4.0.0-rc.8"
18
18
  },
19
19
  "devDependencies": {
package/src/authority.ts CHANGED
@@ -34,11 +34,14 @@ import {
34
34
  eventsForFailedRun,
35
35
  latestModelRequestJournalState,
36
36
  planBotRunRecovery,
37
+ type ProviderReconcilesV1,
38
+ repairOrphanedOpenTurnV1,
37
39
  unresolvedModelRequestFailure,
38
40
  } from "./run-recovery.js";
39
41
  import {
40
42
  BotTurnReconciliationRequiredError,
41
43
  BotTurnRecoveryRequiredError,
44
+ BotTurnRefusedError,
42
45
  } from "./turn-errors.js";
43
46
  import {
44
47
  ACTIVE_RUN_KEY,
@@ -142,6 +145,16 @@ export interface BotDurableAuthorityHooks<Snapshot> {
142
145
  run: StoredRunV1<Snapshot>;
143
146
  read<T>(key: string): Promise<T | undefined>;
144
147
  }): Promise<Record<string, unknown>>;
148
+ /**
149
+ * Whether the named provider can be asked what happened to a model request
150
+ * it never answered (ADR 0028).
151
+ *
152
+ * Synchronous and pure, because it is consulted inside the recovery
153
+ * transaction: it answers from what the deployment knows about a provider
154
+ * Package, never by reaching one. Absent means every provider reconciles,
155
+ * which is the behaviour that predates the ADR.
156
+ */
157
+ providerReconciles?: ProviderReconcilesV1;
145
158
  }
146
159
 
147
160
  /** What a `turn/end` records when a later user message took a Turn's place. */
@@ -150,6 +163,22 @@ export const SUPERSEDED_TURN_REASON_V1 = "superseded by a new user message";
150
163
  /** How many times a queued Turn retries the object before giving up. */
151
164
  const MAX_QUEUED_RUN_START_ATTEMPTS = 8;
152
165
 
166
+ /**
167
+ * True when this object has already durably decided to throw the Turn away.
168
+ *
169
+ * Reconciliation exists to retrieve an external outcome the Turn still needs.
170
+ * A Turn a Stop or a supersede has already discarded needs nothing: its
171
+ * provider outcome cannot change what it settles as, and parking it would keep
172
+ * the active-run marker — and so refuse every later message — over an answer
173
+ * nobody is waiting for. The intent the User expressed wins, and the run
174
+ * settles `cancelled` or `superseded` with everything it had already said.
175
+ */
176
+ function runWasDiscardedV1(
177
+ run: { stopRequestedAt?: string; supersededAt?: string } | undefined,
178
+ ): boolean {
179
+ return Boolean(run?.stopRequestedAt || run?.supersededAt);
180
+ }
181
+
153
182
  export interface BotDurableAuthorityOptions<Snapshot> {
154
183
  state: DurableObjectState;
155
184
  codec: StoredRunCodecV1<Snapshot>;
@@ -193,7 +222,14 @@ export class BotDurableAuthority<Snapshot> {
193
222
 
194
223
  async run(command: OwnedBotTurnCommand): Promise<BotTurnCompletion> {
195
224
  await this.assertMatchingRunCommand(command);
196
- await this.recoverActiveRun();
225
+ // Recovering whatever this object was left holding must never decide the
226
+ // fate of a new command. `recoverActiveRun` executes the *previous* Turn
227
+ // inline and rethrows, so a recovery that failed — an uncertain effect, a
228
+ // mount failure, a provider that was down — threw before the new message
229
+ // was ever admitted, and the person's message was simply lost. The old
230
+ // Turn is durable either way and the alarm retries it; admission now
231
+ // refuses or supersedes on its own terms.
232
+ await this.recoverActiveRun().catch(() => undefined);
197
233
  const replay = await this.settledRunResult(command);
198
234
  if (replay) return replay;
199
235
  const admission = await this.acceptRun(command);
@@ -240,8 +276,10 @@ export class BotDurableAuthority<Snapshot> {
240
276
  const promoted = await this.promoteQueuedRun(command.runId);
241
277
  if (promoted === "blocked") {
242
278
  // Another Turn holds the object. Recovery drives it to its own
243
- // durable terminal or resumable state, and this one tries again.
244
- await this.recoverActiveRun();
279
+ // durable terminal or resumable state, and this one tries again
280
+ // including when that recovery fails, which is the other Turn's
281
+ // problem and not this one's.
282
+ await this.recoverActiveRun().catch(() => undefined);
245
283
  // Unless what holds the object is an uncertain effect. That is
246
284
  // settled by an explicit reconciliation the User asks for, on their
247
285
  // own clock, and retrying against it would only burn this caller's
@@ -249,11 +287,14 @@ export class BotDurableAuthority<Snapshot> {
249
287
  // run is durable: it stays queued, and the reconciliation's own
250
288
  // settlement — or the recovery alarm — starts it.
251
289
  if (await this.activeRunAwaitsReconciliation()) {
252
- return {
253
- runId: command.runId,
254
- text: "",
255
- events: [],
256
- } satisfies BotTurnCompletion;
290
+ // A Turn that has not run is not a completed Turn. Answering with
291
+ // an empty completion made the browser render the person's new
292
+ // message as answered with silence; the durable queue entry stays,
293
+ // and the refusal says why nothing has happened yet.
294
+ throw new BotTurnRefusedError(
295
+ "reconciliation-required",
296
+ `run "${command.runId}" is queued: the active run requires reconciliation before another Turn can be admitted`,
297
+ );
257
298
  }
258
299
  continue;
259
300
  }
@@ -369,12 +410,22 @@ export class BotDurableAuthority<Snapshot> {
369
410
  (await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
370
411
  ).map(decodeSessionEvent);
371
412
  const settings = run.configurationSnapshot;
372
- await transaction.put(key, {
373
- ...run,
374
- status: "running",
375
- phase: "executing",
376
- failure: undefined,
377
- } satisfies StoredRunV1<Snapshot>);
413
+ // The failure is *removed*, not set to `undefined`: a running run that
414
+ // carries a `failure` key is a shape the run record does not allow, and
415
+ // writing one turned "resolve this Turn" into a record nothing could
416
+ // read afterwards. `require` checks it here, where the write is, rather
417
+ // than leaving the projector to fail on every later read.
418
+ const { failure: _failure, ...resumed } = run;
419
+ await transaction.put(
420
+ key,
421
+ structuredClone(
422
+ this.codec.require({
423
+ ...resumed,
424
+ status: "running",
425
+ phase: "executing",
426
+ } satisfies StoredRunV1<Snapshot>),
427
+ ),
428
+ );
378
429
  await this.refreshRecoveryAlarm(transaction);
379
430
  return { run, latest, settings };
380
431
  });
@@ -480,8 +531,9 @@ export class BotDurableAuthority<Snapshot> {
480
531
  }
481
532
  const modelState = latestModelRequestJournalState(events);
482
533
  if (
483
- error instanceof BotTurnReconciliationRequiredError ||
484
- modelState.status === "unresolved"
534
+ (error instanceof BotTurnReconciliationRequiredError ||
535
+ modelState.status === "unresolved") &&
536
+ !runWasDiscardedV1(durableRun)
485
537
  ) {
486
538
  await this.requireRunReconciliation(
487
539
  command.runId,
@@ -612,8 +664,9 @@ export class BotDurableAuthority<Snapshot> {
612
664
  }
613
665
  const modelState = latestModelRequestJournalState(events);
614
666
  if (
615
- error instanceof BotTurnReconciliationRequiredError ||
616
- modelState.status === "unresolved"
667
+ (error instanceof BotTurnReconciliationRequiredError ||
668
+ modelState.status === "unresolved") &&
669
+ !runWasDiscardedV1(durableRun)
617
670
  ) {
618
671
  await this.requireRunReconciliation(
619
672
  run.runId,
@@ -659,7 +712,8 @@ export class BotDurableAuthority<Snapshot> {
659
712
  );
660
713
  if (!run) return undefined;
661
714
  if (run.commandFingerprint !== botTurnCommandFingerprintV1(command)) {
662
- throw new Error(
715
+ throw new BotTurnRefusedError(
716
+ "duplicate",
663
717
  `Turn idempotency key "${runId}" was reused for a different command`,
664
718
  );
665
719
  }
@@ -674,7 +728,8 @@ export class BotDurableAuthority<Snapshot> {
674
728
  };
675
729
  }
676
730
  if (run.status !== "completed") {
677
- throw new Error(
731
+ throw new BotTurnRefusedError(
732
+ "duplicate",
678
733
  `run "${runId}" already exists with status ${run.status}`,
679
734
  );
680
735
  }
@@ -702,7 +757,8 @@ export class BotDurableAuthority<Snapshot> {
702
757
  run &&
703
758
  run.commandFingerprint !== botTurnCommandFingerprintV1(command)
704
759
  ) {
705
- throw new Error(
760
+ throw new BotTurnRefusedError(
761
+ "duplicate",
706
762
  `Turn idempotency key "${command.runId}" was reused for a different command`,
707
763
  );
708
764
  }
@@ -788,7 +844,16 @@ export class BotDurableAuthority<Snapshot> {
788
844
  this.ctx.storage.get<BotIdentity>(IDENTITY_KEY),
789
845
  ]);
790
846
  const run = this.codec.optional(storedRun);
791
- if (run?.status === "reconciliation-required" && identity) return;
847
+ if (run?.status === "reconciliation-required" && identity) {
848
+ // A parked run is not this alarm's to settle — only an explicit
849
+ // reconciliation settles it — but returning without rescheduling
850
+ // dropped the object's *other* deadlines with it: a Routine due while
851
+ // a Bot sat parked never fired, and nothing set the alarm again.
852
+ await this.ctx.storage.transaction((transaction) =>
853
+ this.refreshRecoveryAlarm(transaction),
854
+ );
855
+ return;
856
+ }
792
857
  }
793
858
  await this.recoverActiveRun();
794
859
  }
@@ -855,17 +920,15 @@ export class BotDurableAuthority<Snapshot> {
855
920
  const storedFences = storedRunAdmissionFences(
856
921
  await transaction.get<unknown>(RUN_ADMISSION_FENCE_INDEX_KEY),
857
922
  );
858
- if (
859
- !storedFences.includes(runId) &&
860
- storedFences.length >= MAX_RUN_ADMISSION_FENCES
861
- ) {
862
- throw new Error("Run admission fence capacity reached");
863
- }
923
+ // A bounded FIFO, not a cliff. Nothing ever evicted an entry, so a Bot
924
+ // that had refused 256 sends over its life answered every later fence
925
+ // with a 500 and left the client retrying "Turn admission lookup
926
+ // failed" forever. A run id old enough to age out here can no longer
927
+ // be admitted by any live caller.
928
+ const kept = storedFences.filter((fenced) => fenced !== runId);
929
+ while (kept.length >= MAX_RUN_ADMISSION_FENCES) kept.shift();
864
930
  await transaction.put({
865
- [RUN_ADMISSION_FENCE_INDEX_KEY]: [
866
- ...storedFences.filter((fenced) => fenced !== runId),
867
- runId,
868
- ],
931
+ [RUN_ADMISSION_FENCE_INDEX_KEY]: [...kept, runId],
869
932
  [IDENTITY_KEY]: durableIdentity ?? identity,
870
933
  });
871
934
  await transaction.delete(`${RUN_ADMISSION_FENCE_PREFIX}${runId}`);
@@ -932,7 +995,10 @@ export class BotDurableAuthority<Snapshot> {
932
995
  fences.includes(command.runId) ||
933
996
  (await this.ctx.storage.get(fenceKey))
934
997
  ) {
935
- throw new Error(`run "${command.runId}" admission was fenced`);
998
+ throw new BotTurnRefusedError(
999
+ "fenced",
1000
+ `run "${command.runId}" admission was fenced`,
1001
+ );
936
1002
  }
937
1003
  const settings = await this.hooks.resolveAdmissionSnapshot(command);
938
1004
  // Materialized before the transaction; the pin itself is read inside it.
@@ -944,20 +1010,30 @@ export class BotDurableAuthority<Snapshot> {
944
1010
  if (
945
1011
  existing.commandFingerprint !== botTurnCommandFingerprintV1(command)
946
1012
  ) {
947
- throw new Error(
1013
+ throw new BotTurnRefusedError(
1014
+ "duplicate",
948
1015
  `Turn idempotency key "${command.runId}" was reused for a different command`,
949
1016
  );
950
1017
  }
951
1018
  if (existing.status === "completed") {
952
- throw new Error(`run "${command.runId}" already completed`);
1019
+ throw new BotTurnRefusedError(
1020
+ "duplicate",
1021
+ `run "${command.runId}" already completed`,
1022
+ );
953
1023
  }
954
- throw new Error(`run "${command.runId}" already exists`);
1024
+ throw new BotTurnRefusedError(
1025
+ "duplicate",
1026
+ `run "${command.runId}" already exists`,
1027
+ );
955
1028
  }
956
1029
  const fences = storedRunAdmissionFences(
957
1030
  await transaction.get<unknown>(RUN_ADMISSION_FENCE_INDEX_KEY),
958
1031
  );
959
1032
  if (fences.includes(command.runId) || (await transaction.get(fenceKey))) {
960
- throw new Error(`run "${command.runId}" admission was fenced`);
1033
+ throw new BotTurnRefusedError(
1034
+ "fenced",
1035
+ `run "${command.runId}" admission was fenced`,
1036
+ );
961
1037
  }
962
1038
  const identity = await transaction.get<BotIdentity>(IDENTITY_KEY);
963
1039
  if (
@@ -970,9 +1046,42 @@ export class BotDurableAuthority<Snapshot> {
970
1046
  const supersede = activeRunId
971
1047
  ? await this.planSupersede(transaction, command, activeRunId)
972
1048
  : undefined;
973
- const latestEvents = (
1049
+ const storedEvents = (
974
1050
  (await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
975
1051
  ).map(decodeSessionEvent);
1052
+ // A Turn that died between `turn/start` and `turn/end` — an event the
1053
+ // encoder refused, a durable write that failed — left the log open, and
1054
+ // every later Turn failed validation with "turn N started while turn
1055
+ // N-1 is open". Nothing owned that repair, because the run that would
1056
+ // have closed it is already terminal, so admission does: with nothing
1057
+ // executing, an open Turn is one nobody is going to finish.
1058
+ //
1059
+ // The pointer alone is not the test. A Bot can hold an `active-run` id
1060
+ // whose record is already terminal — a settlement that landed while the
1061
+ // pointer clear did not, a supersede whose Turn ended between the two
1062
+ // writes — and gating the repair on the pointer left exactly those Bots
1063
+ // wedged. What matters is whether anything is still entitled to write
1064
+ // that Turn's end: a `running` record is, and so is a
1065
+ // `reconciliation-required` one, whose Turn is held open on purpose
1066
+ // until its outcome is retrieved. Nothing else is.
1067
+ const activeRun = activeRunId
1068
+ ? this.codec.optional(
1069
+ await transaction.get<unknown>(`${RUN_PREFIX}${activeRunId}`),
1070
+ )
1071
+ : undefined;
1072
+ const stillOwned =
1073
+ activeRun?.status === "running" ||
1074
+ activeRun?.status === "reconciliation-required";
1075
+ const repairs = stillOwned
1076
+ ? []
1077
+ : repairOrphanedOpenTurnV1(command.sessionId, storedEvents);
1078
+ const latestEvents = [...storedEvents, ...repairs];
1079
+ if (repairs.length > 0) {
1080
+ await transaction.put(
1081
+ LATEST_EVENTS_KEY,
1082
+ structuredClone(latestEvents.map(decodeSessionEvent)),
1083
+ );
1084
+ }
976
1085
  const admittedSettings = await this.hooks.admittedSnapshot(
977
1086
  transaction,
978
1087
  settings,
@@ -1060,21 +1169,23 @@ export class BotDurableAuthority<Snapshot> {
1060
1169
  // no run when the person pressed send — supersedes exactly as a named one
1061
1170
  // does; only an absent field is "no intent", and that is still refused.
1062
1171
  if (lane !== "user" || !command.supersedes) {
1063
- throw new Error("bot already has an active run");
1172
+ throw new BotTurnRefusedError("busy", "bot already has an active run");
1064
1173
  }
1065
1174
  const active = this.codec.optional(
1066
1175
  await transaction.get<unknown>(`${RUN_PREFIX}${activeRunId}`),
1067
1176
  );
1068
- if (!active) throw new Error("bot already has an active run");
1177
+ if (!active)
1178
+ throw new BotTurnRefusedError("busy", "bot already has an active run");
1069
1179
  if (active.status === "reconciliation-required") {
1070
1180
  // An uncertain external effect is never abandoned to admit something
1071
1181
  // else: the outcome has to be retrieved before this object runs again.
1072
- throw new Error(
1182
+ throw new BotTurnRefusedError(
1183
+ "reconciliation-required",
1073
1184
  `run "${activeRunId}" requires reconciliation before another Turn can be admitted`,
1074
1185
  );
1075
1186
  }
1076
1187
  if (active.status !== "running") {
1077
- throw new Error("bot already has an active run");
1188
+ throw new BotTurnRefusedError("busy", "bot already has an active run");
1078
1189
  }
1079
1190
  const pendingRunId = await transaction.get<string>(PENDING_RUN_KEY);
1080
1191
  // A Turn that has not dispatched a model request has no durable work to
@@ -1335,7 +1446,12 @@ export class BotDurableAuthority<Snapshot> {
1335
1446
  const latest = (
1336
1447
  (await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
1337
1448
  ).map(decodeSessionEvent);
1338
- const plan = planBotRunRecovery(run, latest, this.codec);
1449
+ const plan = planBotRunRecovery(
1450
+ run,
1451
+ latest,
1452
+ this.codec,
1453
+ this.hooks.providerReconciles ?? (() => true),
1454
+ );
1339
1455
  if (plan.kind === "complete") {
1340
1456
  const result = {
1341
1457
  runId: run.runId,
@@ -1360,13 +1476,19 @@ export class BotDurableAuthority<Snapshot> {
1360
1476
  return undefined;
1361
1477
  }
1362
1478
  if (plan.kind === "fail") {
1479
+ // The repairs matter when the failure is ADR 0028's: they close the
1480
+ // tool occurrences the restart left open, so the settled run's journal
1481
+ // is a complete account rather than one that stops mid-sentence twice.
1482
+ const events = plan.repairs
1483
+ ? [...run.events, ...plan.repairs]
1484
+ : run.events;
1363
1485
  await failStoredRun(
1364
1486
  this.codec,
1365
1487
  transaction,
1366
1488
  this.terminalKeys(run.runId),
1367
1489
  run.runId,
1368
1490
  latest.slice(0, run.previousEventCount),
1369
- run.events,
1491
+ events,
1370
1492
  plan.failure,
1371
1493
  this.supersededPackageRecords(),
1372
1494
  );
@@ -1400,6 +1522,22 @@ export class BotDurableAuthority<Snapshot> {
1400
1522
  await this.refreshRecoveryAlarm(transaction);
1401
1523
  return { kind: "resume" as const, run, latest, settings };
1402
1524
  }
1525
+ if (runWasDiscardedV1(run)) {
1526
+ // Recovery of a Turn Stop or supersede already discarded settles it on
1527
+ // that intent rather than parking it: nothing is owed the answer.
1528
+ await failStoredRun(
1529
+ this.codec,
1530
+ transaction,
1531
+ this.terminalKeys(run.runId),
1532
+ run.runId,
1533
+ latest.slice(0, run.previousEventCount),
1534
+ [...run.events, ...plan.repairs],
1535
+ "Execution outcome requires reconciliation before it can resume",
1536
+ this.supersededPackageRecords(),
1537
+ );
1538
+ await this.refreshRecoveryAlarm(transaction);
1539
+ return undefined;
1540
+ }
1403
1541
  await transaction.put({
1404
1542
  [key]: {
1405
1543
  ...run,
@@ -758,7 +758,15 @@ export function botTurnCommandFingerprintV1(
758
758
  ...(lane === defaultRunLaneV1(turnType) ? {} : { lane }),
759
759
  ...(command.subagentRole ? { subagentRole: command.subagentRole } : {}),
760
760
  ...(command.origin ? { origin: command.origin } : {}),
761
- ...(command.supersedes ? { supersedes: command.supersedes } : {}),
761
+ // The *intent* is part of the command's identity, exactly as ADR 0024
762
+ // requires: a replay of a command that carried no supersede can never
763
+ // become one that interrupts a second Turn. The provenance is not. A
764
+ // client retrying the same send — same commandId, same text — names
765
+ // whichever run it happened to have observed by then, and that is a fact
766
+ // about its polling, not about what the person asked for. Hashing it
767
+ // turned an ordinary retry into "this idempotency key was reused for a
768
+ // different command" and refused the send.
769
+ ...(command.supersedes ? { supersedes: true } : {}),
762
770
  ...(skills.length > 0 ? { skills: skills.map(formatSkillRefV1) } : {}),
763
771
  ...(command.directTool ? { directTool: command.directTool } : {}),
764
772
  })}`;
@@ -2,8 +2,11 @@ import { describe, expect, test } from "bun:test";
2
2
  import type { SessionEvent } from "@frockbot/kernel-contracts";
3
3
  import {
4
4
  latestModelRequestJournalState,
5
+ planBotRunRecovery,
6
+ UNRECONCILABLE_RUN_FAILURE_V1,
5
7
  unresolvedModelRequestFailure,
6
8
  } from "./run-recovery.js";
9
+ import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.js";
7
10
 
8
11
  // Distributive, so each member of the union keeps its own fields: a bare
9
12
  // `Omit` over the union collapses to the keys they all share.
@@ -76,18 +79,6 @@ describe("unresolvedModelRequestFailure", () => {
76
79
  );
77
80
  });
78
81
 
79
- test("reads the last reason when the Turn was retried", () => {
80
- expect(
81
- unresolved(
82
- request,
83
- reconciliationRequired("request-1", "first attempt"),
84
- reconciliationRequired("request-1", "second attempt"),
85
- ),
86
- ).toBe(
87
- 'Model request "request-1" has no durable provider outcome: second attempt',
88
- );
89
- });
90
-
91
82
  test("ignores a reason journaled against another request", () => {
92
83
  expect(
93
84
  unresolved(
@@ -104,3 +95,120 @@ describe("unresolvedModelRequestFailure", () => {
104
95
  );
105
96
  });
106
97
  });
98
+
99
+ // ADR 0028. A restart mid-Turn used to park every in-flight run on a
100
+ // reconciliation nobody could perform: the providers this deployment actually
101
+ // uses expose no response retrieval, so the banner's Resolve action had one
102
+ // possible outcome and the Bot stayed wedged until somebody clicked it.
103
+ describe("a restart with no retrievable provider outcome", () => {
104
+ const codec = createStoredRunCodecV1<null>({
105
+ decodeRunId: (value) => String(value),
106
+ decodeConfigurationSnapshot: () => null,
107
+ });
108
+
109
+ function runWith(events: SessionEvent[]): StoredRunV1<null> {
110
+ return {
111
+ runId: "run-1",
112
+ commandFingerprint: "fingerprint-1",
113
+ sessionId: "user-1:bot-1",
114
+ acceptedAt: new Date(Date.UTC(2026, 8, 3)).toISOString(),
115
+ input: "hello",
116
+ events,
117
+ effectAdmissions: [],
118
+ status: "running",
119
+ phase: "executing",
120
+ compositionGenerationId: "generation-1",
121
+ configurationSnapshot: null,
122
+ previousEventCount: 0,
123
+ };
124
+ }
125
+
126
+ /** `Session` requires a zero-based contiguous log; this file's own stamper is one-based. */
127
+ function durableJournal(...events: UnstampedEvent[]): SessionEvent[] {
128
+ return events.map(
129
+ (event, index) =>
130
+ ({
131
+ ...event,
132
+ seq: index,
133
+ timestamp: new Date(Date.UTC(2026, 8, 3, 0, 0, index)).toISOString(),
134
+ }) as SessionEvent,
135
+ );
136
+ }
137
+
138
+ const openTurn: UnstampedEvent[] = [
139
+ {
140
+ type: "session/created",
141
+ createdAt: new Date(Date.UTC(2026, 8, 3)).toISOString(),
142
+ },
143
+ { type: "input/queued", messageId: "message-1", text: "hello" },
144
+ { type: "turn/start", turn: 1 },
145
+ { type: "step/start", turn: 1, step: 1 },
146
+ {
147
+ type: "user/message",
148
+ turn: 1,
149
+ step: 1,
150
+ messageId: "message-1",
151
+ text: "hello",
152
+ },
153
+ ];
154
+
155
+ test("settles the run as failed rather than parking it", () => {
156
+ const events = durableJournal(...openTurn, request);
157
+ const plan = planBotRunRecovery(
158
+ runWith(events),
159
+ events,
160
+ codec,
161
+ (provider) => provider === "foundation",
162
+ );
163
+
164
+ expect(plan.kind).toBe("fail");
165
+ expect(plan.kind === "fail" ? plan.failure : "").toBe(
166
+ UNRECONCILABLE_RUN_FAILURE_V1,
167
+ );
168
+ // Whatever repairs the resume would have written travel with the
169
+ // settlement, so an unresolved tool occurrence is closed rather than left
170
+ // open in a record nothing will ever revisit. This journal needs none.
171
+ expect(plan.kind === "fail" ? plan.repairs : undefined).toEqual([]);
172
+ });
173
+
174
+ test("keeps parking a run whose provider can be asked", () => {
175
+ const events = durableJournal(...openTurn, request);
176
+ const plan = planBotRunRecovery(runWith(events), events, codec, () => true);
177
+
178
+ expect(plan.kind).toBe("reconcile");
179
+ });
180
+
181
+ test("parks by default, so a host that names no policy is unaffected", () => {
182
+ const events = durableJournal(...openTurn, request);
183
+
184
+ expect(planBotRunRecovery(runWith(events), events, codec).kind).toBe(
185
+ "reconcile",
186
+ );
187
+ });
188
+
189
+ test("preserves the words the Turn had already streamed", () => {
190
+ const events = durableJournal(...openTurn, request, {
191
+ type: "assistant/chunk",
192
+ turn: 1,
193
+ step: 1,
194
+ requestId: "request-1",
195
+ text: "Half a thought",
196
+ });
197
+ const plan = planBotRunRecovery(
198
+ runWith(events),
199
+ events,
200
+ codec,
201
+ () => false,
202
+ );
203
+
204
+ expect(plan.kind).toBe("fail");
205
+ // Nothing in the plan discards the journal: the settled record carries the
206
+ // run's own events, and the projection reads the partial answer back out.
207
+ expect(
208
+ events.some(
209
+ (event) =>
210
+ event.type === "assistant/chunk" && event.text === "Half a thought",
211
+ ),
212
+ ).toBe(true);
213
+ });
214
+ });