@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 +1 -1
- package/src/context-switch.ts +259 -52
- package/src/host-machine.ts +31 -8
- package/src/index.ts +8 -0
- package/src/notify.ts +9 -1
- package/src/types/builtin-tools.ts +23 -0
package/package.json
CHANGED
package/src/context-switch.ts
CHANGED
|
@@ -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
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
* **
|
|
82
|
-
* is single-threaded — and that
|
|
83
|
-
* allows.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* sweep's job
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
115
|
-
* {@link
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
-
|
|
194
|
-
|
|
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
|
|
404
|
+
if (settled) return; // the throw path settles before rejecting; never settle twice
|
|
199
405
|
settled = true;
|
|
200
406
|
cs.depth--;
|
|
201
|
-
|
|
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
|
-
|
|
415
|
+
return Promise.reject(e instanceof Error ? e : new Error(String(e)));
|
|
209
416
|
}
|
|
210
|
-
return result instanceof Promise ?
|
|
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
|
}
|
package/src/host-machine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
/**
|
|
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`. */
|