@ixo/oracle-runtime-workers 0.10.0 → 0.11.0
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/README.md +5 -2
- package/package.json +1 -1
- package/src/core/context-budget.ts +201 -0
- package/src/core/context-window.ts +289 -0
- package/src/core/env.ts +29 -0
- package/src/core/main-agent-types.ts +46 -0
- package/src/core/main-agent.ts +86 -9
- package/src/core/middlewares/context-guard.ts +270 -0
- package/src/core/middlewares/result-cap.ts +180 -0
- package/src/core/middlewares/summarization.ts +87 -6
- package/src/core/middlewares/tool-marks.ts +209 -0
- package/src/core/openrouter-pricing.ts +42 -1
- package/src/core/read-result-tool.ts +77 -0
- package/src/core/runtime-context.ts +7 -0
- package/src/core/sub-agent-fallback.ts +20 -3
- package/src/core/wrap-plugin-tool.ts +24 -3
- package/src/do/ambient.ts +17 -0
- package/src/do/contracts.ts +227 -0
- package/src/do/result-store-test-do.ts +88 -0
- package/src/do/result-store.ts +339 -0
- package/src/do/run-buffer.ts +204 -0
- package/src/do/run-coordinator.ts +572 -0
- package/src/do/run-request.ts +52 -0
- package/src/do/run-store-test-do.ts +130 -0
- package/src/do/run-store.ts +604 -0
- package/src/do/session-id-migration.ts +85 -0
- package/src/do/speaker-prefix.ts +37 -0
- package/src/do/sse-stream.ts +567 -400
- package/src/do/transcript.ts +238 -0
- package/src/do/turn-body.ts +10 -0
- package/src/do/user-oracle-do.ts +875 -146
- package/src/matrix/gateway-do.ts +230 -29
- package/src/matrix/group-chat-summarizer.ts +107 -0
- package/src/matrix/group-chat.ts +1163 -0
- package/src/matrix/inbox-store.ts +23 -3
- package/src/matrix/ingest.ts +54 -11
- package/src/matrix/reply-chain.ts +34 -25
- package/src/plugin-api/tool-helper.ts +9 -1
- package/src/plugin-api/types.ts +86 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/matrix-group-chats/README.md +70 -0
- package/src/plugins/matrix-group-chats/index.ts +132 -0
- package/src/plugins/matrix-group-chats/tools.ts +172 -0
- package/src/plugins/tasks/tasks-tools.ts +4 -0
- package/src/shell/app.ts +66 -0
- package/src/sqlite/sqlite-saver.ts +81 -0
- package/src/tasks/scheduler.ts +134 -3
- package/src/tasks/store.ts +22 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runs of one user object, live and recovering (docs/plans/durable-runs.md).
|
|
3
|
+
*
|
|
4
|
+
* The coordinator owns the in-memory side of durability: which runs are in
|
|
5
|
+
* flight in this instance, their output buffers, their abort controllers,
|
|
6
|
+
* the keep-alive that holds the object loaded while a run is active, the
|
|
7
|
+
* per-session multitask rule, re-joins, and the recovery schedule for runs
|
|
8
|
+
* a previous incarnation left behind. The object supplies the host: how to
|
|
9
|
+
* run one attempt, the latest checkpoint of a session, and what to do when
|
|
10
|
+
* a run ends (deliver a task result, close a Matrix card).
|
|
11
|
+
*/
|
|
12
|
+
import type { Logger } from '../plugin-api/types';
|
|
13
|
+
import {
|
|
14
|
+
framesOfSegments,
|
|
15
|
+
partialTextOf,
|
|
16
|
+
RunBuffer,
|
|
17
|
+
type RunFrame,
|
|
18
|
+
} from './run-buffer';
|
|
19
|
+
import {
|
|
20
|
+
attemptSeqBase,
|
|
21
|
+
decideRecovery,
|
|
22
|
+
type RunDurabilityConfig,
|
|
23
|
+
type RunRecord,
|
|
24
|
+
type RunStore,
|
|
25
|
+
} from './run-store';
|
|
26
|
+
import { isImmediateFrame } from './sse-stream';
|
|
27
|
+
|
|
28
|
+
export interface RunOutcome {
|
|
29
|
+
status: 'finished' | 'aborted' | 'interrupted' | 'failed';
|
|
30
|
+
/** The reply (the last assistant message, or the streamed text so far). */
|
|
31
|
+
text: string;
|
|
32
|
+
messageId?: string;
|
|
33
|
+
toolCalls?: Array<{ name: string; status: 'done' | 'error' }>;
|
|
34
|
+
error?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LiveRun {
|
|
38
|
+
readonly runId: string;
|
|
39
|
+
readonly sessionId: string;
|
|
40
|
+
readonly requestId: string;
|
|
41
|
+
record: RunRecord;
|
|
42
|
+
buffer: RunBuffer;
|
|
43
|
+
abort: AbortController;
|
|
44
|
+
/** Set on a resumed attempt: the reply text the user already received. */
|
|
45
|
+
continuation: string | null;
|
|
46
|
+
/** Resolves when the run reaches a terminal state (any outcome). */
|
|
47
|
+
readonly done: Promise<RunOutcome>;
|
|
48
|
+
resolve: (outcome: RunOutcome) => void;
|
|
49
|
+
/** An attempt is executing right now. */
|
|
50
|
+
attemptInFlight: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The slice of `RunStore` the coordinator drives (tests supply an in-memory one). */
|
|
54
|
+
export type RunCoordinatorStore = Pick<
|
|
55
|
+
RunStore,
|
|
56
|
+
| 'create'
|
|
57
|
+
| 'get'
|
|
58
|
+
| 'update'
|
|
59
|
+
| 'listActive'
|
|
60
|
+
| 'readSegments'
|
|
61
|
+
| 'appendSegment'
|
|
62
|
+
| 'deleteSegments'
|
|
63
|
+
>;
|
|
64
|
+
|
|
65
|
+
export interface RunCoordinatorHost {
|
|
66
|
+
store: RunCoordinatorStore;
|
|
67
|
+
config: RunDurabilityConfig;
|
|
68
|
+
instanceId: string;
|
|
69
|
+
log: Logger;
|
|
70
|
+
now?: () => number;
|
|
71
|
+
/** Arm the object's multiplexed alarm no later than `at` (ms epoch). */
|
|
72
|
+
requestAlarm: (at: number) => void;
|
|
73
|
+
/**
|
|
74
|
+
* Execute one attempt: build the agent, stream the turn's frames into
|
|
75
|
+
* `live.buffer`, and resolve with the outcome. Never throws for a turn
|
|
76
|
+
* that failed (that is a `failed` outcome); a throw is an internal fault.
|
|
77
|
+
*/
|
|
78
|
+
runAttempt: (live: LiveRun, resumed: boolean) => Promise<RunOutcome>;
|
|
79
|
+
/** Latest checkpoint id of a session (progress detection across attempts). */
|
|
80
|
+
checkpointIdOf: (sessionId: string) => Promise<string | null>;
|
|
81
|
+
/** After a run reached a terminal state and its row was closed. */
|
|
82
|
+
onRunEnded?: (record: RunRecord, outcome: RunOutcome) => Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface BeginRunInput {
|
|
86
|
+
runId: string;
|
|
87
|
+
sessionId: string;
|
|
88
|
+
requestId: string;
|
|
89
|
+
client: 'portal' | 'matrix';
|
|
90
|
+
/** JSON the host needs to rebuild the attempt (see `StoredRunRequest`). */
|
|
91
|
+
request: string;
|
|
92
|
+
multitask: 'interrupt' | 'enqueue';
|
|
93
|
+
taskRunId?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** How long a superseded run gets to wind down before the next one starts. */
|
|
97
|
+
const SUPERSEDE_GRACE_MS = 5_000;
|
|
98
|
+
|
|
99
|
+
export interface JoinResult {
|
|
100
|
+
record: RunRecord;
|
|
101
|
+
/** Frames after the cursor, from the segments and the live tail. */
|
|
102
|
+
replay: RunFrame[];
|
|
103
|
+
/** The live buffer, when the run is still producing (or waiting to). */
|
|
104
|
+
buffer?: RunBuffer;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class RunCoordinator {
|
|
108
|
+
private readonly live = new Map<string, LiveRun>();
|
|
109
|
+
|
|
110
|
+
private keepAliveArmedUntil = 0;
|
|
111
|
+
|
|
112
|
+
private readonly now: () => number;
|
|
113
|
+
|
|
114
|
+
constructor(private readonly host: RunCoordinatorHost) {
|
|
115
|
+
this.now = host.now ?? (() => Date.now());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── lookups ─────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
get(runId: string): LiveRun | undefined {
|
|
121
|
+
return this.live.get(runId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The run this session is executing or about to (running/recovering first). */
|
|
125
|
+
activeForSession(sessionId: string): LiveRun | undefined {
|
|
126
|
+
let candidate: LiveRun | undefined;
|
|
127
|
+
for (const run of this.live.values()) {
|
|
128
|
+
if (run.sessionId !== sessionId) continue;
|
|
129
|
+
if (run.record.status === 'running' || run.record.status === 'recovering')
|
|
130
|
+
return run;
|
|
131
|
+
if (run.record.status === 'queued' && !candidate) candidate = run;
|
|
132
|
+
}
|
|
133
|
+
return candidate;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
byRequestId(requestId: string): LiveRun | undefined {
|
|
137
|
+
for (const run of this.live.values())
|
|
138
|
+
if (run.requestId === requestId) return run;
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
byTaskRunId(taskRunId: string): LiveRun | undefined {
|
|
143
|
+
for (const run of this.live.values())
|
|
144
|
+
if (run.record.taskRunId === taskRunId) return run;
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Attempts executing right now. */
|
|
149
|
+
get activeCount(): number {
|
|
150
|
+
let n = 0;
|
|
151
|
+
for (const run of this.live.values()) if (run.attemptInFlight) n += 1;
|
|
152
|
+
return n;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
get size(): number {
|
|
156
|
+
return this.live.size;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── keep-alive ──────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Hold the object loaded while attempts execute: the alarm is re-armed
|
|
163
|
+
* only when it is within a quarter of the horizon of expiring (one
|
|
164
|
+
* storage write per ~15 s of active turn, coalesced across runs).
|
|
165
|
+
*/
|
|
166
|
+
touchKeepAlive(): void {
|
|
167
|
+
if (this.activeCount === 0) return;
|
|
168
|
+
const now = this.now();
|
|
169
|
+
const horizon = this.host.config.keepAliveMs;
|
|
170
|
+
if (this.keepAliveArmedUntil - now > horizon / 4) return;
|
|
171
|
+
this.keepAliveArmedUntil = now + horizon;
|
|
172
|
+
this.host.requestAlarm(this.keepAliveArmedUntil);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The alarm deadline the keep-alive needs, if anything is executing. */
|
|
176
|
+
keepAliveDeadline(now: number): number | null {
|
|
177
|
+
if (this.activeCount === 0) return null;
|
|
178
|
+
this.keepAliveArmedUntil = now + this.host.config.keepAliveMs;
|
|
179
|
+
return this.keepAliveArmedUntil;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── begin / queue / abort ───────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Record a run and start it, or queue it behind the session's active run
|
|
186
|
+
* under the `enqueue` rule. Under `interrupt` (the default) the active run
|
|
187
|
+
* of the session is aborted first — and any run queued behind it.
|
|
188
|
+
*/
|
|
189
|
+
async begin(
|
|
190
|
+
input: BeginRunInput,
|
|
191
|
+
): Promise<{ live: LiveRun; queued: boolean }> {
|
|
192
|
+
const active = this.activeForSession(input.sessionId);
|
|
193
|
+
let queued = false;
|
|
194
|
+
if (active) {
|
|
195
|
+
if (input.multitask === 'enqueue') {
|
|
196
|
+
queued = true;
|
|
197
|
+
} else {
|
|
198
|
+
this.host.log.log(
|
|
199
|
+
`[runs] ${input.sessionId}: new message supersedes ${active.runId} (${active.record.status})`,
|
|
200
|
+
);
|
|
201
|
+
for (const run of [...this.live.values()])
|
|
202
|
+
if (
|
|
203
|
+
run.sessionId === input.sessionId &&
|
|
204
|
+
run.record.status === 'queued'
|
|
205
|
+
)
|
|
206
|
+
await this.cancelQueued(run, 'superseded');
|
|
207
|
+
if (active.record.status !== 'queued') {
|
|
208
|
+
this.abortRun(active, 'superseded');
|
|
209
|
+
await Promise.race([
|
|
210
|
+
active.done,
|
|
211
|
+
new Promise((r) => setTimeout(r, SUPERSEDE_GRACE_MS)),
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const checkpointId = await this.host.checkpointIdOf(input.sessionId);
|
|
217
|
+
await this.host.store.create({
|
|
218
|
+
runId: input.runId,
|
|
219
|
+
sessionId: input.sessionId,
|
|
220
|
+
requestId: input.requestId,
|
|
221
|
+
client: input.client,
|
|
222
|
+
status: queued ? 'queued' : 'running',
|
|
223
|
+
request: input.request,
|
|
224
|
+
checkpointId,
|
|
225
|
+
taskRunId: input.taskRunId ?? null,
|
|
226
|
+
instanceId: this.host.instanceId,
|
|
227
|
+
});
|
|
228
|
+
const record = (await this.host.store.get(input.runId))!;
|
|
229
|
+
const live = this.open(record);
|
|
230
|
+
if (!queued) void this.startAttempt(live, false);
|
|
231
|
+
return { live, queued };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Abort the session's active run (`POST /messages/abort`). */
|
|
235
|
+
abortSession(sessionId: string): boolean {
|
|
236
|
+
const active = this.activeForSession(sessionId);
|
|
237
|
+
if (!active) return false;
|
|
238
|
+
if (active.record.status === 'queued') {
|
|
239
|
+
void this.cancelQueued(active, 'aborted');
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
this.abortRun(active, 'aborted');
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private abortRun(live: LiveRun, reason: 'aborted' | 'superseded'): void {
|
|
247
|
+
live.abort.abort(new Error(`run aborted (${reason})`));
|
|
248
|
+
if (!live.attemptInFlight) {
|
|
249
|
+
// Recovering and not yet re-attempted: nothing is running to notice
|
|
250
|
+
// the signal; close it here.
|
|
251
|
+
void this.finalize(live, {
|
|
252
|
+
status: 'aborted',
|
|
253
|
+
text: live.continuation ?? '',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async cancelQueued(
|
|
259
|
+
live: LiveRun,
|
|
260
|
+
reason: 'aborted' | 'superseded',
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
this.host.log.log(`[runs] ${live.runId} ${reason} while queued`);
|
|
263
|
+
live.buffer.push('done', { runId: live.runId, aborted: true });
|
|
264
|
+
await this.finalize(live, { status: 'aborted', text: '' });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── join ────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Everything a client needs to (re-)attach after `after`: the packed
|
|
271
|
+
* segments, the unflushed tail, and the live buffer. A run that already
|
|
272
|
+
* ended replays what is left (usually nothing: segments are dropped at
|
|
273
|
+
* cutover) with no buffer.
|
|
274
|
+
*/
|
|
275
|
+
async join(runId: string, after = 0): Promise<JoinResult | undefined> {
|
|
276
|
+
const record =
|
|
277
|
+
this.live.get(runId)?.record ?? (await this.host.store.get(runId));
|
|
278
|
+
if (!record) return undefined;
|
|
279
|
+
const live = this.live.get(runId);
|
|
280
|
+
const segments = await this.host.store.readSegments(runId, after);
|
|
281
|
+
const replay = framesOfSegments(segments, after);
|
|
282
|
+
const lastReplayed =
|
|
283
|
+
replay.length > 0 ? replay[replay.length - 1]!.seq : after;
|
|
284
|
+
if (!live) return { record, replay };
|
|
285
|
+
// No await between reading the tail and subscribing (the caller
|
|
286
|
+
// subscribes synchronously on the returned buffer), so no frame slips
|
|
287
|
+
// between the two.
|
|
288
|
+
replay.push(...live.buffer.tailAfter(lastReplayed));
|
|
289
|
+
return { record, replay, buffer: live.buffer };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── attempts ────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
private open(record: RunRecord, startSeq = 0): LiveRun {
|
|
295
|
+
let resolve!: (outcome: RunOutcome) => void;
|
|
296
|
+
const done = new Promise<RunOutcome>((r) => {
|
|
297
|
+
resolve = r;
|
|
298
|
+
});
|
|
299
|
+
const live: LiveRun = {
|
|
300
|
+
runId: record.runId,
|
|
301
|
+
sessionId: record.sessionId,
|
|
302
|
+
requestId: record.requestId,
|
|
303
|
+
record,
|
|
304
|
+
buffer: this.makeBuffer(record.runId, startSeq),
|
|
305
|
+
abort: new AbortController(),
|
|
306
|
+
continuation: null,
|
|
307
|
+
done,
|
|
308
|
+
resolve,
|
|
309
|
+
attemptInFlight: false,
|
|
310
|
+
};
|
|
311
|
+
this.live.set(record.runId, live);
|
|
312
|
+
return live;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private makeBuffer(runId: string, startSeq: number): RunBuffer {
|
|
316
|
+
return new RunBuffer({
|
|
317
|
+
flushMs: this.host.config.segmentFlushMs,
|
|
318
|
+
flushBytes: this.host.config.segmentBytes,
|
|
319
|
+
immediate: isImmediateFrame,
|
|
320
|
+
startSeq,
|
|
321
|
+
onPack: (segment) => this.host.store.appendSegment(runId, segment),
|
|
322
|
+
onPackError: (error) =>
|
|
323
|
+
this.host.log.warn(
|
|
324
|
+
`[runs] ${runId}: segment not persisted: ${error instanceof Error ? error.message : String(error)}`,
|
|
325
|
+
),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private async startAttempt(live: LiveRun, resumed: boolean): Promise<void> {
|
|
330
|
+
live.attemptInFlight = true;
|
|
331
|
+
this.touchKeepAlive();
|
|
332
|
+
let outcome: RunOutcome;
|
|
333
|
+
try {
|
|
334
|
+
outcome = await this.host.runAttempt(live, resumed);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
outcome = {
|
|
337
|
+
status: 'failed',
|
|
338
|
+
text: live.continuation ?? '',
|
|
339
|
+
error,
|
|
340
|
+
};
|
|
341
|
+
this.host.log.error(
|
|
342
|
+
`[runs] ${live.runId}: attempt crashed: ${error instanceof Error ? error.message : String(error)}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
live.attemptInFlight = false;
|
|
346
|
+
await this.finalize(live, outcome);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private async finalize(live: LiveRun, outcome: RunOutcome): Promise<void> {
|
|
350
|
+
if (!this.live.has(live.runId)) return;
|
|
351
|
+
this.live.delete(live.runId);
|
|
352
|
+
const { runId } = live;
|
|
353
|
+
try {
|
|
354
|
+
await live.buffer.close();
|
|
355
|
+
const partialText =
|
|
356
|
+
outcome.status === 'finished'
|
|
357
|
+
? null
|
|
358
|
+
: outcome.text ||
|
|
359
|
+
partialTextOf([
|
|
360
|
+
...framesOfSegments(await this.host.store.readSegments(runId)),
|
|
361
|
+
]);
|
|
362
|
+
await this.host.store.update(runId, {
|
|
363
|
+
status: outcome.status,
|
|
364
|
+
lastSeq: live.buffer.lastSeq,
|
|
365
|
+
messageId: outcome.messageId ?? null,
|
|
366
|
+
partialText,
|
|
367
|
+
error:
|
|
368
|
+
outcome.error === undefined
|
|
369
|
+
? null
|
|
370
|
+
: outcome.error instanceof Error
|
|
371
|
+
? outcome.error.message
|
|
372
|
+
: String(outcome.error),
|
|
373
|
+
nextAttemptAt: null,
|
|
374
|
+
});
|
|
375
|
+
// Cutover: the reply (or the partial text) is in the row/transcript.
|
|
376
|
+
await this.host.store.deleteSegments(runId);
|
|
377
|
+
live.record = (await this.host.store.get(runId)) ?? live.record;
|
|
378
|
+
this.host.log.log(
|
|
379
|
+
`[runs] ${runId} ${outcome.status} (${live.record.client}, session ${live.sessionId}, ${live.buffer.lastSeq} frames)`,
|
|
380
|
+
);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
this.host.log.error(
|
|
383
|
+
`[runs] ${runId}: could not close the run row: ${error instanceof Error ? error.message : String(error)}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
live.resolve(outcome);
|
|
387
|
+
try {
|
|
388
|
+
await this.host.onRunEnded?.(live.record, outcome);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
this.host.log.error(
|
|
391
|
+
`[runs] ${runId}: onRunEnded failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
await this.startNextQueued(live.sessionId);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private async startNextQueued(sessionId: string): Promise<void> {
|
|
398
|
+
// A running turn, or one waiting for its recovery attempt, keeps the
|
|
399
|
+
// queue waiting: the session's turns stay strictly ordered.
|
|
400
|
+
const active = this.activeForSession(sessionId);
|
|
401
|
+
if (active && active.record.status !== 'queued') return;
|
|
402
|
+
for (const run of this.live.values()) {
|
|
403
|
+
if (run.sessionId !== sessionId || run.record.status !== 'queued')
|
|
404
|
+
continue;
|
|
405
|
+
await this.host.store.update(run.runId, {
|
|
406
|
+
status: 'running',
|
|
407
|
+
instanceId: this.host.instanceId,
|
|
408
|
+
checkpointId: await this.host.checkpointIdOf(sessionId),
|
|
409
|
+
});
|
|
410
|
+
run.record = (await this.host.store.get(run.runId)) ?? run.record;
|
|
411
|
+
this.host.log.log(`[runs] ${run.runId}: dequeued for ${sessionId}`);
|
|
412
|
+
void this.startAttempt(run, false);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── recovery ────────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Runs a previous incarnation left `queued`/`running`/`recovering`. Every
|
|
421
|
+
* boot is a new instance, so anything active that this coordinator does
|
|
422
|
+
* not hold is an orphan: queued runs are re-opened (and started when
|
|
423
|
+
* their session is idle); running ones get a recovery attempt scheduled
|
|
424
|
+
* with the backoff, or are closed as interrupted after the cap.
|
|
425
|
+
*/
|
|
426
|
+
async recoverOrphans(): Promise<void> {
|
|
427
|
+
const active = await this.host.store.listActive();
|
|
428
|
+
const now = this.now();
|
|
429
|
+
let earliest: number | null = null;
|
|
430
|
+
for (const record of active) {
|
|
431
|
+
if (this.live.has(record.runId)) continue;
|
|
432
|
+
if (record.status === 'queued') {
|
|
433
|
+
this.open(record, record.lastSeq);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const current = await this.host.checkpointIdOf(record.sessionId);
|
|
437
|
+
const decision = decideRecovery(record, current, now, this.host.config);
|
|
438
|
+
// Everything packed so far: the cursor continues after it (the row's
|
|
439
|
+
// `lastSeq` is only written when a run ends) and the continuation note
|
|
440
|
+
// shows the model what the user already read.
|
|
441
|
+
const segments = await this.host.store.readSegments(record.runId);
|
|
442
|
+
const frames = framesOfSegments(segments);
|
|
443
|
+
const lastPacked = Math.max(
|
|
444
|
+
record.lastSeq,
|
|
445
|
+
segments.length > 0 ? segments[segments.length - 1]!.seqTo : 0,
|
|
446
|
+
);
|
|
447
|
+
// The frames of the previous attempt that were still unpacked at the
|
|
448
|
+
// reset are gone, but a client may hold a cursor pointing past them:
|
|
449
|
+
// this attempt numbers from the next attempt base so that cursor
|
|
450
|
+
// stays below everything it emits.
|
|
451
|
+
const generation = record.generation + 1;
|
|
452
|
+
const startSeq = Math.max(lastPacked, attemptSeqBase(generation));
|
|
453
|
+
if (decision.action === 'interrupt') {
|
|
454
|
+
this.host.log.warn(
|
|
455
|
+
`[runs] ${record.runId}: ${decision.attempts} recovery attempt(s) without progress; closing as interrupted`,
|
|
456
|
+
);
|
|
457
|
+
await this.host.store.update(record.runId, { generation });
|
|
458
|
+
const live = this.open(record, startSeq);
|
|
459
|
+
live.continuation = partialTextOf(frames) || null;
|
|
460
|
+
live.buffer.push('error', {
|
|
461
|
+
error:
|
|
462
|
+
'Something went wrong while answering and the reply could not be completed. Please try again.',
|
|
463
|
+
kind: 'interrupted',
|
|
464
|
+
retryable: true,
|
|
465
|
+
sessionId: record.sessionId,
|
|
466
|
+
requestId: record.requestId,
|
|
467
|
+
runId: record.runId,
|
|
468
|
+
timestamp: new Date(now).toISOString(),
|
|
469
|
+
});
|
|
470
|
+
live.buffer.push('done', { runId: record.runId, interrupted: true });
|
|
471
|
+
await this.finalize(live, {
|
|
472
|
+
status: 'interrupted',
|
|
473
|
+
text: live.continuation ?? '',
|
|
474
|
+
});
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
await this.host.store.update(record.runId, {
|
|
478
|
+
status: 'recovering',
|
|
479
|
+
attempts: decision.attempts,
|
|
480
|
+
generation,
|
|
481
|
+
nextAttemptAt: decision.at,
|
|
482
|
+
checkpointId: current,
|
|
483
|
+
instanceId: this.host.instanceId,
|
|
484
|
+
});
|
|
485
|
+
const refreshed = (await this.host.store.get(record.runId)) ?? record;
|
|
486
|
+
const live = this.open(refreshed, startSeq);
|
|
487
|
+
live.continuation = partialTextOf(frames) || null;
|
|
488
|
+
this.host.log.log(
|
|
489
|
+
`[runs] ${record.runId}: attempt ${decision.attempts} in ${Math.round((decision.at - now) / 1000)} s (${frames.length} frames restored)`,
|
|
490
|
+
);
|
|
491
|
+
earliest =
|
|
492
|
+
earliest === null ? decision.at : Math.min(earliest, decision.at);
|
|
493
|
+
}
|
|
494
|
+
// Queued runs of idle sessions.
|
|
495
|
+
for (const run of [...this.live.values()])
|
|
496
|
+
if (run.record.status === 'queued')
|
|
497
|
+
await this.startNextQueued(run.sessionId);
|
|
498
|
+
if (earliest !== null) this.host.requestAlarm(earliest);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Start every recovery attempt that is due (from the alarm). */
|
|
502
|
+
async resumeDue(now: number): Promise<void> {
|
|
503
|
+
for (const run of [...this.live.values()]) {
|
|
504
|
+
if (run.record.status !== 'recovering' || run.attemptInFlight) continue;
|
|
505
|
+
if (run.record.nextAttemptAt !== null && run.record.nextAttemptAt > now)
|
|
506
|
+
continue;
|
|
507
|
+
await this.host.store.update(run.runId, {
|
|
508
|
+
status: 'running',
|
|
509
|
+
nextAttemptAt: null,
|
|
510
|
+
});
|
|
511
|
+
run.record = (await this.host.store.get(run.runId)) ?? run.record;
|
|
512
|
+
// `partialLength` is the reply text this attempt continues from (all
|
|
513
|
+
// packed `message` frames). A client that received more than that
|
|
514
|
+
// before the reset truncates to it, so the continuation never
|
|
515
|
+
// duplicates text the runtime lost with the unpacked tail.
|
|
516
|
+
run.buffer.push('run', {
|
|
517
|
+
runId: run.runId,
|
|
518
|
+
sessionId: run.sessionId,
|
|
519
|
+
requestId: run.requestId,
|
|
520
|
+
resumed: true,
|
|
521
|
+
attempt: run.record.attempts,
|
|
522
|
+
partialLength: run.continuation?.length ?? 0,
|
|
523
|
+
});
|
|
524
|
+
this.host.log.log(
|
|
525
|
+
`[runs] ${run.runId}: resuming (attempt ${run.record.attempts})`,
|
|
526
|
+
);
|
|
527
|
+
void this.startAttempt(run, true);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Earliest pending recovery attempt, for the alarm multiplexer. */
|
|
532
|
+
nextRecoveryAt(): number | null {
|
|
533
|
+
let at: number | null = null;
|
|
534
|
+
for (const run of this.live.values()) {
|
|
535
|
+
if (
|
|
536
|
+
run.record.status !== 'recovering' ||
|
|
537
|
+
run.record.nextAttemptAt === null
|
|
538
|
+
)
|
|
539
|
+
continue;
|
|
540
|
+
at =
|
|
541
|
+
at === null
|
|
542
|
+
? run.record.nextAttemptAt
|
|
543
|
+
: Math.min(at, run.record.nextAttemptAt);
|
|
544
|
+
}
|
|
545
|
+
return at;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Diagnostics. */
|
|
549
|
+
snapshot(): Array<{
|
|
550
|
+
runId: string;
|
|
551
|
+
sessionId: string;
|
|
552
|
+
status: string;
|
|
553
|
+
attemptInFlight: boolean;
|
|
554
|
+
generation: number;
|
|
555
|
+
lastSeq: number;
|
|
556
|
+
packedSeq: number;
|
|
557
|
+
subscribers: number;
|
|
558
|
+
nextAttemptAt: number | null;
|
|
559
|
+
}> {
|
|
560
|
+
return [...this.live.values()].map((run) => ({
|
|
561
|
+
runId: run.runId,
|
|
562
|
+
sessionId: run.sessionId,
|
|
563
|
+
status: run.record.status,
|
|
564
|
+
attemptInFlight: run.attemptInFlight,
|
|
565
|
+
generation: run.record.generation,
|
|
566
|
+
lastSeq: run.buffer.lastSeq,
|
|
567
|
+
packedSeq: run.buffer.packedSeq,
|
|
568
|
+
subscribers: run.buffer.subscriberCount,
|
|
569
|
+
nextAttemptAt: run.record.nextAttemptAt,
|
|
570
|
+
}));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a run row stores so a later incarnation of the user object can
|
|
3
|
+
* rebuild the attempt: the `TurnRequest` (minus attachments — they are in
|
|
4
|
+
* the checkpointed first message) and the stream options the frame producer
|
|
5
|
+
* needs. The browser tool catalogue and the AG-UI action schemas are NOT
|
|
6
|
+
* stored: they live in the graph state (`browserTools` / `agActions`), which
|
|
7
|
+
* a resumed attempt reads back from the checkpoint.
|
|
8
|
+
*/
|
|
9
|
+
import type { RunSummary, TurnRequest } from './contracts';
|
|
10
|
+
import type { RunRecord } from './run-store';
|
|
11
|
+
|
|
12
|
+
export interface StoredRunRequest {
|
|
13
|
+
turn: Omit<TurnRequest, 'attachments'> & {
|
|
14
|
+
attachments?: TurnRequest['attachments'];
|
|
15
|
+
};
|
|
16
|
+
timezone?: string;
|
|
17
|
+
/** Names the client declared as AG-UI actions (rendered as `action_call`). */
|
|
18
|
+
agActionNames?: string[];
|
|
19
|
+
stream?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function storedRunRequest(
|
|
23
|
+
req: TurnRequest,
|
|
24
|
+
extra: Pick<StoredRunRequest, 'timezone' | 'agActionNames' | 'stream'> = {},
|
|
25
|
+
): StoredRunRequest {
|
|
26
|
+
const { attachments, ...turn } = req;
|
|
27
|
+
return {
|
|
28
|
+
turn: { ...turn, ...(attachments?.length ? { attachments } : {}) },
|
|
29
|
+
...(extra.timezone ? { timezone: extra.timezone } : {}),
|
|
30
|
+
...(extra.agActionNames?.length
|
|
31
|
+
? { agActionNames: extra.agActionNames }
|
|
32
|
+
: {}),
|
|
33
|
+
...(extra.stream !== undefined ? { stream: extra.stream } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function runSummaryOf(record: RunRecord): RunSummary {
|
|
38
|
+
return {
|
|
39
|
+
runId: record.runId,
|
|
40
|
+
sessionId: record.sessionId,
|
|
41
|
+
requestId: record.requestId,
|
|
42
|
+
client: record.client,
|
|
43
|
+
status: record.status,
|
|
44
|
+
startedAt: record.startedAt,
|
|
45
|
+
updatedAt: record.updatedAt,
|
|
46
|
+
lastSeq: record.lastSeq,
|
|
47
|
+
...(record.messageId ? { messageId: record.messageId } : {}),
|
|
48
|
+
...(record.partialText && record.status !== 'finished'
|
|
49
|
+
? { partialText: record.partialText }
|
|
50
|
+
: {}),
|
|
51
|
+
};
|
|
52
|
+
}
|