@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 +1 -1
- package/src/context-switch.ts +386 -41
- package/src/host-machine.ts +31 -8
- package/src/index.ts +8 -0
- package/src/notify.ts +9 -1
- package/src/plugin.ts +21 -0
- package/src/types/builtin-tools.ts +23 -0
package/package.json
CHANGED
package/src/context-switch.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
//
|
|
25
|
-
// quiescers and one
|
|
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<
|
|
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<
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
50
|
-
* {@link
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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 =
|
|
412
|
+
result = fn();
|
|
79
413
|
} catch (e) {
|
|
80
414
|
settle();
|
|
81
|
-
|
|
415
|
+
return Promise.reject(e instanceof Error ? e : new Error(String(e)));
|
|
82
416
|
}
|
|
83
|
-
return result instanceof Promise ?
|
|
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
|
}
|
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;
|
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`. */
|