@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 +3 -3
- package/src/authority.ts +182 -44
- package/src/run-records.ts +9 -1
- package/src/run-recovery.test.ts +120 -12
- package/src/run-recovery.ts +86 -2
- package/src/run-terminal-open-turn.test.ts +203 -0
- package/src/run-terminal.ts +51 -8
- package/src/turn-admission.test.ts +38 -0
- package/src/turn-errors.ts +44 -0
- package/src/turn-supersede.test.ts +253 -2
|
@@ -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: {
|
|
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
|
-
|
|
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
|
+
});
|