@ixo/oracle-runtime-workers 0.21.1 → 0.22.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 +1 -0
- package/package.json +1 -1
- package/src/core/env.ts +18 -0
- package/src/core/index.ts +8 -2
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +24 -0
- package/src/core/middlewares/tool-execution.ts +87 -46
- package/src/core/middlewares/tool-marks.ts +54 -19
- package/src/core/runtime-context.ts +15 -1
- package/src/core/state.ts +2 -0
- package/src/core/tool-scheduler.ts +129 -24
- package/src/do/active-runs.ts +171 -0
- package/src/do/contracts.ts +47 -3
- package/src/do/run-coordinator.ts +30 -0
- package/src/do/run-request.ts +15 -2
- package/src/do/run-store-test-do.ts +10 -0
- package/src/do/run-store.ts +66 -13
- package/src/do/turn-body.ts +13 -1
- package/src/do/user-oracle-do.ts +167 -10
- package/src/plugin-api/tool-helper.ts +6 -0
- package/src/plugin-api/types.ts +63 -7
- package/src/plugins/agui/agui.plugin.ts +7 -5
- package/src/plugins/ixo-transaction/wallet-signing.ts +5 -2
- package/src/plugins/portal/declared-tools.ts +12 -0
- package/src/plugins/portal/portal.plugin.ts +32 -8
- package/src/realtime/frontend-call-registry.ts +147 -27
- package/src/realtime/realtime-endpoint.ts +335 -41
- package/src/realtime/session-socket-hub.ts +90 -19
- package/src/shell/app.ts +24 -2
- package/src/sqlite/sessions-store.ts +22 -0
|
@@ -2,18 +2,61 @@
|
|
|
2
2
|
* Bounded tool concurrency for one user object, shared by every session and
|
|
3
3
|
* every turn that object runs:
|
|
4
4
|
*
|
|
5
|
-
* -
|
|
6
|
-
* the same user's data, even from two simultaneous
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* - `write`: one at a time across every session (a mutation never races
|
|
6
|
+
* another mutation of the same user's data, even from two simultaneous
|
|
7
|
+
* sessions);
|
|
8
|
+
* - `read`: up to `maxReads` at a time across every session;
|
|
9
|
+
* - `frontend:<sessionId>`: a tool that acts in the user's browser
|
|
10
|
+
* (`PluginTool.surface: 'frontend'`), one at a time per session and
|
|
11
|
+
* never in the user-wide lanes above, so a call parked while its chat
|
|
12
|
+
* has no tab holds no slot another chat needs;
|
|
13
|
+
* - `subagent:<sessionId>`: sub-agent dispatches, up to `maxSubagents` at
|
|
14
|
+
* a time per session, in their own lane so a child waiting for one of
|
|
15
|
+
* the parent's slots can never deadlock the parent, and a dispatch
|
|
16
|
+
* waiting in one chat (an inner frontend call parked) never holds up
|
|
17
|
+
* another chat's.
|
|
18
|
+
*
|
|
19
|
+
* The per-session lanes are created on demand and removed once idle (no
|
|
20
|
+
* call in flight or queued), so only busy ones exist.
|
|
11
21
|
*
|
|
12
22
|
* A wait is cancelled by the turn's abort signal: the waiter leaves the
|
|
13
23
|
* queue and the signal's reason is thrown, so an aborted turn never holds a
|
|
14
24
|
* slot it will not use.
|
|
15
25
|
*/
|
|
16
|
-
export type ToolLane = 'read' | 'write' | 'subagent';
|
|
26
|
+
export type ToolLane = 'read' | 'write' | 'frontend' | 'subagent';
|
|
27
|
+
|
|
28
|
+
/** The lanes kept per session (`frontend:<sessionId>`, `subagent:<sessionId>`). */
|
|
29
|
+
export type SessionToolLane = Extract<ToolLane, 'frontend' | 'subagent'>;
|
|
30
|
+
|
|
31
|
+
const isSessionLane = (lane: ToolLane): lane is SessionToolLane =>
|
|
32
|
+
lane === 'frontend' || lane === 'subagent';
|
|
33
|
+
|
|
34
|
+
/** The key of a per-session lane: `frontend:<sessionId>` or `subagent:<sessionId>`. */
|
|
35
|
+
export function sessionLaneKey(
|
|
36
|
+
lane: SessionToolLane,
|
|
37
|
+
sessionId: string,
|
|
38
|
+
): string {
|
|
39
|
+
return `${lane}:${sessionId}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LaneOccupancy {
|
|
43
|
+
inFlight: number;
|
|
44
|
+
queued: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ToolSchedulerSnapshot {
|
|
48
|
+
read: LaneOccupancy;
|
|
49
|
+
write: LaneOccupancy;
|
|
50
|
+
/** Summed over every session's frontend lane. */
|
|
51
|
+
frontend: LaneOccupancy;
|
|
52
|
+
/** Summed over every session's sub-agent lane. */
|
|
53
|
+
subagent: LaneOccupancy;
|
|
54
|
+
/**
|
|
55
|
+
* Per-session lanes by key (`sessionLaneKey`); only lanes with a call in
|
|
56
|
+
* flight or queued exist.
|
|
57
|
+
*/
|
|
58
|
+
sessionLanes: Record<string, LaneOccupancy>;
|
|
59
|
+
}
|
|
17
60
|
|
|
18
61
|
class Lane {
|
|
19
62
|
private active = 0;
|
|
@@ -65,6 +108,14 @@ class Lane {
|
|
|
65
108
|
get queued(): number {
|
|
66
109
|
return this.waiting.length;
|
|
67
110
|
}
|
|
111
|
+
|
|
112
|
+
get idle(): boolean {
|
|
113
|
+
return this.active === 0 && this.waiting.length === 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get occupancy(): LaneOccupancy {
|
|
117
|
+
return { inFlight: this.active, queued: this.waiting.length };
|
|
118
|
+
}
|
|
68
119
|
}
|
|
69
120
|
|
|
70
121
|
/** The signal's reason as an Error (an aborter may pass any value). */
|
|
@@ -86,40 +137,94 @@ export interface ToolSchedulerOptions {
|
|
|
86
137
|
}
|
|
87
138
|
|
|
88
139
|
export class ToolScheduler {
|
|
89
|
-
private readonly
|
|
140
|
+
private readonly read: Lane;
|
|
141
|
+
private readonly write = new Lane(1);
|
|
142
|
+
private readonly maxSubagents: number;
|
|
143
|
+
/** Per-session lanes by `sessionLaneKey`; an idle lane is removed. */
|
|
144
|
+
private readonly sessionLanes = new Map<
|
|
145
|
+
string,
|
|
146
|
+
{ kind: SessionToolLane; lane: Lane }
|
|
147
|
+
>();
|
|
90
148
|
|
|
91
149
|
constructor(options: ToolSchedulerOptions = {}) {
|
|
92
|
-
this.
|
|
93
|
-
|
|
94
|
-
read: new Lane(options.maxReads ?? 4),
|
|
95
|
-
subagent: new Lane(options.maxSubagents ?? 4),
|
|
96
|
-
};
|
|
150
|
+
this.read = new Lane(options.maxReads ?? 4);
|
|
151
|
+
this.maxSubagents = options.maxSubagents ?? 4;
|
|
97
152
|
}
|
|
98
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Run `action` once a slot of `lane` is free. `sessionId` keys the
|
|
156
|
+
* per-session lanes (`frontend`, `subagent`) and is required for them; the
|
|
157
|
+
* user-wide `read` and `write` lanes ignore it.
|
|
158
|
+
*/
|
|
99
159
|
async run<T>(
|
|
100
160
|
lane: ToolLane,
|
|
101
161
|
signal: AbortSignal | undefined,
|
|
102
162
|
action: () => Promise<T>,
|
|
163
|
+
sessionId?: string,
|
|
103
164
|
): Promise<T> {
|
|
104
|
-
const
|
|
105
|
-
|
|
165
|
+
const key = isSessionLane(lane)
|
|
166
|
+
? sessionLaneKey(lane, requireSessionId(lane, sessionId))
|
|
167
|
+
: undefined;
|
|
168
|
+
const slot = this.laneFor(lane, key);
|
|
169
|
+
try {
|
|
170
|
+
await slot.acquire(signal);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
this.dropIfIdle(key, slot);
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
106
175
|
try {
|
|
107
176
|
return await action();
|
|
108
177
|
} finally {
|
|
109
178
|
slot.release();
|
|
179
|
+
this.dropIfIdle(key, slot);
|
|
110
180
|
}
|
|
111
181
|
}
|
|
112
182
|
|
|
113
|
-
/** Occupancy per lane (diagnostics). */
|
|
114
|
-
snapshot():
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
queued:
|
|
118
|
-
|
|
183
|
+
/** Occupancy per lane (diagnostics: `GET /debug/runs`). */
|
|
184
|
+
snapshot(): ToolSchedulerSnapshot {
|
|
185
|
+
const sessionLanes: Record<string, LaneOccupancy> = {};
|
|
186
|
+
const summed: Record<SessionToolLane, LaneOccupancy> = {
|
|
187
|
+
frontend: { inFlight: 0, queued: 0 },
|
|
188
|
+
subagent: { inFlight: 0, queued: 0 },
|
|
189
|
+
};
|
|
190
|
+
for (const [key, { kind, lane }] of this.sessionLanes) {
|
|
191
|
+
sessionLanes[key] = lane.occupancy;
|
|
192
|
+
summed[kind].inFlight += lane.inFlight;
|
|
193
|
+
summed[kind].queued += lane.queued;
|
|
194
|
+
}
|
|
119
195
|
return {
|
|
120
|
-
read:
|
|
121
|
-
write:
|
|
122
|
-
|
|
196
|
+
read: this.read.occupancy,
|
|
197
|
+
write: this.write.occupancy,
|
|
198
|
+
frontend: summed.frontend,
|
|
199
|
+
subagent: summed.subagent,
|
|
200
|
+
sessionLanes,
|
|
123
201
|
};
|
|
124
202
|
}
|
|
203
|
+
|
|
204
|
+
private laneFor(lane: ToolLane, key: string | undefined): Lane {
|
|
205
|
+
if (!isSessionLane(lane) || key === undefined)
|
|
206
|
+
return lane === 'read' ? this.read : this.write;
|
|
207
|
+
const existing = this.sessionLanes.get(key);
|
|
208
|
+
if (existing) return existing.lane;
|
|
209
|
+
const created = new Lane(lane === 'frontend' ? 1 : this.maxSubagents);
|
|
210
|
+
this.sessionLanes.set(key, { kind: lane, lane: created });
|
|
211
|
+
return created;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private dropIfIdle(key: string | undefined, slot: Lane): void {
|
|
215
|
+
if (key === undefined || !slot.idle) return;
|
|
216
|
+
if (this.sessionLanes.get(key)?.lane === slot)
|
|
217
|
+
this.sessionLanes.delete(key);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function requireSessionId(
|
|
222
|
+
lane: SessionToolLane,
|
|
223
|
+
sessionId: string | undefined,
|
|
224
|
+
): string {
|
|
225
|
+
if (sessionId === undefined)
|
|
226
|
+
throw new TypeError(
|
|
227
|
+
`ToolScheduler.run: the ${lane} lane is kept per session; pass the call's sessionId.`,
|
|
228
|
+
);
|
|
229
|
+
return sessionId;
|
|
125
230
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user-wide view of runs: which of the user's chats are busy right now
|
|
3
|
+
* (`GET /runs/active`, and `activeRun` on the rows of `GET /sessions`).
|
|
4
|
+
* Task runs (`task:<id>` sessions) are not chats and stay out, as they stay
|
|
5
|
+
* out of the session list; Matrix and channel runs are the user's chats
|
|
6
|
+
* whichever client sent the message, and are in.
|
|
7
|
+
*/
|
|
8
|
+
import type { FrontendWaitingFor } from '../plugin-api/types';
|
|
9
|
+
import { TASK_SESSION_PREFIX } from '../tasks/scheduler';
|
|
10
|
+
import type { RunSummary, SessionSummary } from './contracts';
|
|
11
|
+
import { runSummaryOf } from './run-request';
|
|
12
|
+
import { ACTIVE_RUN_STATUSES, type RunRecord } from './run-store';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every active (queued / running / recovering) chat run, oldest first (run
|
|
16
|
+
* id breaks a tie). `live` are the coordinator's records, `stored` the
|
|
17
|
+
* store's active rows (`RunStore.listActive()`): a run in both is listed
|
|
18
|
+
* once with its live record, which is ahead of the row between writes (a
|
|
19
|
+
* dequeued run is `running` in memory before its row says so). After a boot
|
|
20
|
+
* whose recovery scan succeeded every active row is live and the host
|
|
21
|
+
* passes no `stored` rows: a run leaves the live map before its row is
|
|
22
|
+
* closed, so a row read then would still say it runs.
|
|
23
|
+
*/
|
|
24
|
+
export function activeChatRuns(
|
|
25
|
+
live: Iterable<RunRecord>,
|
|
26
|
+
stored: Iterable<RunRecord>,
|
|
27
|
+
): RunRecord[] {
|
|
28
|
+
const byRunId = new Map<string, RunRecord>();
|
|
29
|
+
for (const record of stored) byRunId.set(record.runId, record);
|
|
30
|
+
for (const record of live) byRunId.set(record.runId, record);
|
|
31
|
+
return [...byRunId.values()]
|
|
32
|
+
.filter(
|
|
33
|
+
(record) =>
|
|
34
|
+
ACTIVE_RUN_STATUSES.has(record.status) &&
|
|
35
|
+
!record.sessionId.startsWith(TASK_SESSION_PREFIX),
|
|
36
|
+
)
|
|
37
|
+
.sort(
|
|
38
|
+
(a, b) =>
|
|
39
|
+
(a.startedAt < b.startedAt ? -1 : a.startedAt > b.startedAt ? 1 : 0) ||
|
|
40
|
+
a.runId.localeCompare(b.runId),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The room a run's stored request names (`turn.roomId`), if any. */
|
|
45
|
+
export function storedTurnRoomId(request: string): string | undefined {
|
|
46
|
+
let parsed: unknown;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(request);
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const turn: unknown =
|
|
53
|
+
typeof parsed === 'object' && parsed !== null
|
|
54
|
+
? Reflect.get(parsed, 'turn')
|
|
55
|
+
: undefined;
|
|
56
|
+
const roomId: unknown =
|
|
57
|
+
typeof turn === 'object' && turn !== null
|
|
58
|
+
? Reflect.get(turn, 'roomId')
|
|
59
|
+
: undefined;
|
|
60
|
+
return typeof roomId === 'string' ? roomId : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The runs of the chats `GET /sessions` lists: with a main oracle room, only
|
|
65
|
+
* sessions in that room (`sessionRooms`: the room of each run's session
|
|
66
|
+
* that has a row). A run whose session has no row yet — a Matrix thread is
|
|
67
|
+
* recorded inside its first attempt — is placed by the room its request
|
|
68
|
+
* names; a Portal run without one is listed (the Portal opens its sessions
|
|
69
|
+
* in the main room). Without a main room every chat is listed, as
|
|
70
|
+
* `listSessions` lists every session then. Task runs are already out
|
|
71
|
+
* (`activeChatRuns`).
|
|
72
|
+
*/
|
|
73
|
+
export function listedChatRuns(
|
|
74
|
+
runs: readonly RunRecord[],
|
|
75
|
+
o: {
|
|
76
|
+
mainRoomId: string | null;
|
|
77
|
+
sessionRooms: ReadonlyMap<string, string | null>;
|
|
78
|
+
},
|
|
79
|
+
): RunRecord[] {
|
|
80
|
+
const { mainRoomId, sessionRooms } = o;
|
|
81
|
+
if (mainRoomId === null) return [...runs];
|
|
82
|
+
return runs.filter((run) => {
|
|
83
|
+
if (sessionRooms.has(run.sessionId))
|
|
84
|
+
return sessionRooms.get(run.sessionId) === mainRoomId;
|
|
85
|
+
const roomId = storedTurnRoomId(run.request);
|
|
86
|
+
if (roomId !== undefined) return roomId === mainRoomId;
|
|
87
|
+
return run.client === 'portal';
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `running` before `recovering` before `queued`, as `RunStore.activeForSession` ranks them. */
|
|
92
|
+
const STATUS_RANK: Partial<Record<RunRecord['status'], number>> = {
|
|
93
|
+
running: 0,
|
|
94
|
+
recovering: 1,
|
|
95
|
+
queued: 2,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The run each session is busy with, from `activeChatRuns` (oldest first):
|
|
100
|
+
* the running or recovering one, else the oldest queued one — the run
|
|
101
|
+
* `GET /sessions/:id/run` reports for that session.
|
|
102
|
+
*/
|
|
103
|
+
export function activeRunBySession(
|
|
104
|
+
runs: readonly RunRecord[],
|
|
105
|
+
): Map<string, RunRecord> {
|
|
106
|
+
const bySession = new Map<string, RunRecord>();
|
|
107
|
+
for (const run of runs) {
|
|
108
|
+
const current = bySession.get(run.sessionId);
|
|
109
|
+
if (
|
|
110
|
+
!current ||
|
|
111
|
+
(STATUS_RANK[run.status] ?? 3) < (STATUS_RANK[current.status] ?? 3)
|
|
112
|
+
)
|
|
113
|
+
bySession.set(run.sessionId, run);
|
|
114
|
+
}
|
|
115
|
+
return bySession;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The session's parked frontend call, if any (`ctx.frontend.parkedFor`):
|
|
120
|
+
* the object's live realtime state, never stored.
|
|
121
|
+
*/
|
|
122
|
+
export type ParkedCallLookup = (sessionId: string) => FrontendWaitingFor | null;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `summary` with `waitingFor` set when the run executes (`running` or
|
|
126
|
+
* `recovering`) and its session has a parked call: only the executing run
|
|
127
|
+
* of a session can have made one. No field at all otherwise (a queued run,
|
|
128
|
+
* a run that is over).
|
|
129
|
+
*/
|
|
130
|
+
export function withWaitingFor(
|
|
131
|
+
summary: RunSummary,
|
|
132
|
+
parkedFor?: ParkedCallLookup,
|
|
133
|
+
): RunSummary {
|
|
134
|
+
if (
|
|
135
|
+
!parkedFor ||
|
|
136
|
+
(summary.status !== 'running' && summary.status !== 'recovering')
|
|
137
|
+
)
|
|
138
|
+
return summary;
|
|
139
|
+
const waitingFor = parkedFor(summary.sessionId);
|
|
140
|
+
return waitingFor ? { ...summary, waitingFor } : summary;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A run as the user-wide listings report it: without the kept reply text. */
|
|
144
|
+
export function activeRunSummaryOf(
|
|
145
|
+
record: RunRecord,
|
|
146
|
+
parkedFor?: ParkedCallLookup,
|
|
147
|
+
): RunSummary {
|
|
148
|
+
return withWaitingFor(
|
|
149
|
+
runSummaryOf(record, { withPartialText: false }),
|
|
150
|
+
parkedFor,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Session rows with `activeRun` set on the ones that have an active run.
|
|
156
|
+
* A row without one gets no field at all (never `null`), so clients that
|
|
157
|
+
* predate it see the rows they always did.
|
|
158
|
+
*/
|
|
159
|
+
export function withActiveRuns(
|
|
160
|
+
sessions: readonly SessionSummary[],
|
|
161
|
+
runs: readonly RunRecord[],
|
|
162
|
+
parkedFor?: ParkedCallLookup,
|
|
163
|
+
): SessionSummary[] {
|
|
164
|
+
const bySession = activeRunBySession(runs);
|
|
165
|
+
return sessions.map((session) => {
|
|
166
|
+
const run = bySession.get(session.sessionId);
|
|
167
|
+
return run
|
|
168
|
+
? { ...session, activeRun: activeRunSummaryOf(run, parkedFor) }
|
|
169
|
+
: session;
|
|
170
|
+
});
|
|
171
|
+
}
|
package/src/do/contracts.ts
CHANGED
|
@@ -14,9 +14,11 @@ import type {
|
|
|
14
14
|
} from '@ixo/common/ai/decisions';
|
|
15
15
|
import type { TranscriptPageOptions } from './transcript';
|
|
16
16
|
import type { TurnUsage } from '../core/turn-budget';
|
|
17
|
+
import type { ToolSchedulerSnapshot } from '../core/tool-scheduler';
|
|
17
18
|
import type { BotStatus, EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
|
|
18
19
|
import type { AttachmentInput } from '../attachments/types';
|
|
19
20
|
import type { RealtimeStatus } from '../realtime/realtime-endpoint';
|
|
21
|
+
import type { FrontendWaitingFor } from '../plugin-api/types';
|
|
20
22
|
import type { TierFlushResult, TierStatus } from '../sqlite/do-vfs';
|
|
21
23
|
import type { OwnedArtifact } from '../artifacts/store';
|
|
22
24
|
import type {
|
|
@@ -251,6 +253,8 @@ export interface OracleWorkerEnv {
|
|
|
251
253
|
TURN_MAX_IDENTICAL_READS?: string;
|
|
252
254
|
/** Time limit per request-admission handler in ms (default 2000). */
|
|
253
255
|
REQUEST_ADMISSION_TIMEOUT_MS?: string;
|
|
256
|
+
/** How long a frontend call waits unsent for a browser, in ms (default 300000). */
|
|
257
|
+
FRONTEND_PARK_TIMEOUT_MS?: string;
|
|
254
258
|
/** Durable-run knobs (docs/plans/durable-runs.md); see `runDurabilityConfig`. */
|
|
255
259
|
RUN_KEEPALIVE_MS?: string;
|
|
256
260
|
RUN_SEGMENT_FLUSH_MS?: string;
|
|
@@ -405,7 +409,6 @@ export interface TurnRequest {
|
|
|
405
409
|
taskRunId?: string;
|
|
406
410
|
}
|
|
407
411
|
|
|
408
|
-
/** A run as the shell reports it (`GET /sessions/:id/run`, `GET /debug/runs`). */
|
|
409
412
|
/** `GET /debug/context` — the context budget a model gets on this object. */
|
|
410
413
|
/**
|
|
411
414
|
* Per-session context counters (`GET /debug/context?session=`): what the
|
|
@@ -500,6 +503,10 @@ export interface DomainContextRouterUpdate {
|
|
|
500
503
|
domainContext: DomainContextProvenance[];
|
|
501
504
|
}
|
|
502
505
|
|
|
506
|
+
/**
|
|
507
|
+
* A run as the shell reports it (`GET /sessions/:id/run`, `GET /runs/active`,
|
|
508
|
+
* `activeRun` on `GET /sessions` rows, `GET /debug/runs`).
|
|
509
|
+
*/
|
|
503
510
|
export interface RunSummary {
|
|
504
511
|
runId: string;
|
|
505
512
|
sessionId: string;
|
|
@@ -513,13 +520,27 @@ export interface RunSummary {
|
|
|
513
520
|
| 'aborted'
|
|
514
521
|
| 'interrupted'
|
|
515
522
|
| 'failed';
|
|
523
|
+
/**
|
|
524
|
+
* When the run's row was created (`RunStore.create`), unchanged when a
|
|
525
|
+
* queued run starts: a dequeued run's elapsed time includes its wait.
|
|
526
|
+
*/
|
|
516
527
|
startedAt: string;
|
|
517
528
|
updatedAt: string;
|
|
518
|
-
/**
|
|
529
|
+
/**
|
|
530
|
+
* Highest frame sequence packed when the row was last written; written
|
|
531
|
+
* when the run ends, so not a live cursor for an active run.
|
|
532
|
+
*/
|
|
519
533
|
lastSeq: number;
|
|
520
534
|
messageId?: string;
|
|
521
535
|
/** The reply so far, for a run that ended without a committed reply. */
|
|
522
536
|
partialText?: string;
|
|
537
|
+
/**
|
|
538
|
+
* The browser tool / AG-UI action the run is waiting on, parked because no
|
|
539
|
+
* browser of the session that can run it is connected: opening the chat
|
|
540
|
+
* sends it. Live state of this object only, never persisted; absent when
|
|
541
|
+
* the run waits on nothing.
|
|
542
|
+
*/
|
|
543
|
+
waitingFor?: FrontendWaitingFor;
|
|
523
544
|
}
|
|
524
545
|
|
|
525
546
|
/** `GET /debug/runs`: the durable-run configuration, live runs and recent rows. */
|
|
@@ -573,6 +594,12 @@ export interface RunsStatus {
|
|
|
573
594
|
startedAt: string;
|
|
574
595
|
state: 'pending' | 'warned';
|
|
575
596
|
}>;
|
|
597
|
+
/**
|
|
598
|
+
* Tool lanes of the object right now (tool-scheduler.ts): the user-wide
|
|
599
|
+
* `read` / `write` lanes, the per-session `frontend` and `subagent` lanes
|
|
600
|
+
* summed, and each busy per-session lane by key (`sessionLanes`).
|
|
601
|
+
*/
|
|
602
|
+
tools: ToolSchedulerSnapshot;
|
|
576
603
|
}
|
|
577
604
|
|
|
578
605
|
/** Non-streaming turn result. */
|
|
@@ -611,6 +638,12 @@ export interface SessionSummary {
|
|
|
611
638
|
createdAt: string;
|
|
612
639
|
lastUpdatedAt: string;
|
|
613
640
|
roomId?: string;
|
|
641
|
+
/**
|
|
642
|
+
* The run the session is busy with (queued / running / recovering), as
|
|
643
|
+
* `GET /runs/active` reports it: without `partialText`. Absent, never
|
|
644
|
+
* null, when the session has none.
|
|
645
|
+
*/
|
|
646
|
+
activeRun?: RunSummary;
|
|
614
647
|
}
|
|
615
648
|
|
|
616
649
|
/** `search_memory_engine`'s raw upstream schema + whether it converts to Zod. */
|
|
@@ -761,12 +794,23 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
|
|
|
761
794
|
target: FeedbackTarget,
|
|
762
795
|
outcome: FeedbackSettlement,
|
|
763
796
|
): Promise<void>;
|
|
764
|
-
|
|
797
|
+
/**
|
|
798
|
+
* Stop the session's current message. With `identity` (the HTTP route)
|
|
799
|
+
* the object boots first and the answer waits until the stopped runs
|
|
800
|
+
* have ended (at most the supersede grace).
|
|
801
|
+
*/
|
|
802
|
+
abortTurn(sessionId: string, identity?: TurnIdentity): Promise<boolean>;
|
|
765
803
|
/** The session's active (queued/running/recovering) run, if any. */
|
|
766
804
|
sessionRun(
|
|
767
805
|
identity: TurnIdentity,
|
|
768
806
|
sessionId: string,
|
|
769
807
|
): Promise<RunSummary | null>;
|
|
808
|
+
/**
|
|
809
|
+
* `GET /runs/active` — every active run of the chats `GET /sessions`
|
|
810
|
+
* lists (same main-room rule, task runs excluded), oldest first, without
|
|
811
|
+
* `partialText`.
|
|
812
|
+
*/
|
|
813
|
+
activeRuns(identity: TurnIdentity): Promise<RunSummary[]>;
|
|
770
814
|
/** Diagnostics: recent runs with their tool marks and live state. */
|
|
771
815
|
runsStatus(): Promise<RunsStatus>;
|
|
772
816
|
/** `GET /debug/context` — the context budget a model gets on this object. */
|
|
@@ -108,6 +108,8 @@ export interface RunCoordinatorHost {
|
|
|
108
108
|
checkpointIdOf: (sessionId: string) => Promise<string | null>;
|
|
109
109
|
/** After a run reached a terminal state and its row was closed. */
|
|
110
110
|
onRunEnded?: (record: RunRecord, outcome: RunOutcome) => Promise<void>;
|
|
111
|
+
/** Before a run's row is created (the host marks that runs may be active). */
|
|
112
|
+
beforeCreate?: () => Promise<void>;
|
|
111
113
|
/** How long a superseded or aborted attempt gets to wind down (default 5 s). */
|
|
112
114
|
supersedeGraceMs?: number;
|
|
113
115
|
}
|
|
@@ -215,6 +217,11 @@ export class RunCoordinator {
|
|
|
215
217
|
return candidate;
|
|
216
218
|
}
|
|
217
219
|
|
|
220
|
+
/** The record of every run this coordinator holds (not ended yet). */
|
|
221
|
+
records(): RunRecord[] {
|
|
222
|
+
return [...this.live.values()].map((run) => run.record);
|
|
223
|
+
}
|
|
224
|
+
|
|
218
225
|
byRequestId(requestId: string): LiveRun | undefined {
|
|
219
226
|
for (const run of this.live.values())
|
|
220
227
|
if (run.requestId === requestId) return run;
|
|
@@ -367,6 +374,7 @@ export class RunCoordinator {
|
|
|
367
374
|
}
|
|
368
375
|
}
|
|
369
376
|
const checkpointId = await this.host.checkpointIdOf(sessionId);
|
|
377
|
+
await this.host.beforeCreate?.();
|
|
370
378
|
const record = await this.host.store.create({
|
|
371
379
|
runId: input.runId,
|
|
372
380
|
sessionId,
|
|
@@ -423,6 +431,28 @@ export class RunCoordinator {
|
|
|
423
431
|
return true;
|
|
424
432
|
}
|
|
425
433
|
|
|
434
|
+
/**
|
|
435
|
+
* `abortSession`, resolving once every run it stopped has ended — out of
|
|
436
|
+
* the live map, its row closed — or after the supersede grace, whichever
|
|
437
|
+
* comes first. Runs enqueued behind the active one are not stopped and
|
|
438
|
+
* not waited for.
|
|
439
|
+
*/
|
|
440
|
+
async abortSessionSettled(sessionId: string): Promise<boolean> {
|
|
441
|
+
const before = [...this.live.values()].filter(
|
|
442
|
+
(run) => run.sessionId === sessionId,
|
|
443
|
+
);
|
|
444
|
+
const stopped = this.abortSession(sessionId);
|
|
445
|
+
const ending = before.filter(
|
|
446
|
+
(run) => this.live.get(run.runId) !== run || run.abort.signal.aborted,
|
|
447
|
+
);
|
|
448
|
+
if (ending.length > 0)
|
|
449
|
+
await withinGrace(
|
|
450
|
+
this.graceMs,
|
|
451
|
+
Promise.all(ending.map((run) => run.done)),
|
|
452
|
+
);
|
|
453
|
+
return stopped;
|
|
454
|
+
}
|
|
455
|
+
|
|
426
456
|
/**
|
|
427
457
|
* End every run of the session: the executing attempt is aborted, queued
|
|
428
458
|
* runs and runs waiting for a recovery attempt are closed, and messages
|
package/src/do/run-request.ts
CHANGED
|
@@ -78,7 +78,20 @@ export function clientSurfaceFor(
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export
|
|
81
|
+
export interface RunSummaryOptions {
|
|
82
|
+
/**
|
|
83
|
+
* Carry the run's kept reply text (default true). The user-wide listings
|
|
84
|
+
* (`GET /runs/active`, `activeRun` on session rows) pass false: the text
|
|
85
|
+
* can be large and a client polling every chat does not need it; the
|
|
86
|
+
* session's own `GET /sessions/:id/run` still has it.
|
|
87
|
+
*/
|
|
88
|
+
withPartialText?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function runSummaryOf(
|
|
92
|
+
record: RunRecord,
|
|
93
|
+
{ withPartialText = true }: RunSummaryOptions = {},
|
|
94
|
+
): RunSummary {
|
|
82
95
|
return {
|
|
83
96
|
runId: record.runId,
|
|
84
97
|
sessionId: record.sessionId,
|
|
@@ -89,7 +102,7 @@ export function runSummaryOf(record: RunRecord): RunSummary {
|
|
|
89
102
|
updatedAt: record.updatedAt,
|
|
90
103
|
lastSeq: record.lastSeq,
|
|
91
104
|
...(record.messageId ? { messageId: record.messageId } : {}),
|
|
92
|
-
...(record.partialText && record.status !== 'finished'
|
|
105
|
+
...(withPartialText && record.partialText && record.status !== 'finished'
|
|
93
106
|
? { partialText: record.partialText }
|
|
94
107
|
: {}),
|
|
95
108
|
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { DurableObject } from 'cloudflare:workers';
|
|
7
7
|
import { DoSqliteDatabase } from '../sqlite/database';
|
|
8
|
+
import { activeChatRuns } from './active-runs';
|
|
8
9
|
import type { RunSummary } from './contracts';
|
|
9
10
|
import type { PackedSegment } from './run-buffer';
|
|
10
11
|
import { RunCoordinator } from './run-coordinator';
|
|
@@ -172,6 +173,11 @@ export class RunStoreTestDO extends DurableObject {
|
|
|
172
173
|
return (await this.runStore()).listActive();
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
/** `activeChatRuns` over the given live records and this store's `listActive()`. */
|
|
177
|
+
async activeChatRuns(live: RunRecord[]): Promise<RunRecord[]> {
|
|
178
|
+
return activeChatRuns(live, await (await this.runStore()).listActive());
|
|
179
|
+
}
|
|
180
|
+
|
|
175
181
|
async queuedForSession(sessionId: string): Promise<RunRecord[]> {
|
|
176
182
|
return (await this.runStore()).queuedForSession(sessionId);
|
|
177
183
|
}
|
|
@@ -202,6 +208,10 @@ export class RunStoreTestDO extends DurableObject {
|
|
|
202
208
|
await (await this.runStore()).bumpMark(runId, toolCallId);
|
|
203
209
|
}
|
|
204
210
|
|
|
211
|
+
async markSent(runId: string, toolCallId: string): Promise<void> {
|
|
212
|
+
await (await this.runStore()).markSent(runId, toolCallId);
|
|
213
|
+
}
|
|
214
|
+
|
|
205
215
|
async finishMark(
|
|
206
216
|
runId: string,
|
|
207
217
|
toolCallId: string,
|