@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 +3 -3
- package/src/authority.ts +130 -31
- package/src/run-failure-bounds.test.ts +46 -0
- package/src/run-records.ts +30 -0
- package/src/turn-supersede.test.ts +122 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-do",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
16
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
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.
|
|
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
|
|
587
|
-
* failure: the Turn settled durably,
|
|
588
|
-
* and its caller reads the rest of
|
|
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
|
|
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")
|
|
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.
|
|
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
|
|
747
|
-
// not a failure: it settled durably, said
|
|
748
|
-
// and the caller reads the rest from durable
|
|
749
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/run-records.ts
CHANGED
|
@@ -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
|
+
});
|