mtech-ai-local-runner 0.1.46 → 0.1.48

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.
@@ -0,0 +1,347 @@
1
+ import { emptyAskState, pruneAskState, sessionKey } from "./store.js";
2
+ import { addDeliveries, closeDeliveries, confirmNotified, confirmWake, isLive, planRoutes, promptSubmitted, sessionEnded, sessionStarted, turnEnded, } from "./routing.js";
3
+ import { droppableDeliveryId, parseAskDelivery } from "./types.js";
4
+ import { renderOutput } from "./templates.js";
5
+ /** The most reports one flush sends, so a misbehaving Gateway can never keep it busy. */
6
+ const MAX_REPORTS_PER_FLUSH = 200;
7
+ /** How many ended delivery ids are remembered, so one offered again is never stored or shown again. */
8
+ const MAX_ENDED = 1000;
9
+ const sameReport = (a, b) => a !== undefined && a.event === b.event && a.via === b.via && a.app === b.app;
10
+ /** The Gateway refused the report itself (not this runner's delivery, or the switch is off): retrying cannot help. */
11
+ const refused = (error) => error?.code === "GATEWAY_TOOL_REJECTED";
12
+ export class AskInbox {
13
+ deps;
14
+ state = emptyAskState();
15
+ watchers = new Map();
16
+ queue = Promise.resolve();
17
+ /** Notifications still running, so idle() can wait for them. */
18
+ notifications = new Set();
19
+ /** Deliveries this runner could not use, to be reported dropped. Not stored: the Gateway offers them again. */
20
+ drops = new Set();
21
+ /** Deliveries that ended here: closed, fallen back, or refused. Offered again, they are ignored. */
22
+ ended = new Set();
23
+ constructor(deps) {
24
+ this.deps = deps;
25
+ }
26
+ now() {
27
+ return (this.deps.now ?? Date.now)();
28
+ }
29
+ log(event) {
30
+ try {
31
+ this.deps.log?.(event);
32
+ }
33
+ catch { /* Logging never stops delivery. */ }
34
+ }
35
+ /** Runs one change at a time, in call order. */
36
+ exclusive(change) {
37
+ const result = this.queue.then(change);
38
+ this.queue = result.then(() => undefined, () => undefined);
39
+ return result;
40
+ }
41
+ /**
42
+ * Reads the stored state. A missing file or content that is not JSON is an empty state (the store decides). Any other
43
+ * error propagates: the caller must run without asks, because the next save would overwrite a file it could not read.
44
+ */
45
+ async load() {
46
+ await this.exclusive(async () => {
47
+ this.state = await this.deps.store.load();
48
+ });
49
+ }
50
+ /** Resolves when every queued change is saved and every notification has finished. For shutdown, before the lock is released. */
51
+ async idle() {
52
+ for (;;) {
53
+ const queue = this.queue;
54
+ await Promise.all([queue, ...this.notifications]);
55
+ if (queue === this.queue && this.notifications.size === 0)
56
+ return;
57
+ }
58
+ }
59
+ status() {
60
+ const now = this.now();
61
+ return {
62
+ deliveries: Object.keys(this.state.items).length,
63
+ live_sessions: Object.values(this.state.sessions).filter(session => isLive(session, now)).length,
64
+ watchers: this.watchers.size,
65
+ };
66
+ }
67
+ /**
68
+ * The answer resolves as soon as the change is made, before the save; the queue is held until the save is done. A
69
+ * client that is gone gets nothing and changes nothing: marking an ask shown for a hook that cannot print it would
70
+ * count it as seen later although nobody saw it.
71
+ */
72
+ event(request, client) {
73
+ return new Promise((answer, fail) => {
74
+ this.exclusive(async () => {
75
+ if (client.gone) {
76
+ answer(null);
77
+ return;
78
+ }
79
+ const context = { app: request.app, sessionId: request.session_id, cwd: request.cwd, now: this.now() };
80
+ let text = null;
81
+ if (request.event === "session-start")
82
+ text = renderOutput(sessionStarted(this.state, context), { sessionId: request.session_id });
83
+ else if (request.event === "prompt")
84
+ text = renderOutput(promptSubmitted(this.state, context));
85
+ else if (request.event === "stop")
86
+ text = renderOutput(turnEnded(this.state, context, { stopHookActive: request.stop_hook_active === true }));
87
+ else {
88
+ sessionEnded(this.state, context);
89
+ this.release(sessionKey(request.app, request.session_id), "session_ended");
90
+ }
91
+ const notify = this.route();
92
+ answer(text);
93
+ await this.commit(notify);
94
+ }).catch(fail);
95
+ });
96
+ }
97
+ /**
98
+ * A Claude Stop watcher. A Claude turn end is always a watch, so registering one is the session's turn end: it runs
99
+ * turnEnded(), which shows what is already pinned to the session (via stop) and marks the session idle. Without that
100
+ * the session would still count as in a turn, and the wake branch of the routing rules could never be reached.
101
+ * What is not pinned makes the watcher wait, replacing any older watcher of the same session.
102
+ */
103
+ watch(request, watcher) {
104
+ return this.exclusive(async () => {
105
+ // The client went away, or gave up, while this waited in the queue. Showing it anything would lose the text, and it must not replace an older watcher.
106
+ if (watcher.gone)
107
+ return;
108
+ const key = sessionKey("claude", request.session_id);
109
+ this.release(key, "replaced");
110
+ // Claude's own loop guard is the hourly wake limit; stop_hook_active belongs to Stop decisions, not to a rewake.
111
+ const text = renderOutput(turnEnded(this.state, { app: "claude", sessionId: request.session_id, cwd: request.cwd, now: this.now() }, { stopHookActive: false }));
112
+ if (text !== null) {
113
+ watcher.wake(text);
114
+ }
115
+ else if (watcher.hold()) {
116
+ this.watchers.set(key, watcher);
117
+ watcher.onClose(() => { if (this.watchers.get(key) === watcher)
118
+ this.watchers.delete(key); });
119
+ }
120
+ await this.settle();
121
+ });
122
+ }
123
+ /**
124
+ * Deliveries from the session-open wait. A valid one is stored, then reported received (by flush). One that fails
125
+ * validation, or cannot be stored, is reported dropped, so the Gateway sends today's Slack message at once.
126
+ */
127
+ receive(entries) {
128
+ return this.exclusive(async () => {
129
+ const deliveries = [];
130
+ for (const entry of entries) {
131
+ const delivery = parseAskDelivery(entry);
132
+ if (delivery !== null && this.ended.has(delivery.deliveryId))
133
+ continue;
134
+ if (delivery !== null) {
135
+ deliveries.push(delivery);
136
+ continue;
137
+ }
138
+ const deliveryId = droppableDeliveryId(entry);
139
+ if (deliveryId !== null && this.state.items[deliveryId] === undefined)
140
+ this.drops.add(deliveryId);
141
+ }
142
+ const { added } = addDeliveries(this.state, deliveries, this.now());
143
+ if (added.length > 0) {
144
+ if (await this.persist()) {
145
+ // Stored now: a dropped report still queued from an earlier failed save is stale.
146
+ for (const item of added)
147
+ this.drops.delete(item.deliveryId);
148
+ }
149
+ else {
150
+ for (const item of added) {
151
+ delete this.state.items[item.deliveryId];
152
+ this.drops.add(item.deliveryId);
153
+ }
154
+ }
155
+ }
156
+ await this.settle();
157
+ });
158
+ }
159
+ /** Deliveries the Gateway closed after this runner received them: answered, decided, expired or fallen back. */
160
+ close(deliveryIds) {
161
+ return this.exclusive(async () => {
162
+ const closed = closeDeliveries(this.state, deliveryIds);
163
+ for (const deliveryId of closed)
164
+ this.end(deliveryId);
165
+ if (closed.length > 0)
166
+ await this.persist();
167
+ });
168
+ }
169
+ /**
170
+ * Asks are off for this runner (the wait result has no ask keys): every stored delivery ends, with no report, and the
171
+ * dropped reports not sent yet are dropped too, since the Gateway would refuse them. Waiting watchers stay.
172
+ */
173
+ endAll() {
174
+ return this.exclusive(async () => {
175
+ const ended = Object.keys(this.state.items);
176
+ for (const deliveryId of ended)
177
+ this.end(deliveryId);
178
+ this.drops.clear();
179
+ if (ended.length === 0)
180
+ return;
181
+ this.log({ event: "ask_deliveries_ended", reason: "asks_off", count: ended.length });
182
+ await this.persist();
183
+ });
184
+ }
185
+ /** Forgets a delivery for good: it is dropped from the store and never stored or shown again. */
186
+ end(deliveryId) {
187
+ delete this.state.items[deliveryId];
188
+ this.ended.add(deliveryId);
189
+ if (this.ended.size > MAX_ENDED)
190
+ this.ended.delete(this.ended.values().next().value);
191
+ }
192
+ /**
193
+ * Sends queued reports in order: drops first, then each delivery's outbox. A transport or server failure stops
194
+ * the flush and leaves the report queued for the next one. A report the Gateway refuses, or a result that says
195
+ * the delivery fell back or closed, ends the delivery here.
196
+ */
197
+ async flush(send, signal) {
198
+ // What to send is picked inside the queue, after any change in progress has been saved: receive() holds the queue
199
+ // until its save is done, so a received report never reaches the Gateway before its delivery is on disk.
200
+ for (const deliveryId of await this.exclusive(async () => [...this.drops])) {
201
+ if (signal.aborted)
202
+ return;
203
+ if (!this.drops.has(deliveryId))
204
+ continue;
205
+ try {
206
+ await send(deliveryId, { event: "dropped" }, signal);
207
+ }
208
+ catch (error) {
209
+ if (!refused(error)) {
210
+ this.reportFailed(error, signal);
211
+ return;
212
+ }
213
+ }
214
+ this.drops.delete(deliveryId);
215
+ }
216
+ for (let sent = 0; sent < MAX_REPORTS_PER_FLUSH && !signal.aborted; sent += 1) {
217
+ const next = await this.exclusive(async () => {
218
+ const item = Object.values(this.state.items).find(candidate => candidate.outbox.length > 0);
219
+ return item === undefined ? undefined : { deliveryId: item.deliveryId, report: item.outbox[0] };
220
+ });
221
+ if (next === undefined)
222
+ return;
223
+ const report = next.report;
224
+ let result;
225
+ try {
226
+ result = await send(next.deliveryId, report, signal);
227
+ }
228
+ catch (error) {
229
+ if (!refused(error)) {
230
+ this.reportFailed(error, signal);
231
+ return;
232
+ }
233
+ await this.exclusive(async () => { this.end(next.deliveryId); await this.persist(); });
234
+ continue;
235
+ }
236
+ const status = result?.delivery?.status;
237
+ await this.exclusive(async () => {
238
+ const current = this.state.items[next.deliveryId];
239
+ if (current === undefined)
240
+ return;
241
+ if (sameReport(current.outbox[0], report))
242
+ current.outbox.shift();
243
+ // The Gateway already sent Slack, or the ask is answered: drop it and never show it again.
244
+ if (status === "fallen_back" || status === "closed")
245
+ this.end(next.deliveryId);
246
+ await this.persist();
247
+ });
248
+ }
249
+ }
250
+ /** A report that did not get through stays queued. A report the shutdown aborted is no failure, so it is not logged. */
251
+ reportFailed(error, signal) {
252
+ if (!signal.aborted)
253
+ this.log({ event: "ask_report_failed", code: errorCode(error) });
254
+ }
255
+ /** Tells every waiting watcher to exit 0, for daemon shutdown. */
256
+ releaseAll(reason) {
257
+ for (const key of [...this.watchers.keys()])
258
+ this.release(key, reason);
259
+ }
260
+ release(key, reason) {
261
+ const watcher = this.watchers.get(key);
262
+ if (watcher === undefined)
263
+ return;
264
+ this.watchers.delete(key);
265
+ watcher.release(reason);
266
+ }
267
+ /**
268
+ * Prunes, re-routes, wakes watchers and claims notifications; returns the deliveries to notify. Synchronous, so the
269
+ * plan, the wake and its confirmation can never interleave with another change. Must run inside exclusive().
270
+ */
271
+ route() {
272
+ const now = this.now();
273
+ pruneAskState(this.state, now);
274
+ const watching = new Set([...this.watchers].filter(([, watcher]) => !watcher.closed).map(([key]) => key));
275
+ const plan = planRoutes(this.state, now, watching);
276
+ for (const { sessionKey: key, output } of plan.wakes) {
277
+ const watcher = this.watchers.get(key);
278
+ const text = renderOutput(output);
279
+ if (watcher === undefined || text === null || !watcher.wake(text))
280
+ continue;
281
+ this.watchers.delete(key);
282
+ confirmWake(this.state, key, output, now);
283
+ }
284
+ // Claimed before the notification runs, so a delivery is never notified twice.
285
+ for (const item of plan.notify)
286
+ confirmNotified(this.state, item.deliveryId, now, false);
287
+ return plan.notify.map(item => item.deliveryId);
288
+ }
289
+ /** Saves, then starts the notifications and the change callback. Whoever waits for an answer has it by now. Inside exclusive(). */
290
+ async commit(notify) {
291
+ await this.persist();
292
+ if (notify.length > 0)
293
+ this.startNotifying(notify);
294
+ try {
295
+ this.deps.onChange?.();
296
+ }
297
+ catch { /* A failed flush trigger is retried after the next wait. */ }
298
+ }
299
+ /** route() and commit() together, for a change that has no answer to hand over first. */
300
+ async settle() {
301
+ await this.commit(this.route());
302
+ }
303
+ async persist() {
304
+ try {
305
+ await this.deps.store.save(this.state);
306
+ return true;
307
+ }
308
+ catch (error) {
309
+ this.log({ event: "ask_store_write_failed", code: error.code ?? "UNKNOWN" });
310
+ return false;
311
+ }
312
+ }
313
+ startNotifying(deliveryIds) {
314
+ const running = this.notifyEach(deliveryIds)
315
+ .catch(error => this.log({ event: "ask_notification_failed", error: error instanceof Error ? error.name : "UNKNOWN" }))
316
+ .then(() => { this.notifications.delete(running); });
317
+ this.notifications.add(running);
318
+ }
319
+ async notifyEach(deliveryIds) {
320
+ for (const deliveryId of deliveryIds) {
321
+ const item = this.state.items[deliveryId];
322
+ if (item === undefined || this.deps.notify === undefined)
323
+ continue;
324
+ let shown = false;
325
+ try {
326
+ shown = await this.deps.notify(item);
327
+ }
328
+ catch {
329
+ shown = false;
330
+ }
331
+ if (!shown)
332
+ continue;
333
+ await this.exclusive(async () => {
334
+ confirmNotified(this.state, deliveryId, this.now(), true);
335
+ await this.persist();
336
+ try {
337
+ this.deps.onChange?.();
338
+ }
339
+ catch { /* Retried after the next wait. */ }
340
+ });
341
+ }
342
+ }
343
+ }
344
+ function errorCode(error) {
345
+ const code = error?.code;
346
+ return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : "UNKNOWN";
347
+ }