@frockbot/kernel-do 0.3.11 → 0.3.13
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 +142 -11
- package/src/conversations.test.ts +12 -3
- package/src/conversations.ts +37 -0
- package/src/index.ts +1 -0
- package/src/model-timeout-settlement.test.ts +265 -0
- package/src/run-liveness.test.ts +299 -0
- package/src/run-liveness.ts +113 -0
- package/src/run-recovery.test.ts +1 -1
- package/src/run-terminal-open-turn.test.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-do",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
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.13",
|
|
16
|
+
"@frockbot/kernel-contracts": "0.3.13",
|
|
17
17
|
"cordis": "4.0.0-rc.8"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
package/src/authority.ts
CHANGED
|
@@ -34,11 +34,13 @@ import {
|
|
|
34
34
|
import {
|
|
35
35
|
eventsForFailedRun,
|
|
36
36
|
latestModelRequestJournalState,
|
|
37
|
+
latestModelRequestProviderV1,
|
|
37
38
|
planBotRunRecovery,
|
|
38
39
|
type ProviderReconcilesV1,
|
|
39
40
|
repairedSessionLogV1,
|
|
40
41
|
unresolvedModelRequestFailure,
|
|
41
42
|
} from "./run-recovery.js";
|
|
43
|
+
import { runLivenessV1, STALE_RUNNING_RUN_FAILURE_V1 } from "./run-liveness.js";
|
|
42
44
|
import {
|
|
43
45
|
BotTurnReconciliationRequiredError,
|
|
44
46
|
BotTurnRecoveryRequiredError,
|
|
@@ -46,6 +48,7 @@ import {
|
|
|
46
48
|
} from "./turn-errors.js";
|
|
47
49
|
import {
|
|
48
50
|
botConversationBaseSessionIdV1,
|
|
51
|
+
ConversationBusyError,
|
|
49
52
|
conversationSessionIdV1,
|
|
50
53
|
decodeConversationRecordV1,
|
|
51
54
|
decodeStoredConversationV1,
|
|
@@ -254,7 +257,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
254
257
|
// Turn is durable either way and the alarm retries it; admission now
|
|
255
258
|
// refuses or supersedes on its own terms.
|
|
256
259
|
await this.recoverActiveRun().catch(() => undefined);
|
|
257
|
-
const replay = await this.
|
|
260
|
+
const replay = await this.settledReplayResult(command);
|
|
258
261
|
if (replay) return replay;
|
|
259
262
|
const admission = await this.acceptRun(command);
|
|
260
263
|
if (admission.kind === "queued") {
|
|
@@ -494,7 +497,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
494
497
|
// followed 500'd on the half-repaired record. The run is durable and
|
|
495
498
|
// terminal by this point, and its own record says why it ended, so the
|
|
496
499
|
// caller is handed that record and reads the reason from the transcript.
|
|
497
|
-
const settled = await this.
|
|
500
|
+
const settled = await this.settledTerminalRunResult(runId);
|
|
498
501
|
if (settled) return settled;
|
|
499
502
|
throw error;
|
|
500
503
|
}
|
|
@@ -505,7 +508,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
505
508
|
* resolving has reached a terminal state — whatever that state turned out to
|
|
506
509
|
* be. Anything still open is not this method's to answer for.
|
|
507
510
|
*/
|
|
508
|
-
private async
|
|
511
|
+
private async settledTerminalRunResult(
|
|
509
512
|
runId: string,
|
|
510
513
|
): Promise<BotTurnCompletion | undefined> {
|
|
511
514
|
const run = this.codec.optional(
|
|
@@ -605,7 +608,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
605
608
|
modelState.status === "unresolved") &&
|
|
606
609
|
!runWasDiscardedV1(durableRun)
|
|
607
610
|
) {
|
|
608
|
-
await this.
|
|
611
|
+
const settled = await this.parkOrSettleUnresolvedRun(
|
|
609
612
|
command.runId,
|
|
610
613
|
previous,
|
|
611
614
|
events,
|
|
@@ -613,10 +616,19 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
613
616
|
? unresolvedModelRequestFailure(events, modelState.request)
|
|
614
617
|
: message,
|
|
615
618
|
);
|
|
619
|
+
if (settled) return settled;
|
|
616
620
|
throw new Error(message);
|
|
617
621
|
}
|
|
618
622
|
await this.failRun(command.runId, previous, events, message);
|
|
619
|
-
|
|
623
|
+
// `settledTerminalRunResult`, not `discardedRunResult`: a Turn the Package failed
|
|
624
|
+
// outright — a provider 401, a step limit — reaches a `turn/end` and a
|
|
625
|
+
// durable `failed` record just as surely as a stopped one does, and
|
|
626
|
+
// rethrowing over the top of that settlement is what made the Worker log
|
|
627
|
+
// `Uncaught Error: Bot turn ended with outcome model-error: Model request
|
|
628
|
+
// failed (401)` and answer 500. The run is terminal by this point and its
|
|
629
|
+
// record says why; the caller is handed that record and the client reads
|
|
630
|
+
// the sentence for the outcome off it.
|
|
631
|
+
const settled = await this.settledTerminalRunResult(command.runId);
|
|
620
632
|
if (settled) return settled;
|
|
621
633
|
throw new Error(message);
|
|
622
634
|
} finally {
|
|
@@ -746,7 +758,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
746
758
|
modelState.status === "unresolved") &&
|
|
747
759
|
!runWasDiscardedV1(durableRun)
|
|
748
760
|
) {
|
|
749
|
-
await this.
|
|
761
|
+
const parked = await this.parkOrSettleUnresolvedRun(
|
|
750
762
|
run.runId,
|
|
751
763
|
previous,
|
|
752
764
|
events,
|
|
@@ -754,10 +766,11 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
754
766
|
? unresolvedModelRequestFailure(events, modelState.request)
|
|
755
767
|
: message,
|
|
756
768
|
);
|
|
769
|
+
if (parked) return parked;
|
|
757
770
|
throw new Error(message);
|
|
758
771
|
}
|
|
759
772
|
await this.failRun(run.runId, previous, events, message);
|
|
760
|
-
const settled = await this.
|
|
773
|
+
const settled = await this.settledTerminalRunResult(run.runId);
|
|
761
774
|
if (settled) return settled;
|
|
762
775
|
throw new Error(message);
|
|
763
776
|
} finally {
|
|
@@ -781,7 +794,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
781
794
|
});
|
|
782
795
|
}
|
|
783
796
|
|
|
784
|
-
private async
|
|
797
|
+
private async settledReplayResult(
|
|
785
798
|
command: OwnedBotTurnCommand,
|
|
786
799
|
): Promise<BotTurnCompletion | undefined> {
|
|
787
800
|
const { runId } = command;
|
|
@@ -1046,9 +1059,10 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1046
1059
|
const active = await transaction.get<string>(ACTIVE_RUN_KEY);
|
|
1047
1060
|
const pending = await transaction.get<string>(PENDING_RUN_KEY);
|
|
1048
1061
|
if (active || pending) {
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1062
|
+
// A typed refusal, not a bare Error: the Durable Object boundary turns
|
|
1063
|
+
// this one case into a 409 value rather than letting it escape the
|
|
1064
|
+
// object's entry frame as an uncaught exception.
|
|
1065
|
+
throw new ConversationBusyError();
|
|
1052
1066
|
}
|
|
1053
1067
|
const current =
|
|
1054
1068
|
decodeStoredConversationV1(
|
|
@@ -1135,6 +1149,81 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1135
1149
|
return run;
|
|
1136
1150
|
}
|
|
1137
1151
|
|
|
1152
|
+
/**
|
|
1153
|
+
* Whether a run is still working, settling its record when it is not.
|
|
1154
|
+
*
|
|
1155
|
+
* This is the only honest answer to "is this Bot busy", and both readers that
|
|
1156
|
+
* ask — the sidebar's activity ring and the transcript's running Turn — go
|
|
1157
|
+
* through here. `status === "running"` alone is a claim the record makes and
|
|
1158
|
+
* nothing renews: a Turn that died mid-answer never wrote its own
|
|
1159
|
+
* settlement, so idle Bots wore a pulsing ring for hours.
|
|
1160
|
+
* {@link runLivenessV1} holds the rule; this adds the two things a pure rule
|
|
1161
|
+
* cannot have.
|
|
1162
|
+
*
|
|
1163
|
+
* The first is the fence. A run this object is executing right now is alive
|
|
1164
|
+
* by direct observation, whatever the durable record and the log look like
|
|
1165
|
+
* mid-flush, and it is never judged or touched. The object is
|
|
1166
|
+
* single-threaded, so `executingRunId` is exact for the run in this isolate,
|
|
1167
|
+
* and a run executing in some *other* isolate cannot be at issue: the durable
|
|
1168
|
+
* `active-run` marker admits one Turn at a time, and a record older than the
|
|
1169
|
+
* Turn deadline is past the point where any isolate is still holding it.
|
|
1170
|
+
*
|
|
1171
|
+
* The second is the repair. A read that finds a dead record settles it rather
|
|
1172
|
+
* than merely hiding it, so the ring goes out for every other reader too and
|
|
1173
|
+
* the next message inherits a closed Turn instead of repairing one. The
|
|
1174
|
+
* settlement is `failStoredRun`, exactly as recovery's is, which closes the
|
|
1175
|
+
* open Turn in the log on the way and routes a run carrying a durable Stop or
|
|
1176
|
+
* supersede intent to the outcome that intent already decided. It is
|
|
1177
|
+
* idempotent — a second caller finds a terminal record and settles nothing —
|
|
1178
|
+
* and the run-record write it commits is what publishes the `runs`
|
|
1179
|
+
* invalidation the watching clients re-read on.
|
|
1180
|
+
*/
|
|
1181
|
+
async resolveRunWorking(runId: string | undefined): Promise<boolean> {
|
|
1182
|
+
if (runId === undefined) return false;
|
|
1183
|
+
if (runId === this.executingRunId) return true;
|
|
1184
|
+
const run = await this.readRun(runId);
|
|
1185
|
+
if (!run || run.status !== "running") return false;
|
|
1186
|
+
const sessionEvents = (
|
|
1187
|
+
(await this.ctx.storage.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
|
|
1188
|
+
).map(decodeSessionEvent);
|
|
1189
|
+
if (runLivenessV1({ run, sessionEvents }).working) return true;
|
|
1190
|
+
await this.settleStaleRun(runId);
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Settles one run whose record says `running` and whose Turn is over.
|
|
1196
|
+
*
|
|
1197
|
+
* The verdict is taken again inside the transaction, against the record and
|
|
1198
|
+
* the log as they are committed there, so a Turn that settled itself between
|
|
1199
|
+
* the read above and this write is left exactly as it settled — and so is one
|
|
1200
|
+
* that started executing in this object in the meantime.
|
|
1201
|
+
*/
|
|
1202
|
+
private async settleStaleRun(runId: string): Promise<void> {
|
|
1203
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
1204
|
+
if (runId === this.executingRunId) return;
|
|
1205
|
+
const run = this.codec.optional(
|
|
1206
|
+
await transaction.get<unknown>(`${RUN_PREFIX}${runId}`),
|
|
1207
|
+
);
|
|
1208
|
+
if (!run || run.runId !== runId || run.status !== "running") return;
|
|
1209
|
+
const latest = (
|
|
1210
|
+
(await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
|
|
1211
|
+
).map(decodeSessionEvent);
|
|
1212
|
+
if (runLivenessV1({ run, sessionEvents: latest }).working) return;
|
|
1213
|
+
await failStoredRun(
|
|
1214
|
+
this.codec,
|
|
1215
|
+
transaction,
|
|
1216
|
+
this.terminalKeys(runId),
|
|
1217
|
+
runId,
|
|
1218
|
+
latest.slice(0, run.previousEventCount),
|
|
1219
|
+
run.events,
|
|
1220
|
+
STALE_RUNNING_RUN_FAILURE_V1,
|
|
1221
|
+
this.supersededPackageRecords(),
|
|
1222
|
+
);
|
|
1223
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1138
1227
|
/** Reverse-ordered admission index page: `[cursor, runId]` entries. */
|
|
1139
1228
|
async listRunIndex(query: {
|
|
1140
1229
|
limit: number;
|
|
@@ -1650,6 +1739,48 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1650
1739
|
});
|
|
1651
1740
|
}
|
|
1652
1741
|
|
|
1742
|
+
/**
|
|
1743
|
+
* Settles a Turn whose model outcome is unknown, or parks it when somebody
|
|
1744
|
+
* can still be asked — and never lets the uncertainty escape as a throw.
|
|
1745
|
+
*
|
|
1746
|
+
* This is ADR 0028 applied to the live path. Recovery already refuses to park
|
|
1747
|
+
* a run whose provider offers no retrieval, because parking there is not
|
|
1748
|
+
* caution but a dead end; the executing path did not, and the asymmetry is
|
|
1749
|
+
* what produced the blocker. A model request that ran past its budget threw
|
|
1750
|
+
* out of the Agent as an uncertain outcome, this method's predecessor parked
|
|
1751
|
+
* the run and rethrew, and the `POST /turns` the composer was holding open
|
|
1752
|
+
* answered 500 — so the person read "Couldn't reach the Bot. Check your
|
|
1753
|
+
* connection", which blamed their network for a model that took too long,
|
|
1754
|
+
* and the Bot stayed wedged behind a banner whose only possible resolution
|
|
1755
|
+
* was the settlement we could have written here.
|
|
1756
|
+
*
|
|
1757
|
+
* When the provider does reconcile, nothing changes: the run parks, the
|
|
1758
|
+
* caller still rethrows, and a later attempt can genuinely retrieve the
|
|
1759
|
+
* effect. Uncertainty is never assumed away in either branch — the request is
|
|
1760
|
+
* not re-sent, and every streamed word stays in the journal.
|
|
1761
|
+
*
|
|
1762
|
+
* Returns the settled completion when it settled, `undefined` when it parked.
|
|
1763
|
+
*/
|
|
1764
|
+
private async parkOrSettleUnresolvedRun(
|
|
1765
|
+
runId: string,
|
|
1766
|
+
previous: SessionEvent[],
|
|
1767
|
+
events: SessionEvent[],
|
|
1768
|
+
reason: string,
|
|
1769
|
+
): Promise<BotTurnCompletion | undefined> {
|
|
1770
|
+
const provider = latestModelRequestProviderV1(events);
|
|
1771
|
+
const reconciles = this.hooks.providerReconciles ?? (() => true);
|
|
1772
|
+
if (provider === undefined || reconciles(provider)) {
|
|
1773
|
+
await this.requireRunReconciliation(runId, previous, events, reason);
|
|
1774
|
+
return undefined;
|
|
1775
|
+
}
|
|
1776
|
+
// `failRun` runs the ordinary terminal settlement: the open Turn is closed
|
|
1777
|
+
// with a `turn/end`, the partial text is kept, and the record carries the
|
|
1778
|
+
// reason. The reason is a diagnostic for the debug surface — what the
|
|
1779
|
+
// person reads is the client's own copy for the outcome.
|
|
1780
|
+
await this.failRun(runId, previous, events, reason);
|
|
1781
|
+
return this.settledTerminalRunResult(runId);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1653
1784
|
/**
|
|
1654
1785
|
* Starts the Turn that was waiting when the object last stopped.
|
|
1655
1786
|
*
|
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
type BotDurableAuthorityHooks,
|
|
10
10
|
} from "./authority.ts";
|
|
11
11
|
import {
|
|
12
|
+
ConversationBusyError,
|
|
12
13
|
conversationSessionIdV1,
|
|
14
|
+
isConversationBusyV1,
|
|
13
15
|
isConversationSessionIdV1,
|
|
14
16
|
} from "./conversations.ts";
|
|
15
17
|
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
@@ -152,8 +154,15 @@ describe("starting a new conversation", () => {
|
|
|
152
154
|
const probe = createAuthority(storage);
|
|
153
155
|
storage.values.set("active-run", "run-9");
|
|
154
156
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
// Typed, not a bare Error: the Durable Object boundary keys on the name to
|
|
158
|
+
// turn this one case into a 409 value rather than letting it escape the
|
|
159
|
+
// object's entry frame as an uncaught exception.
|
|
160
|
+
const refusal = await probe.authority
|
|
161
|
+
.startConversation(IDENTITY)
|
|
162
|
+
.then(() => undefined)
|
|
163
|
+
.catch((error: unknown) => error);
|
|
164
|
+
expect(isConversationBusyV1(refusal)).toBe(true);
|
|
165
|
+
expect(refusal).toBeInstanceOf(ConversationBusyError);
|
|
166
|
+
expect((refusal as Error).message).toMatch(/still working on a Turn/);
|
|
158
167
|
});
|
|
159
168
|
});
|
package/src/conversations.ts
CHANGED
|
@@ -124,3 +124,40 @@ export function isConversationSessionIdV1(
|
|
|
124
124
|
if (!sessionId.startsWith(`${base}#`)) return false;
|
|
125
125
|
return /^[1-9][0-9]{0,6}$/.test(sessionId.slice(base.length + 1));
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What a person is told when a new conversation is refused.
|
|
130
|
+
*
|
|
131
|
+
* The Turn that is running owns the event log the next Turn derives its
|
|
132
|
+
* request from, so a click may not pull it out from under it. That is a "not
|
|
133
|
+
* now", not a fault, and the sentence says what to do about it.
|
|
134
|
+
*/
|
|
135
|
+
export const CONVERSATION_BUSY_MESSAGE_V1 =
|
|
136
|
+
"This Bot is still working on a Turn. Wait for it to finish, then start a new conversation.";
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A refusal, told apart from a genuine failure.
|
|
140
|
+
*
|
|
141
|
+
* It exists so the Durable Object boundary can turn this one case into a value
|
|
142
|
+
* — a 409 the composer already understands — instead of letting it escape as
|
|
143
|
+
* an uncaught exception. An exception that crosses a DO's entry frame is
|
|
144
|
+
* logged by workerd as `Uncaught Error`, and the isolate that logs it has been
|
|
145
|
+
* seen to go down with a broken pipe immediately afterwards; in production the
|
|
146
|
+
* same sequence is a 500 where a 409 belonged.
|
|
147
|
+
*/
|
|
148
|
+
export class ConversationBusyError extends Error {
|
|
149
|
+
override readonly name = "ConversationBusyError";
|
|
150
|
+
constructor(message = CONVERSATION_BUSY_MESSAGE_V1) {
|
|
151
|
+
super(message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether this is the "still working on a Turn" refusal, across bundles. */
|
|
156
|
+
export function isConversationBusyV1(error: unknown): boolean {
|
|
157
|
+
return (
|
|
158
|
+
typeof error === "object" &&
|
|
159
|
+
error !== null &&
|
|
160
|
+
"name" in error &&
|
|
161
|
+
(error as { name?: unknown }).name === "ConversationBusyError"
|
|
162
|
+
);
|
|
163
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./composition-failures.js";
|
|
|
4
4
|
export * from "./conversations.js";
|
|
5
5
|
export * from "./composition-store.js";
|
|
6
6
|
export * from "./run-records.js";
|
|
7
|
+
export * from "./run-liveness.js";
|
|
7
8
|
export * from "./run-recovery.js";
|
|
8
9
|
export * from "./run-terminal.js";
|
|
9
10
|
export * from "./storage-keys.js";
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// A model request that ran out of time settles the Turn instead of escaping.
|
|
2
|
+
//
|
|
3
|
+
// The blocker this file describes: a tool-heavy step against a real Flock AI
|
|
4
|
+
// model crossed the gateway's sixty-second budget, the abort surfaced out of
|
|
5
|
+
// the Agent as `Model response outcome is uncertain: The operation was aborted
|
|
6
|
+
// due to timeout`, the authority parked the run on a reconciliation and
|
|
7
|
+
// rethrew — and the `POST /api/bots/<bot>/turns` the composer was holding open
|
|
8
|
+
// answered 500 after 65 seconds. On screen: "Couldn't reach the Bot. Check your
|
|
9
|
+
// connection and try again", blaming a network that was fine, over a Bot that
|
|
10
|
+
// stayed wedged behind a banner nothing could ever resolve.
|
|
11
|
+
//
|
|
12
|
+
// ADR 0028 already settled the question for recovery: park only when somebody
|
|
13
|
+
// can be asked, and Flock AI keeps no addressable copy of a completion, so
|
|
14
|
+
// nobody can. This is that rule on the live path.
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import {
|
|
17
|
+
bootstrapGeneration,
|
|
18
|
+
type CompositionGenerationV1,
|
|
19
|
+
} from "@frockbot/kernel-composition/generation";
|
|
20
|
+
import {
|
|
21
|
+
MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
|
|
22
|
+
type SessionEvent,
|
|
23
|
+
} from "@frockbot/kernel-contracts";
|
|
24
|
+
import {
|
|
25
|
+
BotDurableAuthority,
|
|
26
|
+
type BotDurableAuthorityHooks,
|
|
27
|
+
type OwnedBotTurnCommand,
|
|
28
|
+
} from "./authority.ts";
|
|
29
|
+
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
30
|
+
import {
|
|
31
|
+
BotTurnExecutionError,
|
|
32
|
+
BotTurnReconciliationRequiredError,
|
|
33
|
+
} from "./turn-errors.ts";
|
|
34
|
+
import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.ts";
|
|
35
|
+
|
|
36
|
+
const codec = createStoredRunCodecV1<undefined>({
|
|
37
|
+
decodeRunId: (value) => value as string,
|
|
38
|
+
decodeConfigurationSnapshot: () => undefined,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const identity = { userId: "user-1", botId: "primary" };
|
|
42
|
+
const SESSION_ID = "user-1:primary";
|
|
43
|
+
|
|
44
|
+
/** Providers that keep an addressable copy of a completion. Neither does. */
|
|
45
|
+
const RECONCILING_PROVIDERS = new Set(["foundation"]);
|
|
46
|
+
|
|
47
|
+
function bootstrap(): Promise<CompositionGenerationV1> {
|
|
48
|
+
return bootstrapGeneration(
|
|
49
|
+
[
|
|
50
|
+
{
|
|
51
|
+
packageId: "shell",
|
|
52
|
+
specifier: "@frockbot/plugin-shell",
|
|
53
|
+
version: "0.0.1",
|
|
54
|
+
manifest: { id: "shell", version: "0.0.1" },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
{ createdAt: "2026-09-03T00:00:00.000Z" },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function command(runId: string, text: string): OwnedBotTurnCommand {
|
|
62
|
+
return {
|
|
63
|
+
...identity,
|
|
64
|
+
runId,
|
|
65
|
+
sessionId: SESSION_ID,
|
|
66
|
+
acceptedAt: "2026-09-03T00:00:01.000Z",
|
|
67
|
+
text,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* An authority whose Package stalls its model request past the budget: it
|
|
73
|
+
* journals the request, streams a first line, then unwinds exactly as the
|
|
74
|
+
* Agent loop does on a deadline — a `model/reconciliation-required` carrying
|
|
75
|
+
* the deadline's own sentence, and no `turn/end`.
|
|
76
|
+
*/
|
|
77
|
+
function createAuthority(
|
|
78
|
+
storage: MemoryStorage,
|
|
79
|
+
provider: string,
|
|
80
|
+
options: { providerReconciles?: boolean; refuseWith?: string } = {},
|
|
81
|
+
): BotDurableAuthority<undefined> {
|
|
82
|
+
const hooks: BotDurableAuthorityHooks<undefined> = {
|
|
83
|
+
resolveAdmissionSnapshot: () => Promise.resolve(undefined),
|
|
84
|
+
bootstrapComposition: () => bootstrap(),
|
|
85
|
+
admittedSnapshot: () => Promise.resolve(undefined),
|
|
86
|
+
...(options.providerReconciles === undefined
|
|
87
|
+
? { providerReconciles: (id: string) => RECONCILING_PROVIDERS.has(id) }
|
|
88
|
+
: { providerReconciles: () => options.providerReconciles as boolean }),
|
|
89
|
+
executeTurn: async (input) => {
|
|
90
|
+
let seq = input.previousEvents.length;
|
|
91
|
+
const appended: SessionEvent[] = [];
|
|
92
|
+
const persist = async (
|
|
93
|
+
...events: Omit<SessionEvent, "seq" | "timestamp">[]
|
|
94
|
+
) => {
|
|
95
|
+
const stamped = events.map(
|
|
96
|
+
(event) =>
|
|
97
|
+
({
|
|
98
|
+
...event,
|
|
99
|
+
seq: seq++,
|
|
100
|
+
timestamp: "2026-09-03T00:00:10.000Z",
|
|
101
|
+
}) as SessionEvent,
|
|
102
|
+
);
|
|
103
|
+
appended.push(...stamped);
|
|
104
|
+
await input.persistSessionEvents(input.command.sessionId, stamped);
|
|
105
|
+
};
|
|
106
|
+
await persist(
|
|
107
|
+
{ type: "turn/start", turn: 1 } as never,
|
|
108
|
+
{ type: "step/start", turn: 1, step: 1 } as never,
|
|
109
|
+
{
|
|
110
|
+
type: "user/message",
|
|
111
|
+
turn: 1,
|
|
112
|
+
step: 1,
|
|
113
|
+
messageId: "message-1",
|
|
114
|
+
text: input.command.text,
|
|
115
|
+
} as never,
|
|
116
|
+
{
|
|
117
|
+
type: "model/request",
|
|
118
|
+
turn: 1,
|
|
119
|
+
step: 1,
|
|
120
|
+
request: {
|
|
121
|
+
requestId: "request-1",
|
|
122
|
+
provider,
|
|
123
|
+
model: "@flock/auto",
|
|
124
|
+
system: "system",
|
|
125
|
+
messages: [{ role: "user", content: input.command.text }],
|
|
126
|
+
tools: [],
|
|
127
|
+
},
|
|
128
|
+
} as never,
|
|
129
|
+
{
|
|
130
|
+
type: "assistant/chunk",
|
|
131
|
+
turn: 1,
|
|
132
|
+
step: 1,
|
|
133
|
+
requestId: "request-1",
|
|
134
|
+
text: "On it — building it now.",
|
|
135
|
+
} as never,
|
|
136
|
+
);
|
|
137
|
+
// A provider that refused definitively — a revoked key answering 401 —
|
|
138
|
+
// reaches a real `turn/end`, the way the Agent loop settles a model
|
|
139
|
+
// error it never has to reconcile.
|
|
140
|
+
if (options.refuseWith) {
|
|
141
|
+
await persist(
|
|
142
|
+
{
|
|
143
|
+
type: "model/effect-not-started",
|
|
144
|
+
turn: 1,
|
|
145
|
+
step: 1,
|
|
146
|
+
requestId: "request-1",
|
|
147
|
+
reason: options.refuseWith,
|
|
148
|
+
} as never,
|
|
149
|
+
{
|
|
150
|
+
type: "step/end",
|
|
151
|
+
turn: 1,
|
|
152
|
+
step: 1,
|
|
153
|
+
outcome: "model-error",
|
|
154
|
+
} as never,
|
|
155
|
+
{
|
|
156
|
+
type: "turn/end",
|
|
157
|
+
turn: 1,
|
|
158
|
+
outcome: "model-error",
|
|
159
|
+
reason: options.refuseWith,
|
|
160
|
+
} as never,
|
|
161
|
+
);
|
|
162
|
+
throw new BotTurnExecutionError(
|
|
163
|
+
`Bot turn ended with outcome model-error: ${options.refuseWith}`,
|
|
164
|
+
appended,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const reason = `Model response outcome is uncertain: ${MODEL_FIRST_BYTE_DEADLINE_REASON_V1}`;
|
|
168
|
+
await persist({
|
|
169
|
+
type: "model/reconciliation-required",
|
|
170
|
+
turn: 1,
|
|
171
|
+
step: 1,
|
|
172
|
+
requestId: "request-1",
|
|
173
|
+
reason,
|
|
174
|
+
} as never);
|
|
175
|
+
throw new BotTurnReconciliationRequiredError(reason, appended);
|
|
176
|
+
},
|
|
177
|
+
notification: () => undefined,
|
|
178
|
+
scheduledDeadlines: () => Promise.resolve([]),
|
|
179
|
+
scheduledWorkInFlight: () => false,
|
|
180
|
+
deferScheduledWork: () => Promise.resolve(),
|
|
181
|
+
settleScheduledWork: () => Promise.resolve(),
|
|
182
|
+
interruptTurn: () => {},
|
|
183
|
+
};
|
|
184
|
+
return new BotDurableAuthority<undefined>({
|
|
185
|
+
state: { storage } as unknown as DurableObjectState,
|
|
186
|
+
codec,
|
|
187
|
+
hooks,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function storedRun(
|
|
192
|
+
storage: MemoryStorage,
|
|
193
|
+
runId: string,
|
|
194
|
+
): StoredRunV1<undefined> {
|
|
195
|
+
return codec.require(storage.values.get(`run:${runId}`));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
describe("a model request that ran out of time", () => {
|
|
199
|
+
test("settles the Turn and answers the caller instead of throwing", async () => {
|
|
200
|
+
const storage = new MemoryStorage();
|
|
201
|
+
const authority = createAuthority(storage, "flock-ai");
|
|
202
|
+
|
|
203
|
+
// The `POST /turns` the composer is holding open. It resolves — this is
|
|
204
|
+
// the whole defect: it used to reject, and the route answered 500.
|
|
205
|
+
const completion = await authority.run(command("run-1", "build me one"));
|
|
206
|
+
|
|
207
|
+
expect(completion.runId).toBe("run-1");
|
|
208
|
+
const run = storedRun(storage, "run-1");
|
|
209
|
+
expect(run.status).toBe("failed");
|
|
210
|
+
// The ordinary run-terminal path: the open Turn is closed rather than left
|
|
211
|
+
// for the next message to trip over.
|
|
212
|
+
const terminal = run.events.findLast((event) => event.type === "turn/end");
|
|
213
|
+
expect(terminal).toMatchObject({ turn: 1, outcome: "interrupted" });
|
|
214
|
+
// The words the person watched arrive are kept.
|
|
215
|
+
expect(run.events.some((event) => event.type === "assistant/chunk")).toBe(
|
|
216
|
+
true,
|
|
217
|
+
);
|
|
218
|
+
// The stored reason is the diagnostic, and it carries the sentence the
|
|
219
|
+
// client's copy layer reads back out of it.
|
|
220
|
+
expect(run.failure).toContain(MODEL_FIRST_BYTE_DEADLINE_REASON_V1);
|
|
221
|
+
// Nothing is left holding the Bot: the next message is admitted.
|
|
222
|
+
expect(storage.values.get("active-run")).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("still parks when the provider can actually be asked", async () => {
|
|
226
|
+
const storage = new MemoryStorage();
|
|
227
|
+
const authority = createAuthority(storage, "foundation");
|
|
228
|
+
|
|
229
|
+
// A provider that keeps a durable copy loses nothing by waiting, so the
|
|
230
|
+
// uncertainty is preserved exactly as before and the caller still learns
|
|
231
|
+
// the Turn did not settle.
|
|
232
|
+
await expect(
|
|
233
|
+
authority.run(command("run-1", "build me one")),
|
|
234
|
+
).rejects.toThrow();
|
|
235
|
+
|
|
236
|
+
const run = storedRun(storage, "run-1");
|
|
237
|
+
expect(run.status).toBe("reconciliation-required");
|
|
238
|
+
expect(run.events.some((event) => event.type === "turn/end")).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// The same leak, one layer over: a Turn that reached a real `turn/end` and a
|
|
242
|
+
// durable `failed` record was *still* rethrown, so the Worker logged
|
|
243
|
+
// `Uncaught Error: Bot turn ended with outcome model-error: Model request
|
|
244
|
+
// failed (401)` and the route answered 500 over a settlement that had
|
|
245
|
+
// already happened.
|
|
246
|
+
test("a provider that refused settles and answers, with no uncaught error", async () => {
|
|
247
|
+
const storage = new MemoryStorage();
|
|
248
|
+
const authority = createAuthority(storage, "flock-ai", {
|
|
249
|
+
refuseWith: "Model request failed (401)",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const completion = await authority.run(command("run-1", "hello"));
|
|
253
|
+
|
|
254
|
+
expect(completion.runId).toBe("run-1");
|
|
255
|
+
const run = storedRun(storage, "run-1");
|
|
256
|
+
expect(run.status).toBe("failed");
|
|
257
|
+
expect(
|
|
258
|
+
run.events.findLast((event) => event.type === "turn/end"),
|
|
259
|
+
).toMatchObject({ outcome: "model-error" });
|
|
260
|
+
// The provider's own words stay on the record for the debug surface; the
|
|
261
|
+
// client maps the outcome to a sentence (`runFailureCopyV1`).
|
|
262
|
+
expect(run.failure).toContain("401");
|
|
263
|
+
expect(storage.values.get("active-run")).toBeUndefined();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// An idle Bot wearing the activity ring.
|
|
2
|
+
//
|
|
3
|
+
// The sidebar's `working` flag was `readRun(newest).status === "running"`, and
|
|
4
|
+
// nothing ever renews that field: a Turn that died mid-answer — a Worker torn
|
|
5
|
+
// down, one of the "turn N started while turn N-1 is open" wedges — leaves a
|
|
6
|
+
// record saying `running` for ever. Production had Bots that had been quiet for
|
|
7
|
+
// hours pulsing as though they were mid-sentence.
|
|
8
|
+
//
|
|
9
|
+
// Liveness is three conditions, not one, and a reader that finds a record
|
|
10
|
+
// failing them settles it rather than merely declining to draw a ring.
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
bootstrapGeneration,
|
|
14
|
+
type CompositionGenerationV1,
|
|
15
|
+
} from "@frockbot/kernel-composition/generation";
|
|
16
|
+
import {
|
|
17
|
+
type SessionEvent,
|
|
18
|
+
TURN_DEADLINE_MS_V1,
|
|
19
|
+
} from "@frockbot/kernel-contracts";
|
|
20
|
+
import {
|
|
21
|
+
BotDurableAuthority,
|
|
22
|
+
type BotDurableAuthorityHooks,
|
|
23
|
+
} from "./authority.ts";
|
|
24
|
+
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
25
|
+
import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.ts";
|
|
26
|
+
import {
|
|
27
|
+
runLivenessV1,
|
|
28
|
+
STALE_RUNNING_RUN_FAILURE_V1,
|
|
29
|
+
STALE_RUNNING_RUN_GRACE_MS_V1,
|
|
30
|
+
} from "./run-liveness.ts";
|
|
31
|
+
import {
|
|
32
|
+
ACTIVE_RUN_KEY,
|
|
33
|
+
IDENTITY_KEY,
|
|
34
|
+
LATEST_EVENTS_KEY,
|
|
35
|
+
RUN_PREFIX,
|
|
36
|
+
runIndexKey,
|
|
37
|
+
} from "./storage-keys.ts";
|
|
38
|
+
|
|
39
|
+
const NOW = Date.UTC(2026, 8, 3, 12, 0, 0);
|
|
40
|
+
const ACCEPTED_AT = new Date(NOW - 1000).toISOString();
|
|
41
|
+
|
|
42
|
+
function event(
|
|
43
|
+
seq: number,
|
|
44
|
+
type: SessionEvent["type"],
|
|
45
|
+
extra: Record<string, unknown> = {},
|
|
46
|
+
): SessionEvent {
|
|
47
|
+
return {
|
|
48
|
+
type,
|
|
49
|
+
seq,
|
|
50
|
+
timestamp: new Date(NOW - 1000 + seq).toISOString(),
|
|
51
|
+
...extra,
|
|
52
|
+
} as SessionEvent;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The events one Turn writes while it runs, opening the Turn and no more. */
|
|
56
|
+
const openTurn: SessionEvent[] = [
|
|
57
|
+
event(0, "turn/start", { turn: 1 }),
|
|
58
|
+
event(1, "step/start", { turn: 1, step: 1 }),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** The same log, with the ending somebody else wrote for it. */
|
|
62
|
+
const closedTurn: SessionEvent[] = [
|
|
63
|
+
...openTurn,
|
|
64
|
+
event(2, "step/end", { turn: 1, step: 1, outcome: "interrupted" }),
|
|
65
|
+
event(3, "turn/end", { turn: 1, outcome: "interrupted" }),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function run(
|
|
69
|
+
overrides: Partial<StoredRunV1<undefined>> = {},
|
|
70
|
+
): StoredRunV1<undefined> {
|
|
71
|
+
return {
|
|
72
|
+
runId: "run-1",
|
|
73
|
+
commandFingerprint: "fingerprint",
|
|
74
|
+
sessionId: "user-1:primary",
|
|
75
|
+
acceptedAt: ACCEPTED_AT,
|
|
76
|
+
input: "hello",
|
|
77
|
+
events: openTurn,
|
|
78
|
+
effectAdmissions: [],
|
|
79
|
+
status: "running",
|
|
80
|
+
phase: "executing",
|
|
81
|
+
compositionGenerationId: "generation-1",
|
|
82
|
+
configurationSnapshot: undefined,
|
|
83
|
+
previousEventCount: 0,
|
|
84
|
+
...overrides,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("whether a run marked running is working", () => {
|
|
89
|
+
test("a Turn admitted a moment ago is working", () => {
|
|
90
|
+
expect(
|
|
91
|
+
runLivenessV1({ run: run(), sessionEvents: openTurn, now: NOW }),
|
|
92
|
+
).toEqual({ working: true, stale: false });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a settled run is not working, and owes no repair", () => {
|
|
96
|
+
expect(
|
|
97
|
+
runLivenessV1({
|
|
98
|
+
run: run({ status: "completed" }),
|
|
99
|
+
sessionEvents: closedTurn,
|
|
100
|
+
now: NOW,
|
|
101
|
+
}),
|
|
102
|
+
).toEqual({ working: false, stale: false });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("one still inside the deadline is left alone", () => {
|
|
106
|
+
expect(
|
|
107
|
+
runLivenessV1({
|
|
108
|
+
run: run(),
|
|
109
|
+
sessionEvents: openTurn,
|
|
110
|
+
now: NOW + TURN_DEADLINE_MS_V1 - 1000,
|
|
111
|
+
}),
|
|
112
|
+
).toEqual({ working: true, stale: false });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("the grace covers the unwind after the deadline fires", () => {
|
|
116
|
+
expect(
|
|
117
|
+
runLivenessV1({
|
|
118
|
+
run: run(),
|
|
119
|
+
sessionEvents: openTurn,
|
|
120
|
+
now: NOW + TURN_DEADLINE_MS_V1 + STALE_RUNNING_RUN_GRACE_MS_V1 - 1000,
|
|
121
|
+
}).working,
|
|
122
|
+
).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("one past the deadline and its grace is stale", () => {
|
|
126
|
+
expect(
|
|
127
|
+
runLivenessV1({
|
|
128
|
+
run: run(),
|
|
129
|
+
sessionEvents: openTurn,
|
|
130
|
+
now: NOW + TURN_DEADLINE_MS_V1 + STALE_RUNNING_RUN_GRACE_MS_V1 + 1000,
|
|
131
|
+
}),
|
|
132
|
+
).toEqual({ working: false, stale: true, reason: "deadline" });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("one whose Turn the log already closed is stale, however fresh", () => {
|
|
136
|
+
expect(
|
|
137
|
+
runLivenessV1({ run: run(), sessionEvents: closedTurn, now: NOW }),
|
|
138
|
+
).toEqual({ working: false, stale: true, reason: "turn-closed" });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("an earlier Turn's ending says nothing about this one", () => {
|
|
142
|
+
// The log ends closed because the *previous* Turn closed it, and this run
|
|
143
|
+
// has not journaled its own `turn/start` yet.
|
|
144
|
+
expect(
|
|
145
|
+
runLivenessV1({
|
|
146
|
+
run: run({ events: [], previousEventCount: closedTurn.length }),
|
|
147
|
+
sessionEvents: closedTurn,
|
|
148
|
+
now: NOW,
|
|
149
|
+
}),
|
|
150
|
+
).toEqual({ working: true, stale: false });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("an unreadable admission time is not evidence of death", () => {
|
|
154
|
+
expect(
|
|
155
|
+
runLivenessV1({
|
|
156
|
+
run: run({ acceptedAt: "not a timestamp" }),
|
|
157
|
+
sessionEvents: openTurn,
|
|
158
|
+
now: NOW + TURN_DEADLINE_MS_V1 * 100,
|
|
159
|
+
}).stale,
|
|
160
|
+
).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const codec = createStoredRunCodecV1<undefined>({
|
|
165
|
+
decodeRunId: (value) => value as string,
|
|
166
|
+
decodeConfigurationSnapshot: () => undefined,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
function bootstrap(): Promise<CompositionGenerationV1> {
|
|
170
|
+
return bootstrapGeneration(
|
|
171
|
+
[
|
|
172
|
+
{
|
|
173
|
+
packageId: "shell",
|
|
174
|
+
specifier: "@frockbot/plugin-shell",
|
|
175
|
+
version: "0.0.1",
|
|
176
|
+
manifest: { id: "shell", version: "0.0.1" },
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
{ createdAt: "2026-09-03T00:00:00.000Z" },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const hooks: BotDurableAuthorityHooks<undefined> = {
|
|
184
|
+
resolveAdmissionSnapshot: () => Promise.resolve(undefined),
|
|
185
|
+
bootstrapComposition: () => bootstrap(),
|
|
186
|
+
admittedSnapshot: () => Promise.resolve(undefined),
|
|
187
|
+
executeTurn: () => Promise.reject(new Error("no Turn should execute here")),
|
|
188
|
+
notification: () => undefined,
|
|
189
|
+
scheduledDeadlines: () => Promise.resolve([]),
|
|
190
|
+
scheduledWorkInFlight: () => false,
|
|
191
|
+
deferScheduledWork: () => Promise.resolve(),
|
|
192
|
+
settleScheduledWork: () => Promise.resolve(),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A Bot left holding exactly what production was left holding: a record that
|
|
197
|
+
* says `running`, an `active-run` marker pointing at it, and a durable log with
|
|
198
|
+
* the Turn it opened.
|
|
199
|
+
*/
|
|
200
|
+
async function seed(input: {
|
|
201
|
+
acceptedAt: string;
|
|
202
|
+
events: SessionEvent[];
|
|
203
|
+
log: SessionEvent[];
|
|
204
|
+
}): Promise<{
|
|
205
|
+
storage: MemoryStorage;
|
|
206
|
+
authority: BotDurableAuthority<undefined>;
|
|
207
|
+
}> {
|
|
208
|
+
const storage = new MemoryStorage();
|
|
209
|
+
const stored = run({ acceptedAt: input.acceptedAt, events: input.events });
|
|
210
|
+
await storage.put({
|
|
211
|
+
[`${RUN_PREFIX}${stored.runId}`]: stored,
|
|
212
|
+
[runIndexKey(stored.acceptedAt, stored.runId)]: stored.runId,
|
|
213
|
+
[ACTIVE_RUN_KEY]: stored.runId,
|
|
214
|
+
[LATEST_EVENTS_KEY]: input.log,
|
|
215
|
+
[IDENTITY_KEY]: { userId: "user-1", botId: "primary" },
|
|
216
|
+
});
|
|
217
|
+
const authority = new BotDurableAuthority<undefined>({
|
|
218
|
+
state: { storage } as unknown as DurableObjectState,
|
|
219
|
+
codec,
|
|
220
|
+
hooks,
|
|
221
|
+
});
|
|
222
|
+
return { storage, authority };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const longAgo = new Date(
|
|
226
|
+
Date.now() - TURN_DEADLINE_MS_V1 - STALE_RUNNING_RUN_GRACE_MS_V1 - 60_000,
|
|
227
|
+
).toISOString();
|
|
228
|
+
|
|
229
|
+
describe("the read that repairs what it finds", () => {
|
|
230
|
+
test("reports a fresh Turn as working and touches nothing", async () => {
|
|
231
|
+
const { storage, authority } = await seed({
|
|
232
|
+
acceptedAt: new Date().toISOString(),
|
|
233
|
+
events: openTurn,
|
|
234
|
+
log: openTurn,
|
|
235
|
+
});
|
|
236
|
+
expect(await authority.resolveRunWorking("run-1")).toBe(true);
|
|
237
|
+
expect(
|
|
238
|
+
(await storage.get<StoredRunV1<undefined>>(`${RUN_PREFIX}run-1`))?.status,
|
|
239
|
+
).toBe("running");
|
|
240
|
+
expect(await storage.get<string>(ACTIVE_RUN_KEY)).toBe("run-1");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("settles one that outlived the Turn deadline", async () => {
|
|
244
|
+
const { storage, authority } = await seed({
|
|
245
|
+
acceptedAt: longAgo,
|
|
246
|
+
events: openTurn,
|
|
247
|
+
log: openTurn,
|
|
248
|
+
});
|
|
249
|
+
expect(await authority.resolveRunWorking("run-1")).toBe(false);
|
|
250
|
+
const settled = await storage.get<StoredRunV1<undefined>>(
|
|
251
|
+
`${RUN_PREFIX}run-1`,
|
|
252
|
+
);
|
|
253
|
+
expect(settled?.status).toBe("failed");
|
|
254
|
+
expect(settled?.failure).toBe(STALE_RUNNING_RUN_FAILURE_V1);
|
|
255
|
+
// The Bot is free: nothing holds the object, and the next Turn admits
|
|
256
|
+
// against a log that reads as a complete history.
|
|
257
|
+
expect(await storage.get<string>(ACTIVE_RUN_KEY)).toBeUndefined();
|
|
258
|
+
const log = (await storage.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? [];
|
|
259
|
+
expect(log.some((entry) => entry.type === "turn/end")).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("settles one whose Turn the log already closed", async () => {
|
|
263
|
+
const { storage, authority } = await seed({
|
|
264
|
+
acceptedAt: new Date().toISOString(),
|
|
265
|
+
events: openTurn,
|
|
266
|
+
log: closedTurn,
|
|
267
|
+
});
|
|
268
|
+
expect(await authority.resolveRunWorking("run-1")).toBe(false);
|
|
269
|
+
expect(
|
|
270
|
+
(await storage.get<StoredRunV1<undefined>>(`${RUN_PREFIX}run-1`))?.status,
|
|
271
|
+
).toBe("failed");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("is idempotent: a second read settles nothing and still says no ring", async () => {
|
|
275
|
+
const { storage, authority } = await seed({
|
|
276
|
+
acceptedAt: longAgo,
|
|
277
|
+
events: openTurn,
|
|
278
|
+
log: openTurn,
|
|
279
|
+
});
|
|
280
|
+
expect(await authority.resolveRunWorking("run-1")).toBe(false);
|
|
281
|
+
const first = await storage.get<StoredRunV1<undefined>>(
|
|
282
|
+
`${RUN_PREFIX}run-1`,
|
|
283
|
+
);
|
|
284
|
+
expect(await authority.resolveRunWorking("run-1")).toBe(false);
|
|
285
|
+
expect(
|
|
286
|
+
await storage.get<StoredRunV1<undefined>>(`${RUN_PREFIX}run-1`),
|
|
287
|
+
).toEqual(first!);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("no run is no ring", async () => {
|
|
291
|
+
const { authority } = await seed({
|
|
292
|
+
acceptedAt: longAgo,
|
|
293
|
+
events: openTurn,
|
|
294
|
+
log: openTurn,
|
|
295
|
+
});
|
|
296
|
+
expect(await authority.resolveRunWorking(undefined)).toBe(false);
|
|
297
|
+
expect(await authority.resolveRunWorking("run-missing")).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SessionEvent,
|
|
3
|
+
TURN_DEADLINE_MS_V1,
|
|
4
|
+
} from "@frockbot/kernel-contracts";
|
|
5
|
+
import type { StoredRunV1 } from "./run-records.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How long past the Turn deadline a `running` record is still given the
|
|
9
|
+
* benefit of the doubt.
|
|
10
|
+
*
|
|
11
|
+
* The deadline is enforced by a timer inside the loop, and the settlement that
|
|
12
|
+
* follows it is a durable write on the far side of an abort: there is a real
|
|
13
|
+
* interval in which a Turn is legitimately still finishing after its clock ran
|
|
14
|
+
* out. A minute is far longer than that unwind takes and far shorter than the
|
|
15
|
+
* hours an abandoned record has been claiming to work.
|
|
16
|
+
*/
|
|
17
|
+
export const STALE_RUNNING_RUN_GRACE_MS_V1 = 60_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a run settled by this rule records, in the register ADR 0028 settles an
|
|
21
|
+
* unretrievable Turn in: what happened, and what to do about it.
|
|
22
|
+
*/
|
|
23
|
+
export const STALE_RUNNING_RUN_FAILURE_V1 =
|
|
24
|
+
"This Turn stopped without finishing and was settled when nothing was left to finish it. Try sending it again.";
|
|
25
|
+
|
|
26
|
+
export interface RunLivenessV1 {
|
|
27
|
+
/** Whether the run may be shown as working — the activity ring's whole rule. */
|
|
28
|
+
readonly working: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the record claims to be running and demonstrably is not, so the
|
|
31
|
+
* reader that asked owes it a terminal settlement.
|
|
32
|
+
*/
|
|
33
|
+
readonly stale: boolean;
|
|
34
|
+
/** Why it is stale, for the failure a settlement records. Absent when it is not. */
|
|
35
|
+
readonly reason?: "deadline" | "turn-closed";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NOT_RUNNING: RunLivenessV1 = { working: false, stale: false };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The seq of the last `turn/start` this run wrote, or `undefined` when it has
|
|
42
|
+
* not opened a Turn in the durable log yet.
|
|
43
|
+
*/
|
|
44
|
+
function openedTurnSeqV1(events: readonly SessionEvent[]): number | undefined {
|
|
45
|
+
let seq: number | undefined;
|
|
46
|
+
for (const event of events) {
|
|
47
|
+
if (event.type === "turn/start") seq = event.seq;
|
|
48
|
+
}
|
|
49
|
+
return seq;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a run is honestly still working.
|
|
54
|
+
*
|
|
55
|
+
* `status === "running"` was the whole test, and it is not one: a record is
|
|
56
|
+
* only ever moved off `running` by the settlement its own Turn performs, so
|
|
57
|
+
* every way a Turn can stop without settling — a Worker torn down mid-answer,
|
|
58
|
+
* the "turn N started while turn N-1 is open" wedges — left a record that says
|
|
59
|
+
* `running` for ever. The sidebar drew an activity ring off that field, so
|
|
60
|
+
* Bots that had been idle for hours pulsed as though they were mid-sentence.
|
|
61
|
+
*
|
|
62
|
+
* Three conditions, all of which must hold:
|
|
63
|
+
*
|
|
64
|
+
* - the record says `running`, which is necessary and was mistaken for
|
|
65
|
+
* sufficient;
|
|
66
|
+
* - it has not outlived {@link TURN_DEADLINE_MS_V1} plus
|
|
67
|
+
* {@link STALE_RUNNING_RUN_GRACE_MS_V1}, because the loop stops waiting at
|
|
68
|
+
* the deadline and a record older than that cannot be a Turn anybody is
|
|
69
|
+
* still running;
|
|
70
|
+
* - the durable session log does not already close the Turn it opened. A
|
|
71
|
+
* `turn/end` at or after this run's own `turn/start` means something has
|
|
72
|
+
* already written the Turn's ending — the admission repair, usually — and a
|
|
73
|
+
* record still saying `running` behind a closed Turn is a leftover, not work.
|
|
74
|
+
*
|
|
75
|
+
* A run that has not written its `turn/start` yet is judged on the deadline
|
|
76
|
+
* alone: there is no Turn in the log to call closed, and the previous Turn's
|
|
77
|
+
* `turn/end` says nothing about this one.
|
|
78
|
+
*
|
|
79
|
+
* Pure, and deliberately so: it is consulted on a read path, on a settlement
|
|
80
|
+
* path, and in tests, and all three have to reach the same verdict.
|
|
81
|
+
*/
|
|
82
|
+
export function runLivenessV1(input: {
|
|
83
|
+
run:
|
|
84
|
+
Pick<StoredRunV1<unknown>, "status" | "acceptedAt" | "events"> | undefined;
|
|
85
|
+
/** The Bot's durable session log, as the run's own events sit inside it. */
|
|
86
|
+
sessionEvents: readonly SessionEvent[];
|
|
87
|
+
now?: number;
|
|
88
|
+
deadlineMs?: number;
|
|
89
|
+
graceMs?: number;
|
|
90
|
+
}): RunLivenessV1 {
|
|
91
|
+
const run = input.run;
|
|
92
|
+
if (!run || run.status !== "running") return NOT_RUNNING;
|
|
93
|
+
const now = input.now ?? Date.now();
|
|
94
|
+
const deadline =
|
|
95
|
+
(input.deadlineMs ?? TURN_DEADLINE_MS_V1) +
|
|
96
|
+
(input.graceMs ?? STALE_RUNNING_RUN_GRACE_MS_V1);
|
|
97
|
+
const acceptedAt = Date.parse(run.acceptedAt);
|
|
98
|
+
// An unparseable timestamp is not evidence of death. The record is left
|
|
99
|
+
// alone rather than settled on a number nobody can read.
|
|
100
|
+
if (Number.isFinite(acceptedAt) && now - acceptedAt > deadline) {
|
|
101
|
+
return { working: false, stale: true, reason: "deadline" };
|
|
102
|
+
}
|
|
103
|
+
const opened = openedTurnSeqV1(run.events);
|
|
104
|
+
if (
|
|
105
|
+
opened !== undefined &&
|
|
106
|
+
input.sessionEvents.some(
|
|
107
|
+
(event) => event.type === "turn/end" && event.seq >= opened,
|
|
108
|
+
)
|
|
109
|
+
) {
|
|
110
|
+
return { working: false, stale: true, reason: "turn-closed" };
|
|
111
|
+
}
|
|
112
|
+
return { working: true, stale: false };
|
|
113
|
+
}
|
package/src/run-recovery.test.ts
CHANGED