@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.
@@ -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
- redo?: { ephemeral: MessageContent[] };
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
- if (s.running) return;
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's deferred edit of this very session) may only land where no
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, which is why there is no separate
283
- // `quiesced()` here any more: a flusher that rewrites this very session (a `session_edit` deferred
284
- // out of the last turn) finishes before the loop below reads its copy, or the read would take the
285
- // pre-edit document and the write-back would put it back. That wait used to be spelled at this call
286
- // site, and spelling it here made it something the next caller of `machineBusy` could omit without
287
- // any symptom — so it moved inside the hold it guards.
288
- return machineBusy(async () => {
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
- while (s.queue.length > 0) {
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 (head.redo === undefined) {
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, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
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
- await deps.store.set(id, {
467
- ...committed,
468
- messages: [...committed.messages,
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
- await deps.store.set(id, { ...committed, messages: [...kept, retractionMsg, ...trailing] });
528
- // Emit the durable fold live (as a robo-user on the kept user turn) before the retraction
529
- // marker, mirroring the screen-time durable path's ordering.
530
- if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
531
- notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
532
- if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
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
- const rt = crypto.randomUUID();
535
- s.queue.unshift({
536
- traceId: rt,
537
- rootTraceId: head.rootTraceId,
538
- content: [],
539
- provider: head.provider,
540
- principal: head.principal,
541
- concatQueue: false,
542
- resubmitDepth: head.resubmitDepth + 1,
543
- redo: { ephemeral: followup.retract.context ?? [] },
544
- ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
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
- // Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
561
- // is not a well-defined moment to total anything at: steers terminate and resume, a retract
562
- // re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
563
- // settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
564
- await flushUsage(id, s);
565
- // Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
566
- // (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
567
- // the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
568
- notify(s, { type: 'idle', sessionId: id });
569
- maybeCleanup(id, s);
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 interrupt = false;
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
- ? 'interrupt'
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
- if (decision === 'interrupt' && s.running && s.runningTraceId === steerTarget) {
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
- interrupt = true;
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 (!interrupt) {
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 region, in order.
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 = s?.running ?? false;
732
- const queued = s?.queue.length ?? 0;
733
- return { busy: running || queued > 0, running, queued };
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
  }