@matatbread/matbot-core 0.4.16 → 0.4.18

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,38 @@ 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): UserContent => ({
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 write-back: its submission and final reply, ready to place. Built once, when the
122
+ // parallel turn settles; placed by whoever holds the session at that moment (see `merges`).
123
+ interface ParallelReply {
124
+ traceId: string;
125
+ messages: [Message, Message];
126
+ /** The id of the message this reply's copy was cut AT — the running turn's head when it was submitted,
127
+ * captured then. The pair belongs immediately ahead of it, because that is where the history the reply
128
+ * was generated against ended. Absent ⇒ the copy was cut at the end of the session (nothing was
129
+ * running, or the turn's rounds were already over and only its `followup` hooks were left), so the
130
+ * pair belongs at the TAIL and there is no id to place it against. */
131
+ head?: string;
132
+ /** Written to the store by `placeNow` when it settled, rather than left for the running turn's next
133
+ * round boundary. It stays on `merges` all the same, because the running turn's in-memory session
134
+ * must still learn of it or that turn's whole-document write-back erases it — but it must never be
135
+ * written or announced a second time, which is what every reader of this flag is for. */
136
+ persisted?: boolean;
137
+ }
138
+
92
139
  // Marker creator for a retract-and-rerun: its `data.retracted` carries the popped (superseded) turn
93
140
  // messages so a frontend can render them struck-through and a post-mortem can audit them. Core-owned
94
141
  // because the pop is a pump operation, not a plugin's.
@@ -117,6 +164,32 @@ interface SessionState {
117
164
  // appends to the very array captured here, so a late arrival needs no coordination: whatever is in
118
165
  // these arrays at the flush is what gets written, and each entry says which turn caused it.
119
166
  pendingUsage: TurnEntry[][];
167
+ // ── parallel turns (`mode: 'parallel'`) ──
168
+ // Each in flight, by traceId: its own replay (it outlives the running turn's, which is cleared at every
169
+ // boundary) and how to stop it.
170
+ parallel: Map<string, { replay: PipelineEvent[]; stop: AbortController }>;
171
+ // Settled replies awaiting placement. Drained by whichever holder reaches a safe point first: the
172
+ // running turn at its next round boundary (`interject`), else the pump between turns. Never the
173
+ // SessionAppender — that writes at the quiescent edge, which cannot arrive until the pump's whole
174
+ // queue drains, and the point of a parallel turn is not waiting for that.
175
+ merges: ParallelReply[];
176
+ // The running turn's first own message (its user message; for a redo, the kept one): its id is where
177
+ // the copy a parallel turn runs on ends, and so where its pair is placed while this turn runs; its text
178
+ // is the request that copy's framing names. Set together with `runningTraceId`, in the step that takes
179
+ // the item off the queue: `runningTraceId` is what admits a parallel turn, so set any later — even after
180
+ // the store read that follows — one arriving in between had no head, ran unframed, and copied the turn
181
+ // it was beside. Cleared once the turn's rounds end, because from then
182
+ // on the turn has answered, and a parallel turn should see the answer rather than be told it is
183
+ // still being worked on.
184
+ turnHead: { id: string; request: string } | undefined;
185
+ // Replies placed into the running turn's in-memory copy and not yet committed: a parallel turn started
186
+ // now must see them, and they reach the store only when that turn ends.
187
+ interjected: ParallelReply[];
188
+ // Set while the pump has run out of queue but still holds the machine for a parallel turn: resolving it
189
+ // resumes the loop.
190
+ wake: (() => void) | undefined;
191
+ // Writes from outside the turns (`SessionRunner.write`), waiting for a moment no turn holds the session.
192
+ writes: Array<() => Promise<void>>;
120
193
  }
121
194
 
122
195
  interface Sink {
@@ -177,20 +250,29 @@ export function wireDescription(description: string, wc: { params: string; resul
177
250
  return `${description}\n\n${block('TypeScript params', wc.params)}\n\n${block('TypeScript result', wc.result)}`;
178
251
  }
179
252
 
253
+ const textOf = (m: Message): string =>
254
+ m.content.filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text').map(c => c.text).join('\n');
255
+
256
+ // Back across a submission boundary: `ingestMedia` has already turned media into refs, so only these arms
257
+ // remain, and the nested runner re-checks the refs against this same session.
258
+ const asSubmitted = (content: readonly MessageContent[]): UserContent[] =>
259
+ content.flatMap((c): UserContent[] => c.type === 'text' || c.type === 'file-ref' || c.type === 'form-response' ? [c] : []);
260
+
180
261
  export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
181
262
  const states = new Map<string, SessionState>();
182
263
 
183
264
  const stateFor = (id: string): SessionState => {
184
265
  let s = states.get(id);
185
266
  if (s === undefined) {
186
- s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [], pendingUsage: [] };
267
+ s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [], pendingUsage: [],
268
+ parallel: new Map(), merges: [], turnHead: undefined, interjected: [], wake: undefined, writes: [] };
187
269
  states.set(id, s);
188
270
  }
189
271
  return s;
190
272
  };
191
273
 
192
274
  const maybeCleanup = (id: string, s: SessionState): void => {
193
- if (!s.running && s.queue.length === 0 && s.subscribers.size === 0) states.delete(id);
275
+ 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
276
  };
195
277
 
196
278
  // Turn events go to the replay buffer (so a mid-flight subscriber sees the in-progress turn) and
@@ -267,11 +349,147 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
267
349
  await deps.store.set(id, { ...session, messages });
268
350
  };
269
351
 
352
+ // Between turns: nothing holds the session but the pump itself, so a settled parallel reply is simply
353
+ // appended — after the turn it ran beside, which by now has committed.
354
+ const drainMerges = async (id: string, s: SessionState): Promise<void> => {
355
+ if (s.merges.length === 0) return;
356
+ // One `placeNow` already wrote is in the store and was announced then; it stayed on the list only so
357
+ // a running turn's in-memory session would pick it up, and there is no running turn here.
358
+ const taken = s.merges.splice(0).filter(r => r.persisted !== true);
359
+ if (taken.length === 0) return;
360
+ const session = await deps.store.get(id);
361
+ if (session === null) return;
362
+ // And one whose `placeNow` is still in flight has no flag set yet, so the id is the authority: every
363
+ // placement path is idempotent on it, and whichever gets there first is the one that announces.
364
+ const have = new Set(session.messages.map(m => m.id));
365
+ const replies = taken.filter(r => !have.has(r.messages[0].id));
366
+ if (replies.length === 0) return;
367
+ try {
368
+ await deps.store.set(id, replies.flatMap(r => r.messages).reduce(appendMessage, session));
369
+ } catch (e) {
370
+ if (!isReadOnlyError(e)) throw e;
371
+ for (const r of replies) notify(s, { type: 'error', error: e.message, traceId: r.traceId });
372
+ return;
373
+ }
374
+ for (const r of replies) notify(s, { type: 'merged', traceId: r.traceId });
375
+ };
376
+
377
+ // Between turns, too: the writes that were waiting for no turn to hold the session. One at a time, in
378
+ // the order they came, each reading the document for itself, so each sees the one before it — two
379
+ // writes of one session never race each other. Never throws (see `write`).
380
+ const drainWrites = async (s: SessionState): Promise<void> => {
381
+ for (let w = s.writes.shift(); w !== undefined; w = s.writes.shift()) await w();
382
+ };
383
+
384
+ // While a turn runs: called by runSession at the top of each round. The pair goes AHEAD of the running
385
+ // turn's own messages, where the copy the parallel turn ran on ended — so the history stays exactly
386
+ // what that reply was generated against, and alternates without help. At the tail, after the latest
387
+ // tool result, the reply would be the last message of the turn's next provider call (a prefill), and
388
+ // once the turn answered, two adjacent assistant messages, which the adapters fold into one — putting
389
+ // text ahead of a `thinking` block. Each later pair lands after the earlier ones, so they stay in order.
390
+ const interjectInto = (s: SessionState) => (cur: Session): { session: Session; merged: string[] } | undefined => {
391
+ if (s.merges.length === 0 || s.turnHead === undefined) return undefined;
392
+ const head = s.turnHead.id;
393
+ const at = cur.messages.findIndex(m => m.id === head);
394
+ if (at < 0) return undefined;
395
+ const replies = s.merges.splice(0);
396
+ s.interjected.push(...replies);
397
+ // Only what is not already here. `placeNow` may have written a pair while a PREVIOUS turn's followup
398
+ // hooks ran, and this turn (a redo, or whatever followup enqueued) then read a session that already
399
+ // contains it — so splicing the list wholesale would duplicate the pair. The id is the authority, the
400
+ // same way it is in `placeNow` and `drainMerges`.
401
+ const have = new Set(cur.messages.map(m => m.id));
402
+ const add = replies.flatMap(r => r.messages).filter(m => !have.has(m.id));
403
+ return {
404
+ session: { ...cur, messages: [...cur.messages.slice(0, at), ...add, ...cur.messages.slice(at)] },
405
+ // A reply `placeNow` wrote announced itself then. It still has to reach the turn's in-memory
406
+ // session — that is the whole reason it is on this list — but announcing it twice would make a
407
+ // frontend place the same pair twice.
408
+ merged: replies.filter(r => r.persisted !== true).map(r => r.traceId),
409
+ };
410
+ };
411
+
412
+ /**
413
+ * A whole-document rewrite of a session whose turn has already committed — the `followup` writes. The
414
+ * pump used to be the only writer at that point, so an unconditional `set` was safe; it no longer is,
415
+ * because `placeNow` may insert a settled parallel pair while the hooks are judging. So these compare
416
+ * against the document the hooks were shown and rebuild from `current` if it moved, which costs no read
417
+ * at all on the uncontended path — a re-read would cost one per turn, and would also consume the next
418
+ * `store.get`, which is a thing tests legitimately hold.
419
+ *
420
+ * `build` is called once per attempt and must therefore derive everything from the document it is given.
421
+ * Giving up is logged rather than thrown: the turn has committed, and the caller decides what a write it
422
+ * could not make means (the retraction does not enqueue a redo it never wrote).
423
+ */
424
+ const rewrite = async (
425
+ id: string, from: Session, build: (cur: Session) => Session, lost: string,
426
+ ): Promise<boolean> => {
427
+ let cur = from;
428
+ for (let n = 0; n < WRITE_ATTEMPTS; n++) {
429
+ const res = await deps.store.cas(id, cur.version, build(cur));
430
+ if (res.ok) return true;
431
+ if (res.current === null) break;
432
+ cur = res.current;
433
+ }
434
+ console.error(lost);
435
+ return false;
436
+ };
437
+
438
+ /**
439
+ * Place a settled parallel reply in the store NOW, while a turn is still running, rather than leaving
440
+ * it to that turn's next round boundary. A turn parked in one long tool call reaches no boundary, so
441
+ * the pair used to exist in no store, no replay and no stream until the tool returned — a client that
442
+ * reconnected in that window could not see it at all, and the pair is a COMPLETED turn, not a partial
443
+ * one: nothing about it is waiting to be finished.
444
+ *
445
+ * The runner still owns the session exclusively. The pair is handed to the running turn as well
446
+ * (`interjectInto`, and the final pull in `end`), so that turn's whole-document write-back carries it
447
+ * instead of erasing it. Placed ahead of the running turn's head, exactly where `interjectInto` puts
448
+ * it, so the two never disagree about where the pair goes.
449
+ *
450
+ * CAS rather than `set`, because the running turn commits with a whole-document write of its own:
451
+ * losing the compare is how we learn it landed, and `current` is what it wrote.
452
+ *
453
+ * Three outcomes, because the pair is on `merges` before this runs and so EVERY placement path may
454
+ * reach it — this one, `interjectInto` at a round boundary, `drainMerges` once the turn is over. They
455
+ * are mutually idempotent on the pair's own message id, and exactly one of them inserts it: `present`
456
+ * says somebody else did, and is why this does not announce a `merged` the other path already sent.
457
+ */
458
+ const placeNow = async (id: string, reply: ParallelReply): Promise<'inserted' | 'present' | 'no'> => {
459
+ if (reply.head === undefined) return 'no';
460
+ let cur = await deps.store.get(id);
461
+ for (let n = 0; n < WRITE_ATTEMPTS; n++) {
462
+ if (cur === null) return 'no';
463
+ if (cur.messages.some(m => m.id === reply.messages[0].id)) return 'present';
464
+ // The reply's OWN head, not the session's current one: a parallel turn submitted during a turn's
465
+ // `followup` ran on a copy that INCLUDED that finished turn, so its pair belongs after it, and
466
+ // `s.turnHead` would have put it in front of the very history it answered. Resolved per attempt,
467
+ // since a lost compare hands us a different document.
468
+ const at = cur.messages.findIndex(m => m.id === reply.head);
469
+ // The head is not in the store yet (it is persisted just after it is set), or a retract has since
470
+ // popped it: either way there is nothing here to place against. Leave it to the turn or the pump.
471
+ if (at < 0) return 'no';
472
+ const next = { ...cur, messages: [...cur.messages.slice(0, at), ...reply.messages, ...cur.messages.slice(at)] };
473
+ try {
474
+ const res = await deps.store.cas(id, cur.version, next);
475
+ if (res.ok) return 'inserted';
476
+ cur = res.current;
477
+ } catch (e) {
478
+ // A shared session this principal may only read. The turn's own placement reports it (drainMerges
479
+ // / the turn's commit); claiming nothing was written is what lets that path run unchanged.
480
+ if (!isReadOnlyError(e)) throw e;
481
+ return 'no';
482
+ }
483
+ }
484
+ return 'no';
485
+ };
486
+
270
487
  const pump = async (id: string, s: SessionState): Promise<void> => {
271
- if (s.running) return;
488
+ // Already pumping: it picks this up, though it may be waiting for a parallel turn and need waking.
489
+ if (s.running) { const wake = s.wake; s.wake = undefined; wake?.(); return; }
272
490
  s.running = true;
273
491
  // 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
492
+ // StorageBackend swap, the remounts a plugin load or unload stages) may only land where no
275
493
  // operation spans it — and the pump's own store work does not stop at a turn's end: it reads the
276
494
  // committed document back for followup, appends markers to it, rewrites it for a retract, and
277
495
  // persists the next turn's user message before that turn opens. All of that sits between turns,
@@ -279,15 +497,45 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
279
497
  // usage flushes at, for the same reason. `machineBusy` releases on every exit including a throw,
280
498
  // so no early return out of the loop below can leave the machine held.
281
499
  //
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 () => {
500
+ // It also waits for deferred machine work to land before taking the hold, so a staged swap is in place
501
+ // before the loop below reads anything. That wait used to be spelled at this call site, and spelling
502
+ // it there made it something the next caller of `machineBusy` could omit without any symptom — so it
503
+ // moved inside the hold it guards.
504
+ //
505
+ // Writes of this session from outside its turns are not machine work, and do not wait for the edge:
506
+ // this loop applies them itself, between its turns (`drainWrites`), so they wait only for this
507
+ // session's turns, and the next turn reads what they wrote.
508
+ //
509
+ // The parallel turns this pump admits are part of the same operation. Their tools use the machine
510
+ // just as the queue's turns do, so the hold stays up until they have settled too (see the wait in the
511
+ // loop below), and their own runner takes none (`SessionRunnerDeps.heldByCreator`).
512
+ const drain = async (): Promise<void> => {
289
513
  try {
290
- while (s.queue.length > 0) {
514
+ for (;;) {
515
+ // Not ahead of a redo. A redo re-runs the turn just retracted, found as the session's LAST user
516
+ // message, so a pair appended here would be re-run in its place, on a history ending in an
517
+ // assistant message. Left in `merges`, the pair is placed by the redo's first round instead,
518
+ // ahead of the kept user message, which is where the copy it ran on ended. Writes wait for the
519
+ // redo to end, for the same reason and because one could remove the message it re-runs.
520
+ //
521
+ // Writes before replies: a cut queued by the turn just ended drops everything from its anchor
522
+ // to the end, so a parallel reply appended first would go with it.
523
+ if (s.queue[0]?.redo === undefined) {
524
+ await drainWrites(s);
525
+ await drainMerges(id, s);
526
+ // One that arrived while the other was writing. Its arrival found the pump busy, so nothing
527
+ // else will come round for it; going on would leave it unwritten, and the session busy.
528
+ if (s.writes.length > 0 || s.merges.length > 0) continue;
529
+ }
530
+ if (s.queue.length === 0) {
531
+ if (s.parallel.size === 0) break;
532
+ // A parallel turn is still out, and this hold covers it. Woken by its settling (its reply is
533
+ // then placed at the top of the loop) or by a new submission. No turn is running meanwhile,
534
+ // so an interrupt or a parallel submission arriving now queues as an ordinary turn.
535
+ s.runningTraceId = undefined;
536
+ await new Promise<void>(resolve => { s.wake = resolve; });
537
+ continue;
538
+ }
291
539
  // Per-submission concat: the head always runs; if the head is a concat submission it absorbs
292
540
  // the following submissions while they too are concat, stopping at the first non-concat (a turn
293
541
  // boundary). Net effect: maximal runs of consecutive concat submissions merge into one turn,
@@ -299,6 +547,20 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
299
547
  }
300
548
  const content = batch.flatMap(i => i.content);
301
549
  s.runningTraceId = head.traceId;
550
+ // The turn head, in this same step (see `SessionState.turnHead`): a fresh turn's user message is
551
+ // minted here, so its id is known before the read below, and persisted after it; a redo's is the
552
+ // message its retraction kept.
553
+ const userMsg = head.redo === undefined
554
+ ? createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider })
555
+ : undefined;
556
+ s.turnHead = userMsg !== undefined ? { id: userMsg.id, request: textOf(userMsg) } : head.redo?.head;
557
+ // In the same synchronous step that takes the item off the queue, so there is no moment at which a
558
+ // stop finds it in neither place: `abort`/`cancelTurn` reach a queued item through the queue and a
559
+ // dequeued one only through this. Created after the preamble below (a store read, the persist,
560
+ // the provider resolving), a stop sent during it found nothing and the turn ran anyway. A turn
561
+ // whose signal is already aborted ends `aborted` at its first round.
562
+ const ac = new AbortController();
563
+ s.ac = ac;
302
564
  s.replay = [];
303
565
  // Seed replay with the running turn's user message as a single merged `queued`, mirroring the
304
566
  // message persisted just below. notify() (in open()) reaches only subscribers that are live at
@@ -314,12 +576,13 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
314
576
 
315
577
  let session = await deps.store.get(id);
316
578
  if (session === null) {
579
+ s.ac = s.turnHead = undefined;
317
580
  emit(s, { type: 'error', error: `Session "${id}" not found`, traceId: head.traceId });
318
581
  continue;
319
582
  }
320
583
 
321
584
  // A redo re-runs the existing committed user turn — no title derivation, no new user message.
322
- if (head.redo === undefined) {
585
+ if (userMsg !== undefined) {
323
586
  if (!session.title && !session.messages.some(m => m.role === 'user')) {
324
587
  // Falls back to the attachment names when the turn has no words of its own: an image-only
325
588
  // first turn is a real submission, and got no title at all — which reads in the session
@@ -339,7 +602,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
339
602
  // Persist-at-turn-start: the user message only hits the store when its turn begins, never
340
603
  // while queued. That is what stops a mid-turn submit from clobbering session state. A robo
341
604
  // 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 }));
605
+ session = appendMessage(session, userMsg);
343
606
  try {
344
607
  await deps.store.set(session.id, session);
345
608
  } catch (e) {
@@ -348,6 +611,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
348
611
  // it as a turn error and drop this submission rather than let it escape the detached pump and
349
612
  // crash the process. Any other write failure stays fatal.
350
613
  if (!isReadOnlyError(e)) throw e;
614
+ s.ac = s.turnHead = undefined;
351
615
  emit(s, { type: 'error', error: e.message, traceId: head.traceId });
352
616
  continue;
353
617
  }
@@ -355,6 +619,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
355
619
 
356
620
  const resolved = await deps.resolveProvider(head.provider);
357
621
  if (resolved === null) {
622
+ s.ac = s.turnHead = undefined;
358
623
  emit(s, { type: 'error', error: `Unknown provider "${head.provider}"`, traceId: head.traceId });
359
624
  continue;
360
625
  }
@@ -363,8 +628,6 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
363
628
  // two never coincide on one item, so either supplies runSession's injectedEphemeral.
364
629
  const inject = head.redo?.ephemeral ?? head.injectEphemeral;
365
630
 
366
- const ac = new AbortController();
367
- s.ac = ac;
368
631
  // Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
369
632
  // (a source tool's ToolContracts arms, or a source-less tool's `toolContract`) — into its description
370
633
  // for this turn, so the model reasons about the real TS shapes, not just the loose inputSchema.
@@ -383,6 +646,15 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
383
646
  }))
384
647
  : undefined;
385
648
 
649
+ // The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
650
+ // commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
651
+ // refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
652
+ // in both cases a followup hook would judge history with no completed answer in it — and a
653
+ // `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
654
+ // budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
655
+ // signal-driven aborts and not the policy ones. Declared outside the `try` because `finally`
656
+ // also asks whether the turn committed at all.
657
+ let terminal: PipelineEvent['type'] | undefined;
386
658
  try {
387
659
  // Establish the submitter's principal for the whole turn here, not inside runSession:
388
660
  // pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
@@ -391,14 +663,6 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
391
663
  // because the pump owns it, not because it must be: runAs rescopes a returned iterator itself.
392
664
  // runAs, not a context switch of its own: the pump holds the machine across the whole queue
393
665
  // (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
666
  // One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
403
667
  // definition and a detached classifier settles whenever it settles, so a scope that ended at
404
668
  // the turn's commit would drop precisely the spend that is hardest to account for otherwise.
@@ -423,17 +687,20 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
423
687
  ...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
424
688
  ...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
425
689
  ...(deps.files !== undefined ? { files: deps.files } : {}),
690
+ ...(deps.appender !== undefined ? { appender: deps.appender } : {}),
426
691
  ...(mediaStore !== undefined ? { mediaStore } : {}),
427
692
  ...(permissionGate !== undefined ? { permissionGate } : {}),
428
693
  ...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
429
694
  ...(deps.vault !== undefined ? { vault: deps.vault } : {}),
430
695
  ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
431
696
  ...(inject !== undefined ? { injectedEphemeral: inject } : {}),
697
+ interject: interjectInto(s),
432
698
  })) {
433
699
  if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
434
700
  emit(s, ev);
435
701
  }
436
702
  });
703
+ s.turnHead = undefined;
437
704
 
438
705
  // followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
439
706
  // head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
@@ -463,11 +730,9 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
463
730
  // the message tail, so these markers are folded into that single write instead (below)
464
731
  // to keep ordering sane (a separate append here would be sliced into the popped region).
465
732
  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
- });
733
+ const markerMsg = createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId });
734
+ await rewrite(id, committed, cur => ({ ...cur, messages: [...cur.messages, markerMsg] }),
735
+ `[matbot] followup markers of session "${id}" were not written`);
471
736
  notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
472
737
  }
473
738
  // unshift in reverse so the hooks' order is preserved at the head of the queue. Stamp
@@ -499,50 +764,69 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
499
764
  // head, delivering the trigger tool's output as ephemeral context. Unshifted last so it
500
765
  // sits at the very head (runs next) even if a resubmit was also queued above.
501
766
  if (followup.retract) {
502
- const lastUserIdx = lastUserIndex(committed);
503
- const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
504
767
  // `durable` correction (a contextual fire that landed post-commit) folds onto the user
505
768
  // message we keep — persisted, so the redo AND every later turn see it in history, the
506
769
  // exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
507
770
  // The fold runs on the *truncated* session, whose last user message is the one we keep,
508
771
  // so it goes through the same helper as the screen and raced-verdict folds.
509
772
  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
773
  // Any followup markers ride along as a trailing marker message (not folded into the
523
774
  // retraction marker) so each creator's trace stays its own block.
524
775
  const trailing: Message[] = followup.markers.length > 0
525
776
  ? [createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId })]
526
777
  : [];
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 });
778
+ // Recomputed per attempt: a settled parallel pair may have been placed while the hooks
779
+ // judged, ahead of the turn we are about to pop, so the landmarks are taken from the
780
+ // document we are actually writing over. Every one of them is "the last user message",
781
+ // which a pair placed ahead of it does not move — so this changes nothing but which
782
+ // document the kept prefix comes from.
783
+ let keptHead: Message | undefined;
784
+ let retractionMsg: Message | undefined;
785
+ const wrote = await rewrite(id, committed, cur => {
786
+ const lastUserIdx = lastUserIndex(cur);
787
+ const popped = lastUserIdx >= 0 ? cur.messages.slice(lastUserIdx + 1) : [];
788
+ const truncated = lastUserIdx >= 0
789
+ ? { ...cur, messages: cur.messages.slice(0, lastUserIdx + 1) }
790
+ : cur;
791
+ const kept = foldOntoUserTurn(truncated, durable).messages;
792
+ // The redo's turn head: the user message kept here, as folded — what it re-runs.
793
+ keptHead = lastUserIdx >= 0 ? kept[lastUserIdx] : undefined;
794
+ retractionMsg = createMessage({
795
+ role: 'marker',
796
+ traceId: head.traceId,
797
+ // `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
798
+ // context fed to the redo — neither is otherwise persisted, so the pair fully traces
799
+ // the swap for a post-mortem (and a frontend can render the strike-through + the cause).
800
+ content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract?.context ?? [], traceId: head.traceId } }],
801
+ });
802
+ return { ...cur, messages: [...kept, retractionMsg, ...trailing] };
803
+ }, `[matbot] the retraction of session "${id}" was not written; it will not be re-run`);
804
+ // Only if it landed: nothing was popped otherwise, so there is nothing to re-run and
805
+ // nothing to announce — a redo enqueued here would re-answer a turn whose original
806
+ // answer is still in the session.
807
+ if (wrote && retractionMsg !== undefined) {
808
+ // Emit the durable fold live (as a robo-user on the kept user turn) before the
809
+ // retraction marker, mirroring the screen-time durable path's ordering.
810
+ if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
811
+ notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
812
+ if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
533
813
 
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
- });
814
+ const rt = crypto.randomUUID();
815
+ s.queue.unshift({
816
+ traceId: rt,
817
+ rootTraceId: head.rootTraceId,
818
+ content: [],
819
+ provider: head.provider,
820
+ principal: head.principal,
821
+ concatQueue: false,
822
+ resubmitDepth: head.resubmitDepth + 1,
823
+ redo: {
824
+ ephemeral: followup.retract.context ?? [],
825
+ ...(keptHead !== undefined ? { head: { id: keptHead.id, request: textOf(keptHead) } } : {}),
826
+ },
827
+ ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
828
+ });
829
+ }
546
830
  }
547
831
  }
548
832
  }
@@ -551,24 +835,173 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
551
835
  emit(s, { type: 'error', error: String(e), traceId: head.traceId });
552
836
  } finally {
553
837
  s.ac = undefined;
838
+ s.turnHead = undefined;
839
+ // Every terminal commits, and the replies placed into the turn went with it. No terminal means
840
+ // runSession threw before committing, taking them with it — so they go back to be placed again.
841
+ // Not one `placeNow` already wrote: that is in the store whatever this turn did, and placing
842
+ // it again would duplicate the pair.
843
+ const placed = s.interjected.splice(0);
844
+ if (terminal === undefined) s.merges.unshift(...placed.filter(r => r.persisted !== true));
554
845
  }
555
846
  }
556
847
  } finally {
557
- s.running = false;
558
848
  s.runningTraceId = undefined;
849
+ s.turnHead = undefined;
850
+ s.ac = undefined;
559
851
  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);
852
+ try {
853
+ // Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
854
+ // is not a well-defined moment to total anything at: steers terminate and resume, a retract
855
+ // re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
856
+ // settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
857
+ //
858
+ // And while `running` still holds. The flush is a read-modify-write of the session, so a
859
+ // submission arriving during it must queue behind this pump rather than start a second one —
860
+ // whose persist-at-turn-start write would interleave with it, and whichever landed second
861
+ // erased the other: this flush's entries, or that turn's user message.
862
+ await flushUsage(id, s);
863
+ } finally {
864
+ s.running = false;
865
+ // A submission or a write that arrived during the flush found the pump running and did not start
866
+ // one; nor did a parallel reply that settled then — possible only when a throw left the loop above
867
+ // with parallel turns still out, since it otherwise ends only once none are.
868
+ if (s.queue.length > 0 || s.merges.length > 0 || s.writes.length > 0) void pump(id, s);
869
+ else {
870
+ // Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
871
+ // (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
872
+ // the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
873
+ notify(s, { type: 'idle', sessionId: id });
874
+ maybeCleanup(id, s);
875
+ }
876
+ }
570
877
  }
571
- });
878
+ };
879
+ return deps.heldByCreator ? drain() : machineBusy(drain);
880
+ };
881
+
882
+ /**
883
+ * A parallel turn: the submission runs at once on a nested runner over a private copy of the session's
884
+ * completed turns, and only it and its final reply come back (`ParallelReply`). The copy ends where the
885
+ * running turn's own messages begin (`head`), plus any replies already placed into that turn but not
886
+ * yet committed (`interjected`) — both captured at submit, before any await could outlive the turn.
887
+ *
888
+ * Its events are forwarded under its own traceId as progress, never persisted: its tool calls belong to
889
+ * the copy, which is dropped here. A nested runner rather than `runSession` so the turn is an ordinary
890
+ * one — screen, followup, accounting, media — with nothing re-implemented.
891
+ */
892
+ const runParallel = async (
893
+ id: string, s: SessionState, rec: { replay: PipelineEvent[]; stop: AbortController },
894
+ p: { traceId: string; content: MessageContent[]; provider: string; principal: Principal; prompt?: PromptFn;
895
+ head: { id: string; request: string } | undefined; interjected: Message[] },
896
+ ): Promise<void> => {
897
+ const forward = (ev: PipelineEvent): void => {
898
+ rec.replay.push(ev);
899
+ for (const sink of s.subscribers) sink.push(ev);
900
+ };
901
+ let reply: ParallelReply | undefined;
902
+ try {
903
+ const stored = await deps.store.get(id);
904
+ if (stored === null) { forward({ type: 'error', error: `Session "${id}" not found`, traceId: p.traceId }); return; }
905
+ // The head may not be in the store yet — the turn's user message is persisted after the head is set
906
+ // — in which case nothing of the running turn is there to cut.
907
+ const at = p.head !== undefined ? stored.messages.findIndex(m => m.id === p.head!.id) : -1;
908
+ const prefix = at >= 0 ? stored.messages.slice(0, at) : stored.messages;
909
+ // By the time this read lands the running turn may have committed, interjected replies included.
910
+ const have = new Set(prefix.map(m => m.id));
911
+ const copy = { ...stored, messages: [...prefix, ...p.interjected.filter(m => !have.has(m.id))] };
912
+ const running = p.head?.request ?? '';
913
+
914
+ const store = new MemoryStore<Session>();
915
+ await store.set(id, copy);
916
+ const sub = createSessionRunner({ ...deps, store, heldByCreator: true });
917
+ if (rec.stop.signal.aborted) { forward({ type: 'cancelled', sessionId: id, traceId: p.traceId }); return; }
918
+ rec.stop.signal.addEventListener('abort', () => sub.abort(id), { once: true });
919
+
920
+ // Tap BEFORE submitting: the nested pump starts inside `open`, and an event (or its `idle`) emitted
921
+ // before a later tap would be missed — the last of them leaving this loop waiting for ever.
922
+ const tapAc = new AbortController();
923
+ const tap = (await sub.open({ sessionId: id, signal: tapAc.signal })).events;
924
+ await sub.open({
925
+ sessionId: id, signal: tapAc.signal, traceId: p.traceId, provider: p.provider, principal: p.principal,
926
+ content: [...asSubmitted(p.content), ...(running ? [parallelFraming(running)] : [])],
927
+ ...(p.prompt !== undefined ? { prompt: p.prompt } : {}),
928
+ });
929
+ // Again, now that there is something to stop: a stop landing while the submission was on its way in
930
+ // (its media boundary is an await) reached a runner with nothing queued, and the listener has fired.
931
+ if (rec.stop.signal.aborted) sub.abort(id);
932
+ for await (const ev of tap) {
933
+ if (ev.type === 'idle') break;
934
+ // Announced as `parallel` already, with the content as submitted rather than the framed copy.
935
+ if (ev.type === 'queued') continue;
936
+ forward(ev);
937
+ }
938
+ tapAc.abort();
939
+
940
+ const final = await store.get(id);
941
+ const msgs = final?.messages ?? [];
942
+ const hIdx = msgs.findIndex(m => m.role === 'user' && m.traceId === p.traceId);
943
+ const qHead = msgs[hIdx];
944
+ // Only the reply's text crosses back: its tool calls stay in the copy (an unpaired one would break
945
+ // every later turn), and its thinking was signed against a history that included those tool rounds.
946
+ const last = hIdx >= 0 ? msgs.slice(hIdx + 1).findLast(m => m.role === 'assistant') : undefined;
947
+ const answer = (last?.content ?? []).filter(c => c.type === 'text');
948
+ const ended = rec.replay.findLast(e => e.type === 'done' || e.type === 'aborted' || e.type === 'error' || e.type === 'cancelled');
949
+ const reason = ended?.type === 'aborted' ? `was stopped (${ended.reason})`
950
+ : ended?.type === 'cancelled' ? 'was stopped before it started'
951
+ : ended?.type === 'error' ? `failed: ${ended.error}`
952
+ : 'produced no text';
953
+ reply = {
954
+ traceId: p.traceId,
955
+ ...(p.head !== undefined ? { head: p.head.id } : {}),
956
+ messages: [
957
+ // The copy's own head, carrying its accounting, but with the content as the person submitted it.
958
+ qHead !== undefined
959
+ ? { ...qHead, content: p.content }
960
+ : createMessage({ role: 'user', content: p.content, traceId: p.traceId, providerName: p.provider }),
961
+ last !== undefined && answer.length > 0
962
+ ? { ...last, content: answer }
963
+ : createMessage({ role: 'assistant', content: [{ type: 'text', text: `(No reply — this message's parallel turn ${reason}.)` }],
964
+ traceId: p.traceId, providerName: p.provider }),
965
+ ],
966
+ };
967
+ } catch (e) {
968
+ forward({ type: 'error', error: String(e), traceId: p.traceId });
969
+ } finally {
970
+ s.parallel.delete(p.traceId);
971
+ if (reply !== undefined) {
972
+ // On the list BEFORE the write below, and never only in the write: the turn may end while we are
973
+ // writing, and then `end`'s final pull or `drainMerges` is the path that places this pair. Being
974
+ // on the list is also how the running turn's in-memory session learns of it, so its whole-document
975
+ // write-back carries the pair instead of erasing it.
976
+ s.merges.push(reply);
977
+ // Placed NOW while a turn is in progress, rather than left for that turn's next round boundary,
978
+ // which a turn parked in one long tool call never reaches — nor does one whose rounds are over and
979
+ // whose `followup` hooks are still judging it, which with LLM-judged trigger conditions is seconds.
980
+ // `ac` spans the whole turn, followup included; `turnHead` does not, and is not what anchors this.
981
+ // A reply with no head of its own belongs at the TAIL, which is the pump's to append and must wait
982
+ // for it: appended now it would become the session's last user message, which is the turn a redo
983
+ // re-runs. A failure places it exactly as it used to be.
984
+ if (s.ac !== undefined && reply.head !== undefined) {
985
+ let placed: 'inserted' | 'present' | 'no' = 'no';
986
+ try {
987
+ placed = await placeNow(id, reply);
988
+ } catch (e) {
989
+ console.error(`[matbot] placing a parallel reply of session "${id}" failed:`,
990
+ e instanceof Error ? e : String(e));
991
+ }
992
+ reply.persisted = placed !== 'no';
993
+ // Only the writer that inserted it announces. `into` is the running turn: the pair went ahead of
994
+ // its user message, which is where the copy this ran on ended.
995
+ if (placed === 'inserted') {
996
+ notify(s, { type: 'merged', traceId: reply.traceId,
997
+ ...(s.runningTraceId !== undefined ? { into: s.runningTraceId } : {}) });
998
+ }
999
+ }
1000
+ }
1001
+ // Placed by the running turn if there is one (it is polled each round), else by the pump that admitted
1002
+ // this turn, which is waiting for it.
1003
+ void pump(id, s);
1004
+ }
572
1005
  };
573
1006
 
574
1007
  return {
@@ -612,7 +1045,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
612
1045
  // The decision — and, on interrupt, the abort+unshift — happen here synchronously against
613
1046
  // s.running (re-checked after any classify/nudge await) so an interrupt can never land on the
614
1047
  // wrong (a later) turn: the fatal race of deciding across a wire is closed by deciding in-runner.
615
- let interrupt = false;
1048
+ let handled = false;
616
1049
  if (mode !== 'queue' && s.running) {
617
1050
  const policy = deps.steeringPolicy?.();
618
1051
  // Fetch the committed session once (history up to the running turn's start) only when a policy
@@ -622,8 +1055,8 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
622
1055
  const wantNudge = policy?.nudge !== undefined;
623
1056
  const committed = (wantClassify || wantNudge) ? await deps.store.get(opts.sessionId) : null;
624
1057
 
625
- const decision: SteeringDecision = mode === 'interrupt'
626
- ? 'interrupt'
1058
+ const decision: SteeringDecision = mode === 'interrupt' || mode === 'parallel'
1059
+ ? mode
627
1060
  : policy?.classify !== undefined
628
1061
  ? (committed ? await policy.classify({ session: committed, steer: content }) : DEFAULT_STEERING_POLICY)
629
1062
  : DEFAULT_STEERING_POLICY;
@@ -631,11 +1064,14 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
631
1064
  // Any await above may have outlived the turn this steer was for: interrupt only if THAT one
632
1065
  // is still the running turn. Otherwise fall through and queue — the turn they were steering
633
1066
  // has already committed, which makes this an ordinary follow-up rather than an interruption.
634
- if (decision === 'interrupt' && s.running && s.runningTraceId === steerTarget) {
1067
+ // `running` alone is not "a turn is running": it holds through the usage flush after the queue
1068
+ // drains, where `runningTraceId` is already cleared — and `steerTarget` captured then is
1069
+ // undefined too, so without the first test the two compare equal and nothing gets steered.
1070
+ if (decision === 'interrupt' && s.runningTraceId !== undefined && s.runningTraceId === steerTarget) {
635
1071
  const nudge = policy?.nudge !== undefined && committed
636
1072
  ? policy.nudge({ session: committed, steer: content })
637
1073
  : DEFAULT_STEER_NUDGE;
638
- const interruptedTraceId = s.runningTraceId ?? '';
1074
+ const interruptedTraceId = s.runningTraceId;
639
1075
  // Head of the queue (ahead of anything else already queued): the steer runs immediately
640
1076
  // after the interrupted turn commits its partial work. Own turn (never concat).
641
1077
  s.queue.unshift({
@@ -653,11 +1089,29 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
653
1089
  // imminent `aborted` (reason 'steer') as a yield, not a dead-end.
654
1090
  notify(s, { type: 'steer', content, interruptedTraceId, traceId, rootTraceId: traceId });
655
1091
  s.ac?.abort('steer');
656
- interrupt = true;
1092
+ handled = true;
1093
+ }
1094
+
1095
+ // Unlike a steer, a parallel turn needs no particular turn to still be running — only SOME turn,
1096
+ // since that is what it runs beside. Its copy is cut at whichever turn that is, captured here,
1097
+ // synchronously, because the next await could outlive it.
1098
+ if (decision === 'parallel' && s.running && s.runningTraceId !== undefined) {
1099
+ const rec = { replay: [] as PipelineEvent[], stop: new AbortController() };
1100
+ const announce: PipelineEvent = { type: 'parallel', content, runningTraceId: s.runningTraceId, traceId, rootTraceId: traceId };
1101
+ rec.replay.push(announce);
1102
+ s.parallel.set(traceId, rec);
1103
+ notify(s, announce);
1104
+ void runParallel(opts.sessionId, s, rec, {
1105
+ traceId, content, provider, principal,
1106
+ head: s.turnHead,
1107
+ interjected: s.interjected.flatMap(r => r.messages),
1108
+ ...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
1109
+ });
1110
+ handled = true;
657
1111
  }
658
1112
  }
659
1113
 
660
- if (!interrupt) {
1114
+ if (!handled) {
661
1115
  const concatQueue = opts.concatQueue ?? false;
662
1116
  s.queue.push({
663
1117
  traceId,
@@ -695,8 +1149,10 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
695
1149
  const remove = (): void => { st.subscribers.delete(sink); maybeCleanup(opts.sessionId, st); };
696
1150
  const sink = createSink(remove);
697
1151
  st.subscribers.add(sink);
698
- // Replay the in-progress turn, then the pending queue — the delta region, in order.
1152
+ // Replay the in-progress turn, any parallel turns beside it, then the pending queue — the delta
1153
+ // region, in order.
699
1154
  for (const ev of st.replay) sink.push(ev);
1155
+ for (const { replay } of st.parallel.values()) for (const ev of replay) sink.push(ev);
700
1156
  st.queue.forEach((item, i) =>
701
1157
  sink.push({ type: 'queued', content: item.content, queued: aheadOf(st, i), concatQueue: item.concatQueue, traceId: item.traceId, rootTraceId: item.rootTraceId }));
702
1158
  if (opts.signal.aborted) { sink.close(); remove(); }
@@ -716,6 +1172,14 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
716
1172
  emit(s, { type: 'cancelled', sessionId, traceId: item.traceId });
717
1173
  }
718
1174
  s.ac?.abort('user-abort');
1175
+ for (const { stop } of s.parallel.values()) stop.abort('user-abort');
1176
+ },
1177
+
1178
+ abortParallel(sessionId: string, traceId: string): void {
1179
+ // Only this turn's own stop. Nothing else is touched — not the running turn it was answered
1180
+ // beside, not the queue, not its siblings — and it settles through the ordinary path, so its pair
1181
+ // is still written back with a note where the reply would have been.
1182
+ states.get(sessionId)?.parallel.get(traceId)?.stop.abort('user-abort');
719
1183
  },
720
1184
 
721
1185
  cancelTurn(sessionId: string): void {
@@ -726,11 +1190,42 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
726
1190
  s.ac?.abort('user-cancel');
727
1191
  },
728
1192
 
729
- status(sessionId: string): { busy: boolean; running: boolean; queued: number } {
1193
+ status(sessionId: string): { busy: boolean; running: boolean; queued: number; parallel: number } {
730
1194
  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 };
1195
+ const running = s?.running ?? false;
1196
+ const queued = s?.queue.length ?? 0;
1197
+ const parallel = s?.parallel.size ?? 0;
1198
+ return { busy: running || queued > 0 || parallel > 0 || (s?.merges.length ?? 0) > 0, running, queued, parallel };
1199
+ },
1200
+
1201
+ write(sessionId: string, attempt: () => Promise<boolean>, lost: string): SessionWrite {
1202
+ const s = stateFor(sessionId);
1203
+ // As the caller. The pump runs in the scope of whatever started it, and the store checks ownership.
1204
+ const principal = tryCurrentPrincipal();
1205
+ // It waits for a turn exactly when one is in progress (`ac` is set from dequeue until the turn,
1206
+ // followup included, is done), or when a redo is next — it waits for that too (see the loop).
1207
+ // Otherwise the pump reaches it before taking the next turn off the queue.
1208
+ const deferred = s.ac !== undefined || s.queue[0]?.redo !== undefined;
1209
+ let settle!: () => void;
1210
+ const done = new Promise<void>(resolve => { settle = resolve; });
1211
+ const tries = async (): Promise<void> => {
1212
+ for (let n = 0; n < WRITE_ATTEMPTS; n++) if (await attempt()) return;
1213
+ console.error(lost);
1214
+ };
1215
+ s.writes.push(async () => {
1216
+ try {
1217
+ await (principal !== undefined ? runAs(principal, tries) : tries());
1218
+ } catch (e) {
1219
+ // Nobody may be awaiting it, and the pump must go on to the next write and turn.
1220
+ console.error(`[matbot] a write of session "${sessionId}" failed:`, e instanceof Error ? e : String(e));
1221
+ } finally {
1222
+ settle();
1223
+ }
1224
+ });
1225
+ // On a microtask, so the write cannot start before this returns: a caller decides what to do from
1226
+ // `deferred`, and its attempt may read what it set after the call.
1227
+ queueMicrotask(() => { void pump(sessionId, s); });
1228
+ return { deferred, done };
734
1229
  },
735
1230
  };
736
1231
  }