@frockbot/kernel-do 0.0.0 → 0.1.1
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 +25 -6
- package/src/authority.ts +989 -0
- package/src/composition-failures.test.ts +405 -0
- package/src/composition-failures.ts +108 -0
- package/src/composition-store.test.ts +497 -0
- package/src/composition-store.ts +473 -0
- package/src/index.ts +10 -0
- package/src/memory-storage.fixture.ts +61 -0
- package/src/run-records.ts +624 -0
- package/src/run-recovery.ts +163 -0
- package/src/run-terminal.ts +220 -0
- package/src/storage-keys.ts +129 -0
- package/src/turn-admission.test.ts +450 -0
- package/src/turn-errors.ts +33 -0
- package/src/workspace-generations.test.ts +228 -0
- package/src/workspace-generations.ts +189 -0
- package/src/workspace-sync-effects.ts +103 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/authority.ts
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
// Durable Object authority for one Bot: command admission, the append-only
|
|
3
|
+
// event log, the resumable execution cursor, idempotency records, cancellation,
|
|
4
|
+
// serialization, and durable scheduling. Cloudflare types are the only host
|
|
5
|
+
// detail here; everything above them is Package policy behind narrow hooks.
|
|
6
|
+
import {
|
|
7
|
+
decodeSessionEvent,
|
|
8
|
+
type NormalizedModelRequest,
|
|
9
|
+
type SessionEvent,
|
|
10
|
+
} from "@frockbot/kernel-contracts";
|
|
11
|
+
import type { CompositionGenerationV1 } from "@frockbot/kernel-composition/generation";
|
|
12
|
+
import { DurableCompositionStore } from "./composition-store.js";
|
|
13
|
+
import { DurableCompositionFailureLog } from "./composition-failures.js";
|
|
14
|
+
import {
|
|
15
|
+
botTurnCommandFingerprintV1,
|
|
16
|
+
storedRunAdmissionV1,
|
|
17
|
+
storedRunSubagentRoleV1,
|
|
18
|
+
storedRunTurnTypeV1,
|
|
19
|
+
type BotNotificationIntent,
|
|
20
|
+
type BotTurnCommand,
|
|
21
|
+
type BotTurnCompletion,
|
|
22
|
+
type StoredRunCodecV1,
|
|
23
|
+
type StoredRunV1,
|
|
24
|
+
} from "./run-records.js";
|
|
25
|
+
import {
|
|
26
|
+
completeStoredRun,
|
|
27
|
+
type TerminalPackageRecords,
|
|
28
|
+
failStoredRun,
|
|
29
|
+
requireStoredRunReconciliation,
|
|
30
|
+
} from "./run-terminal.js";
|
|
31
|
+
import {
|
|
32
|
+
eventsForFailedRun,
|
|
33
|
+
latestModelRequestJournalState,
|
|
34
|
+
planBotRunRecovery,
|
|
35
|
+
} from "./run-recovery.js";
|
|
36
|
+
import {
|
|
37
|
+
BotTurnReconciliationRequiredError,
|
|
38
|
+
BotTurnRecoveryRequiredError,
|
|
39
|
+
} from "./turn-errors.js";
|
|
40
|
+
import {
|
|
41
|
+
ACTIVE_RUN_KEY,
|
|
42
|
+
IDENTITY_KEY,
|
|
43
|
+
LATEST_EVENTS_KEY,
|
|
44
|
+
MAX_RUN_ADMISSION_FENCES,
|
|
45
|
+
NOTIFICATION_PREFIX,
|
|
46
|
+
RECOVERY_ALARM_DELAY_MS,
|
|
47
|
+
RUN_ADMISSION_FENCE_INDEX_KEY,
|
|
48
|
+
RUN_ADMISSION_FENCE_PREFIX,
|
|
49
|
+
RUN_INDEX_PREFIX,
|
|
50
|
+
RUN_PREFIX,
|
|
51
|
+
runIndexKey,
|
|
52
|
+
storedRunAdmissionFences,
|
|
53
|
+
} from "./storage-keys.js";
|
|
54
|
+
|
|
55
|
+
export interface BotIdentity {
|
|
56
|
+
userId: string;
|
|
57
|
+
botId: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface OwnedBotTurnCommand extends BotTurnCommand, BotIdentity {}
|
|
61
|
+
|
|
62
|
+
/** One admitted Turn, handed to the Package that owns the Composition. */
|
|
63
|
+
export interface BotTurnExecutionInput<Snapshot> {
|
|
64
|
+
identity: BotIdentity;
|
|
65
|
+
command: BotTurnCommand;
|
|
66
|
+
previousEvents: readonly SessionEvent[];
|
|
67
|
+
configurationSnapshot: Snapshot;
|
|
68
|
+
/** The Composition generation pinned to this Turn at admission. */
|
|
69
|
+
compositionGenerationId: string;
|
|
70
|
+
persistSessionEvents(
|
|
71
|
+
sessionId: string,
|
|
72
|
+
events: readonly SessionEvent[],
|
|
73
|
+
): Promise<void>;
|
|
74
|
+
resume: boolean;
|
|
75
|
+
/** Present when a resumed Turn already has a durable model request. */
|
|
76
|
+
admittedRequest?: NormalizedModelRequest;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The narrow surface the Bot Durable Object authority consumes from the Package
|
|
81
|
+
* that owns configuration policy, Composition, and notification content. The
|
|
82
|
+
* kernel owns admission, the log, the cursor, idempotency, cancellation, and
|
|
83
|
+
* durable scheduling, and holds no implementation of any of these.
|
|
84
|
+
*/
|
|
85
|
+
export interface BotDurableAuthorityHooks<Snapshot> {
|
|
86
|
+
/** Configuration snapshot a Turn is admitted under, resolved before admission. */
|
|
87
|
+
resolveAdmissionSnapshot(command: OwnedBotTurnCommand): Promise<Snapshot>;
|
|
88
|
+
/** The first-party generation a Bot with no Composition records starts on. */
|
|
89
|
+
bootstrapComposition(): Promise<CompositionGenerationV1>;
|
|
90
|
+
/** Durable snapshot read inside the admission transaction. */
|
|
91
|
+
admittedSnapshot(
|
|
92
|
+
transaction: DurableObjectTransaction,
|
|
93
|
+
resolved: Snapshot,
|
|
94
|
+
): Promise<Snapshot>;
|
|
95
|
+
/** Run the admitted Turn on the Package Composition pinned to it. */
|
|
96
|
+
executeTurn(
|
|
97
|
+
input: BotTurnExecutionInput<Snapshot>,
|
|
98
|
+
): Promise<BotTurnCompletion>;
|
|
99
|
+
/** Notification policy; `undefined` records no notification. */
|
|
100
|
+
notification(
|
|
101
|
+
snapshot: Snapshot,
|
|
102
|
+
result: BotTurnCompletion,
|
|
103
|
+
): BotNotificationIntent | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Package records written in the same transaction that settles a Turn, given
|
|
106
|
+
* the settled run and the admission-index cursor it was admitted under. The
|
|
107
|
+
* kernel writes the returned keys without reading them, so the policy that
|
|
108
|
+
* produced them stays entirely in the Package.
|
|
109
|
+
*/
|
|
110
|
+
terminalRecords?(input: {
|
|
111
|
+
snapshot: Snapshot;
|
|
112
|
+
run: StoredRunV1<Snapshot>;
|
|
113
|
+
cursor: string;
|
|
114
|
+
read<T>(key: string): Promise<T | undefined>;
|
|
115
|
+
}): Promise<Record<string, unknown>>;
|
|
116
|
+
/** Package deadlines that share this object's single durable alarm. */
|
|
117
|
+
scheduledDeadlines(transaction: DurableObjectTransaction): Promise<number[]>;
|
|
118
|
+
/** True while Package work is in flight and recovery must be deferred. */
|
|
119
|
+
scheduledWorkInFlight(): boolean;
|
|
120
|
+
/** Push Package deadlines out when the alarm fires while work is in flight. */
|
|
121
|
+
deferScheduledWork(transaction: DurableObjectTransaction): Promise<void>;
|
|
122
|
+
/** Settle Package deadlines when the alarm fires idle. */
|
|
123
|
+
settleScheduledWork(): Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface BotDurableAuthorityOptions<Snapshot> {
|
|
127
|
+
state: DurableObjectState;
|
|
128
|
+
codec: StoredRunCodecV1<Snapshot>;
|
|
129
|
+
hooks: BotDurableAuthorityHooks<Snapshot>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class BotDurableAuthority<Snapshot> {
|
|
133
|
+
readonly ctx: DurableObjectState;
|
|
134
|
+
private readonly codec: StoredRunCodecV1<Snapshot>;
|
|
135
|
+
private readonly hooks: BotDurableAuthorityHooks<Snapshot>;
|
|
136
|
+
/** Durable Composition generations; every admitted Turn pins the current one. */
|
|
137
|
+
readonly composition: DurableCompositionStore;
|
|
138
|
+
/** Why a generation failed to activate, and whether it is quarantined. */
|
|
139
|
+
readonly compositionFailures: DurableCompositionFailureLog;
|
|
140
|
+
private executingRunId: string | undefined;
|
|
141
|
+
|
|
142
|
+
constructor(options: BotDurableAuthorityOptions<Snapshot>) {
|
|
143
|
+
this.ctx = options.state;
|
|
144
|
+
this.codec = options.codec;
|
|
145
|
+
this.hooks = options.hooks;
|
|
146
|
+
this.composition = new DurableCompositionStore({
|
|
147
|
+
state: options.state,
|
|
148
|
+
bootstrap: () => options.hooks.bootstrapComposition(),
|
|
149
|
+
});
|
|
150
|
+
this.compositionFailures = new DurableCompositionFailureLog({
|
|
151
|
+
state: options.state,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async run(command: OwnedBotTurnCommand): Promise<BotTurnCompletion> {
|
|
156
|
+
await this.assertMatchingRunCommand(command);
|
|
157
|
+
await this.recoverActiveRun();
|
|
158
|
+
const replay = await this.completedRunResult(command);
|
|
159
|
+
if (replay) return replay;
|
|
160
|
+
const admission = await this.acceptRun(command);
|
|
161
|
+
return this.executeAcceptedRun(
|
|
162
|
+
command,
|
|
163
|
+
admission.previous,
|
|
164
|
+
admission.settings,
|
|
165
|
+
admission.compositionGenerationId,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async reconcileRun(
|
|
170
|
+
identity: BotIdentity,
|
|
171
|
+
runId: string,
|
|
172
|
+
): Promise<BotTurnCompletion> {
|
|
173
|
+
await this.assertIdentity(identity);
|
|
174
|
+
const key = `${RUN_PREFIX}${runId}`;
|
|
175
|
+
const recovery = await this.ctx.storage.transaction(async (transaction) => {
|
|
176
|
+
const run = this.codec.optional(await transaction.get<unknown>(key));
|
|
177
|
+
const activeRunId = await transaction.get<string>(ACTIVE_RUN_KEY);
|
|
178
|
+
if (
|
|
179
|
+
!run ||
|
|
180
|
+
run.status !== "reconciliation-required" ||
|
|
181
|
+
activeRunId !== runId
|
|
182
|
+
) {
|
|
183
|
+
throw new Error(`run "${runId}" does not require reconciliation`);
|
|
184
|
+
}
|
|
185
|
+
const latest = (
|
|
186
|
+
(await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
|
|
187
|
+
).map(decodeSessionEvent);
|
|
188
|
+
const settings = run.configurationSnapshot;
|
|
189
|
+
await transaction.put(key, {
|
|
190
|
+
...run,
|
|
191
|
+
status: "running",
|
|
192
|
+
phase: "executing",
|
|
193
|
+
failure: undefined,
|
|
194
|
+
} satisfies StoredRunV1<Snapshot>);
|
|
195
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
196
|
+
return { run, latest, settings };
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
return await this.executeResumedRun(
|
|
200
|
+
identity,
|
|
201
|
+
recovery.run,
|
|
202
|
+
recovery.latest,
|
|
203
|
+
recovery.settings,
|
|
204
|
+
);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const current = this.codec.optional(
|
|
207
|
+
await this.ctx.storage.get<unknown>(key),
|
|
208
|
+
);
|
|
209
|
+
if (current?.status === "reconciliation-required") {
|
|
210
|
+
const previous = recovery.latest.slice(0, current.previousEventCount);
|
|
211
|
+
const failure =
|
|
212
|
+
error instanceof Error ? error.message : "Reconciliation failed";
|
|
213
|
+
await this.failRun(
|
|
214
|
+
runId,
|
|
215
|
+
previous,
|
|
216
|
+
current.events,
|
|
217
|
+
`Reconciliation was explicitly abandoned: ${failure}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private withNotification(
|
|
225
|
+
snapshot: Snapshot,
|
|
226
|
+
result: BotTurnCompletion,
|
|
227
|
+
): BotTurnCompletion {
|
|
228
|
+
const notification = this.hooks.notification(snapshot, result);
|
|
229
|
+
return notification ? { ...result, notification } : result;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async executeAcceptedRun(
|
|
233
|
+
command: OwnedBotTurnCommand,
|
|
234
|
+
previous: SessionEvent[],
|
|
235
|
+
settings: Snapshot,
|
|
236
|
+
compositionGenerationId: string,
|
|
237
|
+
): Promise<BotTurnCompletion> {
|
|
238
|
+
this.executingRunId = command.runId;
|
|
239
|
+
try {
|
|
240
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
241
|
+
const key = `${RUN_PREFIX}${command.runId}`;
|
|
242
|
+
const run = this.codec.optional(await transaction.get<unknown>(key));
|
|
243
|
+
if (!run || run.status !== "running") {
|
|
244
|
+
throw new Error(`run "${command.runId}" is not resumable`);
|
|
245
|
+
}
|
|
246
|
+
await transaction.put(key, {
|
|
247
|
+
...run,
|
|
248
|
+
phase: "executing",
|
|
249
|
+
} satisfies StoredRunV1<Snapshot>);
|
|
250
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
251
|
+
});
|
|
252
|
+
const result = await this.hooks.executeTurn({
|
|
253
|
+
identity: command,
|
|
254
|
+
command,
|
|
255
|
+
previousEvents: previous,
|
|
256
|
+
configurationSnapshot: settings,
|
|
257
|
+
compositionGenerationId,
|
|
258
|
+
persistSessionEvents: (_sessionId, events) =>
|
|
259
|
+
this.persistRunEvents(command.runId, events),
|
|
260
|
+
resume: false,
|
|
261
|
+
});
|
|
262
|
+
const completed = this.withNotification(settings, result);
|
|
263
|
+
await this.completeRun(command.runId, previous, completed, settings);
|
|
264
|
+
return completed;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const durableRun = this.codec.optional(
|
|
267
|
+
await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${command.runId}`),
|
|
268
|
+
);
|
|
269
|
+
const events = eventsForFailedRun(durableRun, error);
|
|
270
|
+
const message =
|
|
271
|
+
error instanceof Error ? error.message : "Bot turn failed";
|
|
272
|
+
if (error instanceof BotTurnRecoveryRequiredError) {
|
|
273
|
+
await this.deferRunRecovery(command.runId);
|
|
274
|
+
throw new Error(message);
|
|
275
|
+
}
|
|
276
|
+
const modelState = latestModelRequestJournalState(events);
|
|
277
|
+
if (
|
|
278
|
+
error instanceof BotTurnReconciliationRequiredError ||
|
|
279
|
+
modelState.status === "unresolved"
|
|
280
|
+
) {
|
|
281
|
+
await this.requireRunReconciliation(
|
|
282
|
+
command.runId,
|
|
283
|
+
previous,
|
|
284
|
+
events,
|
|
285
|
+
modelState.status === "unresolved"
|
|
286
|
+
? `Model request "${modelState.request.request.requestId}" has no durable provider outcome`
|
|
287
|
+
: message,
|
|
288
|
+
);
|
|
289
|
+
throw new Error(message);
|
|
290
|
+
}
|
|
291
|
+
await this.failRun(command.runId, previous, events, message);
|
|
292
|
+
throw new Error(message);
|
|
293
|
+
} finally {
|
|
294
|
+
if (this.executingRunId === command.runId) {
|
|
295
|
+
this.executingRunId = undefined;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async executeResumedRun(
|
|
301
|
+
identity: BotIdentity,
|
|
302
|
+
run: StoredRunV1<Snapshot>,
|
|
303
|
+
latest: SessionEvent[],
|
|
304
|
+
settings: Snapshot,
|
|
305
|
+
): Promise<BotTurnCompletion> {
|
|
306
|
+
this.executingRunId = run.runId;
|
|
307
|
+
this.codec.require(run);
|
|
308
|
+
const previous = latest.slice(0, run.previousEventCount);
|
|
309
|
+
try {
|
|
310
|
+
const modelState = latestModelRequestJournalState(latest);
|
|
311
|
+
const result = await this.hooks.executeTurn({
|
|
312
|
+
identity,
|
|
313
|
+
command: {
|
|
314
|
+
runId: run.runId,
|
|
315
|
+
sessionId: run.sessionId,
|
|
316
|
+
acceptedAt: run.acceptedAt,
|
|
317
|
+
text: run.input,
|
|
318
|
+
// Recovery re-mounts on the recorded turn type, so the resumed Turn
|
|
319
|
+
// sees the same trimmed catalog the evicted one did.
|
|
320
|
+
turnType: storedRunTurnTypeV1(run),
|
|
321
|
+
...(storedRunSubagentRoleV1(run)
|
|
322
|
+
? { subagentRole: storedRunSubagentRoleV1(run) }
|
|
323
|
+
: {}),
|
|
324
|
+
...(run.admission?.origin ? { origin: run.admission.origin } : {}),
|
|
325
|
+
},
|
|
326
|
+
previousEvents: latest,
|
|
327
|
+
configurationSnapshot: settings,
|
|
328
|
+
compositionGenerationId: run.compositionGenerationId,
|
|
329
|
+
persistSessionEvents: (_sessionId, events) =>
|
|
330
|
+
this.persistRunEvents(run.runId, events),
|
|
331
|
+
resume: true,
|
|
332
|
+
...(modelState.status === "completed"
|
|
333
|
+
? { admittedRequest: modelState.request.request }
|
|
334
|
+
: {}),
|
|
335
|
+
});
|
|
336
|
+
const durableRun = this.codec.optional(
|
|
337
|
+
await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${run.runId}`),
|
|
338
|
+
);
|
|
339
|
+
if (!durableRun) throw new Error(`run "${run.runId}" was not accepted`);
|
|
340
|
+
const fullResult = {
|
|
341
|
+
...result,
|
|
342
|
+
events: durableRun.events,
|
|
343
|
+
} satisfies BotTurnCompletion;
|
|
344
|
+
const completed = this.withNotification(settings, fullResult);
|
|
345
|
+
await this.completeRun(run.runId, previous, completed, settings);
|
|
346
|
+
return completed;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
const durableRun = this.codec.optional(
|
|
349
|
+
await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${run.runId}`),
|
|
350
|
+
);
|
|
351
|
+
const events = durableRun?.events ?? run.events;
|
|
352
|
+
const message =
|
|
353
|
+
error instanceof Error ? error.message : "Bot turn failed";
|
|
354
|
+
if (error instanceof BotTurnRecoveryRequiredError) {
|
|
355
|
+
await this.deferRunRecovery(run.runId);
|
|
356
|
+
throw new Error(message);
|
|
357
|
+
}
|
|
358
|
+
const modelState = latestModelRequestJournalState(events);
|
|
359
|
+
if (
|
|
360
|
+
error instanceof BotTurnReconciliationRequiredError ||
|
|
361
|
+
modelState.status === "unresolved"
|
|
362
|
+
) {
|
|
363
|
+
await this.requireRunReconciliation(
|
|
364
|
+
run.runId,
|
|
365
|
+
previous,
|
|
366
|
+
events,
|
|
367
|
+
modelState.status === "unresolved"
|
|
368
|
+
? `Model request "${modelState.request.request.requestId}" has no durable provider outcome`
|
|
369
|
+
: message,
|
|
370
|
+
);
|
|
371
|
+
throw new Error(message);
|
|
372
|
+
}
|
|
373
|
+
await this.failRun(run.runId, previous, events, message);
|
|
374
|
+
throw new Error(message);
|
|
375
|
+
} finally {
|
|
376
|
+
if (this.executingRunId === run.runId) this.executingRunId = undefined;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private async deferRunRecovery(runId: string): Promise<void> {
|
|
381
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
382
|
+
const run = this.codec.optional(
|
|
383
|
+
await transaction.get<unknown>(`${RUN_PREFIX}${runId}`),
|
|
384
|
+
);
|
|
385
|
+
if (!run || run.status !== "running") {
|
|
386
|
+
throw new Error(`run "${runId}" is not resumable`);
|
|
387
|
+
}
|
|
388
|
+
await transaction.put(`${RUN_PREFIX}${runId}`, {
|
|
389
|
+
...run,
|
|
390
|
+
phase: "executing",
|
|
391
|
+
} satisfies StoredRunV1<Snapshot>);
|
|
392
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private async completedRunResult(
|
|
397
|
+
command: OwnedBotTurnCommand,
|
|
398
|
+
): Promise<BotTurnCompletion | undefined> {
|
|
399
|
+
const { runId } = command;
|
|
400
|
+
const run = this.codec.optional(
|
|
401
|
+
await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
|
|
402
|
+
);
|
|
403
|
+
if (!run) return undefined;
|
|
404
|
+
if (run.commandFingerprint !== botTurnCommandFingerprintV1(command)) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`Turn idempotency key "${runId}" was reused for a different command`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (run.status !== "completed") {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`run "${runId}" already exists with status ${run.status}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (run.responseText === undefined) {
|
|
415
|
+
throw new Error(`run "${runId}" has no response text`);
|
|
416
|
+
}
|
|
417
|
+
const notification = await this.ctx.storage.get<BotNotificationIntent>(
|
|
418
|
+
`${NOTIFICATION_PREFIX}${runId}`,
|
|
419
|
+
);
|
|
420
|
+
return {
|
|
421
|
+
runId,
|
|
422
|
+
text: run.responseText,
|
|
423
|
+
events: structuredClone(run.events),
|
|
424
|
+
notification,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private async assertMatchingRunCommand(
|
|
429
|
+
command: OwnedBotTurnCommand,
|
|
430
|
+
): Promise<void> {
|
|
431
|
+
const run = this.codec.optional(
|
|
432
|
+
await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${command.runId}`),
|
|
433
|
+
);
|
|
434
|
+
if (
|
|
435
|
+
run &&
|
|
436
|
+
run.commandFingerprint !== botTurnCommandFingerprintV1(command)
|
|
437
|
+
) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`Turn idempotency key "${command.runId}" was reused for a different command`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async readDurableIdentity(): Promise<BotIdentity | undefined> {
|
|
445
|
+
return this.ctx.storage.get<BotIdentity>(IDENTITY_KEY);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async validateIdentity(identity: BotIdentity): Promise<void> {
|
|
449
|
+
const existing = await this.ctx.storage.get<BotIdentity>(IDENTITY_KEY);
|
|
450
|
+
if (
|
|
451
|
+
existing &&
|
|
452
|
+
(existing.userId !== identity.userId || existing.botId !== identity.botId)
|
|
453
|
+
) {
|
|
454
|
+
throw new Error("Bot authority does not match its durable identity");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Raises a visible failure through notifications. Used when a
|
|
460
|
+
* failure has no Turn completion to ride along with — a Composition that
|
|
461
|
+
* failed closed, for instance, whose Turn still succeeds on the last known
|
|
462
|
+
* good and would otherwise report nothing wrong.
|
|
463
|
+
*/
|
|
464
|
+
async recordNotification(intent: BotNotificationIntent): Promise<void> {
|
|
465
|
+
await this.ctx.storage.put(
|
|
466
|
+
`${NOTIFICATION_PREFIX}${intent.notificationId}`,
|
|
467
|
+
structuredClone(intent),
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Re-pins an admitted, still-running Turn onto the generation it actually
|
|
473
|
+
* ran under. Only fail-closed activation calls this: the Turn was admitted on
|
|
474
|
+
* a generation that would not mount, and the durable record must name the
|
|
475
|
+
* last known good it fell back to rather than the generation that failed.
|
|
476
|
+
*/
|
|
477
|
+
async repinRun(
|
|
478
|
+
runId: string,
|
|
479
|
+
compositionGenerationId: string,
|
|
480
|
+
): Promise<void> {
|
|
481
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
482
|
+
const key = `${RUN_PREFIX}${runId}`;
|
|
483
|
+
const run = this.codec.optional(await transaction.get<unknown>(key));
|
|
484
|
+
if (!run || run.status !== "running") {
|
|
485
|
+
throw new Error(`run "${runId}" is not resumable`);
|
|
486
|
+
}
|
|
487
|
+
if (run.compositionGenerationId === compositionGenerationId) return;
|
|
488
|
+
await transaction.put(key, {
|
|
489
|
+
...run,
|
|
490
|
+
compositionGenerationId,
|
|
491
|
+
} satisfies StoredRunV1<Snapshot>);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async listNotifications(): Promise<BotNotificationIntent[]> {
|
|
496
|
+
const entries = await this.ctx.storage.list<BotNotificationIntent>({
|
|
497
|
+
prefix: NOTIFICATION_PREFIX,
|
|
498
|
+
});
|
|
499
|
+
return [...entries.values()].sort((left, right) =>
|
|
500
|
+
left.createdAt.localeCompare(right.createdAt),
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async acknowledgeNotification(notificationId: string): Promise<void> {
|
|
505
|
+
await this.ctx.storage.delete(`${NOTIFICATION_PREFIX}${notificationId}`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async alarm(): Promise<void> {
|
|
509
|
+
if (this.executingRunId || this.hooks.scheduledWorkInFlight()) {
|
|
510
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
511
|
+
await this.hooks.deferScheduledWork(transaction);
|
|
512
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
513
|
+
});
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
await this.hooks.settleScheduledWork();
|
|
517
|
+
const activeRunId = await this.ctx.storage.get<string>(ACTIVE_RUN_KEY);
|
|
518
|
+
if (activeRunId) {
|
|
519
|
+
const [storedRun, identity] = await Promise.all([
|
|
520
|
+
this.ctx.storage.get<unknown>(`${RUN_PREFIX}${activeRunId}`),
|
|
521
|
+
this.ctx.storage.get<BotIdentity>(IDENTITY_KEY),
|
|
522
|
+
]);
|
|
523
|
+
const run = this.codec.optional(storedRun);
|
|
524
|
+
if (run?.status === "reconciliation-required" && identity) return;
|
|
525
|
+
}
|
|
526
|
+
await this.recoverActiveRun();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Active run id, for Package projections of durable run state. */
|
|
530
|
+
async readActiveRunId(): Promise<string | undefined> {
|
|
531
|
+
return this.ctx.storage.get<string>(ACTIVE_RUN_KEY);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Durable run record, unchecked against its lookup key. */
|
|
535
|
+
async readStoredRun(
|
|
536
|
+
runId: string,
|
|
537
|
+
): Promise<StoredRunV1<Snapshot> | undefined> {
|
|
538
|
+
return this.codec.optional(
|
|
539
|
+
await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Durable run record, checked against the key it was looked up by. */
|
|
544
|
+
async readRun(runId: string): Promise<StoredRunV1<Snapshot> | undefined> {
|
|
545
|
+
const run = await this.readStoredRun(runId);
|
|
546
|
+
if (run && run.runId !== runId) {
|
|
547
|
+
throw new Error("stored run does not match its lookup key");
|
|
548
|
+
}
|
|
549
|
+
return run;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Reverse-ordered admission index page: `[cursor, runId]` entries. */
|
|
553
|
+
async listRunIndex(query: {
|
|
554
|
+
limit: number;
|
|
555
|
+
before?: string;
|
|
556
|
+
}): Promise<Array<{ cursor: string; runId: string }>> {
|
|
557
|
+
const entries = await this.ctx.storage.list<string>({
|
|
558
|
+
prefix: RUN_INDEX_PREFIX,
|
|
559
|
+
reverse: true,
|
|
560
|
+
limit: query.limit,
|
|
561
|
+
...(query.before?.startsWith(RUN_INDEX_PREFIX)
|
|
562
|
+
? { end: query.before }
|
|
563
|
+
: {}),
|
|
564
|
+
});
|
|
565
|
+
return [...entries].map(([cursor, runId]) => ({ cursor, runId }));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async fenceRunAdmission(
|
|
569
|
+
identity: BotIdentity,
|
|
570
|
+
runId: string,
|
|
571
|
+
): Promise<StoredRunV1<Snapshot> | undefined> {
|
|
572
|
+
return this.ctx.storage.transaction(async (transaction) => {
|
|
573
|
+
const durableIdentity = await transaction.get<BotIdentity>(IDENTITY_KEY);
|
|
574
|
+
if (
|
|
575
|
+
durableIdentity &&
|
|
576
|
+
(durableIdentity.userId !== identity.userId ||
|
|
577
|
+
durableIdentity.botId !== identity.botId)
|
|
578
|
+
) {
|
|
579
|
+
throw new Error("Bot authority does not match its durable identity");
|
|
580
|
+
}
|
|
581
|
+
const run = this.codec.optional(
|
|
582
|
+
await transaction.get<unknown>(`${RUN_PREFIX}${runId}`),
|
|
583
|
+
);
|
|
584
|
+
if (run && run.runId !== runId) {
|
|
585
|
+
throw new Error("stored run does not match its lookup key");
|
|
586
|
+
}
|
|
587
|
+
if (!run) {
|
|
588
|
+
const storedFences = storedRunAdmissionFences(
|
|
589
|
+
await transaction.get<unknown>(RUN_ADMISSION_FENCE_INDEX_KEY),
|
|
590
|
+
);
|
|
591
|
+
if (
|
|
592
|
+
!storedFences.includes(runId) &&
|
|
593
|
+
storedFences.length >= MAX_RUN_ADMISSION_FENCES
|
|
594
|
+
) {
|
|
595
|
+
throw new Error("Run admission fence capacity reached");
|
|
596
|
+
}
|
|
597
|
+
await transaction.put({
|
|
598
|
+
[RUN_ADMISSION_FENCE_INDEX_KEY]: [
|
|
599
|
+
...storedFences.filter((fenced) => fenced !== runId),
|
|
600
|
+
runId,
|
|
601
|
+
],
|
|
602
|
+
[IDENTITY_KEY]: durableIdentity ?? identity,
|
|
603
|
+
});
|
|
604
|
+
await transaction.delete(`${RUN_ADMISSION_FENCE_PREFIX}${runId}`);
|
|
605
|
+
}
|
|
606
|
+
return run;
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async refreshRecoveryAlarm(
|
|
611
|
+
transaction: DurableObjectTransaction,
|
|
612
|
+
): Promise<void> {
|
|
613
|
+
const [activeRunId, scheduled] = await Promise.all([
|
|
614
|
+
transaction.get<string>(ACTIVE_RUN_KEY),
|
|
615
|
+
this.hooks.scheduledDeadlines(transaction),
|
|
616
|
+
]);
|
|
617
|
+
const activeRun = activeRunId
|
|
618
|
+
? this.codec.optional(
|
|
619
|
+
await transaction.get<unknown>(`${RUN_PREFIX}${activeRunId}`),
|
|
620
|
+
)
|
|
621
|
+
: undefined;
|
|
622
|
+
const deadlines = [...scheduled];
|
|
623
|
+
if (
|
|
624
|
+
activeRunId &&
|
|
625
|
+
(!activeRun || activeRun.status !== "reconciliation-required")
|
|
626
|
+
) {
|
|
627
|
+
deadlines.push(Date.now() + RECOVERY_ALARM_DELAY_MS);
|
|
628
|
+
}
|
|
629
|
+
if (deadlines.length === 0) await transaction.deleteAlarm();
|
|
630
|
+
else await transaction.setAlarm(Math.min(...deadlines));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async assertIdentity(identity: BotIdentity): Promise<void> {
|
|
634
|
+
const existing = await this.ctx.storage.get<BotIdentity>(IDENTITY_KEY);
|
|
635
|
+
if (
|
|
636
|
+
existing &&
|
|
637
|
+
(existing.userId !== identity.userId || existing.botId !== identity.botId)
|
|
638
|
+
) {
|
|
639
|
+
throw new Error("Bot authority does not match its durable identity");
|
|
640
|
+
}
|
|
641
|
+
if (!existing) await this.ctx.storage.put(IDENTITY_KEY, identity);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private async acceptRun(command: OwnedBotTurnCommand): Promise<{
|
|
645
|
+
previous: SessionEvent[];
|
|
646
|
+
settings: Snapshot;
|
|
647
|
+
compositionGenerationId: string;
|
|
648
|
+
}> {
|
|
649
|
+
const fenceKey = `${RUN_ADMISSION_FENCE_PREFIX}${command.runId}`;
|
|
650
|
+
const fences = storedRunAdmissionFences(
|
|
651
|
+
await this.ctx.storage.get<unknown>(RUN_ADMISSION_FENCE_INDEX_KEY),
|
|
652
|
+
);
|
|
653
|
+
if (
|
|
654
|
+
fences.includes(command.runId) ||
|
|
655
|
+
(await this.ctx.storage.get(fenceKey))
|
|
656
|
+
) {
|
|
657
|
+
throw new Error(`run "${command.runId}" admission was fenced`);
|
|
658
|
+
}
|
|
659
|
+
const settings = await this.hooks.resolveAdmissionSnapshot(command);
|
|
660
|
+
// Materialized before the transaction; the pin itself is read inside it.
|
|
661
|
+
await this.composition.materialize();
|
|
662
|
+
const key = `${RUN_PREFIX}${command.runId}`;
|
|
663
|
+
return this.ctx.storage.transaction(async (transaction) => {
|
|
664
|
+
const existing = this.codec.optional(await transaction.get<unknown>(key));
|
|
665
|
+
if (existing) {
|
|
666
|
+
if (
|
|
667
|
+
existing.commandFingerprint !== botTurnCommandFingerprintV1(command)
|
|
668
|
+
) {
|
|
669
|
+
throw new Error(
|
|
670
|
+
`Turn idempotency key "${command.runId}" was reused for a different command`,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
if (existing.status === "completed") {
|
|
674
|
+
throw new Error(`run "${command.runId}" already completed`);
|
|
675
|
+
}
|
|
676
|
+
throw new Error(`run "${command.runId}" already exists`);
|
|
677
|
+
}
|
|
678
|
+
const fences = storedRunAdmissionFences(
|
|
679
|
+
await transaction.get<unknown>(RUN_ADMISSION_FENCE_INDEX_KEY),
|
|
680
|
+
);
|
|
681
|
+
if (fences.includes(command.runId) || (await transaction.get(fenceKey))) {
|
|
682
|
+
throw new Error(`run "${command.runId}" admission was fenced`);
|
|
683
|
+
}
|
|
684
|
+
const identity = await transaction.get<BotIdentity>(IDENTITY_KEY);
|
|
685
|
+
if (
|
|
686
|
+
identity &&
|
|
687
|
+
(identity.userId !== command.userId || identity.botId !== command.botId)
|
|
688
|
+
) {
|
|
689
|
+
throw new Error("Bot authority does not match its durable identity");
|
|
690
|
+
}
|
|
691
|
+
if (await transaction.get(ACTIVE_RUN_KEY)) {
|
|
692
|
+
throw new Error("bot already has an active run");
|
|
693
|
+
}
|
|
694
|
+
const latestEvents = (
|
|
695
|
+
(await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
|
|
696
|
+
).map(decodeSessionEvent);
|
|
697
|
+
const admittedSettings = await this.hooks.admittedSnapshot(
|
|
698
|
+
transaction,
|
|
699
|
+
settings,
|
|
700
|
+
);
|
|
701
|
+
const pin = await this.composition.pin(transaction);
|
|
702
|
+
const admittedRun = this.codec.require({
|
|
703
|
+
runId: command.runId,
|
|
704
|
+
commandFingerprint: botTurnCommandFingerprintV1(command),
|
|
705
|
+
sessionId: command.sessionId,
|
|
706
|
+
acceptedAt: command.acceptedAt,
|
|
707
|
+
input: command.text,
|
|
708
|
+
events: [],
|
|
709
|
+
effectAdmissions: [],
|
|
710
|
+
status: "running",
|
|
711
|
+
phase: "admitted",
|
|
712
|
+
compositionGenerationId: pin.generationId,
|
|
713
|
+
configurationSnapshot: structuredClone(admittedSettings),
|
|
714
|
+
previousEventCount: latestEvents.length,
|
|
715
|
+
...storedRunAdmissionV1(
|
|
716
|
+
command.turnType,
|
|
717
|
+
command.origin,
|
|
718
|
+
command.subagentRole,
|
|
719
|
+
),
|
|
720
|
+
} satisfies StoredRunV1<Snapshot>);
|
|
721
|
+
await transaction.put({
|
|
722
|
+
[key]: admittedRun,
|
|
723
|
+
[runIndexKey(command.acceptedAt, command.runId)]: command.runId,
|
|
724
|
+
[ACTIVE_RUN_KEY]: command.runId,
|
|
725
|
+
[IDENTITY_KEY]: identity ?? {
|
|
726
|
+
userId: command.userId,
|
|
727
|
+
botId: command.botId,
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
731
|
+
return {
|
|
732
|
+
previous: latestEvents,
|
|
733
|
+
settings: admittedSettings,
|
|
734
|
+
compositionGenerationId: pin.generationId,
|
|
735
|
+
};
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private async persistRunEvents(
|
|
740
|
+
runId: string,
|
|
741
|
+
events: readonly SessionEvent[],
|
|
742
|
+
): Promise<void> {
|
|
743
|
+
const durableEvents = events
|
|
744
|
+
.filter((event) => event.type !== "session/disposed")
|
|
745
|
+
.map(decodeSessionEvent);
|
|
746
|
+
if (durableEvents.length === 0) return;
|
|
747
|
+
const key = `${RUN_PREFIX}${runId}`;
|
|
748
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
749
|
+
const run = this.codec.optional(await transaction.get<unknown>(key));
|
|
750
|
+
if (!run) throw new Error(`run "${runId}" was not accepted`);
|
|
751
|
+
const latest = (
|
|
752
|
+
(await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
|
|
753
|
+
).map(decodeSessionEvent);
|
|
754
|
+
for (const [index, event] of durableEvents.entries()) {
|
|
755
|
+
if (event.seq !== latest.length + index) {
|
|
756
|
+
throw new Error(
|
|
757
|
+
"Bot session persistence received non-contiguous events",
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const next = this.codec.require({
|
|
762
|
+
...run,
|
|
763
|
+
events: [...run.events, ...durableEvents],
|
|
764
|
+
} satisfies StoredRunV1<Snapshot>);
|
|
765
|
+
await transaction.put({
|
|
766
|
+
[key]: structuredClone(next),
|
|
767
|
+
[LATEST_EVENTS_KEY]: structuredClone([...latest, ...durableEvents]),
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The Package's terminal-record hook, bound to the snapshot the Turn ran
|
|
774
|
+
* under. Absent when the Package contributes none.
|
|
775
|
+
*/
|
|
776
|
+
private terminalPackageRecords(
|
|
777
|
+
snapshot: Snapshot,
|
|
778
|
+
): TerminalPackageRecords<Snapshot> | undefined {
|
|
779
|
+
const hook = this.hooks.terminalRecords;
|
|
780
|
+
if (!hook) return undefined;
|
|
781
|
+
return ({ run, read }) =>
|
|
782
|
+
hook.call(this.hooks, {
|
|
783
|
+
snapshot,
|
|
784
|
+
run,
|
|
785
|
+
cursor: runIndexKey(run.acceptedAt, run.runId),
|
|
786
|
+
read,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private terminalKeys(runId: string) {
|
|
791
|
+
return {
|
|
792
|
+
run: `${RUN_PREFIX}${runId}`,
|
|
793
|
+
activeRun: ACTIVE_RUN_KEY,
|
|
794
|
+
latestEvents: LATEST_EVENTS_KEY,
|
|
795
|
+
notificationPrefix: NOTIFICATION_PREFIX,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private async completeRun(
|
|
800
|
+
runId: string,
|
|
801
|
+
previous: SessionEvent[],
|
|
802
|
+
result: BotTurnCompletion,
|
|
803
|
+
snapshot: Snapshot,
|
|
804
|
+
): Promise<void> {
|
|
805
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
806
|
+
await completeStoredRun(
|
|
807
|
+
this.codec,
|
|
808
|
+
transaction,
|
|
809
|
+
this.terminalKeys(runId),
|
|
810
|
+
runId,
|
|
811
|
+
previous,
|
|
812
|
+
result,
|
|
813
|
+
this.terminalPackageRecords(snapshot),
|
|
814
|
+
);
|
|
815
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private async failRun(
|
|
820
|
+
runId: string,
|
|
821
|
+
previous: SessionEvent[],
|
|
822
|
+
events: SessionEvent[],
|
|
823
|
+
failure: string,
|
|
824
|
+
): Promise<void> {
|
|
825
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
826
|
+
await failStoredRun(
|
|
827
|
+
this.codec,
|
|
828
|
+
transaction,
|
|
829
|
+
this.terminalKeys(runId),
|
|
830
|
+
runId,
|
|
831
|
+
previous,
|
|
832
|
+
events,
|
|
833
|
+
failure,
|
|
834
|
+
);
|
|
835
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
private async requireRunReconciliation(
|
|
840
|
+
runId: string,
|
|
841
|
+
previous: SessionEvent[],
|
|
842
|
+
events: SessionEvent[],
|
|
843
|
+
failure: string,
|
|
844
|
+
): Promise<void> {
|
|
845
|
+
await this.ctx.storage.transaction(async (transaction) => {
|
|
846
|
+
await requireStoredRunReconciliation(
|
|
847
|
+
this.codec,
|
|
848
|
+
transaction,
|
|
849
|
+
this.terminalKeys(runId),
|
|
850
|
+
runId,
|
|
851
|
+
previous,
|
|
852
|
+
events,
|
|
853
|
+
failure,
|
|
854
|
+
);
|
|
855
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async recoverActiveRun(): Promise<void> {
|
|
860
|
+
const activeRunId = await this.ctx.storage.get<string>(ACTIVE_RUN_KEY);
|
|
861
|
+
if (!activeRunId || activeRunId === this.executingRunId) return;
|
|
862
|
+
const durableIdentity =
|
|
863
|
+
await this.ctx.storage.get<BotIdentity>(IDENTITY_KEY);
|
|
864
|
+
const key = `${RUN_PREFIX}${activeRunId}`;
|
|
865
|
+
const recovery = await this.ctx.storage.transaction(async (transaction) => {
|
|
866
|
+
const current = await transaction.get<string>(ACTIVE_RUN_KEY);
|
|
867
|
+
if (!current || current === this.executingRunId) return undefined;
|
|
868
|
+
const run = this.codec.optional(await transaction.get<unknown>(key));
|
|
869
|
+
if (run?.status === "reconciliation-required") {
|
|
870
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
871
|
+
return undefined;
|
|
872
|
+
}
|
|
873
|
+
if (!run || run.status !== "running") {
|
|
874
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
875
|
+
return undefined;
|
|
876
|
+
}
|
|
877
|
+
const latest = (
|
|
878
|
+
(await transaction.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? []
|
|
879
|
+
).map(decodeSessionEvent);
|
|
880
|
+
const plan = planBotRunRecovery(run, latest, this.codec);
|
|
881
|
+
if (plan.kind === "complete") {
|
|
882
|
+
const result = {
|
|
883
|
+
runId: run.runId,
|
|
884
|
+
text: plan.responseText,
|
|
885
|
+
events: run.events,
|
|
886
|
+
} satisfies BotTurnCompletion;
|
|
887
|
+
const completed = this.withNotification(
|
|
888
|
+
run.configurationSnapshot,
|
|
889
|
+
result,
|
|
890
|
+
);
|
|
891
|
+
await completeStoredRun(
|
|
892
|
+
this.codec,
|
|
893
|
+
transaction,
|
|
894
|
+
this.terminalKeys(run.runId),
|
|
895
|
+
run.runId,
|
|
896
|
+
latest.slice(0, run.previousEventCount),
|
|
897
|
+
completed,
|
|
898
|
+
this.terminalPackageRecords(run.configurationSnapshot),
|
|
899
|
+
);
|
|
900
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
901
|
+
return undefined;
|
|
902
|
+
}
|
|
903
|
+
if (plan.kind === "fail") {
|
|
904
|
+
await failStoredRun(
|
|
905
|
+
this.codec,
|
|
906
|
+
transaction,
|
|
907
|
+
this.terminalKeys(run.runId),
|
|
908
|
+
run.runId,
|
|
909
|
+
latest.slice(0, run.previousEventCount),
|
|
910
|
+
run.events,
|
|
911
|
+
plan.failure,
|
|
912
|
+
);
|
|
913
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
914
|
+
return undefined;
|
|
915
|
+
}
|
|
916
|
+
if (plan.kind === "restart") {
|
|
917
|
+
const settings = run.configurationSnapshot;
|
|
918
|
+
await transaction.put({
|
|
919
|
+
[key]: {
|
|
920
|
+
...run,
|
|
921
|
+
events: [],
|
|
922
|
+
phase: "admitted",
|
|
923
|
+
} satisfies StoredRunV1<Snapshot>,
|
|
924
|
+
[LATEST_EVENTS_KEY]: plan.previous,
|
|
925
|
+
});
|
|
926
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
927
|
+
return {
|
|
928
|
+
kind: "restart" as const,
|
|
929
|
+
run,
|
|
930
|
+
previous: plan.previous,
|
|
931
|
+
settings,
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
if (plan.kind === "resume") {
|
|
935
|
+
const settings = run.configurationSnapshot;
|
|
936
|
+
await transaction.put(key, {
|
|
937
|
+
...run,
|
|
938
|
+
phase: "executing",
|
|
939
|
+
} satisfies StoredRunV1<Snapshot>);
|
|
940
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
941
|
+
return { kind: "resume" as const, run, latest, settings };
|
|
942
|
+
}
|
|
943
|
+
await transaction.put({
|
|
944
|
+
[key]: {
|
|
945
|
+
...run,
|
|
946
|
+
events: [...run.events, ...plan.repairs],
|
|
947
|
+
status: "reconciliation-required",
|
|
948
|
+
phase: "reconciliation-required",
|
|
949
|
+
failure:
|
|
950
|
+
"Execution outcome requires reconciliation before it can resume",
|
|
951
|
+
} satisfies StoredRunV1<Snapshot>,
|
|
952
|
+
[LATEST_EVENTS_KEY]: [...latest, ...plan.repairs],
|
|
953
|
+
});
|
|
954
|
+
await this.refreshRecoveryAlarm(transaction);
|
|
955
|
+
return undefined;
|
|
956
|
+
});
|
|
957
|
+
if (!recovery) return;
|
|
958
|
+
if (!durableIdentity) throw new Error("Bot identity is unavailable");
|
|
959
|
+
if (recovery.kind === "resume") {
|
|
960
|
+
await this.executeResumedRun(
|
|
961
|
+
durableIdentity,
|
|
962
|
+
recovery.run,
|
|
963
|
+
recovery.latest,
|
|
964
|
+
recovery.settings,
|
|
965
|
+
);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
await this.executeAcceptedRun(
|
|
969
|
+
{
|
|
970
|
+
userId: durableIdentity.userId,
|
|
971
|
+
botId: durableIdentity.botId,
|
|
972
|
+
runId: recovery.run.runId,
|
|
973
|
+
sessionId: recovery.run.sessionId,
|
|
974
|
+
acceptedAt: recovery.run.acceptedAt,
|
|
975
|
+
text: recovery.run.input,
|
|
976
|
+
turnType: storedRunTurnTypeV1(recovery.run),
|
|
977
|
+
...(storedRunSubagentRoleV1(recovery.run)
|
|
978
|
+
? { subagentRole: storedRunSubagentRoleV1(recovery.run) }
|
|
979
|
+
: {}),
|
|
980
|
+
...(recovery.run.admission?.origin
|
|
981
|
+
? { origin: recovery.run.admission.origin }
|
|
982
|
+
: {}),
|
|
983
|
+
},
|
|
984
|
+
recovery.previous,
|
|
985
|
+
recovery.settings,
|
|
986
|
+
recovery.run.compositionGenerationId,
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
}
|