@matatbread/matbot-core 0.4.17 → 0.4.19
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 +2 -2
- package/src/assemble.ts +25 -4
- package/src/ephemeral-turn.ts +67 -0
- package/src/index.ts +3 -1
- package/src/providers-base/index.ts +1 -0
- package/src/providers-base/text-document.ts +55 -0
- package/src/runner.ts +75 -3
- package/src/session-append.ts +77 -0
- package/src/session-runner.ts +621 -88
- package/src/session.ts +7 -8
- package/src/storage-base/index.ts +1 -0
- package/src/storage-base/memory-store.ts +34 -0
package/src/session-runner.ts
CHANGED
|
@@ -6,12 +6,13 @@ import type {
|
|
|
6
6
|
} from './types.js';
|
|
7
7
|
import type { MatbotPlugin } from './plugin.js';
|
|
8
8
|
import type { HookRegistry } from './hooks.js';
|
|
9
|
-
import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
9
|
+
import type { ToolTypeIndex, ToolPresenter, SessionAppender, SessionWrite } from '@matatbread/matbot-plugin-api';
|
|
10
10
|
import { appendMessage, createMessage } from './session.js';
|
|
11
|
-
import { isReadOnlyError, foldOntoUserTurn, lastUserIndex, runAs } from '@matatbread/matbot-plugin-api';
|
|
11
|
+
import { isReadOnlyError, foldOntoUserTurn, lastUserIndex, runAs, tryCurrentPrincipal } from '@matatbread/matbot-plugin-api';
|
|
12
12
|
import { machineBusy, withUsageScope } from '@matatbread/matbot-plugin-api/host';
|
|
13
13
|
import { runSession } from './runner.js';
|
|
14
14
|
import { ingestMedia } from './media.js';
|
|
15
|
+
import { MemoryStore } from './storage-base/memory-store.js';
|
|
15
16
|
|
|
16
17
|
export interface SessionRunnerDeps {
|
|
17
18
|
store: Store<Session>;
|
|
@@ -41,6 +42,14 @@ export interface SessionRunnerDeps {
|
|
|
41
42
|
systemContext?: SystemContextRegistry;
|
|
42
43
|
vault?: Vault;
|
|
43
44
|
files?: FileStore;
|
|
45
|
+
// Only for a runner over a store the machine's appender cannot reach — see `MatbotRuntime.ephemeral`.
|
|
46
|
+
appender?: SessionAppender;
|
|
47
|
+
// Set only for a parallel turn's runner, whose whole life falls inside the hold of the pump that
|
|
48
|
+
// admitted it (that pump waits for its parallel turns before it releases). So this runner's pump takes
|
|
49
|
+
// no hold of its own. Taking one would gain nothing, since nothing deferred can land while the outer
|
|
50
|
+
// hold is up. And it would cost the full admit timeout whenever anything is staged, because the
|
|
51
|
+
// barrier would be waiting for a drain that its own parent prevents.
|
|
52
|
+
heldByCreator?: true;
|
|
44
53
|
workdir?: string;
|
|
45
54
|
configPath?: string;
|
|
46
55
|
loadPlugin: (specifier: string, prompt?: PromptFn, refresh?: boolean) => Promise<MatbotPlugin>;
|
|
@@ -64,7 +73,8 @@ interface QueuedItem {
|
|
|
64
73
|
// than introducing a new user message: the pump skips the persist-at-turn-start append (the user
|
|
65
74
|
// message already exists) and hands `ephemeral` (the trigger tool's output) to runSession, which
|
|
66
75
|
// tail-folds it for this run only. `content` is empty for such an item — there is no new bubble.
|
|
67
|
-
|
|
76
|
+
// `head` is the user message the retraction kept (see `SessionState.turnHead`), absent if there was none.
|
|
77
|
+
redo?: { ephemeral: MessageContent[]; head?: { id: string; request: string } };
|
|
68
78
|
// Turn-scoped ephemeral context to fold onto this item's turn (tail-folded, never persisted) — used
|
|
69
79
|
// to carry an interrupt's "keep going, noting the above" nudge. Feeds runSession's injectedEphemeral,
|
|
70
80
|
// the same path `redo` uses; the two are mutually exclusive in practice (redo re-runs an existing
|
|
@@ -75,6 +85,11 @@ interface QueuedItem {
|
|
|
75
85
|
|
|
76
86
|
const MAX_RESUBMIT_DEPTH = 8;
|
|
77
87
|
|
|
88
|
+
// Tries a write gets (see `SessionRunner.write`). Writes of one session are applied one at a time, so a lost
|
|
89
|
+
// compare-and-swap means a writer outside this runner — another process on the same store, a storage swap
|
|
90
|
+
// `mediumGuard` refused — and reading again composes with it. The bound only stops a pathological loop.
|
|
91
|
+
const WRITE_ATTEMPTS = 3;
|
|
92
|
+
|
|
78
93
|
// Disposition for a `mode: 'auto'` mid-turn submission when no SteeringPolicy is registered (or it
|
|
79
94
|
// declares no `classify`). Interrupt-by-default: a message sent while the agent works stops the
|
|
80
95
|
// running turn (its committed partial work is preserved) and runs next. Flip to 'queue' to make the
|
|
@@ -89,6 +104,59 @@ const DEFAULT_STEER_NUDGE: MessageContent[] = [{
|
|
|
89
104
|
text: 'The user sent the message above while you were working. Take it into account and keep going — continue the task you were on, incorporating this new input rather than discarding your progress.',
|
|
90
105
|
}];
|
|
91
106
|
|
|
107
|
+
// Folded onto a parallel turn's message in ITS copy only (never written back): without it, a model asked
|
|
108
|
+
// "how's it going?" mid-task sees a history that ends a turn early and answers as though nothing were
|
|
109
|
+
// running. Names the running request so the reply can be about it, and says what happens to the reply.
|
|
110
|
+
const PARALLEL_FRAMING_CAP = 2_000;
|
|
111
|
+
const parallelFraming = (running: string): Extract<UserContent, { type: 'text' }> => ({
|
|
112
|
+
type: 'text',
|
|
113
|
+
origin: 'robo',
|
|
114
|
+
text: 'A separate turn of this conversation is still working on the request below, and you cannot see its '
|
|
115
|
+
+ 'progress or results. Answer the message above on its own terms; your final reply is added to the '
|
|
116
|
+
+ 'conversation that turn sees.\n\n<running-request>\n'
|
|
117
|
+
+ (running.length > PARALLEL_FRAMING_CAP ? `${running.slice(0, PARALLEL_FRAMING_CAP)}…` : running)
|
|
118
|
+
+ '\n</running-request>',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// A parallel turn's span as it can be placed into shared history: trimmed back to the last point at
|
|
122
|
+
// which every tool-call in it has its result. An abort (or a failure mid-round) leaves a trailing
|
|
123
|
+
// tool-call with nothing answering it, and an unpaired call is not valid wire for any provider — it
|
|
124
|
+
// would break not just this placement but every later turn of the session.
|
|
125
|
+
const closedSpan = (msgs: Message[]): Message[] => {
|
|
126
|
+
const open = new Set<string>();
|
|
127
|
+
let closed = 0;
|
|
128
|
+
for (const [i, m] of msgs.entries()) {
|
|
129
|
+
for (const c of m.content) {
|
|
130
|
+
if (c.type === 'tool-call') open.add(c.id);
|
|
131
|
+
else if (c.type === 'tool-result') open.delete(c.id);
|
|
132
|
+
}
|
|
133
|
+
if (open.size === 0) closed = i + 1;
|
|
134
|
+
}
|
|
135
|
+
return msgs.slice(0, closed);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// A parallel turn's write-back: its submission and everything the turn produced, ready to place. Built
|
|
139
|
+
// once, when the parallel turn settles; placed by whoever holds the session at that moment (see
|
|
140
|
+
// `merges`).
|
|
141
|
+
interface ParallelReply {
|
|
142
|
+
traceId: string;
|
|
143
|
+
/** The turn's head first, then its whole span — thinking, tool calls, their results, its markers and
|
|
144
|
+
* its final reply. Non-empty by construction, which is what keeps `messages[0]` the head: every
|
|
145
|
+
* placement path dedupes on that id. */
|
|
146
|
+
messages: [Message, ...Message[]];
|
|
147
|
+
/** The id of the message this reply's copy was cut AT — the running turn's head when it was submitted,
|
|
148
|
+
* captured then. The pair belongs immediately ahead of it, because that is where the history the reply
|
|
149
|
+
* was generated against ended. Absent ⇒ the copy was cut at the end of the session (nothing was
|
|
150
|
+
* running, or the turn's rounds were already over and only its `followup` hooks were left), so the
|
|
151
|
+
* pair belongs at the TAIL and there is no id to place it against. */
|
|
152
|
+
head?: string;
|
|
153
|
+
/** Written to the store by `placeNow` when it settled, rather than left for the running turn's next
|
|
154
|
+
* round boundary. It stays on `merges` all the same, because the running turn's in-memory session
|
|
155
|
+
* must still learn of it or that turn's whole-document write-back erases it — but it must never be
|
|
156
|
+
* written or announced a second time, which is what every reader of this flag is for. */
|
|
157
|
+
persisted?: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
92
160
|
// Marker creator for a retract-and-rerun: its `data.retracted` carries the popped (superseded) turn
|
|
93
161
|
// messages so a frontend can render them struck-through and a post-mortem can audit them. Core-owned
|
|
94
162
|
// because the pop is a pump operation, not a plugin's.
|
|
@@ -117,6 +185,32 @@ interface SessionState {
|
|
|
117
185
|
// appends to the very array captured here, so a late arrival needs no coordination: whatever is in
|
|
118
186
|
// these arrays at the flush is what gets written, and each entry says which turn caused it.
|
|
119
187
|
pendingUsage: TurnEntry[][];
|
|
188
|
+
// ── parallel turns (`mode: 'parallel'`) ──
|
|
189
|
+
// Each in flight, by traceId: its own replay (it outlives the running turn's, which is cleared at every
|
|
190
|
+
// boundary) and how to stop it.
|
|
191
|
+
parallel: Map<string, { replay: PipelineEvent[]; stop: AbortController }>;
|
|
192
|
+
// Settled replies awaiting placement. Drained by whichever holder reaches a safe point first: the
|
|
193
|
+
// running turn at its next round boundary (`interject`), else the pump between turns. Never the
|
|
194
|
+
// SessionAppender — that writes at the quiescent edge, which cannot arrive until the pump's whole
|
|
195
|
+
// queue drains, and the point of a parallel turn is not waiting for that.
|
|
196
|
+
merges: ParallelReply[];
|
|
197
|
+
// The running turn's first own message (its user message; for a redo, the kept one): its id is where
|
|
198
|
+
// the copy a parallel turn runs on ends, and so where its pair is placed while this turn runs; its text
|
|
199
|
+
// is the request that copy's framing names. Set together with `runningTraceId`, in the step that takes
|
|
200
|
+
// the item off the queue: `runningTraceId` is what admits a parallel turn, so set any later — even after
|
|
201
|
+
// the store read that follows — one arriving in between had no head, ran unframed, and copied the turn
|
|
202
|
+
// it was beside. Cleared once the turn's rounds end, because from then
|
|
203
|
+
// on the turn has answered, and a parallel turn should see the answer rather than be told it is
|
|
204
|
+
// still being worked on.
|
|
205
|
+
turnHead: { id: string; request: string } | undefined;
|
|
206
|
+
// Replies placed into the running turn's in-memory copy and not yet committed: a parallel turn started
|
|
207
|
+
// now must see them, and they reach the store only when that turn ends.
|
|
208
|
+
interjected: ParallelReply[];
|
|
209
|
+
// Set while the pump has run out of queue but still holds the machine for a parallel turn: resolving it
|
|
210
|
+
// resumes the loop.
|
|
211
|
+
wake: (() => void) | undefined;
|
|
212
|
+
// Writes from outside the turns (`SessionRunner.write`), waiting for a moment no turn holds the session.
|
|
213
|
+
writes: Array<() => Promise<void>>;
|
|
120
214
|
}
|
|
121
215
|
|
|
122
216
|
interface Sink {
|
|
@@ -177,20 +271,29 @@ export function wireDescription(description: string, wc: { params: string; resul
|
|
|
177
271
|
return `${description}\n\n${block('TypeScript params', wc.params)}\n\n${block('TypeScript result', wc.result)}`;
|
|
178
272
|
}
|
|
179
273
|
|
|
274
|
+
const textOf = (m: Message): string =>
|
|
275
|
+
m.content.filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text').map(c => c.text).join('\n');
|
|
276
|
+
|
|
277
|
+
// Back across a submission boundary: `ingestMedia` has already turned media into refs, so only these arms
|
|
278
|
+
// remain, and the nested runner re-checks the refs against this same session.
|
|
279
|
+
const asSubmitted = (content: readonly MessageContent[]): UserContent[] =>
|
|
280
|
+
content.flatMap((c): UserContent[] => c.type === 'text' || c.type === 'file-ref' || c.type === 'form-response' ? [c] : []);
|
|
281
|
+
|
|
180
282
|
export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
181
283
|
const states = new Map<string, SessionState>();
|
|
182
284
|
|
|
183
285
|
const stateFor = (id: string): SessionState => {
|
|
184
286
|
let s = states.get(id);
|
|
185
287
|
if (s === undefined) {
|
|
186
|
-
s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [], pendingUsage: []
|
|
288
|
+
s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [], pendingUsage: [],
|
|
289
|
+
parallel: new Map(), merges: [], turnHead: undefined, interjected: [], wake: undefined, writes: [] };
|
|
187
290
|
states.set(id, s);
|
|
188
291
|
}
|
|
189
292
|
return s;
|
|
190
293
|
};
|
|
191
294
|
|
|
192
295
|
const maybeCleanup = (id: string, s: SessionState): void => {
|
|
193
|
-
if (!s.running && s.queue.length === 0 && s.subscribers.size === 0) states.delete(id);
|
|
296
|
+
if (!s.running && s.queue.length === 0 && s.subscribers.size === 0 && s.parallel.size === 0 && s.merges.length === 0 && s.writes.length === 0) states.delete(id);
|
|
194
297
|
};
|
|
195
298
|
|
|
196
299
|
// Turn events go to the replay buffer (so a mid-flight subscriber sees the in-progress turn) and
|
|
@@ -267,11 +370,147 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
267
370
|
await deps.store.set(id, { ...session, messages });
|
|
268
371
|
};
|
|
269
372
|
|
|
373
|
+
// Between turns: nothing holds the session but the pump itself, so a settled parallel reply is simply
|
|
374
|
+
// appended — after the turn it ran beside, which by now has committed.
|
|
375
|
+
const drainMerges = async (id: string, s: SessionState): Promise<void> => {
|
|
376
|
+
if (s.merges.length === 0) return;
|
|
377
|
+
// One `placeNow` already wrote is in the store and was announced then; it stayed on the list only so
|
|
378
|
+
// a running turn's in-memory session would pick it up, and there is no running turn here.
|
|
379
|
+
const taken = s.merges.splice(0).filter(r => r.persisted !== true);
|
|
380
|
+
if (taken.length === 0) return;
|
|
381
|
+
const session = await deps.store.get(id);
|
|
382
|
+
if (session === null) return;
|
|
383
|
+
// And one whose `placeNow` is still in flight has no flag set yet, so the id is the authority: every
|
|
384
|
+
// placement path is idempotent on it, and whichever gets there first is the one that announces.
|
|
385
|
+
const have = new Set(session.messages.map(m => m.id));
|
|
386
|
+
const replies = taken.filter(r => !have.has(r.messages[0].id));
|
|
387
|
+
if (replies.length === 0) return;
|
|
388
|
+
try {
|
|
389
|
+
await deps.store.set(id, replies.flatMap(r => r.messages).reduce(appendMessage, session));
|
|
390
|
+
} catch (e) {
|
|
391
|
+
if (!isReadOnlyError(e)) throw e;
|
|
392
|
+
for (const r of replies) notify(s, { type: 'error', error: e.message, traceId: r.traceId });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
for (const r of replies) notify(s, { type: 'merged', traceId: r.traceId });
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// Between turns, too: the writes that were waiting for no turn to hold the session. One at a time, in
|
|
399
|
+
// the order they came, each reading the document for itself, so each sees the one before it — two
|
|
400
|
+
// writes of one session never race each other. Never throws (see `write`).
|
|
401
|
+
const drainWrites = async (s: SessionState): Promise<void> => {
|
|
402
|
+
for (let w = s.writes.shift(); w !== undefined; w = s.writes.shift()) await w();
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// While a turn runs: called by runSession at the top of each round. The pair goes AHEAD of the running
|
|
406
|
+
// turn's own messages, where the copy the parallel turn ran on ended — so the history stays exactly
|
|
407
|
+
// what that reply was generated against, and alternates without help. At the tail, after the latest
|
|
408
|
+
// tool result, the reply would be the last message of the turn's next provider call (a prefill), and
|
|
409
|
+
// once the turn answered, two adjacent assistant messages, which the adapters fold into one — putting
|
|
410
|
+
// text ahead of a `thinking` block. Each later pair lands after the earlier ones, so they stay in order.
|
|
411
|
+
const interjectInto = (s: SessionState) => (cur: Session): { session: Session; merged: string[] } | undefined => {
|
|
412
|
+
if (s.merges.length === 0 || s.turnHead === undefined) return undefined;
|
|
413
|
+
const head = s.turnHead.id;
|
|
414
|
+
const at = cur.messages.findIndex(m => m.id === head);
|
|
415
|
+
if (at < 0) return undefined;
|
|
416
|
+
const replies = s.merges.splice(0);
|
|
417
|
+
s.interjected.push(...replies);
|
|
418
|
+
// Only what is not already here. `placeNow` may have written a pair while a PREVIOUS turn's followup
|
|
419
|
+
// hooks ran, and this turn (a redo, or whatever followup enqueued) then read a session that already
|
|
420
|
+
// contains it — so splicing the list wholesale would duplicate the pair. The id is the authority, the
|
|
421
|
+
// same way it is in `placeNow` and `drainMerges`.
|
|
422
|
+
const have = new Set(cur.messages.map(m => m.id));
|
|
423
|
+
const add = replies.flatMap(r => r.messages).filter(m => !have.has(m.id));
|
|
424
|
+
return {
|
|
425
|
+
session: { ...cur, messages: [...cur.messages.slice(0, at), ...add, ...cur.messages.slice(at)] },
|
|
426
|
+
// A reply `placeNow` wrote announced itself then. It still has to reach the turn's in-memory
|
|
427
|
+
// session — that is the whole reason it is on this list — but announcing it twice would make a
|
|
428
|
+
// frontend place the same pair twice.
|
|
429
|
+
merged: replies.filter(r => r.persisted !== true).map(r => r.traceId),
|
|
430
|
+
};
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* A whole-document rewrite of a session whose turn has already committed — the `followup` writes. The
|
|
435
|
+
* pump used to be the only writer at that point, so an unconditional `set` was safe; it no longer is,
|
|
436
|
+
* because `placeNow` may insert a settled parallel pair while the hooks are judging. So these compare
|
|
437
|
+
* against the document the hooks were shown and rebuild from `current` if it moved, which costs no read
|
|
438
|
+
* at all on the uncontended path — a re-read would cost one per turn, and would also consume the next
|
|
439
|
+
* `store.get`, which is a thing tests legitimately hold.
|
|
440
|
+
*
|
|
441
|
+
* `build` is called once per attempt and must therefore derive everything from the document it is given.
|
|
442
|
+
* Giving up is logged rather than thrown: the turn has committed, and the caller decides what a write it
|
|
443
|
+
* could not make means (the retraction does not enqueue a redo it never wrote).
|
|
444
|
+
*/
|
|
445
|
+
const rewrite = async (
|
|
446
|
+
id: string, from: Session, build: (cur: Session) => Session, lost: string,
|
|
447
|
+
): Promise<boolean> => {
|
|
448
|
+
let cur = from;
|
|
449
|
+
for (let n = 0; n < WRITE_ATTEMPTS; n++) {
|
|
450
|
+
const res = await deps.store.cas(id, cur.version, build(cur));
|
|
451
|
+
if (res.ok) return true;
|
|
452
|
+
if (res.current === null) break;
|
|
453
|
+
cur = res.current;
|
|
454
|
+
}
|
|
455
|
+
console.error(lost);
|
|
456
|
+
return false;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Place a settled parallel reply in the store NOW, while a turn is still running, rather than leaving
|
|
461
|
+
* it to that turn's next round boundary. A turn parked in one long tool call reaches no boundary, so
|
|
462
|
+
* the pair used to exist in no store, no replay and no stream until the tool returned — a client that
|
|
463
|
+
* reconnected in that window could not see it at all, and the pair is a COMPLETED turn, not a partial
|
|
464
|
+
* one: nothing about it is waiting to be finished.
|
|
465
|
+
*
|
|
466
|
+
* The runner still owns the session exclusively. The pair is handed to the running turn as well
|
|
467
|
+
* (`interjectInto`, and the final pull in `end`), so that turn's whole-document write-back carries it
|
|
468
|
+
* instead of erasing it. Placed ahead of the running turn's head, exactly where `interjectInto` puts
|
|
469
|
+
* it, so the two never disagree about where the pair goes.
|
|
470
|
+
*
|
|
471
|
+
* CAS rather than `set`, because the running turn commits with a whole-document write of its own:
|
|
472
|
+
* losing the compare is how we learn it landed, and `current` is what it wrote.
|
|
473
|
+
*
|
|
474
|
+
* Three outcomes, because the pair is on `merges` before this runs and so EVERY placement path may
|
|
475
|
+
* reach it — this one, `interjectInto` at a round boundary, `drainMerges` once the turn is over. They
|
|
476
|
+
* are mutually idempotent on the pair's own message id, and exactly one of them inserts it: `present`
|
|
477
|
+
* says somebody else did, and is why this does not announce a `merged` the other path already sent.
|
|
478
|
+
*/
|
|
479
|
+
const placeNow = async (id: string, reply: ParallelReply): Promise<'inserted' | 'present' | 'no'> => {
|
|
480
|
+
if (reply.head === undefined) return 'no';
|
|
481
|
+
let cur = await deps.store.get(id);
|
|
482
|
+
for (let n = 0; n < WRITE_ATTEMPTS; n++) {
|
|
483
|
+
if (cur === null) return 'no';
|
|
484
|
+
if (cur.messages.some(m => m.id === reply.messages[0].id)) return 'present';
|
|
485
|
+
// The reply's OWN head, not the session's current one: a parallel turn submitted during a turn's
|
|
486
|
+
// `followup` ran on a copy that INCLUDED that finished turn, so its pair belongs after it, and
|
|
487
|
+
// `s.turnHead` would have put it in front of the very history it answered. Resolved per attempt,
|
|
488
|
+
// since a lost compare hands us a different document.
|
|
489
|
+
const at = cur.messages.findIndex(m => m.id === reply.head);
|
|
490
|
+
// The head is not in the store yet (it is persisted just after it is set), or a retract has since
|
|
491
|
+
// popped it: either way there is nothing here to place against. Leave it to the turn or the pump.
|
|
492
|
+
if (at < 0) return 'no';
|
|
493
|
+
const next = { ...cur, messages: [...cur.messages.slice(0, at), ...reply.messages, ...cur.messages.slice(at)] };
|
|
494
|
+
try {
|
|
495
|
+
const res = await deps.store.cas(id, cur.version, next);
|
|
496
|
+
if (res.ok) return 'inserted';
|
|
497
|
+
cur = res.current;
|
|
498
|
+
} catch (e) {
|
|
499
|
+
// A shared session this principal may only read. The turn's own placement reports it (drainMerges
|
|
500
|
+
// / the turn's commit); claiming nothing was written is what lets that path run unchanged.
|
|
501
|
+
if (!isReadOnlyError(e)) throw e;
|
|
502
|
+
return 'no';
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return 'no';
|
|
506
|
+
};
|
|
507
|
+
|
|
270
508
|
const pump = async (id: string, s: SessionState): Promise<void> => {
|
|
271
|
-
|
|
509
|
+
// Already pumping: it picks this up, though it may be waiting for a parallel turn and need waking.
|
|
510
|
+
if (s.running) { const wake = s.wake; s.wake = undefined; wake?.(); return; }
|
|
272
511
|
s.running = true;
|
|
273
512
|
// Hold the machine for the WHOLE queue, not per turn. A deferred machine mutation (the
|
|
274
|
-
// StorageBackend swap, a plugin
|
|
513
|
+
// StorageBackend swap, the remounts a plugin load or unload stages) may only land where no
|
|
275
514
|
// operation spans it — and the pump's own store work does not stop at a turn's end: it reads the
|
|
276
515
|
// committed document back for followup, appends markers to it, rewrites it for a retract, and
|
|
277
516
|
// persists the next turn's user message before that turn opens. All of that sits between turns,
|
|
@@ -279,15 +518,45 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
279
518
|
// usage flushes at, for the same reason. `machineBusy` releases on every exit including a throw,
|
|
280
519
|
// so no early return out of the loop below can leave the machine held.
|
|
281
520
|
//
|
|
282
|
-
// It also waits for deferred work to land before taking the hold,
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
521
|
+
// It also waits for deferred machine work to land before taking the hold, so a staged swap is in place
|
|
522
|
+
// before the loop below reads anything. That wait used to be spelled at this call site, and spelling
|
|
523
|
+
// it there made it something the next caller of `machineBusy` could omit without any symptom — so it
|
|
524
|
+
// moved inside the hold it guards.
|
|
525
|
+
//
|
|
526
|
+
// Writes of this session from outside its turns are not machine work, and do not wait for the edge:
|
|
527
|
+
// this loop applies them itself, between its turns (`drainWrites`), so they wait only for this
|
|
528
|
+
// session's turns, and the next turn reads what they wrote.
|
|
529
|
+
//
|
|
530
|
+
// The parallel turns this pump admits are part of the same operation. Their tools use the machine
|
|
531
|
+
// just as the queue's turns do, so the hold stays up until they have settled too (see the wait in the
|
|
532
|
+
// loop below), and their own runner takes none (`SessionRunnerDeps.heldByCreator`).
|
|
533
|
+
const drain = async (): Promise<void> => {
|
|
289
534
|
try {
|
|
290
|
-
|
|
535
|
+
for (;;) {
|
|
536
|
+
// Not ahead of a redo. A redo re-runs the turn just retracted, found as the session's LAST user
|
|
537
|
+
// message, so a pair appended here would be re-run in its place, on a history ending in an
|
|
538
|
+
// assistant message. Left in `merges`, the pair is placed by the redo's first round instead,
|
|
539
|
+
// ahead of the kept user message, which is where the copy it ran on ended. Writes wait for the
|
|
540
|
+
// redo to end, for the same reason and because one could remove the message it re-runs.
|
|
541
|
+
//
|
|
542
|
+
// Writes before replies: a cut queued by the turn just ended drops everything from its anchor
|
|
543
|
+
// to the end, so a parallel reply appended first would go with it.
|
|
544
|
+
if (s.queue[0]?.redo === undefined) {
|
|
545
|
+
await drainWrites(s);
|
|
546
|
+
await drainMerges(id, s);
|
|
547
|
+
// One that arrived while the other was writing. Its arrival found the pump busy, so nothing
|
|
548
|
+
// else will come round for it; going on would leave it unwritten, and the session busy.
|
|
549
|
+
if (s.writes.length > 0 || s.merges.length > 0) continue;
|
|
550
|
+
}
|
|
551
|
+
if (s.queue.length === 0) {
|
|
552
|
+
if (s.parallel.size === 0) break;
|
|
553
|
+
// A parallel turn is still out, and this hold covers it. Woken by its settling (its reply is
|
|
554
|
+
// then placed at the top of the loop) or by a new submission. No turn is running meanwhile,
|
|
555
|
+
// so an interrupt or a parallel submission arriving now queues as an ordinary turn.
|
|
556
|
+
s.runningTraceId = undefined;
|
|
557
|
+
await new Promise<void>(resolve => { s.wake = resolve; });
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
291
560
|
// Per-submission concat: the head always runs; if the head is a concat submission it absorbs
|
|
292
561
|
// the following submissions while they too are concat, stopping at the first non-concat (a turn
|
|
293
562
|
// boundary). Net effect: maximal runs of consecutive concat submissions merge into one turn,
|
|
@@ -299,6 +568,20 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
299
568
|
}
|
|
300
569
|
const content = batch.flatMap(i => i.content);
|
|
301
570
|
s.runningTraceId = head.traceId;
|
|
571
|
+
// The turn head, in this same step (see `SessionState.turnHead`): a fresh turn's user message is
|
|
572
|
+
// minted here, so its id is known before the read below, and persisted after it; a redo's is the
|
|
573
|
+
// message its retraction kept.
|
|
574
|
+
const userMsg = head.redo === undefined
|
|
575
|
+
? createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider })
|
|
576
|
+
: undefined;
|
|
577
|
+
s.turnHead = userMsg !== undefined ? { id: userMsg.id, request: textOf(userMsg) } : head.redo?.head;
|
|
578
|
+
// In the same synchronous step that takes the item off the queue, so there is no moment at which a
|
|
579
|
+
// stop finds it in neither place: `abort`/`cancelTurn` reach a queued item through the queue and a
|
|
580
|
+
// dequeued one only through this. Created after the preamble below (a store read, the persist,
|
|
581
|
+
// the provider resolving), a stop sent during it found nothing and the turn ran anyway. A turn
|
|
582
|
+
// whose signal is already aborted ends `aborted` at its first round.
|
|
583
|
+
const ac = new AbortController();
|
|
584
|
+
s.ac = ac;
|
|
302
585
|
s.replay = [];
|
|
303
586
|
// Seed replay with the running turn's user message as a single merged `queued`, mirroring the
|
|
304
587
|
// message persisted just below. notify() (in open()) reaches only subscribers that are live at
|
|
@@ -314,12 +597,13 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
314
597
|
|
|
315
598
|
let session = await deps.store.get(id);
|
|
316
599
|
if (session === null) {
|
|
600
|
+
s.ac = s.turnHead = undefined;
|
|
317
601
|
emit(s, { type: 'error', error: `Session "${id}" not found`, traceId: head.traceId });
|
|
318
602
|
continue;
|
|
319
603
|
}
|
|
320
604
|
|
|
321
605
|
// A redo re-runs the existing committed user turn — no title derivation, no new user message.
|
|
322
|
-
if (
|
|
606
|
+
if (userMsg !== undefined) {
|
|
323
607
|
if (!session.title && !session.messages.some(m => m.role === 'user')) {
|
|
324
608
|
// Falls back to the attachment names when the turn has no words of its own: an image-only
|
|
325
609
|
// first turn is a real submission, and got no title at all — which reads in the session
|
|
@@ -339,7 +623,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
339
623
|
// Persist-at-turn-start: the user message only hits the store when its turn begins, never
|
|
340
624
|
// while queued. That is what stops a mid-turn submit from clobbering session state. A robo
|
|
341
625
|
// resubmission's blocks already carry `origin: 'robo'` (stamped where it was enqueued).
|
|
342
|
-
session = appendMessage(session,
|
|
626
|
+
session = appendMessage(session, userMsg);
|
|
343
627
|
try {
|
|
344
628
|
await deps.store.set(session.id, session);
|
|
345
629
|
} catch (e) {
|
|
@@ -348,6 +632,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
348
632
|
// it as a turn error and drop this submission rather than let it escape the detached pump and
|
|
349
633
|
// crash the process. Any other write failure stays fatal.
|
|
350
634
|
if (!isReadOnlyError(e)) throw e;
|
|
635
|
+
s.ac = s.turnHead = undefined;
|
|
351
636
|
emit(s, { type: 'error', error: e.message, traceId: head.traceId });
|
|
352
637
|
continue;
|
|
353
638
|
}
|
|
@@ -355,6 +640,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
355
640
|
|
|
356
641
|
const resolved = await deps.resolveProvider(head.provider);
|
|
357
642
|
if (resolved === null) {
|
|
643
|
+
s.ac = s.turnHead = undefined;
|
|
358
644
|
emit(s, { type: 'error', error: `Unknown provider "${head.provider}"`, traceId: head.traceId });
|
|
359
645
|
continue;
|
|
360
646
|
}
|
|
@@ -363,8 +649,6 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
363
649
|
// two never coincide on one item, so either supplies runSession's injectedEphemeral.
|
|
364
650
|
const inject = head.redo?.ephemeral ?? head.injectEphemeral;
|
|
365
651
|
|
|
366
|
-
const ac = new AbortController();
|
|
367
|
-
s.ac = ac;
|
|
368
652
|
// Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
|
|
369
653
|
// (a source tool's ToolContracts arms, or a source-less tool's `toolContract`) — into its description
|
|
370
654
|
// for this turn, so the model reasons about the real TS shapes, not just the loose inputSchema.
|
|
@@ -383,6 +667,15 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
383
667
|
}))
|
|
384
668
|
: undefined;
|
|
385
669
|
|
|
670
|
+
// The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
|
|
671
|
+
// commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
|
|
672
|
+
// refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
|
|
673
|
+
// in both cases a followup hook would judge history with no completed answer in it — and a
|
|
674
|
+
// `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
|
|
675
|
+
// budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
|
|
676
|
+
// signal-driven aborts and not the policy ones. Declared outside the `try` because `finally`
|
|
677
|
+
// also asks whether the turn committed at all.
|
|
678
|
+
let terminal: PipelineEvent['type'] | undefined;
|
|
386
679
|
try {
|
|
387
680
|
// Establish the submitter's principal for the whole turn here, not inside runSession:
|
|
388
681
|
// pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
|
|
@@ -391,14 +684,6 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
391
684
|
// because the pump owns it, not because it must be: runAs rescopes a returned iterator itself.
|
|
392
685
|
// runAs, not a context switch of its own: the pump holds the machine across the whole queue
|
|
393
686
|
// (see above), so a turn declares only its owner. Each item carries its own submitter.
|
|
394
|
-
// The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
|
|
395
|
-
// commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
|
|
396
|
-
// refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
|
|
397
|
-
// in both cases a followup hook would judge history with no completed answer in it — and a
|
|
398
|
-
// `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
|
|
399
|
-
// budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
|
|
400
|
-
// signal-driven aborts and not the policy ones.
|
|
401
|
-
let terminal: PipelineEvent['type'] | undefined;
|
|
402
687
|
// One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
|
|
403
688
|
// definition and a detached classifier settles whenever it settles, so a scope that ended at
|
|
404
689
|
// the turn's commit would drop precisely the spend that is hardest to account for otherwise.
|
|
@@ -423,17 +708,20 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
423
708
|
...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
|
|
424
709
|
...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
|
|
425
710
|
...(deps.files !== undefined ? { files: deps.files } : {}),
|
|
711
|
+
...(deps.appender !== undefined ? { appender: deps.appender } : {}),
|
|
426
712
|
...(mediaStore !== undefined ? { mediaStore } : {}),
|
|
427
713
|
...(permissionGate !== undefined ? { permissionGate } : {}),
|
|
428
714
|
...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
|
|
429
715
|
...(deps.vault !== undefined ? { vault: deps.vault } : {}),
|
|
430
716
|
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
431
717
|
...(inject !== undefined ? { injectedEphemeral: inject } : {}),
|
|
718
|
+
interject: interjectInto(s),
|
|
432
719
|
})) {
|
|
433
720
|
if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
|
|
434
721
|
emit(s, ev);
|
|
435
722
|
}
|
|
436
723
|
});
|
|
724
|
+
s.turnHead = undefined;
|
|
437
725
|
|
|
438
726
|
// followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
|
|
439
727
|
// head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
|
|
@@ -463,11 +751,9 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
463
751
|
// the message tail, so these markers are folded into that single write instead (below)
|
|
464
752
|
// to keep ordering sane (a separate append here would be sliced into the popped region).
|
|
465
753
|
if (followup.markers.length > 0 && !followup.retract) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId })],
|
|
470
|
-
});
|
|
754
|
+
const markerMsg = createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId });
|
|
755
|
+
await rewrite(id, committed, cur => ({ ...cur, messages: [...cur.messages, markerMsg] }),
|
|
756
|
+
`[matbot] followup markers of session "${id}" were not written`);
|
|
471
757
|
notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
|
|
472
758
|
}
|
|
473
759
|
// unshift in reverse so the hooks' order is preserved at the head of the queue. Stamp
|
|
@@ -499,50 +785,69 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
499
785
|
// head, delivering the trigger tool's output as ephemeral context. Unshifted last so it
|
|
500
786
|
// sits at the very head (runs next) even if a resubmit was also queued above.
|
|
501
787
|
if (followup.retract) {
|
|
502
|
-
const lastUserIdx = lastUserIndex(committed);
|
|
503
|
-
const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
|
|
504
788
|
// `durable` correction (a contextual fire that landed post-commit) folds onto the user
|
|
505
789
|
// message we keep — persisted, so the redo AND every later turn see it in history, the
|
|
506
790
|
// exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
|
|
507
791
|
// The fold runs on the *truncated* session, whose last user message is the one we keep,
|
|
508
792
|
// so it goes through the same helper as the screen and raced-verdict folds.
|
|
509
793
|
const durable = followup.retract.durable ?? [];
|
|
510
|
-
const truncated = lastUserIdx >= 0
|
|
511
|
-
? { ...committed, messages: committed.messages.slice(0, lastUserIdx + 1) }
|
|
512
|
-
: committed;
|
|
513
|
-
const kept = foldOntoUserTurn(truncated, durable).messages;
|
|
514
|
-
const retractionMsg: Message = createMessage({
|
|
515
|
-
role: 'marker',
|
|
516
|
-
traceId: head.traceId,
|
|
517
|
-
// `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
|
|
518
|
-
// context fed to the redo — neither is otherwise persisted, so the pair fully traces
|
|
519
|
-
// the swap for a post-mortem (and a frontend can render the strike-through + the cause).
|
|
520
|
-
content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context ?? [], traceId: head.traceId } }],
|
|
521
|
-
});
|
|
522
794
|
// Any followup markers ride along as a trailing marker message (not folded into the
|
|
523
795
|
// retraction marker) so each creator's trace stays its own block.
|
|
524
796
|
const trailing: Message[] = followup.markers.length > 0
|
|
525
797
|
? [createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId })]
|
|
526
798
|
: [];
|
|
527
|
-
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
799
|
+
// Recomputed per attempt: a settled parallel pair may have been placed while the hooks
|
|
800
|
+
// judged, ahead of the turn we are about to pop, so the landmarks are taken from the
|
|
801
|
+
// document we are actually writing over. Every one of them is "the last user message",
|
|
802
|
+
// which a pair placed ahead of it does not move — so this changes nothing but which
|
|
803
|
+
// document the kept prefix comes from.
|
|
804
|
+
let keptHead: Message | undefined;
|
|
805
|
+
let retractionMsg: Message | undefined;
|
|
806
|
+
const wrote = await rewrite(id, committed, cur => {
|
|
807
|
+
const lastUserIdx = lastUserIndex(cur);
|
|
808
|
+
const popped = lastUserIdx >= 0 ? cur.messages.slice(lastUserIdx + 1) : [];
|
|
809
|
+
const truncated = lastUserIdx >= 0
|
|
810
|
+
? { ...cur, messages: cur.messages.slice(0, lastUserIdx + 1) }
|
|
811
|
+
: cur;
|
|
812
|
+
const kept = foldOntoUserTurn(truncated, durable).messages;
|
|
813
|
+
// The redo's turn head: the user message kept here, as folded — what it re-runs.
|
|
814
|
+
keptHead = lastUserIdx >= 0 ? kept[lastUserIdx] : undefined;
|
|
815
|
+
retractionMsg = createMessage({
|
|
816
|
+
role: 'marker',
|
|
817
|
+
traceId: head.traceId,
|
|
818
|
+
// `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
|
|
819
|
+
// context fed to the redo — neither is otherwise persisted, so the pair fully traces
|
|
820
|
+
// the swap for a post-mortem (and a frontend can render the strike-through + the cause).
|
|
821
|
+
content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract?.context ?? [], traceId: head.traceId } }],
|
|
822
|
+
});
|
|
823
|
+
return { ...cur, messages: [...kept, retractionMsg, ...trailing] };
|
|
824
|
+
}, `[matbot] the retraction of session "${id}" was not written; it will not be re-run`);
|
|
825
|
+
// Only if it landed: nothing was popped otherwise, so there is nothing to re-run and
|
|
826
|
+
// nothing to announce — a redo enqueued here would re-answer a turn whose original
|
|
827
|
+
// answer is still in the session.
|
|
828
|
+
if (wrote && retractionMsg !== undefined) {
|
|
829
|
+
// Emit the durable fold live (as a robo-user on the kept user turn) before the
|
|
830
|
+
// retraction marker, mirroring the screen-time durable path's ordering.
|
|
831
|
+
if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
|
|
832
|
+
notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
|
|
833
|
+
if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
|
|
533
834
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
835
|
+
const rt = crypto.randomUUID();
|
|
836
|
+
s.queue.unshift({
|
|
837
|
+
traceId: rt,
|
|
838
|
+
rootTraceId: head.rootTraceId,
|
|
839
|
+
content: [],
|
|
840
|
+
provider: head.provider,
|
|
841
|
+
principal: head.principal,
|
|
842
|
+
concatQueue: false,
|
|
843
|
+
resubmitDepth: head.resubmitDepth + 1,
|
|
844
|
+
redo: {
|
|
845
|
+
ephemeral: followup.retract.context ?? [],
|
|
846
|
+
...(keptHead !== undefined ? { head: { id: keptHead.id, request: textOf(keptHead) } } : {}),
|
|
847
|
+
},
|
|
848
|
+
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
849
|
+
});
|
|
850
|
+
}
|
|
546
851
|
}
|
|
547
852
|
}
|
|
548
853
|
}
|
|
@@ -551,24 +856,190 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
551
856
|
emit(s, { type: 'error', error: String(e), traceId: head.traceId });
|
|
552
857
|
} finally {
|
|
553
858
|
s.ac = undefined;
|
|
859
|
+
s.turnHead = undefined;
|
|
860
|
+
// Every terminal commits, and the replies placed into the turn went with it. No terminal means
|
|
861
|
+
// runSession threw before committing, taking them with it — so they go back to be placed again.
|
|
862
|
+
// Not one `placeNow` already wrote: that is in the store whatever this turn did, and placing
|
|
863
|
+
// it again would duplicate the pair.
|
|
864
|
+
const placed = s.interjected.splice(0);
|
|
865
|
+
if (terminal === undefined) s.merges.unshift(...placed.filter(r => r.persisted !== true));
|
|
554
866
|
}
|
|
555
867
|
}
|
|
556
868
|
} finally {
|
|
557
|
-
s.running = false;
|
|
558
869
|
s.runningTraceId = undefined;
|
|
870
|
+
s.turnHead = undefined;
|
|
871
|
+
s.ac = undefined;
|
|
559
872
|
s.replay = [];
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
873
|
+
try {
|
|
874
|
+
// Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
|
|
875
|
+
// is not a well-defined moment to total anything at: steers terminate and resume, a retract
|
|
876
|
+
// re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
|
|
877
|
+
// settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
|
|
878
|
+
//
|
|
879
|
+
// And while `running` still holds. The flush is a read-modify-write of the session, so a
|
|
880
|
+
// submission arriving during it must queue behind this pump rather than start a second one —
|
|
881
|
+
// whose persist-at-turn-start write would interleave with it, and whichever landed second
|
|
882
|
+
// erased the other: this flush's entries, or that turn's user message.
|
|
883
|
+
await flushUsage(id, s);
|
|
884
|
+
} finally {
|
|
885
|
+
s.running = false;
|
|
886
|
+
// A submission or a write that arrived during the flush found the pump running and did not start
|
|
887
|
+
// one; nor did a parallel reply that settled then — possible only when a throw left the loop above
|
|
888
|
+
// with parallel turns still out, since it otherwise ends only once none are.
|
|
889
|
+
if (s.queue.length > 0 || s.merges.length > 0 || s.writes.length > 0) void pump(id, s);
|
|
890
|
+
else {
|
|
891
|
+
// Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
|
|
892
|
+
// (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
|
|
893
|
+
// the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
|
|
894
|
+
notify(s, { type: 'idle', sessionId: id });
|
|
895
|
+
maybeCleanup(id, s);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
570
898
|
}
|
|
571
|
-
}
|
|
899
|
+
};
|
|
900
|
+
return deps.heldByCreator ? drain() : machineBusy(drain);
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* A parallel turn: the submission runs at once on a nested runner over a private copy of the session's
|
|
905
|
+
* completed turns, and its submission plus everything it produced come back (`ParallelReply`). The copy ends where the
|
|
906
|
+
* running turn's own messages begin (`head`), plus any replies already placed into that turn but not
|
|
907
|
+
* yet committed (`interjected`) — both captured at submit, before any await could outlive the turn.
|
|
908
|
+
*
|
|
909
|
+
* Its events are forwarded under its own traceId as progress; what persists is the span the write-back
|
|
910
|
+
* carries, read from the copy once the turn has settled. A nested runner rather than `runSession` so the turn is an ordinary
|
|
911
|
+
* one — screen, followup, accounting, media — with nothing re-implemented.
|
|
912
|
+
*/
|
|
913
|
+
const runParallel = async (
|
|
914
|
+
id: string, s: SessionState, rec: { replay: PipelineEvent[]; stop: AbortController },
|
|
915
|
+
p: { traceId: string; content: MessageContent[]; provider: string; principal: Principal; prompt?: PromptFn;
|
|
916
|
+
head: { id: string; request: string } | undefined; interjected: Message[] },
|
|
917
|
+
): Promise<void> => {
|
|
918
|
+
const forward = (ev: PipelineEvent): void => {
|
|
919
|
+
rec.replay.push(ev);
|
|
920
|
+
for (const sink of s.subscribers) sink.push(ev);
|
|
921
|
+
};
|
|
922
|
+
let reply: ParallelReply | undefined;
|
|
923
|
+
try {
|
|
924
|
+
const stored = await deps.store.get(id);
|
|
925
|
+
if (stored === null) { forward({ type: 'error', error: `Session "${id}" not found`, traceId: p.traceId }); return; }
|
|
926
|
+
// The head may not be in the store yet — the turn's user message is persisted after the head is set
|
|
927
|
+
// — in which case nothing of the running turn is there to cut.
|
|
928
|
+
const at = p.head !== undefined ? stored.messages.findIndex(m => m.id === p.head!.id) : -1;
|
|
929
|
+
const prefix = at >= 0 ? stored.messages.slice(0, at) : stored.messages;
|
|
930
|
+
// By the time this read lands the running turn may have committed, interjected replies included.
|
|
931
|
+
const have = new Set(prefix.map(m => m.id));
|
|
932
|
+
const copy = { ...stored, messages: [...prefix, ...p.interjected.filter(m => !have.has(m.id))] };
|
|
933
|
+
const running = p.head?.request ?? '';
|
|
934
|
+
const framing = running ? parallelFraming(running) : undefined;
|
|
935
|
+
|
|
936
|
+
const store = new MemoryStore<Session>();
|
|
937
|
+
await store.set(id, copy);
|
|
938
|
+
const sub = createSessionRunner({ ...deps, store, heldByCreator: true });
|
|
939
|
+
if (rec.stop.signal.aborted) { forward({ type: 'cancelled', sessionId: id, traceId: p.traceId }); return; }
|
|
940
|
+
rec.stop.signal.addEventListener('abort', () => sub.abort(id), { once: true });
|
|
941
|
+
|
|
942
|
+
// Tap BEFORE submitting: the nested pump starts inside `open`, and an event (or its `idle`) emitted
|
|
943
|
+
// before a later tap would be missed — the last of them leaving this loop waiting for ever.
|
|
944
|
+
const tapAc = new AbortController();
|
|
945
|
+
const tap = (await sub.open({ sessionId: id, signal: tapAc.signal })).events;
|
|
946
|
+
await sub.open({
|
|
947
|
+
sessionId: id, signal: tapAc.signal, traceId: p.traceId, provider: p.provider, principal: p.principal,
|
|
948
|
+
content: [...asSubmitted(p.content), ...(running ? [parallelFraming(running)] : [])],
|
|
949
|
+
...(p.prompt !== undefined ? { prompt: p.prompt } : {}),
|
|
950
|
+
});
|
|
951
|
+
// Again, now that there is something to stop: a stop landing while the submission was on its way in
|
|
952
|
+
// (its media boundary is an await) reached a runner with nothing queued, and the listener has fired.
|
|
953
|
+
if (rec.stop.signal.aborted) sub.abort(id);
|
|
954
|
+
for await (const ev of tap) {
|
|
955
|
+
if (ev.type === 'idle') break;
|
|
956
|
+
// Announced as `parallel` already, with the content as submitted rather than the framed copy.
|
|
957
|
+
if (ev.type === 'queued') continue;
|
|
958
|
+
forward(ev);
|
|
959
|
+
}
|
|
960
|
+
tapAc.abort();
|
|
961
|
+
|
|
962
|
+
const final = await store.get(id);
|
|
963
|
+
const msgs = final?.messages ?? [];
|
|
964
|
+
const hIdx = msgs.findIndex(m => m.role === 'user' && m.traceId === p.traceId);
|
|
965
|
+
const qHead = msgs[hIdx];
|
|
966
|
+
// The turn's WHOLE span crosses back, not just its final text: thinking, tool calls, their results
|
|
967
|
+
// and any markers. A history holding only the answer makes everything a parallel turn actually did
|
|
968
|
+
// unrecoverable the moment the live stream is gone — `determine_provenance` reads a real tool result
|
|
969
|
+
// as fabricated, and so does a person scrolling back. The span is placed exactly where the copy was
|
|
970
|
+
// cut, so it sits in the history it was generated against, and `closedSpan` is what keeps it valid
|
|
971
|
+
// wire. Thinking rides along persisted but unsubmitted (every adapter elides a historical block),
|
|
972
|
+
// and a foreign round-trip token is the adapters' to drop by `providerName`, which these messages
|
|
973
|
+
// carry — so nothing here depends on which provider ran the turn.
|
|
974
|
+
const span = hIdx >= 0 ? closedSpan(msgs.slice(hIdx + 1)) : [];
|
|
975
|
+
// Answered iff the span ENDS with assistant text. Not "contains some": a turn aborted after
|
|
976
|
+
// "let me check that" has said nothing, and a span ending on a tool result needs the note after it
|
|
977
|
+
// to close the turn on an assistant message.
|
|
978
|
+
const tail = span[span.length - 1];
|
|
979
|
+
const answer = tail?.role === 'assistant'
|
|
980
|
+
? tail.content.filter(c => c.type === 'text' && c.text.trim() !== '') : [];
|
|
981
|
+
const ended = rec.replay.findLast(e => e.type === 'done' || e.type === 'aborted' || e.type === 'error' || e.type === 'cancelled');
|
|
982
|
+
const reason = ended?.type === 'aborted' ? `was stopped (${ended.reason})`
|
|
983
|
+
: ended?.type === 'cancelled' ? 'was stopped before it started'
|
|
984
|
+
: ended?.type === 'error' ? `failed: ${ended.error}`
|
|
985
|
+
: 'produced no text';
|
|
986
|
+
reply = {
|
|
987
|
+
traceId: p.traceId,
|
|
988
|
+
...(p.head !== undefined ? { head: p.head.id } : {}),
|
|
989
|
+
messages: [
|
|
990
|
+
// The copy's own head, carrying its accounting, with the framing block taken back OFF — that
|
|
991
|
+
// block is this turn's own context and not something the person wrote. Subtracted rather than
|
|
992
|
+
// replaced with `p.content` wholesale: a `contextual` trigger folds its correction DURABLY onto
|
|
993
|
+
// the head, and overwriting the content dropped exactly that, so the correction informed the
|
|
994
|
+
// answer and then vanished from the history the answer sits in.
|
|
995
|
+
qHead !== undefined
|
|
996
|
+
? { ...qHead, content: qHead.content.filter(c => !(framing !== undefined && c.type === 'text'
|
|
997
|
+
&& c.origin === framing.origin && c.text === framing.text)) }
|
|
998
|
+
: createMessage({ role: 'user', content: p.content, traceId: p.traceId, providerName: p.provider }),
|
|
999
|
+
...span,
|
|
1000
|
+
...(answer.length > 0 ? [] : [createMessage({
|
|
1001
|
+
role: 'assistant', content: [{ type: 'text', text: `(No reply — this message's parallel turn ${reason}.)` }],
|
|
1002
|
+
traceId: p.traceId, providerName: p.provider })]),
|
|
1003
|
+
],
|
|
1004
|
+
};
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
forward({ type: 'error', error: String(e), traceId: p.traceId });
|
|
1007
|
+
} finally {
|
|
1008
|
+
s.parallel.delete(p.traceId);
|
|
1009
|
+
if (reply !== undefined) {
|
|
1010
|
+
// On the list BEFORE the write below, and never only in the write: the turn may end while we are
|
|
1011
|
+
// writing, and then `end`'s final pull or `drainMerges` is the path that places this pair. Being
|
|
1012
|
+
// on the list is also how the running turn's in-memory session learns of it, so its whole-document
|
|
1013
|
+
// write-back carries the pair instead of erasing it.
|
|
1014
|
+
s.merges.push(reply);
|
|
1015
|
+
// Placed NOW while a turn is in progress, rather than left for that turn's next round boundary,
|
|
1016
|
+
// which a turn parked in one long tool call never reaches — nor does one whose rounds are over and
|
|
1017
|
+
// whose `followup` hooks are still judging it, which with LLM-judged trigger conditions is seconds.
|
|
1018
|
+
// `ac` spans the whole turn, followup included; `turnHead` does not, and is not what anchors this.
|
|
1019
|
+
// A reply with no head of its own belongs at the TAIL, which is the pump's to append and must wait
|
|
1020
|
+
// for it: appended now it would become the session's last user message, which is the turn a redo
|
|
1021
|
+
// re-runs. A failure places it exactly as it used to be.
|
|
1022
|
+
if (s.ac !== undefined && reply.head !== undefined) {
|
|
1023
|
+
let placed: 'inserted' | 'present' | 'no' = 'no';
|
|
1024
|
+
try {
|
|
1025
|
+
placed = await placeNow(id, reply);
|
|
1026
|
+
} catch (e) {
|
|
1027
|
+
console.error(`[matbot] placing a parallel reply of session "${id}" failed:`,
|
|
1028
|
+
e instanceof Error ? e : String(e));
|
|
1029
|
+
}
|
|
1030
|
+
reply.persisted = placed !== 'no';
|
|
1031
|
+
// Only the writer that inserted it announces. `into` is the running turn: the pair went ahead of
|
|
1032
|
+
// its user message, which is where the copy this ran on ended.
|
|
1033
|
+
if (placed === 'inserted') {
|
|
1034
|
+
notify(s, { type: 'merged', traceId: reply.traceId,
|
|
1035
|
+
...(s.runningTraceId !== undefined ? { into: s.runningTraceId } : {}) });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
// Placed by the running turn if there is one (it is polled each round), else by the pump that admitted
|
|
1040
|
+
// this turn, which is waiting for it.
|
|
1041
|
+
void pump(id, s);
|
|
1042
|
+
}
|
|
572
1043
|
};
|
|
573
1044
|
|
|
574
1045
|
return {
|
|
@@ -612,7 +1083,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
612
1083
|
// The decision — and, on interrupt, the abort+unshift — happen here synchronously against
|
|
613
1084
|
// s.running (re-checked after any classify/nudge await) so an interrupt can never land on the
|
|
614
1085
|
// wrong (a later) turn: the fatal race of deciding across a wire is closed by deciding in-runner.
|
|
615
|
-
let
|
|
1086
|
+
let handled = false;
|
|
616
1087
|
if (mode !== 'queue' && s.running) {
|
|
617
1088
|
const policy = deps.steeringPolicy?.();
|
|
618
1089
|
// Fetch the committed session once (history up to the running turn's start) only when a policy
|
|
@@ -622,8 +1093,8 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
622
1093
|
const wantNudge = policy?.nudge !== undefined;
|
|
623
1094
|
const committed = (wantClassify || wantNudge) ? await deps.store.get(opts.sessionId) : null;
|
|
624
1095
|
|
|
625
|
-
const decision: SteeringDecision = mode === 'interrupt'
|
|
626
|
-
?
|
|
1096
|
+
const decision: SteeringDecision = mode === 'interrupt' || mode === 'parallel'
|
|
1097
|
+
? mode
|
|
627
1098
|
: policy?.classify !== undefined
|
|
628
1099
|
? (committed ? await policy.classify({ session: committed, steer: content }) : DEFAULT_STEERING_POLICY)
|
|
629
1100
|
: DEFAULT_STEERING_POLICY;
|
|
@@ -631,11 +1102,14 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
631
1102
|
// Any await above may have outlived the turn this steer was for: interrupt only if THAT one
|
|
632
1103
|
// is still the running turn. Otherwise fall through and queue — the turn they were steering
|
|
633
1104
|
// has already committed, which makes this an ordinary follow-up rather than an interruption.
|
|
634
|
-
|
|
1105
|
+
// `running` alone is not "a turn is running": it holds through the usage flush after the queue
|
|
1106
|
+
// drains, where `runningTraceId` is already cleared — and `steerTarget` captured then is
|
|
1107
|
+
// undefined too, so without the first test the two compare equal and nothing gets steered.
|
|
1108
|
+
if (decision === 'interrupt' && s.runningTraceId !== undefined && s.runningTraceId === steerTarget) {
|
|
635
1109
|
const nudge = policy?.nudge !== undefined && committed
|
|
636
1110
|
? policy.nudge({ session: committed, steer: content })
|
|
637
1111
|
: DEFAULT_STEER_NUDGE;
|
|
638
|
-
const interruptedTraceId = s.runningTraceId
|
|
1112
|
+
const interruptedTraceId = s.runningTraceId;
|
|
639
1113
|
// Head of the queue (ahead of anything else already queued): the steer runs immediately
|
|
640
1114
|
// after the interrupted turn commits its partial work. Own turn (never concat).
|
|
641
1115
|
s.queue.unshift({
|
|
@@ -653,11 +1127,29 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
653
1127
|
// imminent `aborted` (reason 'steer') as a yield, not a dead-end.
|
|
654
1128
|
notify(s, { type: 'steer', content, interruptedTraceId, traceId, rootTraceId: traceId });
|
|
655
1129
|
s.ac?.abort('steer');
|
|
656
|
-
|
|
1130
|
+
handled = true;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Unlike a steer, a parallel turn needs no particular turn to still be running — only SOME turn,
|
|
1134
|
+
// since that is what it runs beside. Its copy is cut at whichever turn that is, captured here,
|
|
1135
|
+
// synchronously, because the next await could outlive it.
|
|
1136
|
+
if (decision === 'parallel' && s.running && s.runningTraceId !== undefined) {
|
|
1137
|
+
const rec = { replay: [] as PipelineEvent[], stop: new AbortController() };
|
|
1138
|
+
const announce: PipelineEvent = { type: 'parallel', content, runningTraceId: s.runningTraceId, traceId, rootTraceId: traceId };
|
|
1139
|
+
rec.replay.push(announce);
|
|
1140
|
+
s.parallel.set(traceId, rec);
|
|
1141
|
+
notify(s, announce);
|
|
1142
|
+
void runParallel(opts.sessionId, s, rec, {
|
|
1143
|
+
traceId, content, provider, principal,
|
|
1144
|
+
head: s.turnHead,
|
|
1145
|
+
interjected: s.interjected.flatMap(r => r.messages),
|
|
1146
|
+
...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
|
|
1147
|
+
});
|
|
1148
|
+
handled = true;
|
|
657
1149
|
}
|
|
658
1150
|
}
|
|
659
1151
|
|
|
660
|
-
if (!
|
|
1152
|
+
if (!handled) {
|
|
661
1153
|
const concatQueue = opts.concatQueue ?? false;
|
|
662
1154
|
s.queue.push({
|
|
663
1155
|
traceId,
|
|
@@ -695,8 +1187,10 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
695
1187
|
const remove = (): void => { st.subscribers.delete(sink); maybeCleanup(opts.sessionId, st); };
|
|
696
1188
|
const sink = createSink(remove);
|
|
697
1189
|
st.subscribers.add(sink);
|
|
698
|
-
// Replay the in-progress turn, then the pending queue — the delta
|
|
1190
|
+
// Replay the in-progress turn, any parallel turns beside it, then the pending queue — the delta
|
|
1191
|
+
// region, in order.
|
|
699
1192
|
for (const ev of st.replay) sink.push(ev);
|
|
1193
|
+
for (const { replay } of st.parallel.values()) for (const ev of replay) sink.push(ev);
|
|
700
1194
|
st.queue.forEach((item, i) =>
|
|
701
1195
|
sink.push({ type: 'queued', content: item.content, queued: aheadOf(st, i), concatQueue: item.concatQueue, traceId: item.traceId, rootTraceId: item.rootTraceId }));
|
|
702
1196
|
if (opts.signal.aborted) { sink.close(); remove(); }
|
|
@@ -716,6 +1210,14 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
716
1210
|
emit(s, { type: 'cancelled', sessionId, traceId: item.traceId });
|
|
717
1211
|
}
|
|
718
1212
|
s.ac?.abort('user-abort');
|
|
1213
|
+
for (const { stop } of s.parallel.values()) stop.abort('user-abort');
|
|
1214
|
+
},
|
|
1215
|
+
|
|
1216
|
+
abortParallel(sessionId: string, traceId: string): void {
|
|
1217
|
+
// Only this turn's own stop. Nothing else is touched — not the running turn it was answered
|
|
1218
|
+
// beside, not the queue, not its siblings — and it settles through the ordinary path, so its pair
|
|
1219
|
+
// is still written back with a note where the reply would have been.
|
|
1220
|
+
states.get(sessionId)?.parallel.get(traceId)?.stop.abort('user-abort');
|
|
719
1221
|
},
|
|
720
1222
|
|
|
721
1223
|
cancelTurn(sessionId: string): void {
|
|
@@ -726,11 +1228,42 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
726
1228
|
s.ac?.abort('user-cancel');
|
|
727
1229
|
},
|
|
728
1230
|
|
|
729
|
-
status(sessionId: string): { busy: boolean; running: boolean; queued: number } {
|
|
1231
|
+
status(sessionId: string): { busy: boolean; running: boolean; queued: number; parallel: number } {
|
|
730
1232
|
const s = states.get(sessionId);
|
|
731
|
-
const running
|
|
732
|
-
const queued
|
|
733
|
-
|
|
1233
|
+
const running = s?.running ?? false;
|
|
1234
|
+
const queued = s?.queue.length ?? 0;
|
|
1235
|
+
const parallel = s?.parallel.size ?? 0;
|
|
1236
|
+
return { busy: running || queued > 0 || parallel > 0 || (s?.merges.length ?? 0) > 0, running, queued, parallel };
|
|
1237
|
+
},
|
|
1238
|
+
|
|
1239
|
+
write(sessionId: string, attempt: () => Promise<boolean>, lost: string): SessionWrite {
|
|
1240
|
+
const s = stateFor(sessionId);
|
|
1241
|
+
// As the caller. The pump runs in the scope of whatever started it, and the store checks ownership.
|
|
1242
|
+
const principal = tryCurrentPrincipal();
|
|
1243
|
+
// It waits for a turn exactly when one is in progress (`ac` is set from dequeue until the turn,
|
|
1244
|
+
// followup included, is done), or when a redo is next — it waits for that too (see the loop).
|
|
1245
|
+
// Otherwise the pump reaches it before taking the next turn off the queue.
|
|
1246
|
+
const deferred = s.ac !== undefined || s.queue[0]?.redo !== undefined;
|
|
1247
|
+
let settle!: () => void;
|
|
1248
|
+
const done = new Promise<void>(resolve => { settle = resolve; });
|
|
1249
|
+
const tries = async (): Promise<void> => {
|
|
1250
|
+
for (let n = 0; n < WRITE_ATTEMPTS; n++) if (await attempt()) return;
|
|
1251
|
+
console.error(lost);
|
|
1252
|
+
};
|
|
1253
|
+
s.writes.push(async () => {
|
|
1254
|
+
try {
|
|
1255
|
+
await (principal !== undefined ? runAs(principal, tries) : tries());
|
|
1256
|
+
} catch (e) {
|
|
1257
|
+
// Nobody may be awaiting it, and the pump must go on to the next write and turn.
|
|
1258
|
+
console.error(`[matbot] a write of session "${sessionId}" failed:`, e instanceof Error ? e : String(e));
|
|
1259
|
+
} finally {
|
|
1260
|
+
settle();
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
// On a microtask, so the write cannot start before this returns: a caller decides what to do from
|
|
1264
|
+
// `deferred`, and its attempt may read what it set after the call.
|
|
1265
|
+
queueMicrotask(() => { void pump(sessionId, s); });
|
|
1266
|
+
return { deferred, done };
|
|
734
1267
|
},
|
|
735
1268
|
};
|
|
736
1269
|
}
|