@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.
@@ -12,6 +12,10 @@ import {
12
12
  type OwnedBotTurnCommand,
13
13
  } from "./authority.ts";
14
14
  import { MemoryStorage } from "./memory-storage.fixture.ts";
15
+ import {
16
+ BotTurnReconciliationRequiredError,
17
+ BotTurnRecoveryRequiredError,
18
+ } from "./turn-errors.ts";
15
19
  import {
16
20
  createStoredRunCodecV1,
17
21
  storedRunLaneV1,
@@ -108,7 +112,23 @@ function deferred<T>(): Deferred<T> {
108
112
  */
109
113
  function createAuthority(
110
114
  storage: MemoryStorage,
111
- options: { dispatch?(runId: string): boolean } = {},
115
+ options: {
116
+ dispatch?(runId: string): boolean;
117
+ /**
118
+ * Ends the named Turn the way the Agent loop ends one whose model stream
119
+ * was aborted mid-flight: a journaled `model/request` with no durable
120
+ * provider outcome, and a reconciliation demand.
121
+ */
122
+ uncertain?(runId: string): boolean;
123
+ /**
124
+ * Parks the named Turn when it is released: the provider call it had
125
+ * dispatched by then has no durable outcome, and nothing but an explicit
126
+ * reconciliation can settle it.
127
+ */
128
+ parkOnRelease?(runId: string): boolean;
129
+ /** Fails the recovery of an evicted Turn, leaving it active and owed. */
130
+ failRecovery?(runId: string): boolean;
131
+ } = {},
112
132
  ): Probe {
113
133
  const observed: BotTurnExecutionInput<undefined>[] = [];
114
134
  const interrupts: { runId: string; reason: string }[] = [];
@@ -138,6 +158,9 @@ function createAuthority(
138
158
  executeTurn: async (input) => {
139
159
  observed.push(input);
140
160
  const runId = input.command.runId;
161
+ if (input.resume && options.failRecovery?.(runId)) {
162
+ throw new BotTurnRecoveryRequiredError([]);
163
+ }
141
164
  const turn = observed.length;
142
165
  const handle = handleFor(runId);
143
166
  let seq = input.previousEvents.length;
@@ -166,7 +189,22 @@ function createAuthority(
166
189
  text: input.command.text,
167
190
  } as never,
168
191
  );
169
- if (options.dispatch?.(runId) ?? true) {
192
+ const uncertain = options.uncertain?.(runId) ?? false;
193
+ if (uncertain) {
194
+ await persist({
195
+ type: "model/request",
196
+ turn,
197
+ step: 1,
198
+ request: {
199
+ requestId: `request-${runId}`,
200
+ provider: "foundation",
201
+ model: "foundation-model",
202
+ system: "system",
203
+ messages: [{ role: "user", content: input.command.text }],
204
+ tools: [],
205
+ },
206
+ } as never);
207
+ } else if (options.dispatch?.(runId) ?? true) {
170
208
  await persist(
171
209
  {
172
210
  type: "model/request",
@@ -193,6 +231,43 @@ function createAuthority(
193
231
  }
194
232
  handle.started.resolve();
195
233
  const outcome = await handle.settled.promise;
234
+ if (outcome.interrupted === undefined && options.parkOnRelease?.(runId)) {
235
+ const reason = `Model request "request-${runId}" has no durable provider outcome`;
236
+ await persist(
237
+ {
238
+ type: "model/request",
239
+ turn,
240
+ step: 1,
241
+ request: {
242
+ requestId: `request-${runId}`,
243
+ provider: "foundation",
244
+ model: "foundation-model",
245
+ system: "system",
246
+ messages: [{ role: "user", content: input.command.text }],
247
+ tools: [],
248
+ },
249
+ } as never,
250
+ {
251
+ type: "model/reconciliation-required",
252
+ turn,
253
+ step: 1,
254
+ requestId: `request-${runId}`,
255
+ reason,
256
+ } as never,
257
+ );
258
+ throw new BotTurnReconciliationRequiredError(reason, appended);
259
+ }
260
+ if (outcome.interrupted !== undefined && uncertain) {
261
+ const reason = `Model response outcome is uncertain after cancellation: ${outcome.interrupted}`;
262
+ await persist({
263
+ type: "model/reconciliation-required",
264
+ turn,
265
+ step: 1,
266
+ requestId: `request-${runId}`,
267
+ reason,
268
+ } as never);
269
+ throw new BotTurnReconciliationRequiredError(reason, appended);
270
+ }
196
271
  if (outcome.interrupted !== undefined) {
197
272
  await persist({
198
273
  type: "turn/end",
@@ -576,3 +651,179 @@ describe("eviction between the two Turns", () => {
576
651
  expect(restarted.observed).toHaveLength(1);
577
652
  });
578
653
  });
654
+
655
+ describe("a durable log left inside a Turn", () => {
656
+ test("is repaired at admission instead of refusing every later Turn", async () => {
657
+ const storage = new MemoryStorage();
658
+ // Exactly what a Turn that threw between `turn/start` and `turn/end`
659
+ // leaves behind: an open Turn, and no run to close it.
660
+ storage.values.set("latest-events", [
661
+ {
662
+ type: "session/created",
663
+ createdAt: "2026-09-03T00:00:00.000Z",
664
+ seq: 0,
665
+ timestamp: "2026-09-03T00:00:00.000Z",
666
+ },
667
+ {
668
+ type: "turn/start",
669
+ turn: 1,
670
+ seq: 1,
671
+ timestamp: "2026-09-03T00:00:01.000Z",
672
+ },
673
+ ]);
674
+ const probe = createAuthority(storage);
675
+
676
+ const run = probe.authority.run(command("run-1", "hello"));
677
+ await probe.handle("run-1").started;
678
+ probe.handle("run-1").finish();
679
+ await run;
680
+
681
+ const events = storage.values.get("latest-events") as Array<{
682
+ type: string;
683
+ turn?: number;
684
+ }>;
685
+ // The orphaned Turn is closed, so the new one starts.
686
+ expect(events[2]).toMatchObject({
687
+ type: "turn/end",
688
+ turn: 1,
689
+ outcome: "interrupted",
690
+ });
691
+ expect(storedRun(storage, "run-1").status).toBe("completed");
692
+ });
693
+ });
694
+
695
+ describe("a failing recovery of an older Turn", () => {
696
+ test("does not swallow the message the User just sent", async () => {
697
+ const storage = new MemoryStorage();
698
+ const probe = createAuthority(storage);
699
+ const first = probe.authority.run(command("run-1", "first"));
700
+ await probe.handle("run-1").started;
701
+ // Evicted mid-Turn: run-1 stays active and durable, and the object that
702
+ // comes back recovers it — badly.
703
+ const restarted = createAuthority(storage, {
704
+ failRecovery: (runId) => runId === "run-1",
705
+ });
706
+ const second = restarted.authority
707
+ .run(
708
+ command("run-2", "second", {
709
+ lane: "user",
710
+ supersedes: { runId: "run-1" },
711
+ }),
712
+ )
713
+ .catch(() => undefined);
714
+ await admitted();
715
+
716
+ // The new message is durable regardless of what happened to the old Turn.
717
+ // Before this, the recovery's own error threw out of `run()` before
718
+ // admission was ever attempted and the message was simply gone.
719
+ expect(storedRun(storage, "run-2").runId).toBe("run-2");
720
+ expect(storedRun(storage, "run-2").input).toBe("second");
721
+ probe.handle("run-1").finish();
722
+ await first.catch(() => undefined);
723
+ restarted.handle("run-2").finish();
724
+ await second;
725
+ });
726
+ });
727
+
728
+ describe("the run admission fence index", () => {
729
+ test("ages the oldest entry out rather than refusing the operation", async () => {
730
+ const storage = new MemoryStorage();
731
+ const probe = createAuthority(storage);
732
+ for (let index = 0; index < 300; index += 1) {
733
+ await probe.authority.fenceRunAdmission(identity, `fence-${index}`);
734
+ }
735
+ const fences = storage.values.get("run-admission-fences") as string[];
736
+ expect(fences.length).toBeLessThanOrEqual(256);
737
+ // The newest fence is the one that still matters; the oldest aged out.
738
+ expect(fences.at(-1)).toBe("fence-299");
739
+ expect(fences).not.toContain("fence-0");
740
+ });
741
+ });
742
+
743
+ describe("a Turn queued behind a parked run", () => {
744
+ test("is refused rather than answered with an empty completion", async () => {
745
+ const storage = new MemoryStorage();
746
+ // The first Turn has not dispatched when the second arrives, so it is left
747
+ // to finish and the second queues behind it. It then parks on a provider
748
+ // outcome only a User can retrieve.
749
+ const probe = createAuthority(storage, {
750
+ dispatch: () => false,
751
+ parkOnRelease: (runId) => runId === "run-1",
752
+ });
753
+
754
+ const first = probe.authority.run(command("run-1", "first"));
755
+ await probe.handle("run-1").started;
756
+ const second = probe.authority.run(
757
+ command("run-2", "second", {
758
+ lane: "user",
759
+ supersedes: { runId: "run-1" },
760
+ }),
761
+ );
762
+ await admitted();
763
+ probe.handle("run-1").finish();
764
+ await first.catch(() => undefined);
765
+
766
+ await expect(second).rejects.toThrow(
767
+ /is queued: the active run requires reconciliation/,
768
+ );
769
+ // And it is still owed a Turn: durable, queued, and started by the
770
+ // reconciliation's own settlement or by the recovery alarm.
771
+ const queued = storedRun(storage, "run-2");
772
+ expect(queued.status).toBe("running");
773
+ expect(queued.phase).toBe("queued");
774
+ expect(storage.values.get("pending-run")).toBe("run-2");
775
+ });
776
+ });
777
+
778
+ describe("an interrupt while the model is streaming", () => {
779
+ test("a superseded Turn settles superseded rather than parking the Bot", async () => {
780
+ const storage = new MemoryStorage();
781
+ const probe = createAuthority(storage, { uncertain: () => true });
782
+
783
+ const first = probe.authority.run(command("run-1", "first"));
784
+ await probe.handle("run-1").started;
785
+ const second = probe.authority.run(
786
+ command("run-2", "second", {
787
+ lane: "user",
788
+ supersedes: { runId: "run-1" },
789
+ }),
790
+ );
791
+ await first.catch(() => undefined);
792
+ probe.handle("run-2").finish();
793
+ const result = await second;
794
+
795
+ // The provider outcome of a Turn nobody is waiting for is worthless: the
796
+ // intent the User expressed is what settles it.
797
+ const superseded = storedRun(storage, "run-1");
798
+ expect(superseded.status).toBe("superseded");
799
+ expect(superseded.supersededBy).toBe("run-2");
800
+ expect(storage.values.get("active-run")).toBeUndefined();
801
+ expect(result.text).toBe("done: second");
802
+ });
803
+
804
+ test("a stopped Turn settles cancelled and the next message is admitted", async () => {
805
+ const storage = new MemoryStorage();
806
+ const probe = createAuthority(storage, { uncertain: () => true });
807
+
808
+ const first = probe.authority.run(command("run-1", "first"));
809
+ await probe.handle("run-1").started;
810
+ // What an authenticated Stop writes before it signals the Agent.
811
+ const stopped = storedRun(storage, "run-1");
812
+ storage.values.set("run:run-1", {
813
+ ...stopped,
814
+ stopRequestedAt: "2026-09-03T00:00:05.000Z",
815
+ });
816
+ probe.handle("run-1").interrupt("stopped by an authenticated Stop command");
817
+ await first.catch(() => undefined);
818
+
819
+ expect(storedRun(storage, "run-1").status).toBe("cancelled");
820
+ expect(storage.values.get("active-run")).toBeUndefined();
821
+
822
+ // And the Bot takes the next message straight away, with no
823
+ // reconciliation standing between the User and their Bot.
824
+ const next = probe.authority.run(command("run-2", "second"));
825
+ await probe.handle("run-2").started;
826
+ probe.handle("run-2").finish();
827
+ expect((await next).text).toBe("done: second");
828
+ });
829
+ });