@matatbread/matbot-plugin-api 0.4.3 → 0.4.5

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.5",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -5,80 +5,425 @@ import { globalSlot } from './global-state.js';
5
5
  /**
6
6
  * A context switch is the machine analogue of an OS one: it pages in any pending machine state and
7
7
  * sets the owner. {@link runAs} is the bare "set the owner" instruction (the principal carrier);
8
- * {@link contextSwitch} is the scheduler operation layered on top — it also lands any machine
9
- * mutation that was deferred while the machine was busy.
8
+ * {@link machineBusy} is the other half — "hold the machine, I am mid-operation" — and
9
+ * {@link contextSwitch} is the two together, for an operation that is both.
10
10
  *
11
11
  * Why deferral exists: a service like the `StorageBackend` is the system of record, and swapping it
12
12
  * under a running operation would split a compare-and-swap (or a turn) across two backends. So a
13
- * swap is queued and applied only at the *quiescent edge* — the instant no context is active — which
14
- * is exactly a context-switch boundary. The principal carrier stays a pure identity primitive; this
15
- * module owns the machine-update half, so the two concerns share call sites without sharing code.
16
- *
17
- * The host registers what to flush ({@link onContextQuiesce}); the three entry points (a web
18
- * request, a telegram message, a turn) switch context through {@link contextSwitch} instead of
19
- * calling {@link runAs} directly. The boot principal (an `enter`, not a `run`) is the depth-0
20
- * baseline and is never counted.
13
+ * swap is queued and applied only at the *quiescent edge* — the instant nothing holds the machine.
14
+ * The principal carrier stays a pure identity primitive; this module owns the machine half.
15
+ *
16
+ * **What counts as busy is the operation, not the turn.** The two are not the same thing, and
17
+ * assuming they were is what made this edge wrong: a turn commits its session document, and *then*
18
+ * its pump reads that document back, appends followup markers, rewrites it for a retract, and starts
19
+ * the next queued turn — all of it store work outside the turn, and all of it quiescent under a
20
+ * per-turn hold. A mutation landing in that window splits exactly what the deferral exists to keep
21
+ * whole. So the pump holds the machine for its whole queue and switches principal per item, which is
22
+ * also where accounting already flushes: "the end of a turn" is not a moment anything can be totalled
23
+ * or swapped at, and the queue draining is.
24
+ *
25
+ * The host registers what to flush ({@link onContextQuiesce}). Entry points that receive work but do
26
+ * not perform it — a web request holding an SSE stream open, a telegram message — use {@link runAs}
27
+ * and are deliberately NOT busy: their scope spans a long-lived stream, and counting it would mean
28
+ * the machine never reaches an edge at all. The boot principal (an `enter`, not a `run`) is likewise
29
+ * never counted.
21
30
  */
22
31
 
23
32
  // State-shaped and reachable from a plugin (a storage plugin registering a deferred-swap flusher, or
24
- // switching context), so it lives in the global slot: a duplicated plugin-api copy shares one set of
25
- // quiescers and one depth count rather than splitting them. See ./global-state.ts.
33
+ // holding the machine), so it lives in the global slot: a duplicated plugin-api copy shares one set
34
+ // of quiescers and one hold count rather than splitting them. See ./global-state.ts.
35
+ //
36
+ // `depth` keeps its name across the change of meaning — it counts machine holds now, of which a
37
+ // principal-bearing context switch is one — because the slot is shared by every loaded copy of this
38
+ // module, including published older ones. Both spellings of "busy" increment the same field, so a
39
+ // skewed install still agrees on when the machine is idle; a renamed field would have left an old
40
+ // copy marking a turn busy on `depth` while a new copy read `busy` and called the machine quiet.
26
41
  interface ContextSwitchState {
27
- quiescers: Set<() => void>;
42
+ quiescers: Set<Quiescer>;
28
43
  depth: number;
29
44
  flushing: boolean;
45
+ /** The flush currently settling, when a flusher went async. Held so a second caller joins the one
46
+ * in flight rather than starting a second, and so {@link quiesced} has something to wait on. */
47
+ inflight?: Promise<void> | undefined;
48
+ /** Deferred work was staged while the machine was held, so the edge it needs was out of reach. Raised
49
+ * by {@link flushIfQuiescent} — which is only ever called by a stager — and lowered when a flush
50
+ * actually runs. While it is up, {@link machineBusy} bars new entrants so `depth` can reach 0. */
51
+ wanted?: boolean;
52
+ /** Entrants parked at the barrier, woken on every release and at the end of every flush. */
53
+ waiters?: Array<() => void>;
30
54
  }
31
55
  const cs = globalSlot<ContextSwitchState>('context-switch', () => ({
32
- quiescers: new Set<() => void>(),
56
+ quiescers: new Set<Quiescer>(),
33
57
  depth: 0,
34
58
  flushing: false,
35
59
  }));
36
60
 
61
+ // The slot is shared by every loaded copy of this module, INCLUDING published copies older than the
62
+ // barrier — one of those may have created it, so the two fields above can be absent rather than merely
63
+ // false. Read them through here. A stager living in an old copy raises no `wanted`, so entry is not
64
+ // barred on its behalf: that is the pre-barrier behaviour (its work lands at the next natural release),
65
+ // not a broken one.
66
+ function waiters(): Array<() => void> {
67
+ return (cs.waiters ??= []);
68
+ }
69
+
70
+ /** Nothing staged and nothing settling — the barrier is down and an entrant walks straight in. */
71
+ function clear(): boolean {
72
+ return cs.wanted !== true && !cs.flushing;
73
+ }
74
+
75
+ function wake(): void {
76
+ const parked = waiters();
77
+ if (parked.length === 0) return;
78
+ cs.waiters = [];
79
+ for (const w of parked) w();
80
+ }
81
+
37
82
  /**
38
- * Register a machine-update flush, run at every quiescent edge (no context active). Flushers MUST be
39
- * idempotent: a no-op when nothing is pending, since the edge is reached after every operation.
40
- * Returns an unregister fn. A throwing flusher is isolated — one must not block the others or escape
41
- * into the operation that triggered the edge.
83
+ * A machine-update flush. Returning a promise makes the edge *wait*: the deferred work is no longer
84
+ * merely started at the edge but finished before the machine is handed to whoever asked to be
85
+ * quiesced first (see {@link quiesced}). That matters for work that reads and rewrites a document the
86
+ * next operation is about to read — a store edge deferred out of a turn lands in the window before
87
+ * the next turn takes its copy, or not at all.
42
88
  */
43
- export function onContextQuiesce(flush: () => void): () => void {
89
+ export type Quiescer = (unregister: () => void) => void | Promise<void>;
90
+
91
+ /**
92
+ * Register work to run at a quiescent edge — the next moment nothing holds the machine. Returns an
93
+ * unregister fn, and passes the same fn to the flusher.
94
+ *
95
+ * **Registering is all a stager does.** It announces the work, so the barrier engages and the edge is
96
+ * guaranteed to arrive; there is no second call to remember. Two idioms, and between them they cover
97
+ * everything in-tree:
98
+ *
99
+ * ```ts
100
+ * // ONE-SHOT — "do this at the next edge". The closure holds the work, so there is no pending flag.
101
+ * onContextQuiesce(un => { un(); applyTheThing(); });
102
+ *
103
+ * // REPEATED — "do this at every edge". Same thing without the un(), and then it MUST be idempotent:
104
+ * onContextQuiesce(() => { if (nothingToDo) return; … });
105
+ * ```
106
+ *
107
+ * **This or {@link scheduleAtEdge}?** They answer different questions, and the difference is what happens
108
+ * when you ask twice.
109
+ *
110
+ * This is a *subscription*: each call adds a callback, and every callback added runs. Ask twice and the work
111
+ * happens twice — correct when each call carries its own work (two deferred session edits are two edits, and
112
+ * both must land). `scheduleAtEdge` is a *dirty flag*: you create one scheduler up front, and however many
113
+ * times you poke it before an edge, the work runs once. Correct when the calls describe a **slot** rather
114
+ * than a queue — three `register('StorageBackend')` calls mean one backend to install, not three to install
115
+ * in turn, and the work reads the slot at fire time so the last writer wins.
116
+ *
117
+ * Picking wrongly is not subtle in either direction: a subscription where you wanted a flag re-applies stale
118
+ * intermediate values, and a flag where you wanted a subscription silently drops work.
119
+ *
120
+ * Continuous delivery is a *standing registration* — this, without calling `unregister` — and never a
121
+ * callback re-registering itself: see {@link scheduleAtEdge} for why re-registration re-enters the edge
122
+ * immediately and forever.
123
+ *
124
+ * What the edge guarantees a flusher:
125
+ *
126
+ * - Flushers are **invoked in registration order**, in one synchronous sweep; one that goes async is
127
+ * started in that order and then left to settle alongside the rest.
128
+ * - **The synchronous prefix lands within the call.** As long as flushers are synchronous nothing
129
+ * yields at all, which is what lets the host stage a mutation and land it with a bare
130
+ * `flushIfQuiescent()` — in effect before the call returns.
131
+ * - **No second sweep begins until the current one has completely settled**, and a mutation staged
132
+ * meanwhile is turned away rather than applied.
133
+ * - A throwing flusher is isolated, and so is a rejecting one — neither may deny the others their
134
+ * completion, nor escape into the operation whose release happened to reach the edge.
135
+ *
136
+ * - **Exclusivity, for a flusher's whole extent.** A synchronous flusher has always had it for free —
137
+ * the runtime is single-threaded — and that remains the argument for keeping one synchronous whenever
138
+ * the work allows. An asynchronous one now has it too, because {@link machineBusy} bars entry while a
139
+ * flush is settling. Flushers still overlap *each other*, deliberately: sequencing them would remove
140
+ * one source of concurrent mutation while leaving every other in place, at the price of every flusher
141
+ * waiting on the slowest.
142
+ *
143
+ * Exclusivity is a recent acquisition and the reasoning that preceded it is worth keeping, because it
144
+ * says what the barrier is really for. This edge used to be a counter, not a gate: `depth` was 0 while
145
+ * a flush settled, so an HTTP endpoint could accept a request and run a whole tool call inside a single
146
+ * `await`, and the conclusion drawn was that contention over a service is the *service's* to resolve —
147
+ * a `Store` answers it with compare-and-swap — and that the sweep's job was only to make contention
148
+ * rare rather than to pretend the machine stops.
149
+ *
150
+ * That is still true, and `mediumGuard` is the shipped form of it: a write whose read came from another
151
+ * `StorageBackend` fails on the version stamp, so coherence does not depend on this module at all.
152
+ * Which is precisely what makes the barrier safe to add. It is not the correctness mechanism; it is the
153
+ * **liveness** one. A counter cannot force `depth` to reach 0, so under continuously overlapping holds —
154
+ * several sessions on a busy server, each pump holding across its own queue — the edge simply never
155
+ * arrives and staged work waits for an idle moment that never comes. A deferred session edit that never
156
+ * lands, and a staged backend swap that never applies, are both failures with no symptom. Barring entry
157
+ * is the only way to make the drain reachable, and exclusivity falls out of it for free.
158
+ *
159
+ * The keys that repoint *immediately* — `Vault`, `KnowledgeIndex`, `Notifier`, anything a plugin
160
+ * registers — carry no protection here or anywhere: they change under a running turn too, by the
161
+ * design decision that only the system of record is worth deferring. A flusher that depends on one
162
+ * should resolve it once rather than either side of an await, exactly as a turn should.
163
+ */
164
+ export function onContextQuiesce(flush: Quiescer): () => void {
165
+ const unregister = (): void => { cs.quiescers.delete(flush); };
44
166
  cs.quiescers.add(flush);
45
- return () => { cs.quiescers.delete(flush); };
167
+
168
+ // **Registering IS announcing.** A stager should not have to also remember to ask for an edge: the one
169
+ // plugin doing this in-tree registered a one-shot and never called `flushIfQuiescent`, so the barrier
170
+ // never engaged on its behalf and its work depended on someone else happening to release. Two calls that
171
+ // must be paired, where omitting the second is silent, is the shape this module exists to avoid.
172
+ //
173
+ // The barrier is raised synchronously (it invokes nothing) so it engages at once, while the edge ATTEMPT
174
+ // waits for a microtask — a callback must not run before the statement registering it has finished, or a
175
+ // one-shot reading anything initialised on that same line, `unregister` included, would see it missing.
176
+ // Nothing depends on registration landing synchronously; the host's inline-landing guarantee is attached
177
+ // to an explicit `flushIfQuiescent()`, which still has it.
178
+ if (cs.depth !== 0 || cs.flushing) cs.wanted = true;
179
+ void Promise.resolve().then(() => { if (cs.quiescers.has(flush)) flushIfQuiescent(); });
180
+
181
+ return unregister;
46
182
  }
47
183
 
48
184
  /**
49
- * Run the registered flushers iff no context is currently active — the quiescent edge. Called by
50
- * {@link contextSwitch} at both edges, and by the host right after it *queues* a deferred mutation:
51
- * queued while busy (depth > 0) this no-ops and the exit edge lands it; queued while idle (depth 0)
52
- * it applies immediately, so an idle-time swap doesn't wait for the next request to take effect.
185
+ * Announce registered work and land it if the edge is already here — **private**, and the second half of
186
+ * {@link onContextQuiesce}.
187
+ *
188
+ * It was public, and announcing was then a call a stager had to remember to pair with its registration. The
189
+ * one plugin registering a one-shot never made it, so the barrier never engaged for the very work whose
190
+ * starvation motivated it. Registering announces now, which is the same guarantee with nothing to forget —
191
+ * and once that was true, nothing outside this module had any reason to call this. Exposing "force an edge"
192
+ * only offered a way to reason about firing that a caller should not have to have.
193
+ *
194
+ * It stays separate from the opportunistic sweep {@link machineBusy} performs at its own edges: only a
195
+ * registration knows work exists, and a sweep that raised `wanted` merely because it found the machine busy
196
+ * would bar every overlapping operation on behalf of nothing.
197
+ *
198
+ * Returns the settling promise when some flusher went async, and `undefined` when the edge is already
199
+ * complete — the caller decides whether it can afford to wait ({@link quiesced} does; a synchronous
200
+ * caller cannot). Re-entering while one is settling joins it rather than starting a second: `flushing`
201
+ * stays raised for the whole asynchronous extent, not just the synchronous sweep.
202
+ */
203
+ function flushIfQuiescent(): Promise<void> | undefined {
204
+ // Raised whenever this could not land the work — held, or a flush already settling. The settling case
205
+ // matters as much as the held one: `depth` is 0 there, so nothing is coming to force another edge, and
206
+ // an idle process would leave the work staged indefinitely.
207
+ if (cs.depth !== 0 || cs.flushing) { cs.wanted = true; return cs.inflight; }
208
+ return sweep();
209
+ }
210
+
211
+ /**
212
+ * The sweep itself, without the announcement — {@link machineBusy}'s own edges, which are offering the
213
+ * machine rather than asking for it.
53
214
  */
54
- export function flushIfQuiescent(): void {
55
- if (cs.depth !== 0 || cs.flushing) return;
215
+ function sweep(): Promise<void> | undefined {
216
+ if (cs.depth !== 0 || cs.flushing) return cs.inflight;
56
217
  cs.flushing = true;
57
- try {
58
- for (const q of cs.quiescers) {
59
- try { q(); }
60
- catch (e) { console.error('[matbot] context-quiesce flush threw:', e instanceof Error ? e.message : e); }
218
+ // Lowered here rather than on completion: every flusher has now been *offered* this edge, so anything
219
+ // staged before it is accounted for. Work staged DURING the sweep re-raises it through the public
220
+ // entry point above and is answered by the next edge — which is what the sweep already promised
221
+ // ("a mutation staged meanwhile is turned away rather than applied").
222
+ cs.wanted = false;
223
+
224
+ // Snapshot: a one-shot flusher unregisters itself as it fires, and a flusher may register another.
225
+ // Iterating a copy makes both safe and settles what a mid-sweep registration means — it was not
226
+ // registered when this edge began, so it runs at the next one.
227
+ const settling = [...cs.quiescers]
228
+ .map(q => {
229
+ // A synchronous throw is the flusher's own business, never that of the operation whose release
230
+ // happened to reach the edge. A rejection is handled below, by allSettled.
231
+ try { return q(() => { cs.quiescers.delete(q); }) ?? undefined; }
232
+ catch (e) { console.error('[matbot] context-quiesce flush threw:', e instanceof Error ? e.message : e); return undefined; }
233
+ })
234
+ .filter((p): p is Promise<void> => p !== undefined);
235
+
236
+ if (settling.length === 0) {
237
+ // Every flusher was synchronous, so the edge is complete on return — which is what lets the host
238
+ // stage a mutation and land it with a bare `flushIfQuiescent()`, in effect before the call returns.
239
+ cs.flushing = false;
240
+ cs.inflight = undefined;
241
+ wake();
242
+ return undefined;
243
+ }
244
+
245
+ // allSettled, not all: one flusher's rejection must not deny the others their completion, and the
246
+ // aggregate must never reject — the release below depends on it, and there is nobody to catch it.
247
+ const all = Promise.allSettled(settling).then(results => {
248
+ for (const r of results) {
249
+ if (r.status === 'rejected') console.error('[matbot] context-quiesce flush rejected:', r.reason instanceof Error ? r.reason.message : r.reason);
61
250
  }
62
- } finally {
63
251
  cs.flushing = false;
252
+ cs.inflight = undefined;
253
+ // A stager that announced DURING this settle got `wanted` raised and no edge, and if the machine is
254
+ // idle nothing else is coming to give it one. So answer it here — guarded, and therefore terminating:
255
+ // only `flushIfQuiescent` raises `wanted`, and the sweep lowers it on entry, so an idempotent async
256
+ // flusher (which returns a promise whether or not it had work) cannot manufacture the next round. That
257
+ // is the distinction the "one pass, deliberately not a loop" note on `quiesced` turns on.
258
+ if (cs.wanted === true && cs.depth === 0) sweep();
259
+ wake();
260
+ });
261
+ cs.inflight = all;
262
+ return all;
263
+ }
264
+
265
+ /**
266
+ * Wait until the edge is not merely reached but *complete*: reach it, and let any deferred work it
267
+ * starts (or finds already settling) finish. An operation that must not overlap deferred work — a turn
268
+ * about to take its copy of a session that a previous turn's deferred edit is rewriting — awaits this
269
+ * before holding the machine.
270
+ *
271
+ * **One pass, deliberately not a loop.** An `async` flusher returns a promise whether or not it had
272
+ * anything to do — "nothing pending" is unobservable from out here, since an immediately-resolved
273
+ * promise and a real unit of work are the same object to us. A loop that waited for a pass to produce
274
+ * no promise would therefore never terminate against an idempotent async flusher, and neither would a
275
+ * re-run scheduled after each settle: each pass would manufacture the evidence for the next. So work
276
+ * staged *during* a flush lands at the following edge — the next `machineBusy` release, or the next
277
+ * caller through here — which is all the mount contract has ever promised: eventual and ordered,
278
+ * never timed.
279
+ *
280
+ * **Largely redundant since the barrier.** {@link machineBusy} now waits for staged work before it takes
281
+ * the hold, which is this guarantee delivered where it cannot be forgotten — so the pump, the caller this
282
+ * was written for, no longer calls it. It remains for an operation that needs deferred work complete and
283
+ * does *not* want to hold the machine.
284
+ */
285
+ export async function quiesced(): Promise<void> {
286
+ await sweep();
287
+ }
288
+
289
+ /**
290
+ * Run `work` at the next quiescent edge, **coalescing** repeated calls onto one edge. Returns the scheduler;
291
+ * call it every time you stage something.
292
+ *
293
+ * The guarded one-shot, named — because the announcements a host makes are usually about a *slot* rather than
294
+ * a queue: three `register('StorageBackend')` calls before an edge mean one backend to install, not three to
295
+ * install in turn. Read the slot inside `work` and repeated stagings collapse for free, while one callback
296
+ * keeps whatever `work` does in a fixed order.
297
+ *
298
+ * **Why continuous delivery is a standing registration and not this.** Registering means "I have work now",
299
+ * which is what forces the edge — so a callback that re-registered itself would announce fresh work from
300
+ * inside the sweep answering the last lot, and the edge would re-enter immediately and forever. A `setInterval`
301
+ * cannot be built out of `setTimeout` here, because there is no independent clock to wait for: the edge is
302
+ * driven by demand, and re-registration is unbounded demand. Want every edge? Register once and don't
303
+ * unregister — and then be idempotent, since you will be called after every operation.
304
+ */
305
+ export function scheduleAtEdge(work: () => void | Promise<void>): () => void {
306
+ let scheduled = false;
307
+ return () => {
308
+ if (scheduled) return;
309
+ scheduled = true;
310
+ // Lowered before `work`, so work that itself stages more (a mount handler registering a service)
311
+ // schedules a fresh edge rather than finding this one still claiming to be pending.
312
+ onContextQuiesce(un => {
313
+ un();
314
+ scheduled = false;
315
+ return work();
316
+ });
317
+ };
318
+ }
319
+
320
+ /** How long an entrant waits at the barrier before proceeding anyway. See {@link admit}. */
321
+ const ADMIT_TIMEOUT_MS = 2_000;
322
+
323
+ /**
324
+ * The barrier: wait for staged work to land, then let the caller in.
325
+ *
326
+ * This is what makes the drain reachable. Without it `depth` is a counter that overlapping operations can
327
+ * hold above zero indefinitely, and staged work waits for an idle moment that never arrives.
328
+ *
329
+ * **Bounded, because a counter cannot tell a nested entrant from a concurrent one.** An operation already
330
+ * holding the machine that enters again — an LLM reaching a matbot HTTP endpoint through its own `http` or
331
+ * `bash` tool, in-process, inside its own turn — would be waiting for a drain that includes its own outer
332
+ * hold, and if the outer holder awaits the inner one that is a deadlock. Distinguishing the two needs a
333
+ * hold-identity carrier (`AsyncLocalStorage` on node, something else in the browser) and the platform
334
+ * split that implies; a bounded wait costs one constant and degrades to exactly the pre-barrier behaviour
335
+ * instead of hanging. The warning is the diagnosis, since this is otherwise invisible.
336
+ *
337
+ * Giving up lowers `wanted`. Leaving it raised would make every subsequent entrant pay the full timeout
338
+ * for one stuck round — a liveness aid turned into a throughput collapse — and the work is not lost: its
339
+ * stager still holds it, and the next real edge lands it.
340
+ */
341
+ async function admit(): Promise<void> {
342
+ if (clear()) { sweep(); return; } // the common path — and still an edge, as it always was
343
+ const started = Date.now();
344
+ for (;;) {
345
+ if (cs.depth === 0) await sweep(); // the drain we were waiting for; land it here and now
346
+ if (clear()) return;
347
+ const waited = Date.now() - started;
348
+ if (waited >= ADMIT_TIMEOUT_MS) {
349
+ console.warn(
350
+ `[matbot] entering the machine after waiting ${waited}ms for deferred work to land — it is still ` +
351
+ `pending (${cs.quiescers.size} flusher(s), depth ${cs.depth}). If this repeats, an operation is ` +
352
+ `holding the machine while waiting on something that needs it.`,
353
+ );
354
+ cs.wanted = false;
355
+ return;
356
+ }
357
+ // The timer doubles as a poll: a missed wake-up would otherwise park an entrant until the next
358
+ // unrelated release, and the whole point of this function is that progress does not depend on one.
359
+ await new Promise<void>(resolve => {
360
+ const done = (): void => { clearTimeout(timer); resolve(); };
361
+ const timer = setTimeout(done, Math.min(25, ADMIT_TIMEOUT_MS - waited));
362
+ waiters().push(done);
363
+ });
64
364
  }
65
365
  }
66
366
 
67
367
  /**
68
- * Switch into the machine as `principal`: land any pending machine mutation (only safe while idle),
69
- * then run `fn` under the principal. Mirrors {@link runAs}'s sync/async return — when `fn` is async,
70
- * the context stays open until its promise settles, so the depth count tracks the real operation.
368
+ * Hold the machine for the extent of `fn`: wait for any staged mutation to land, then keep the machine
369
+ * off-limits to further ones until `fn` is done. When `fn` is async the hold stays up until its promise
370
+ * settles, so the count tracks the real operation rather than the synchronous call.
371
+ *
372
+ * **Always a promise, because entry can wait** ({@link admit}) — that is the barrier, and it is in here
373
+ * rather than at the call site on purpose. An explicit "wait, then hold" pair reads better and puts the
374
+ * cost in view, but it makes the waiting *optional*, and a second caller who omits it gets no error and
375
+ * no symptom: staged work simply stops landing. That is the failure this whole module exists to prevent,
376
+ * so it is not one to leave to a caller's memory — the same reasoning that makes the principal ambient
377
+ * rather than threaded, and binds a mount interest to its plugin's load extent.
378
+ *
379
+ * When nothing is staged, `fn` still runs **synchronously** within the call, before the returned promise
380
+ * is handed back: the barrier adds a hold, never a scheduling gap, so nothing can slip between the check
381
+ * and the hold. A synchronous throw becomes a rejection, so the two paths report failure identically.
382
+ *
383
+ * The hold is released on **every** exit — a throw, a rejected promise, a bare `return` from anywhere
384
+ * inside — because the release is bound to the call, not written at the exits. That is the whole reason
385
+ * this is a wrapper and not a `begin()`/`end()` pair: a caller with a dozen early returns (the pump has
386
+ * several per queue item) cannot leave the counter stuck, and a stuck counter is unrecoverable — every
387
+ * later flush would silently no-op forever, and the symptom would be a deferred mutation that simply
388
+ * never happens.
71
389
  */
72
- export function contextSwitch<T>(principal: Principal, fn: () => T): T {
73
- flushIfQuiescent();
390
+ export function machineBusy<T>(fn: () => T | Promise<T>): Promise<T> {
391
+ if (!clear()) return admit().then(() => held(fn));
392
+ // Nothing staged, so this entry is itself an edge — as it always was. If the sweep settles inline the
393
+ // hold goes up within this call; if a flusher went async we wait for it, which is the guarantee the
394
+ // pump used to spell as `quiesced()`: deferred work that rewrites a document must finish before the
395
+ // next operation reads it, not merely have been started.
396
+ const settling = sweep();
397
+ return settling === undefined ? held(fn) : settling.then(() => held(fn));
398
+ }
399
+
400
+ function held<T>(fn: () => T | Promise<T>): Promise<T> {
74
401
  cs.depth++;
75
- const settle = (): void => { cs.depth--; flushIfQuiescent(); };
76
- let result: T;
402
+ let settled = false;
403
+ const settle = (): void => {
404
+ if (settled) return; // the throw path settles before rejecting; never settle twice
405
+ settled = true;
406
+ cs.depth--;
407
+ sweep();
408
+ wake(); // a release is a chance for anyone parked at the barrier to get in
409
+ };
410
+ let result: T | Promise<T>;
77
411
  try {
78
- result = runAs(principal, fn);
412
+ result = fn();
79
413
  } catch (e) {
80
414
  settle();
81
- throw e;
415
+ return Promise.reject(e instanceof Error ? e : new Error(String(e)));
82
416
  }
83
- return result instanceof Promise ? (result.finally(settle) as T) : (settle(), result);
417
+ return result instanceof Promise ? result.finally(settle) : (settle(), Promise.resolve(result));
418
+ }
419
+
420
+ /**
421
+ * Switch into the machine as `principal`: hold it for the extent of `fn` ({@link machineBusy}) and
422
+ * run `fn` under the principal ({@link runAs}). The composition, for an operation that both owns the
423
+ * machine and has an owner — a single-turn request standing on its own. An operation that decomposes
424
+ * into differently-owned units (the pump: one queue, a principal per item) holds the machine once
425
+ * around the whole of it and calls `runAs` per unit instead.
426
+ */
427
+ export function contextSwitch<T>(principal: Principal, fn: () => T | Promise<T>): Promise<T> {
428
+ return machineBusy(() => runAs(principal, fn));
84
429
  }
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  CompletionRequest, MatbotMachine, MatbotServices, Mounted, MountConsumeOptions, SingleTurnRequest,
3
3
  } from './plugin.js';
4
+ import { RegistryChangeKind } from './notify.js';
4
5
 
5
6
  /*
6
7
  * Host-side machine assembly — the wiring an *embedder* does once at boot, not API a plugin author
@@ -111,6 +112,20 @@ function reportMountHandlerError(e: unknown): void {
111
112
  * the last-committed presence per key, so a reload collapses to one remount and a committed unload is
112
113
  * well-defined. Presence is read by member access on the unified machine, which resolves both the core
113
114
  * getters (StorageBackend/Vault/KnowledgeIndex) and the registry-backed augmented keys.
115
+ *
116
+ * Each transition is ALSO published onto the bus as a `RegistryChange` on the `services` registry — the
117
+ * mount table reaches in-process subscribers only, and a service being replaced is exactly the kind of
118
+ * fact a reader outside this process has no other way to learn. It matters most for `StorageBackend`:
119
+ * `notifyingStore` announces writes made THROUGH it, so replacing the medium wholesale — every document
120
+ * in every namespace now coming from somewhere else — passes it silently, and a browser listing sessions,
121
+ * skills and files went on showing the displaced backend's contents until something unrelated happened
122
+ * to make it re-query.
123
+ *
124
+ * Published AFTER that key's handlers settle, and independently of whether any exist. The handlers are
125
+ * how the in-process caches a remote reader queries THROUGH (a SkillManager's documents, function-tools'
126
+ * compiled set) get rebuilt, so announcing first would invite exactly the stale read this exists to fix.
127
+ * A subscriber-less key still announces: the interests map holds this process's plugins, and says nothing
128
+ * about who is listening on the bus.
114
129
  */
115
130
  export function createMountTable(getMachine: () => MatbotMachine): MountTable {
116
131
  const interests = new Map<string, Set<MountInterest>>();
@@ -119,11 +134,13 @@ export function createMountTable(getMachine: () => MatbotMachine): MountTable {
119
134
 
120
135
  const present = (key: string): boolean => (getMachine() as unknown as Record<string, unknown>)[key] !== undefined;
121
136
 
122
- const run = (fn: (machine: MatbotMachine) => void | Promise<void>, machine: MatbotMachine): void => {
137
+ // Returns when the handler has settled, so flush can hold the bus announcement until the in-process
138
+ // caches are rebuilt. Still isolates a throw: an announcement must not be lost to a bad subscriber.
139
+ const run = (fn: (machine: MatbotMachine) => void | Promise<void>, machine: MatbotMachine): Promise<void> => {
123
140
  try {
124
141
  const r = fn(machine);
125
- if (r instanceof Promise) r.catch(reportMountHandlerError);
126
- } catch (e) { reportMountHandlerError(e); }
142
+ return r instanceof Promise ? r.catch(reportMountHandlerError) : Promise.resolve();
143
+ } catch (e) { reportMountHandlerError(e); return Promise.resolve(); }
127
144
  };
128
145
 
129
146
  const mounted: Mounted = {
@@ -161,13 +178,19 @@ export function createMountTable(getMachine: () => MatbotMachine): MountTable {
161
178
  const before = committed.get(key) ?? false;
162
179
  const after = present(key);
163
180
  committed.set(key, after);
164
- const subs = interests.get(key);
165
- if (subs === undefined) continue;
181
+ if (!after && !before) continue; // marked dirty, but never present: nothing happened
182
+ const subs = interests.get(key) ?? [];
183
+ const settled: Promise<void>[] = [];
166
184
  if (after) {
167
- for (const i of subs) run(i.handler, machine); // mount / remount
168
- } else if (before) {
169
- for (const i of subs) if (i.onUnmount !== undefined) run(i.onUnmount, machine); // committed unload
185
+ for (const i of subs) settled.push(run(i.handler, machine)); // mount / remount
186
+ } else {
187
+ for (const i of subs) if (i.onUnmount !== undefined) settled.push(run(i.onUnmount, machine)); // committed unload
170
188
  }
189
+ // Detached: the edge does not wait on fan-out, only on the flushers that asked to suspend it.
190
+ void Promise.allSettled(settled).then(() => machine.Notifier.notify({
191
+ kind: RegistryChangeKind, source: 'services', registry: 'services',
192
+ name: key, operation: after ? 'added' : 'removed',
193
+ }));
171
194
  }
172
195
  },
173
196
  };
package/src/index.ts CHANGED
@@ -30,3 +30,11 @@ export { currentPrincipal, tryCurrentPrincipal, runAs } from './principal-contex
30
30
  // notifier is the host's job (`/host`).
31
31
  export type * from './notify.js';
32
32
  export { ItemChangeKind, RegistryChangeKind, notifyingStore } from './notify.js';
33
+
34
+ // The quiescent edge: defer work to the next moment it is safe to touch machine state. A plugin that has
35
+ // something to do after the current operation — a store edit the running turn would otherwise write over —
36
+ // wants exactly this and nothing else in the file. HOLDING the machine (`machineBusy`, `contextSwitch`),
37
+ // waiting on it (`quiesced`) and coalescing repeated stagings (`scheduleAtEdge`) are the host's side, so
38
+ // they stay behind `/host`.
39
+ export type * from './context-switch.js';
40
+ export { onContextQuiesce } from './context-switch.js';
package/src/notify.ts CHANGED
@@ -58,7 +58,15 @@ export interface ItemChange extends NotificationBase {
58
58
  readonly detail?: unknown;
59
59
  }
60
60
 
61
- /** A process-global registry gained or lost a member (tools, plugins). No owner, no item identity. */
61
+ /**
62
+ * A process-global registry gained or lost a member. No owner, no item identity.
63
+ *
64
+ * `registry` is `tools`, `plugins`, or `services` — the last published by the mount table at the
65
+ * quiescent edge, where `name` is the `MatbotServices` key and a remount reads as `added` (the key
66
+ * has a member; it is not the one it had). It is how "the medium under everything was replaced" reaches
67
+ * a reader outside this process, which no {@link ItemChange} can say: that addresses one item, and a
68
+ * `StorageBackend` swap invalidates every item in every namespace at once without writing any of them.
69
+ */
62
70
  export interface RegistryChange extends NotificationBase {
63
71
  readonly registry: string;
64
72
  readonly name: string;
package/src/plugin.ts CHANGED
@@ -361,6 +361,27 @@ export interface StorageBackend {
361
361
  createStore<T extends { id: string; version: string }>(namespace: string): Store<T>;
362
362
  readonly fileStore: FileStore;
363
363
  close?(): Promise<void>;
364
+ /**
365
+ * The namespaces this backend currently holds documents for — the `createStore` arguments that
366
+ * would reach real data. `createStore` is addressed BY name, so without this a caller can only read
367
+ * a namespace it already knows about; enumerating is what makes a backend traversable (copying one
368
+ * backend into another, auditing what is stored, reporting on a `.data` directory).
369
+ *
370
+ * **Optional because absence is a type.** A backend addressing a medium with no listing operation
371
+ * cannot answer, and must not guess: a caller that needs a complete list degrades to being told the
372
+ * namespaces explicitly rather than silently working from a partial one. Do not implement this by
373
+ * returning "the namespaces `createStore` happened to be called with this session" — that is a lower
374
+ * bound masquerading as an answer, and a traversal built on it silently skips whatever no plugin
375
+ * touched. If the medium cannot be listed, leave it off, or throw with a reason.
376
+ *
377
+ * Excludes the file store: files are a separate axis with their own enumeration
378
+ * ({@link FileStore.list}), not a namespace among the document stores.
379
+ *
380
+ * A namespace holding no documents may be omitted — "empty" and "absent" are not usefully different
381
+ * to a caller that is about to read it. Implementations return the list sorted, so a diff of two
382
+ * backends is stable.
383
+ */
384
+ namespaces?(): Promise<string[]>;
364
385
  }
365
386
 
366
387
  /**
@@ -77,6 +77,29 @@ export interface PluginListResult {
77
77
  failed?: FailedPlugin[] | undefined;
78
78
  /** Tools registered by the host rather than by any plugin. */
79
79
  builtinTools?: ToolSummary[] | undefined;
80
+ /** Second copies of a host singleton that something would resolve to. Absent ⇒ none, or a host that
81
+ * cannot tell (the browser resolves every specifier through one import map, so it cannot have two). */
82
+ duplicateSingletons?: DuplicateSingleton[] | undefined;
83
+ }
84
+
85
+ /**
86
+ * A second physical copy of `plugin-api` or `core`, found where something would actually resolve it.
87
+ *
88
+ * Reported rather than repaired: duplication is survivable by design (singleton state lives on
89
+ * `globalThis`, errors are brand-checked rather than `instanceof`'d — see docs/duplicate-singletons.md),
90
+ * and replacing a package-manager-installed directory with a symlink invites the next `install` to undo it.
91
+ */
92
+ export interface DuplicateSingleton {
93
+ /** The singleton's package name. */
94
+ package: string;
95
+ /** The version this host is running. */
96
+ hostVersion: string;
97
+ /** The version the other copy declares. */
98
+ version: string;
99
+ /** The other copy's real directory — symlinks resolved, which is how a link to the host is excluded. */
100
+ dir: string;
101
+ /** What resolves to it: plugin names, and/or the config directory. */
102
+ reachedFrom: string[];
80
103
  }
81
104
 
82
105
  /** One installable plugin found by `plugin discover_local`. */