@matatbread/matbot-core 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.3",
7
+ "version": "0.4.4",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@matatbread/matbot-plugin-api": "^0.4.3"
38
+ "@matatbread/matbot-plugin-api": "^0.4.4"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
package/src/index.ts CHANGED
@@ -28,13 +28,13 @@ export {
28
28
  unifyServices, forwardingProxy, makeSwappable, singleTurnRequest, createMountTable,
29
29
  createBroadcaster, subscribable, createNotifier, scopedNotifier,
30
30
  installPrincipalCarrier, enterPrincipal, createConstantPrincipalCarrier,
31
- contextSwitch, onContextQuiesce, flushIfQuiescent,
31
+ contextSwitch, machineBusy, quiesced, onContextQuiesce, flushIfQuiescent,
32
32
  installUsageCarrier, createSerialUsageCarrier, recordUsage, recordSpan, currentUsageSink,
33
33
  currentUsageScope, currentUsageSite, withUsageScope, withUsageSite,
34
34
  } from '@matatbread/matbot-plugin-api/host';
35
35
  export type {
36
36
  Subscribable, Broadcaster, Routed, RoutedFilter, SwapFn, MountTable, PrincipalCarrier, UsageCarrier,
37
- UsageScope,
37
+ UsageScope, Quiescer,
38
38
  } from '@matatbread/matbot-plugin-api/host';
39
39
  export * from './session.js';
40
40
  export * from './usage.js';
@@ -8,8 +8,8 @@ import type { MatbotPlugin } from './plugin.js';
8
8
  import type { HookRegistry } from './hooks.js';
9
9
  import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
10
10
  import { appendMessage, createMessage } from './session.js';
11
- import { isReadOnlyError, foldOntoUserTurn, lastUserIndex } from '@matatbread/matbot-plugin-api';
12
- import { contextSwitch, withUsageScope } from '@matatbread/matbot-plugin-api/host';
11
+ import { isReadOnlyError, foldOntoUserTurn, lastUserIndex, runAs } from '@matatbread/matbot-plugin-api';
12
+ import { machineBusy, quiesced, withUsageScope } from '@matatbread/matbot-plugin-api/host';
13
13
  import { runSession } from './runner.js';
14
14
 
15
15
  export interface SessionRunnerDeps {
@@ -255,6 +255,21 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
255
255
  const pump = async (id: string, s: SessionState): Promise<void> => {
256
256
  if (s.running) return;
257
257
  s.running = true;
258
+ // Hold the machine for the WHOLE queue, not per turn. A deferred machine mutation (the
259
+ // StorageBackend swap, a plugin's deferred edit of this very session) may only land where no
260
+ // operation spans it — and the pump's own store work does not stop at a turn's end: it reads the
261
+ // committed document back for followup, appends markers to it, rewrites it for a retract, and
262
+ // persists the next turn's user message before that turn opens. All of that sits between turns,
263
+ // and a per-turn hold declared it idle. The queue draining is the real boundary — the same one
264
+ // usage flushes at, for the same reason. `machineBusy` releases on every exit including a throw,
265
+ // so no early return out of the loop below can leave the machine held.
266
+ //
267
+ // Wait out any deferred work still settling BEFORE holding: a flusher that rewrites this very
268
+ // session (a `session_edit` deferred out of the last turn) must finish before this turn reads its
269
+ // copy, or the read would take the pre-edit document and the write-back below would put it back.
270
+ // The edge is only reachable while nothing holds the machine, so the wait comes first and the
271
+ // hold second — the reverse would deadlock on an edge this pump is itself preventing.
272
+ return quiesced().then(() => machineBusy(async () => {
258
273
  try {
259
274
  while (s.queue.length > 0) {
260
275
  // Per-submission concat: the head always runs; if the head is a concat submission it absorbs
@@ -350,8 +365,8 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
350
365
  // turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
351
366
  // access they trigger) reads it via currentPrincipal(). The consumption MUST happen inside
352
367
  // the callback: an async iterator returned out of the scope would lose it before it pulls.
353
- // contextSwitch (not bare runAs): a turn is the transactional unit, so a StorageBackend swap
354
- // deferred during it lands at this scope's quiescent edge — never mid-CAS.
368
+ // runAs, not a context switch of its own: the pump holds the machine across the whole queue
369
+ // (see above), so a turn declares only its owner. Each item carries its own submitter.
355
370
  // The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
356
371
  // commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
357
372
  // refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
@@ -367,7 +382,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
367
382
  // in something already tracked and needs no coordination of its own.
368
383
  await withUsageScope(async usage => {
369
384
  s.pendingUsage.push(usage.entries);
370
- await contextSwitch(head.principal, async () => {
385
+ await runAs(head.principal, async () => {
371
386
  for await (const ev of runSession({
372
387
  session,
373
388
  config: { provider: head.provider, traceId: head.traceId },
@@ -403,7 +418,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
403
418
  const committed = await deps.store.get(id);
404
419
  if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
405
420
  let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
406
- await contextSwitch(head.principal, async () => {
421
+ await runAs(head.principal, async () => {
407
422
  followup = await hooks.runFollowup({
408
423
  session: committed,
409
424
  resubmitDepth: head.resubmitDepth,
@@ -530,6 +545,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
530
545
  notify(s, { type: 'idle', sessionId: id });
531
546
  maybeCleanup(id, s);
532
547
  }
548
+ }));
533
549
  };
534
550
 
535
551
  return {
@@ -138,9 +138,22 @@ export class CachingStorageBackend implements StorageBackend {
138
138
  private readonly ttlMs: number | undefined;
139
139
  private readonly stores = new Map<string, CachingStore<{ id: string; version: string }>>();
140
140
 
141
+ /**
142
+ * Forwarded only when the wrapped backend has it. `namespaces` is optional, and its ABSENCE is the
143
+ * signal a caller reads ("this backend cannot be enumerated") — so a decorator that always declared
144
+ * the method would answer for backends that cannot, turning a degradable capability into a runtime
145
+ * failure. Assigned per instance rather than declared as a method so `'namespaces' in backend` stays
146
+ * truthful through the wrapper.
147
+ *
148
+ * Not cached: the cache is per-namespace and populated by reads, so it knows only what has been
149
+ * asked for — never the set of namespaces that exist.
150
+ */
151
+ readonly namespaces?: () => Promise<string[]>;
152
+
141
153
  constructor(inner: StorageBackend, options: CachingOptions = {}) {
142
154
  this.inner = inner;
143
155
  this.ttlMs = options.ttlMs;
156
+ if (inner.namespaces !== undefined) this.namespaces = () => inner.namespaces!();
144
157
  }
145
158
 
146
159
  createStore<T extends { id: string; version: string }>(namespace: string): Store<T> {
@@ -1,2 +1,3 @@
1
1
  export * from './query/index.js';
2
2
  export { CachingStorageBackend, type CachingOptions, type CacheNamespaceStats } from './caching-backend.js';
3
+ export { mediumGuard } from './medium-guard.js';
@@ -0,0 +1,114 @@
1
+ import type { Store, CASResult, StoreQuery, QueryResult } from '@matatbread/matbot-plugin-api';
2
+
3
+ /**
4
+ * Fail a write whose read came from a different storage medium.
5
+ *
6
+ * Exactly one `StorageBackend` is active, nothing is migrated between them, and a swap repoints every
7
+ * store proxy at once. A caller that read a document before the swap and writes it after is therefore
8
+ * addressing two different media with one read-modify-write, and neither end can tell: `cas` asks
9
+ * "did this document change?", which the new backend answers about a document it never issued — most
10
+ * often "there is nothing here", at which point an unconditional `set` writes the old backend's
11
+ * document into the new one and the session has silently migrated. Compare-and-swap cannot see this,
12
+ * because the medium is not what it compares.
13
+ *
14
+ * The fix belongs here rather than at whatever happened to be running: the exposure is not particular
15
+ * to the quiescent edge's deferred work — an HTTP tool call has always been able to straddle a swap
16
+ * the same way — so pushing the check up to every caller would be asking each of them to know
17
+ * something only storage knows.
18
+ *
19
+ * The version is the only token tying a read to its write, so it carries the medium: stamped on the
20
+ * way out, checked and stripped on the way in. Stripping is what keeps it invisible — a caller that
21
+ * writes a document straight back (`store.set(id, { ...doc, title })`, which does not bump the
22
+ * version) must not persist a stamp, or the next read would stamp it twice.
23
+ *
24
+ * An UNstamped version is always accepted: that is a document the caller minted rather than read, and
25
+ * a create has no earlier medium to disagree with.
26
+ */
27
+
28
+ const STAMP = /^m(\d+)~([\s\S]*)$/;
29
+
30
+ interface Stamped { id: string; version: string }
31
+
32
+ function stamp<T extends Stamped>(doc: T, generation: number): T {
33
+ return { ...doc, version: `m${generation}~${doc.version}` };
34
+ }
35
+
36
+ type Checked =
37
+ | { ok: true; version: string }
38
+ | { ok: false; from: number };
39
+
40
+ function check(version: string, generation: number): Checked {
41
+ const m = STAMP.exec(version);
42
+ if (m === null) return { ok: true, version }; // minted, not read
43
+ if (Number(m[1]) === generation) return { ok: true, version: m[2]! };
44
+ return { ok: false, from: Number(m[1]) };
45
+ }
46
+
47
+ /** Strip a stamp wherever one appears, so nothing a caller hands back is persisted with one. */
48
+ function strip<T extends Stamped>(doc: T): T {
49
+ const m = STAMP.exec(doc.version);
50
+ return m === null ? doc : { ...doc, version: m[2]! };
51
+ }
52
+
53
+ /**
54
+ * Wrap `inner` so every document it hands out is stamped with the medium generation it came from, and
55
+ * every write is checked against the generation current at the time of the write. `generation` must
56
+ * increase on every backend swap; the wrapper is placed OUTSIDE the swap proxy, so it is one stable
57
+ * object across every swap and a captured store reference keeps working.
58
+ */
59
+ export function mediumGuard<T extends { id: string; version: string }>(
60
+ inner: Store<T>,
61
+ generation: () => number,
62
+ namespace: string,
63
+ ): Store<T> {
64
+ const stale = (op: string, id: string, from: number): string =>
65
+ `${op} on "${namespace}/${id}" read from storage backend generation ${from}, but generation ${generation()} is now active. ` +
66
+ 'Nothing is migrated between backends, so this write would put a document from the previous one into its replacement. Re-read and retry.';
67
+
68
+ return {
69
+ async get(id) {
70
+ const doc = await inner.get(id);
71
+ return doc === null ? null : stamp(doc, generation());
72
+ },
73
+
74
+ async query(q: StoreQuery): Promise<QueryResult<T>> {
75
+ const page = await inner.query(q);
76
+ const gen = generation();
77
+ return { ...page, items: page.items.map(d => stamp(d, gen)) };
78
+ },
79
+
80
+ async set(id, value) {
81
+ const seen = check(value.version, generation());
82
+ // `set` has no failure channel, and silence is what makes the migration invisible — so it
83
+ // throws. It is reachable only after a swap, holding a document read before it.
84
+ if (!seen.ok) throw new Error(stale('set', id, seen.from));
85
+ await inner.set(id, strip(value));
86
+ },
87
+
88
+ async cas(id, expected, next): Promise<CASResult<T>> {
89
+ const seen = check(expected, generation());
90
+ // Reported as a lost CAS rather than thrown: the caller already has a path for "someone else got
91
+ // there first", and this is that, with the medium in the role of the other writer. `current` is
92
+ // read from the backend now in force — the document the caller must reconcile against.
93
+ if (!seen.ok) {
94
+ console.warn(`[matbot] ${stale('cas', id, seen.from)}`);
95
+ const current = await inner.get(id);
96
+ return { ok: false, current: current === null ? null : stamp(current, generation()) };
97
+ }
98
+ const res = await inner.cas(id, seen.version, strip(next));
99
+ const gen = generation();
100
+ if (res.ok) return { ok: true, doc: stamp(res.doc, gen) };
101
+ return { ok: false, current: res.current === null ? null : stamp(res.current, gen) };
102
+ },
103
+
104
+ async delete(id, expectedVersion) {
105
+ if (expectedVersion === undefined) return inner.delete(id);
106
+ const seen = check(expectedVersion, generation());
107
+ if (!seen.ok) {
108
+ console.warn(`[matbot] ${stale('delete', id, seen.from)}`);
109
+ return false;
110
+ }
111
+ return inner.delete(id, seen.version);
112
+ },
113
+ };
114
+ }