@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-do",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
16
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
|
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
|
|
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
|
|
1019
|
+
throw new BotTurnRefusedError(
|
|
1020
|
+
"duplicate",
|
|
1021
|
+
`run "${command.runId}" already completed`,
|
|
1022
|
+
);
|
|
953
1023
|
}
|
|
954
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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,
|
package/src/run-records.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
})}`;
|
package/src/run-recovery.test.ts
CHANGED
|
@@ -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
|
+
});
|