@frockbot/kernel-do 0.3.7 → 0.3.8

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.7",
3
+ "version": "0.3.8",
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.7",
16
- "@frockbot/kernel-contracts": "0.3.7",
15
+ "@frockbot/kernel-composition": "0.3.8",
16
+ "@frockbot/kernel-contracts": "0.3.8",
17
17
  "cordis": "4.0.0-rc.8"
18
18
  },
19
19
  "devDependencies": {
package/src/authority.ts CHANGED
@@ -12,6 +12,7 @@ import type { CompositionGenerationV1 } from "@frockbot/kernel-composition/gener
12
12
  import { DurableCompositionStore } from "./composition-store.js";
13
13
  import { DurableCompositionFailureLog } from "./composition-failures.js";
14
14
  import {
15
+ boundedRunFailureV1,
15
16
  botTurnCommandFingerprintV1,
16
17
  defaultRunLaneV1,
17
18
  storedRunAdmissionV1,
@@ -175,6 +176,16 @@ export const SUPERSEDED_TURN_REASON_V1 = "superseded by a new user message";
175
176
  /** How many times a queued Turn retries the object before giving up. */
176
177
  const MAX_QUEUED_RUN_START_ATTEMPTS = 8;
177
178
 
179
+ /**
180
+ * The failure a discarded Turn is settled with when recovery finds it.
181
+ *
182
+ * It is never read by anybody: `failStoredRun` routes a run carrying a Stop or
183
+ * supersede intent to `cancelStoredRun`/`supersedeStoredRun`, and both drop the
184
+ * failure — the User's own intent is the outcome, not an error.
185
+ */
186
+ const DISCARDED_RUN_RECOVERY_FAILURE_V1 =
187
+ "Turn was discarded before recovery could resume it";
188
+
178
189
  /**
179
190
  * True when this object has already durably decided to throw the Turn away.
180
191
  *
@@ -477,10 +488,43 @@ export class BotDurableAuthority<Snapshot> {
477
488
  `Reconciliation was explicitly abandoned: ${failure}`,
478
489
  );
479
490
  }
491
+ // "Try again" that ends in a settled run is a *successful* abandon, not a
492
+ // failed request. Rethrowing here made the button answer 409 and left the
493
+ // browser reading a run it thought had not moved — and the read that
494
+ // followed 500'd on the half-repaired record. The run is durable and
495
+ // terminal by this point, and its own record says why it ended, so the
496
+ // caller is handed that record and reads the reason from the transcript.
497
+ const settled = await this.settledReconciliationResult(runId);
498
+ if (settled) return settled;
480
499
  throw error;
481
500
  }
482
501
  }
483
502
 
503
+ /**
504
+ * The completion an abandoned reconciliation reports once the run it was
505
+ * resolving has reached a terminal state — whatever that state turned out to
506
+ * be. Anything still open is not this method's to answer for.
507
+ */
508
+ private async settledReconciliationResult(
509
+ runId: string,
510
+ ): Promise<BotTurnCompletion | undefined> {
511
+ const run = this.codec.optional(
512
+ await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
513
+ );
514
+ if (
515
+ run?.status !== "failed" &&
516
+ run?.status !== "cancelled" &&
517
+ run?.status !== "superseded"
518
+ ) {
519
+ return undefined;
520
+ }
521
+ return {
522
+ runId,
523
+ text: run.responseText ?? "",
524
+ events: structuredClone(run.events),
525
+ };
526
+ }
527
+
484
528
  private withNotification(
485
529
  snapshot: Snapshot,
486
530
  result: BotTurnCompletion,
@@ -572,7 +616,7 @@ export class BotDurableAuthority<Snapshot> {
572
616
  throw new Error(message);
573
617
  }
574
618
  await this.failRun(command.runId, previous, events, message);
575
- const settled = await this.supersededRunResult(command.runId);
619
+ const settled = await this.discardedRunResult(command.runId);
576
620
  if (settled) return settled;
577
621
  throw new Error(message);
578
622
  } finally {
@@ -583,17 +627,25 @@ export class BotDurableAuthority<Snapshot> {
583
627
  }
584
628
 
585
629
  /**
586
- * The completion a Turn another user message replaced reports. It is not a
587
- * failure: the Turn settled durably, keeping everything it had already sent,
588
- * and its caller reads the rest of the conversation from durable state.
630
+ * The completion a discarded Turn reports one the User stopped, or one a
631
+ * later message replaced. Neither is a failure: the Turn settled durably,
632
+ * keeping everything it had already sent, and its caller reads the rest of
633
+ * the conversation from durable state.
634
+ *
635
+ * Stop used to be missing from here, so the long-lived `POST /turns` the
636
+ * composer was still holding open answered 500 the instant Stop was pressed:
637
+ * the UI said "You stopped this." and the console said the send had failed.
638
+ * A Turn the person stopped on purpose is the most ordinary outcome there is.
589
639
  */
590
- private async supersededRunResult(
640
+ private async discardedRunResult(
591
641
  runId: string,
592
642
  ): Promise<BotTurnCompletion | undefined> {
593
643
  const run = this.codec.optional(
594
644
  await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
595
645
  );
596
- if (run?.status !== "superseded") return undefined;
646
+ if (run?.status !== "superseded" && run?.status !== "cancelled") {
647
+ return undefined;
648
+ }
597
649
  return { runId, text: "", events: structuredClone(run.events) };
598
650
  }
599
651
 
@@ -608,7 +660,7 @@ export class BotDurableAuthority<Snapshot> {
608
660
  const run = this.codec.optional(
609
661
  await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
610
662
  );
611
- if (run?.status === "superseded") {
663
+ if (run?.status === "superseded" || run?.status === "cancelled") {
612
664
  return { runId, text: "", events: structuredClone(run.events) };
613
665
  }
614
666
  if (run?.status !== "completed") return undefined;
@@ -705,7 +757,7 @@ export class BotDurableAuthority<Snapshot> {
705
757
  throw new Error(message);
706
758
  }
707
759
  await this.failRun(run.runId, previous, events, message);
708
- const settled = await this.supersededRunResult(run.runId);
760
+ const settled = await this.discardedRunResult(run.runId);
709
761
  if (settled) return settled;
710
762
  throw new Error(message);
711
763
  } finally {
@@ -743,10 +795,11 @@ export class BotDurableAuthority<Snapshot> {
743
795
  `Turn idempotency key "${runId}" was reused for a different command`,
744
796
  );
745
797
  }
746
- // A Turn another user message took the place of is an ordinary outcome,
747
- // not a failure: it settled durably, said whatever it had already said,
748
- // and the caller reads the rest from durable state.
749
- if (run.status === "superseded") {
798
+ // A Turn the User stopped, or one another user message took the place of,
799
+ // is an ordinary outcome and not a failure: it settled durably, said
800
+ // whatever it had already said, and the caller reads the rest from durable
801
+ // state. A retry of either replays that settlement rather than refusing.
802
+ if (run.status === "superseded" || run.status === "cancelled") {
750
803
  return {
751
804
  runId,
752
805
  text: "",
@@ -881,7 +934,30 @@ export class BotDurableAuthority<Snapshot> {
881
934
  return;
882
935
  }
883
936
  }
884
- await this.recoverActiveRun();
937
+ // An alarm has no caller. A rejection here is an uncaught exception in the
938
+ // object, and in the dev Worker it took the whole process down: a Stop left
939
+ // a run whose model outcome was uncertain, recovery re-entered it,
940
+ // `executeAdmittedRun` rethrew after recording the failure durably, and
941
+ // wrangler exited mid-run for every agent sharing the stack.
942
+ //
943
+ // Nothing about that throw is actionable here. Recovery has already written
944
+ // whatever it decided to durable storage before it rethrew, so the only
945
+ // thing left to do is record the reason and make sure the object still has
946
+ // a deadline — the re-arm is deliberately in a `finally`, because a failed
947
+ // recovery is exactly the case where the *next* firing matters most.
948
+ try {
949
+ await this.recoverActiveRun();
950
+ } catch (error) {
951
+ console.error(
952
+ `Bot run recovery alarm failed: ${
953
+ error instanceof Error ? error.message : String(error)
954
+ }`,
955
+ );
956
+ } finally {
957
+ await this.ctx.storage
958
+ .transaction((transaction) => this.refreshRecoveryAlarm(transaction))
959
+ .catch(() => undefined);
960
+ }
885
961
  }
886
962
 
887
963
  /** Active run id, for Package projections of durable run state. */
@@ -1518,6 +1594,20 @@ export class BotDurableAuthority<Snapshot> {
1518
1594
  });
1519
1595
  }
1520
1596
 
1597
+ /**
1598
+ * Settles a run `failed` on a reason the authority composed from an error.
1599
+ *
1600
+ * The reason is bounded on the way in because nothing upstream bounds an
1601
+ * error's `message`: a provider that echoes the request back produced one far
1602
+ * past what the record allows, the settlement wrote it anyway, and every
1603
+ * later read of that run threw — so a Turn that failed once went on to 500
1604
+ * the transcript endpoint for ever. A reason a person reads loses nothing by
1605
+ * being cut; a transcript nobody can read loses everything.
1606
+ *
1607
+ * Recovery's own `failStoredRun` is deliberately not routed through here: a
1608
+ * failure derived from a malformed durable history is the one case where
1609
+ * refusing to settle, and keeping the work active, is the right answer.
1610
+ */
1521
1611
  private async failRun(
1522
1612
  runId: string,
1523
1613
  previous: SessionEvent[],
@@ -1532,13 +1622,14 @@ export class BotDurableAuthority<Snapshot> {
1532
1622
  runId,
1533
1623
  previous,
1534
1624
  events,
1535
- failure,
1625
+ boundedRunFailureV1(failure),
1536
1626
  this.supersededPackageRecords(),
1537
1627
  );
1538
1628
  await this.refreshRecoveryAlarm(transaction);
1539
1629
  });
1540
1630
  }
1541
1631
 
1632
+ /** Parks a run on a reason the authority composed, bounded as `failRun`'s is. */
1542
1633
  private async requireRunReconciliation(
1543
1634
  runId: string,
1544
1635
  previous: SessionEvent[],
@@ -1553,7 +1644,7 @@ export class BotDurableAuthority<Snapshot> {
1553
1644
  runId,
1554
1645
  previous,
1555
1646
  events,
1556
- failure,
1647
+ boundedRunFailureV1(failure),
1557
1648
  );
1558
1649
  await this.refreshRecoveryAlarm(transaction);
1559
1650
  });
@@ -1631,6 +1722,30 @@ export class BotDurableAuthority<Snapshot> {
1631
1722
  const latest = (
1632
1723
  (await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
1633
1724
  ).map(decodeSessionEvent);
1725
+ // A Turn the User stopped, or one a later message replaced, is terminal
1726
+ // in intent before recovery ever looks at it. There is nothing to
1727
+ // recover: no answer is owed, and the provider outcome cannot change what
1728
+ // it settles as. Re-entering it is how the Worker died — the run resumed,
1729
+ // reached "Model response outcome is uncertain after cancellation", and
1730
+ // the alarm had nothing to hand the rejection to.
1731
+ //
1732
+ // `failStoredRun` routes a discarded run to `cancelStoredRun` or
1733
+ // `supersedeStoredRun` on the intent that is already durable, and closes
1734
+ // the open turn on the way, so the settled log is a complete account.
1735
+ if (runWasDiscardedV1(run)) {
1736
+ await failStoredRun(
1737
+ this.codec,
1738
+ transaction,
1739
+ this.terminalKeys(run.runId),
1740
+ run.runId,
1741
+ latest.slice(0, run.previousEventCount),
1742
+ run.events,
1743
+ DISCARDED_RUN_RECOVERY_FAILURE_V1,
1744
+ this.supersededPackageRecords(),
1745
+ );
1746
+ await this.refreshRecoveryAlarm(transaction);
1747
+ return undefined;
1748
+ }
1634
1749
  const plan = planBotRunRecovery(
1635
1750
  run,
1636
1751
  latest,
@@ -1707,22 +1822,6 @@ export class BotDurableAuthority<Snapshot> {
1707
1822
  await this.refreshRecoveryAlarm(transaction);
1708
1823
  return { kind: "resume" as const, run, latest, settings };
1709
1824
  }
1710
- if (runWasDiscardedV1(run)) {
1711
- // Recovery of a Turn Stop or supersede already discarded settles it on
1712
- // that intent rather than parking it: nothing is owed the answer.
1713
- await failStoredRun(
1714
- this.codec,
1715
- transaction,
1716
- this.terminalKeys(run.runId),
1717
- run.runId,
1718
- latest.slice(0, run.previousEventCount),
1719
- [...run.events, ...plan.repairs],
1720
- "Execution outcome requires reconciliation before it can resume",
1721
- this.supersededPackageRecords(),
1722
- );
1723
- await this.refreshRecoveryAlarm(transaction);
1724
- return undefined;
1725
- }
1726
1825
  await transaction.put({
1727
1826
  [key]: {
1728
1827
  ...run,
@@ -0,0 +1,46 @@
1
+ // A failure the record could not hold, and the transcript it took with it.
2
+ //
3
+ // A run's `failure` is whatever an error's `message` happened to be, and
4
+ // nothing upstream bounds it. A provider that echoed the request back produced
5
+ // one past the record's own limit, the settlement wrote it anyway, and every
6
+ // later read of that run threw `has invalid failure` — so a Turn that failed
7
+ // once went on to 500 the transcript endpoint for ever, and "Try again" could
8
+ // not get the person out of it either.
9
+ import { describe, expect, test } from "bun:test";
10
+ import {
11
+ boundedRunFailureV1,
12
+ MAX_RUN_FAILURE_BYTES_V1,
13
+ } from "./run-records.ts";
14
+
15
+ const bytes = (value: string) => new TextEncoder().encode(value).byteLength;
16
+
17
+ describe("the failure a settlement may durably write", () => {
18
+ test("leaves a reason that already fits exactly as it was written", () => {
19
+ const reason = "Reconciliation was explicitly abandoned: response lost";
20
+ expect(boundedRunFailureV1(reason)).toBe(reason);
21
+ });
22
+
23
+ test("cuts one the record could not hold, and says it was cut", () => {
24
+ const bounded = boundedRunFailureV1(
25
+ "x".repeat(MAX_RUN_FAILURE_BYTES_V1 * 2),
26
+ );
27
+ expect(bytes(bounded)).toBeLessThanOrEqual(MAX_RUN_FAILURE_BYTES_V1);
28
+ expect(bounded.endsWith("…")).toBe(true);
29
+ });
30
+
31
+ test("keeps the opening words, which are the ones a person reads", () => {
32
+ const bounded = boundedRunFailureV1(
33
+ `Model request failed: ${"detail ".repeat(MAX_RUN_FAILURE_BYTES_V1)}`,
34
+ );
35
+ expect(bounded.startsWith("Model request failed: detail")).toBe(true);
36
+ });
37
+
38
+ test("never cuts inside a character", () => {
39
+ // Multi-byte throughout: a byte-wise slice would leave a lone surrogate
40
+ // and the decoder would refuse the record for a different reason.
41
+ const bounded = boundedRunFailureV1("🐑".repeat(MAX_RUN_FAILURE_BYTES_V1));
42
+ expect(bytes(bounded)).toBeLessThanOrEqual(MAX_RUN_FAILURE_BYTES_V1);
43
+ expect(bounded).not.toContain("�");
44
+ expect([...bounded].every((character) => character.length <= 2)).toBe(true);
45
+ });
46
+ });
@@ -277,6 +277,36 @@ function boundedString(
277
277
  );
278
278
  }
279
279
 
280
+ /** The largest failure a run record may carry, in UTF-8 bytes. */
281
+ export const MAX_RUN_FAILURE_BYTES_V1 = 8_000;
282
+
283
+ /**
284
+ * The failure a settlement may durably write, cut to what the record allows.
285
+ *
286
+ * A failure string is whatever an error's `message` happened to be, and nothing
287
+ * upstream bounds it — a provider that echoes a request back, or a message
288
+ * built by concatenating one, easily runs past the limit. Writing it anyway
289
+ * produced a record the codec refused on every later read, so a Turn that
290
+ * failed once went on to 500 the transcript endpoint for ever. The message is
291
+ * for a person to read: cutting it costs nothing the record does not already
292
+ * hold, and losing the whole transcript costs everything.
293
+ */
294
+ export function boundedRunFailureV1(failure: string): string {
295
+ if (UTF8_ENCODER.encode(failure).byteLength <= MAX_RUN_FAILURE_BYTES_V1) {
296
+ return failure;
297
+ }
298
+ const ellipsis = "…";
299
+ const budget =
300
+ MAX_RUN_FAILURE_BYTES_V1 - UTF8_ENCODER.encode(ellipsis).byteLength;
301
+ let kept = failure;
302
+ // Cutting by characters and re-measuring keeps the result valid UTF-8; a
303
+ // byte-wise slice can land inside a multi-byte sequence.
304
+ while (UTF8_ENCODER.encode(kept).byteLength > budget) {
305
+ kept = kept.slice(0, Math.max(0, Math.floor(kept.length * 0.9) - 1));
306
+ }
307
+ return `${kept}${ellipsis}`;
308
+ }
309
+
280
310
  function decodeDirectToolCommandV1(value: unknown): DirectToolCommandV1 {
281
311
  if (!value || typeof value !== "object" || Array.isArray(value)) {
282
312
  throw new Error("stored run has invalid direct tool command");
@@ -911,3 +911,125 @@ describe("an interrupt while the model is streaming", () => {
911
911
  expect((await next).text).toBe("done: second");
912
912
  });
913
913
  });
914
+
915
+ describe("a discarded Turn never crashes the object", () => {
916
+ test("the long-lived caller is answered with the cancelled run, not a throw", async () => {
917
+ const storage = new MemoryStorage();
918
+ const probe = createAuthority(storage, { uncertain: () => true });
919
+
920
+ const first = probe.authority.run(command("run-1", "first"));
921
+ await probe.handle("run-1").started;
922
+ const stopped = storedRun(storage, "run-1");
923
+ storage.values.set("run:run-1", {
924
+ ...stopped,
925
+ stopRequestedAt: "2026-09-03T00:00:05.000Z",
926
+ });
927
+ probe.handle("run-1").interrupt("agent cancelled by user");
928
+
929
+ // The composer is still holding this request open when Stop is pressed. It
930
+ // used to be answered with a 500 and a red console error while the UI
931
+ // beside it said "You stopped this." A Turn the person stopped on purpose
932
+ // is an ordinary outcome and settles as one.
933
+ const settled = await first;
934
+ expect(settled.runId).toBe("run-1");
935
+ expect(settled.text).toBe("");
936
+ expect(storedRun(storage, "run-1").status).toBe("cancelled");
937
+ expect(storage.values.get("active-run")).toBeUndefined();
938
+ });
939
+
940
+ test("recovery settles a stopped Turn instead of re-entering it", async () => {
941
+ const storage = new MemoryStorage();
942
+ const probe = createAuthority(storage, { uncertain: () => true });
943
+
944
+ const first = probe.authority.run(command("run-1", "first"));
945
+ await probe.handle("run-1").started;
946
+ const running = storedRun(storage, "run-1");
947
+ storage.values.set("run:run-1", {
948
+ ...running,
949
+ stopRequestedAt: "2026-09-03T00:00:05.000Z",
950
+ });
951
+ // The object is evicted with the Stop durable and the Turn still active:
952
+ // exactly the state the recovery alarm wakes up to.
953
+ first.catch(() => undefined);
954
+
955
+ const evicted = createAuthority(storage, { uncertain: () => true });
956
+ await expect(evicted.authority.alarm()).resolves.toBeUndefined();
957
+
958
+ // Re-entering it is what took the dev Worker down: the run resumed, reached
959
+ // "Model response outcome is uncertain after cancellation", and the alarm
960
+ // had nobody to hand the rejection to. There is nothing to recover — the
961
+ // User already said to throw it away.
962
+ expect(evicted.observed).toEqual([]);
963
+ expect(storedRun(storage, "run-1").status).toBe("cancelled");
964
+ expect(storage.values.get("active-run")).toBeUndefined();
965
+ });
966
+
967
+ test("recovery settles a superseded Turn instead of re-entering it", async () => {
968
+ const storage = new MemoryStorage();
969
+ const probe = createAuthority(storage, { uncertain: () => true });
970
+
971
+ const first = probe.authority.run(command("run-1", "first"));
972
+ await probe.handle("run-1").started;
973
+ const running = storedRun(storage, "run-1");
974
+ storage.values.set("run:run-1", {
975
+ ...running,
976
+ supersededAt: "2026-09-03T00:00:05.000Z",
977
+ supersededBy: "run-2",
978
+ });
979
+ first.catch(() => undefined);
980
+
981
+ const evicted = createAuthority(storage, { uncertain: () => true });
982
+ await expect(evicted.authority.alarm()).resolves.toBeUndefined();
983
+
984
+ expect(evicted.observed).toEqual([]);
985
+ expect(storedRun(storage, "run-1").status).toBe("superseded");
986
+ expect(storage.values.get("active-run")).toBeUndefined();
987
+ });
988
+
989
+ test("an alarm records a recovery failure instead of rejecting", async () => {
990
+ const storage = new MemoryStorage();
991
+ const probe = createAuthority(storage, { failRecovery: () => true });
992
+
993
+ const first = probe.authority.run(command("run-1", "first"));
994
+ await probe.handle("run-1").started;
995
+ first.catch(() => undefined);
996
+
997
+ // A recovery that cannot run the Turn is a durable fact, not a fault of the
998
+ // alarm: an alarm has no caller, so anything it lets escape is an uncaught
999
+ // exception in the object — one of the ways the dev Worker died.
1000
+ const evicted = createAuthority(storage, { failRecovery: () => true });
1001
+ await expect(evicted.authority.alarm()).resolves.toBeUndefined();
1002
+ // And the object still has a deadline, so the next firing tries again.
1003
+ expect(storage.alarmAt).toBeGreaterThan(0);
1004
+ });
1005
+ });
1006
+
1007
+ describe("Try again on a parked Turn", () => {
1008
+ test("answers with the run it settled rather than throwing", async () => {
1009
+ const storage = new MemoryStorage();
1010
+ // The Turn parks on a provider outcome only a User can retrieve, which is
1011
+ // the state the Resolve Turn button exists for.
1012
+ const probe = createAuthority(storage, {
1013
+ dispatch: () => false,
1014
+ parkOnRelease: () => true,
1015
+ });
1016
+
1017
+ const first = probe.authority.run(command("run-1", "first"));
1018
+ await probe.handle("run-1").started;
1019
+ probe.handle("run-1").finish();
1020
+ await first.catch(() => undefined);
1021
+ expect(storedRun(storage, "run-1").status).toBe("reconciliation-required");
1022
+
1023
+ // "Try again": the retry fails again, the run is abandoned, and that is a
1024
+ // successful abandon — not a failed request. Rethrowing here made the
1025
+ // button answer 409, and the transcript read the browser makes straight
1026
+ // afterwards 500 on the half-repaired record.
1027
+ const abandoned = await probe.authority.reconcileRun(identity, "run-1");
1028
+ expect(abandoned.runId).toBe("run-1");
1029
+
1030
+ const settled = storedRun(storage, "run-1");
1031
+ expect(settled.status).toBe("failed");
1032
+ expect(settled.failure).toContain("explicitly abandoned");
1033
+ expect(storage.values.get("active-run")).toBeUndefined();
1034
+ });
1035
+ });