@matatbread/matbot-plugin-api 0.4.4 → 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.4",
7
+ "version": "0.4.5",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -45,6 +45,12 @@ interface ContextSwitchState {
45
45
  /** The flush currently settling, when a flusher went async. Held so a second caller joins the one
46
46
  * in flight rather than starting a second, and so {@link quiesced} has something to wait on. */
47
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>;
48
54
  }
49
55
  const cs = globalSlot<ContextSwitchState>('context-switch', () => ({
50
56
  quiescers: new Set<Quiescer>(),
@@ -52,6 +58,27 @@ const cs = globalSlot<ContextSwitchState>('context-switch', () => ({
52
58
  flushing: false,
53
59
  }));
54
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
+
55
82
  /**
56
83
  * A machine-update flush. Returning a promise makes the edge *wait*: the deferred work is no longer
57
84
  * merely started at the edge but finished before the machine is handed to whoever asked to be
@@ -59,12 +86,40 @@ const cs = globalSlot<ContextSwitchState>('context-switch', () => ({
59
86
  * next operation is about to read — a store edge deferred out of a turn lands in the window before
60
87
  * the next turn takes its copy, or not at all.
61
88
  */
62
- export type Quiescer = () => void | Promise<void>;
89
+ export type Quiescer = (unregister: () => void) => void | Promise<void>;
63
90
 
64
91
  /**
65
- * Register a machine-update flush, run at every quiescent edge (nothing holding the machine).
66
- * Flushers MUST be idempotent: a no-op when nothing is pending, since the edge is reached after every
67
- * operation. Returns an unregister fn.
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.
68
123
  *
69
124
  * What the edge guarantees a flusher:
70
125
  *
@@ -78,27 +133,28 @@ export type Quiescer = () => void | Promise<void>;
78
133
  * - A throwing flusher is isolated, and so is a rejecting one — neither may deny the others their
79
134
  * completion, nor escape into the operation whose release happened to reach the edge.
80
135
  *
81
- * **It does not give exclusivity, and could not.** A synchronous flusher has it for free — the runtime
82
- * is single-threaded — and that is the argument for keeping a flusher synchronous whenever the work
83
- * allows. The moment one awaits, the window is open: `depth` is 0 while a flush settles, so an HTTP
84
- * endpoint can accept a request and run a tool call to completion inside a single `await`, and only an
85
- * operation that asked to be {@link quiesced} first (the pump) is held back. Flushers may therefore
86
- * overlap each other too, and sequencing them would buy nothing — it would remove one source of
87
- * concurrent mutation while leaving every other in place, at the price of every flusher waiting on the
88
- * slowest.
89
- *
90
- * So contention over a service is the *service's* to resolve, not the sweep's: a `Store` answers it
91
- * with compare-and-swap, and a subsystem with a stronger requirement owns a stronger answer. The
92
- * sweep's job is to make contention rare, not to pretend the machine stops.
93
- *
94
- * **Known gap, not caused here.** Compare-and-swap answers "did this document change?", not "did the
95
- * medium change?" — a read from one `StorageBackend` and a write to another compares a version from
96
- * the first against the second, and a session migrates with nothing in a position to notice. Staging
97
- * a swap rather than applying it while a flush settles narrows that window, but it is a mitigation
98
- * in the wrong place: an HTTP tool call has always been able to straddle a swap the same way, so the
99
- * exposure predates asynchronous flushers and is merely easier to reach with them. The fix belongs
100
- * where the consequence lands — a `cas` that checks the backend it is writing to is the one it read
101
- * from, and fails when it is not.
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.
102
158
  *
103
159
  * The keys that repoint *immediately* — `Vault`, `KnowledgeIndex`, `Notifier`, anything a plugin
104
160
  * registers — carry no protection here or anywhere: they change under a running turn too, by the
@@ -106,24 +162,64 @@ export type Quiescer = () => void | Promise<void>;
106
162
  * should resolve it once rather than either side of an await, exactly as a turn should.
107
163
  */
108
164
  export function onContextQuiesce(flush: Quiescer): () => void {
165
+ const unregister = (): void => { cs.quiescers.delete(flush); };
109
166
  cs.quiescers.add(flush);
110
- 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;
111
182
  }
112
183
 
113
184
  /**
114
- * Run the registered flushers iff nothing holds the machine — the quiescent edge. Called by
115
- * {@link machineBusy} at both edges, and by the host right after it *queues* a deferred mutation:
116
- * queued while busy (depth > 0) this no-ops and the release edge lands it; queued while idle (depth 0)
117
- * 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.
118
197
  *
119
198
  * Returns the settling promise when some flusher went async, and `undefined` when the edge is already
120
199
  * complete — the caller decides whether it can afford to wait ({@link quiesced} does; a synchronous
121
200
  * caller cannot). Re-entering while one is settling joins it rather than starting a second: `flushing`
122
201
  * stays raised for the whole asynchronous extent, not just the synchronous sweep.
123
202
  */
124
- export function flushIfQuiescent(): Promise<void> | undefined {
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.
214
+ */
215
+ function sweep(): Promise<void> | undefined {
125
216
  if (cs.depth !== 0 || cs.flushing) return cs.inflight;
126
217
  cs.flushing = true;
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;
127
223
 
128
224
  // Snapshot: a one-shot flusher unregisters itself as it fires, and a flusher may register another.
129
225
  // Iterating a copy makes both safe and settles what a mid-sweep registration means — it was not
@@ -132,7 +228,7 @@ export function flushIfQuiescent(): Promise<void> | undefined {
132
228
  .map(q => {
133
229
  // A synchronous throw is the flusher's own business, never that of the operation whose release
134
230
  // happened to reach the edge. A rejection is handled below, by allSettled.
135
- try { return q() ?? undefined; }
231
+ try { return q(() => { cs.quiescers.delete(q); }) ?? undefined; }
136
232
  catch (e) { console.error('[matbot] context-quiesce flush threw:', e instanceof Error ? e.message : e); return undefined; }
137
233
  })
138
234
  .filter((p): p is Promise<void> => p !== undefined);
@@ -142,6 +238,7 @@ export function flushIfQuiescent(): Promise<void> | undefined {
142
238
  // stage a mutation and land it with a bare `flushIfQuiescent()`, in effect before the call returns.
143
239
  cs.flushing = false;
144
240
  cs.inflight = undefined;
241
+ wake();
145
242
  return undefined;
146
243
  }
147
244
 
@@ -153,6 +250,13 @@ export function flushIfQuiescent(): Promise<void> | undefined {
153
250
  }
154
251
  cs.flushing = false;
155
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();
156
260
  });
157
261
  cs.inflight = all;
158
262
  return all;
@@ -172,42 +276,145 @@ export function flushIfQuiescent(): Promise<void> | undefined {
172
276
  * staged *during* a flush lands at the following edge — the next `machineBusy` release, or the next
173
277
  * caller through here — which is all the mount contract has ever promised: eventual and ordered,
174
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.
175
284
  */
176
285
  export async function quiesced(): Promise<void> {
177
- await flushIfQuiescent();
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
+ };
178
318
  }
179
319
 
320
+ /** How long an entrant waits at the barrier before proceeding anyway. See {@link admit}. */
321
+ const ADMIT_TIMEOUT_MS = 2_000;
322
+
180
323
  /**
181
- * Hold the machine for the extent of `fn`: land any pending mutation first (only safe while idle),
182
- * then keep the machine off-limits to further ones until `fn` is done. Mirrors {@link runAs}'s
183
- * sync/async return — when `fn` is async the hold stays up until its promise settles, so the count
184
- * tracks the real operation rather than the synchronous call.
185
- *
186
- * The hold is released on **every** exit — a synchronous throw, a rejected promise, a bare `return`
187
- * from anywhere inside — because the release is bound to the call, not written at the exits. That is
188
- * the whole reason this is a wrapper and not a `begin()`/`end()` pair: a caller with a dozen early
189
- * returns (the pump has several per queue item) cannot leave the counter stuck, and a stuck counter
190
- * is unrecoverable — every later flush would silently no-op forever, and the symptom would be a
191
- * deferred mutation that simply never happens.
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.
192
340
  */
193
- export function machineBusy<T>(fn: () => T): T {
194
- flushIfQuiescent();
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
+ });
364
+ }
365
+ }
366
+
367
+ /**
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.
389
+ */
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> {
195
401
  cs.depth++;
196
402
  let settled = false;
197
403
  const settle = (): void => {
198
- if (settled) return; // the sync-throw path settles before rethrowing; never settle twice
404
+ if (settled) return; // the throw path settles before rejecting; never settle twice
199
405
  settled = true;
200
406
  cs.depth--;
201
- flushIfQuiescent();
407
+ sweep();
408
+ wake(); // a release is a chance for anyone parked at the barrier to get in
202
409
  };
203
- let result: T;
410
+ let result: T | Promise<T>;
204
411
  try {
205
412
  result = fn();
206
413
  } catch (e) {
207
414
  settle();
208
- throw e;
415
+ return Promise.reject(e instanceof Error ? e : new Error(String(e)));
209
416
  }
210
- return result instanceof Promise ? (result.finally(settle) as T) : (settle(), result);
417
+ return result instanceof Promise ? result.finally(settle) : (settle(), Promise.resolve(result));
211
418
  }
212
419
 
213
420
  /**
@@ -217,6 +424,6 @@ export function machineBusy<T>(fn: () => T): T {
217
424
  * into differently-owned units (the pump: one queue, a principal per item) holds the machine once
218
425
  * around the whole of it and calls `runAs` per unit instead.
219
426
  */
220
- export function contextSwitch<T>(principal: Principal, fn: () => T): T {
427
+ export function contextSwitch<T>(principal: Principal, fn: () => T | Promise<T>): Promise<T> {
221
428
  return machineBusy(() => runAs(principal, fn));
222
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;
@@ -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`. */