mtech-ai-local-runner 0.1.47 → 0.1.49
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/README.md +110 -2
- package/dist/src/asks/commands.js +100 -0
- package/dist/src/asks/doctor.js +106 -0
- package/dist/src/asks/hook-client.js +236 -0
- package/dist/src/asks/hub-protocol.js +97 -0
- package/dist/src/asks/hub.js +202 -0
- package/dist/src/asks/inbox.js +347 -0
- package/dist/src/asks/integrations.js +433 -0
- package/dist/src/asks/messenger.js +162 -0
- package/dist/src/asks/notifier.js +73 -0
- package/dist/src/asks/routing.js +239 -0
- package/dist/src/asks/runtime.js +206 -0
- package/dist/src/asks/shim.js +78 -0
- package/dist/src/asks/store.js +210 -0
- package/dist/src/asks/templates.js +44 -0
- package/dist/src/asks/types.js +64 -0
- package/dist/src/cli.js +90 -31
- package/dist/src/daemon/heartbeat-scheduler.js +63 -0
- package/dist/src/daemon/job-runner.js +99 -0
- package/dist/src/daemon/runtime.js +126 -57
- package/dist/src/gateway-client.js +84 -3
- package/dist/src/registration/local-config.js +7 -1
- package/dist/src/safety/local-edit-content.js +14 -0
- package/dist/src/safety/local-tool-policy.js +3 -0
- package/dist/src/safety/local-tool-server.js +2 -2
- package/dist/src/safety/local-tool-worker.js +3 -0
- package/dist/src/transport/abortable-delay.js +12 -0
- package/dist/src/transport/pusher-protocol.js +66 -0
- package/dist/src/transport/reverb-socket.js +144 -0
- package/dist/src/transport/runner-transport.js +265 -0
- package/dist/src/transport/side-loops.js +50 -0
- package/dist/src/transport/sync-dispatch.js +91 -0
- package/package.json +4 -2
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { decodeFrame, pingFrame, pongFrame, subscribeFrame } from "./pusher-protocol.js";
|
|
3
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
4
|
+
const SUBSCRIBE_TIMEOUT_MS = 10_000;
|
|
5
|
+
/** How long close() waits for the server's close frame before dropping the connection. */
|
|
6
|
+
const CLOSE_GRACE_MS = 1_000;
|
|
7
|
+
const PONG_TIMEOUT_MS = 10_000;
|
|
8
|
+
const MAX_PING_INTERVAL_MS = 30_000;
|
|
9
|
+
/*
|
|
10
|
+
* ws sends no User-Agent of its own, and the ELSA load balancer's WAF (AWS managed common rules) answers a request
|
|
11
|
+
* without one with 403 before Reverb sees it. Measured against the development Reverb on 2026-10-24.
|
|
12
|
+
*/
|
|
13
|
+
export function pushSocketOptions(packageVersion, connectTimeoutMs) {
|
|
14
|
+
return { handshakeTimeout: connectTimeoutMs, headers: { "User-Agent": `mtec-ai-local-runner/${packageVersion}` } };
|
|
15
|
+
}
|
|
16
|
+
export function pushSocketUrl(target, packageVersion) {
|
|
17
|
+
const url = new URL(target.url);
|
|
18
|
+
if (url.protocol !== "wss:")
|
|
19
|
+
throw new Error("PUSH_URL_NOT_WSS");
|
|
20
|
+
url.pathname = `/app/${encodeURIComponent(target.appKey)}`;
|
|
21
|
+
url.search = new URLSearchParams({ protocol: "7", client: "mtec-ai-local-runner", version: packageVersion, flash: "false" }).toString();
|
|
22
|
+
return url.toString();
|
|
23
|
+
}
|
|
24
|
+
export class ReverbSocket {
|
|
25
|
+
target;
|
|
26
|
+
events;
|
|
27
|
+
options;
|
|
28
|
+
socket;
|
|
29
|
+
ended = false;
|
|
30
|
+
ownerClosed = false;
|
|
31
|
+
pingIntervalMs;
|
|
32
|
+
connectTimer;
|
|
33
|
+
subscribeTimer;
|
|
34
|
+
pingTimer;
|
|
35
|
+
pongTimer;
|
|
36
|
+
constructor(target, events, options) {
|
|
37
|
+
this.target = target;
|
|
38
|
+
this.events = events;
|
|
39
|
+
this.options = options;
|
|
40
|
+
this.pingIntervalMs = options.maxPingIntervalMs ?? MAX_PING_INTERVAL_MS;
|
|
41
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
|
|
42
|
+
const url = pushSocketUrl(target, options.packageVersion);
|
|
43
|
+
const create = options.createSocket ?? ((value) => new WebSocket(value, pushSocketOptions(options.packageVersion, connectTimeoutMs)));
|
|
44
|
+
this.socket = create(url);
|
|
45
|
+
this.connectTimer = setTimeout(() => this.fail("connect_timeout"), connectTimeoutMs);
|
|
46
|
+
this.socket.on("message", data => this.onMessage(String(data)));
|
|
47
|
+
this.socket.on("close", () => this.fail("closed"));
|
|
48
|
+
this.socket.on("error", () => this.fail("error"));
|
|
49
|
+
}
|
|
50
|
+
subscribe(auth) {
|
|
51
|
+
if (this.ended)
|
|
52
|
+
return;
|
|
53
|
+
clearTimeout(this.subscribeTimer);
|
|
54
|
+
this.subscribeTimer = setTimeout(() => this.fail("subscribe_timeout"), this.options.subscribeTimeoutMs ?? SUBSCRIBE_TIMEOUT_MS);
|
|
55
|
+
this.send(subscribeFrame(this.target.channel, auth));
|
|
56
|
+
}
|
|
57
|
+
close() {
|
|
58
|
+
if (this.ended)
|
|
59
|
+
return;
|
|
60
|
+
this.ownerClosed = true;
|
|
61
|
+
this.end();
|
|
62
|
+
try {
|
|
63
|
+
this.socket.close();
|
|
64
|
+
}
|
|
65
|
+
catch { /* Closing a socket that is already gone is fine. */ }
|
|
66
|
+
// ws waits up to 30 s for the server's close frame, which would hold a stopping daemon open.
|
|
67
|
+
const drop = setTimeout(() => { try {
|
|
68
|
+
this.socket.terminate();
|
|
69
|
+
}
|
|
70
|
+
catch { /* Already gone. */ } }, CLOSE_GRACE_MS);
|
|
71
|
+
drop.unref?.();
|
|
72
|
+
}
|
|
73
|
+
onMessage(raw) {
|
|
74
|
+
if (this.ended)
|
|
75
|
+
return;
|
|
76
|
+
this.armPing();
|
|
77
|
+
const frame = decodeFrame(raw, { channel: this.target.channel, event: this.target.event });
|
|
78
|
+
switch (frame.kind) {
|
|
79
|
+
case "connected":
|
|
80
|
+
clearTimeout(this.connectTimer);
|
|
81
|
+
this.pingIntervalMs = Math.min(frame.activityTimeoutSeconds * 1000, this.options.maxPingIntervalMs ?? MAX_PING_INTERVAL_MS);
|
|
82
|
+
this.armPing();
|
|
83
|
+
this.events.onConnected(frame.socketId);
|
|
84
|
+
break;
|
|
85
|
+
case "subscribed":
|
|
86
|
+
clearTimeout(this.subscribeTimer);
|
|
87
|
+
this.events.onSubscribed();
|
|
88
|
+
break;
|
|
89
|
+
case "signal":
|
|
90
|
+
this.events.onSignal();
|
|
91
|
+
break;
|
|
92
|
+
case "ping":
|
|
93
|
+
this.send(pongFrame());
|
|
94
|
+
break;
|
|
95
|
+
case "error":
|
|
96
|
+
// The Pusher close code decides how soon the transport reconnects (4000-4099: not unchanged).
|
|
97
|
+
this.fail(frame.code !== undefined && Number.isInteger(frame.code) && frame.code >= 4000 && frame.code <= 4299 ? `protocol_error_${frame.code}` : "protocol_error");
|
|
98
|
+
break;
|
|
99
|
+
default:
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
armPing() {
|
|
104
|
+
clearTimeout(this.pongTimer);
|
|
105
|
+
this.pongTimer = undefined;
|
|
106
|
+
clearTimeout(this.pingTimer);
|
|
107
|
+
if (this.ended)
|
|
108
|
+
return;
|
|
109
|
+
this.pingTimer = setTimeout(() => {
|
|
110
|
+
this.send(pingFrame());
|
|
111
|
+
if (this.ended)
|
|
112
|
+
return;
|
|
113
|
+
this.pongTimer = setTimeout(() => this.fail("pong_timeout"), this.options.pongTimeoutMs ?? PONG_TIMEOUT_MS);
|
|
114
|
+
}, this.pingIntervalMs);
|
|
115
|
+
}
|
|
116
|
+
send(frame) {
|
|
117
|
+
if (this.ended)
|
|
118
|
+
return;
|
|
119
|
+
try {
|
|
120
|
+
this.socket.send(frame);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
this.fail("send_failed");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
fail(reason) {
|
|
127
|
+
if (this.ended)
|
|
128
|
+
return;
|
|
129
|
+
this.end();
|
|
130
|
+
try {
|
|
131
|
+
this.socket.terminate();
|
|
132
|
+
}
|
|
133
|
+
catch { /* The socket may already be gone. */ }
|
|
134
|
+
if (!this.ownerClosed)
|
|
135
|
+
this.events.onClosed(reason);
|
|
136
|
+
}
|
|
137
|
+
end() {
|
|
138
|
+
this.ended = true;
|
|
139
|
+
clearTimeout(this.connectTimer);
|
|
140
|
+
clearTimeout(this.subscribeTimer);
|
|
141
|
+
clearTimeout(this.pingTimer);
|
|
142
|
+
clearTimeout(this.pongTimer);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
2
|
+
/** A subscription must last this long before a later loss starts the reconnect backoff from 1 s again. */
|
|
3
|
+
const STABLE_SUBSCRIPTION_MS = 60_000;
|
|
4
|
+
/**
|
|
5
|
+
* Pusher closes 4000-4099 mean "do not reconnect unchanged" (for example 4001 unknown app, 4009 unauthorized).
|
|
6
|
+
* The runner keeps its fallback syncs and tries the socket again only this much later, or when the push target changes.
|
|
7
|
+
*/
|
|
8
|
+
const REJECTED_RECONNECT_MS = 600_000;
|
|
9
|
+
const REJECTED_REASON = /^protocol_error_40\d\d$/;
|
|
10
|
+
/** The caught error's own failure code (such as GATEWAY_HTTP_503), never its message. */
|
|
11
|
+
function causeOf(error) {
|
|
12
|
+
const code = error?.code;
|
|
13
|
+
return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : undefined;
|
|
14
|
+
}
|
|
15
|
+
export class RunnerTransport {
|
|
16
|
+
deps;
|
|
17
|
+
controller = new AbortController();
|
|
18
|
+
current = "starting";
|
|
19
|
+
socket;
|
|
20
|
+
target;
|
|
21
|
+
syncSeconds = 30;
|
|
22
|
+
fallbackSeconds = 8;
|
|
23
|
+
/** Set by run(). A request before it is dropped: run() syncs first, and only after the first heartbeat. */
|
|
24
|
+
started = false;
|
|
25
|
+
inFlight = false;
|
|
26
|
+
dirty = false;
|
|
27
|
+
lastSyncAt = Number.NEGATIVE_INFINITY;
|
|
28
|
+
syncFailures = 0;
|
|
29
|
+
/** After a failed sync, requests before this time wait for the retry already scheduled (same backoff as before). */
|
|
30
|
+
retryNotBefore = 0;
|
|
31
|
+
socketFailures = 0;
|
|
32
|
+
syncTimer;
|
|
33
|
+
/** When the pending sync timer fires; a lost socket keeps it when it is already due sooner than the fallback. */
|
|
34
|
+
syncDueAt = Number.POSITIVE_INFINITY;
|
|
35
|
+
subscribedAt;
|
|
36
|
+
reconnectTimer;
|
|
37
|
+
release;
|
|
38
|
+
stopped = new Promise(resolve => { this.release = resolve; });
|
|
39
|
+
constructor(deps) {
|
|
40
|
+
this.deps = deps;
|
|
41
|
+
}
|
|
42
|
+
get state() { return this.current; }
|
|
43
|
+
async run() {
|
|
44
|
+
// A pending promise alone does not keep Node alive; this referenced timer does until stop().
|
|
45
|
+
const keepAlive = setInterval(() => undefined, 60_000);
|
|
46
|
+
try {
|
|
47
|
+
this.started = true;
|
|
48
|
+
this.requestSync("start");
|
|
49
|
+
await this.stopped;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearInterval(keepAlive);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
stop() {
|
|
56
|
+
if (this.current === "stopped")
|
|
57
|
+
return;
|
|
58
|
+
this.current = "stopped";
|
|
59
|
+
this.controller.abort();
|
|
60
|
+
clearTimeout(this.syncTimer);
|
|
61
|
+
clearTimeout(this.reconnectTimer);
|
|
62
|
+
this.closeSocket();
|
|
63
|
+
this.release();
|
|
64
|
+
}
|
|
65
|
+
requestSync(reason) {
|
|
66
|
+
if (!this.started || this.current === "stopped")
|
|
67
|
+
return;
|
|
68
|
+
if (this.inFlight) {
|
|
69
|
+
this.dirty = true;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (Date.now() < this.retryNotBefore)
|
|
73
|
+
return;
|
|
74
|
+
this.trigger(reason);
|
|
75
|
+
}
|
|
76
|
+
/** Starts a sync now, or once the minimum gap has passed. Timers call it directly: they are the backoff. */
|
|
77
|
+
trigger(reason) {
|
|
78
|
+
if (this.current === "stopped")
|
|
79
|
+
return;
|
|
80
|
+
if (this.inFlight) {
|
|
81
|
+
this.dirty = true;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
clearTimeout(this.syncTimer);
|
|
85
|
+
this.syncDueAt = Number.POSITIVE_INFINITY;
|
|
86
|
+
const wait = this.lastSyncAt + (this.deps.minSyncGapMs ?? 1000) - Date.now();
|
|
87
|
+
if (wait > 0) {
|
|
88
|
+
this.syncDueAt = Date.now() + wait;
|
|
89
|
+
this.syncTimer = setTimeout(() => { void this.runSync(reason); }, wait);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
void this.runSync(reason);
|
|
93
|
+
}
|
|
94
|
+
async runSync(reason) {
|
|
95
|
+
if (this.current === "stopped" || this.inFlight)
|
|
96
|
+
return;
|
|
97
|
+
this.inFlight = true;
|
|
98
|
+
this.lastSyncAt = Date.now();
|
|
99
|
+
let retryInMs;
|
|
100
|
+
try {
|
|
101
|
+
const result = await this.deps.sync(reason, this.controller.signal);
|
|
102
|
+
// A sync that resolves after stop() must not reopen a socket or re-arm a timer.
|
|
103
|
+
if (this.isStopped())
|
|
104
|
+
return;
|
|
105
|
+
this.syncFailures = 0;
|
|
106
|
+
this.retryNotBefore = 0;
|
|
107
|
+
this.syncSeconds = result.syncSeconds;
|
|
108
|
+
this.fallbackSeconds = result.fallbackSyncSeconds;
|
|
109
|
+
this.applyPush(result.push);
|
|
110
|
+
try {
|
|
111
|
+
await this.deps.dispatch(result);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
this.report({ event: "transport_dispatch_failed", cause: causeOf(error) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (!this.isStopped()) {
|
|
119
|
+
this.syncFailures += 1;
|
|
120
|
+
retryInMs = this.backoff(this.syncFailures);
|
|
121
|
+
this.retryNotBefore = Date.now() + retryInMs;
|
|
122
|
+
this.report({ event: "transport_sync_failed", reason, cause: causeOf(error) });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
this.inFlight = false;
|
|
127
|
+
}
|
|
128
|
+
if (this.isStopped())
|
|
129
|
+
return;
|
|
130
|
+
if (this.dirty) {
|
|
131
|
+
this.dirty = false;
|
|
132
|
+
// After a failed sync the signal waits out the backoff with the retry instead of skipping it.
|
|
133
|
+
if (retryInMs === undefined) {
|
|
134
|
+
this.requestSync("signal");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
this.schedule(retryInMs);
|
|
139
|
+
}
|
|
140
|
+
schedule(overrideMs) {
|
|
141
|
+
clearTimeout(this.syncTimer);
|
|
142
|
+
const subscribed = this.current === "subscribed";
|
|
143
|
+
const ms = overrideMs ?? (subscribed ? this.syncSeconds : this.fallbackSeconds) * 1000;
|
|
144
|
+
const reason = subscribed ? "periodic" : "fallback";
|
|
145
|
+
this.syncDueAt = Date.now() + ms;
|
|
146
|
+
this.syncTimer = setTimeout(() => this.trigger(reason), ms);
|
|
147
|
+
}
|
|
148
|
+
applyPush(push) {
|
|
149
|
+
if (this.isStopped())
|
|
150
|
+
return;
|
|
151
|
+
if (push === null) {
|
|
152
|
+
this.target = undefined;
|
|
153
|
+
clearTimeout(this.reconnectTimer);
|
|
154
|
+
this.reconnectTimer = undefined;
|
|
155
|
+
this.closeSocket();
|
|
156
|
+
this.current = "fallback";
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const changed = !this.target || this.target.url !== push.url || this.target.appKey !== push.appKey
|
|
160
|
+
|| this.target.channel !== push.channel || this.target.event !== push.event;
|
|
161
|
+
this.target = push;
|
|
162
|
+
if (changed) {
|
|
163
|
+
this.closeSocket();
|
|
164
|
+
this.connect();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!this.socket && !this.reconnectTimer)
|
|
168
|
+
this.connect();
|
|
169
|
+
}
|
|
170
|
+
connect() {
|
|
171
|
+
const target = this.target;
|
|
172
|
+
if (!target || this.current === "stopped")
|
|
173
|
+
return;
|
|
174
|
+
clearTimeout(this.reconnectTimer);
|
|
175
|
+
this.reconnectTimer = undefined;
|
|
176
|
+
this.current = "connecting";
|
|
177
|
+
this.subscribedAt = undefined;
|
|
178
|
+
let socket;
|
|
179
|
+
const events = {
|
|
180
|
+
onConnected: socketId => { void this.subscribe(socket, socketId); },
|
|
181
|
+
onSubscribed: () => {
|
|
182
|
+
if (!socket || this.socket !== socket || this.current === "stopped")
|
|
183
|
+
return;
|
|
184
|
+
this.current = "subscribed";
|
|
185
|
+
// The backoff restarts only once a subscription has held (see lostSocket), so a server that accepts and
|
|
186
|
+
// then drops every subscription cannot drive a reconnect and sync storm.
|
|
187
|
+
this.subscribedAt = Date.now();
|
|
188
|
+
this.report({ event: "transport_subscribed" });
|
|
189
|
+
this.requestSync("signal");
|
|
190
|
+
},
|
|
191
|
+
onSignal: () => { if (socket && this.socket === socket)
|
|
192
|
+
this.requestSync("signal"); },
|
|
193
|
+
onClosed: reason => { if (socket && this.socket === socket)
|
|
194
|
+
this.lostSocket(reason); },
|
|
195
|
+
};
|
|
196
|
+
try {
|
|
197
|
+
socket = this.deps.openSocket(target, events);
|
|
198
|
+
this.socket = socket;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
this.report({ event: "transport_socket_failed", cause: causeOf(error) });
|
|
202
|
+
this.lostSocket("open_failed");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async subscribe(socket, socketId) {
|
|
206
|
+
if (!socket || this.socket !== socket)
|
|
207
|
+
return;
|
|
208
|
+
try {
|
|
209
|
+
const { channel, auth } = await this.deps.authorize(socketId, this.controller.signal);
|
|
210
|
+
if (this.socket !== socket)
|
|
211
|
+
return;
|
|
212
|
+
if (channel !== this.target?.channel)
|
|
213
|
+
throw Object.assign(new Error("channel mismatch"), { code: "PUSH_CHANNEL_MISMATCH" });
|
|
214
|
+
socket.subscribe(auth);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
if (this.socket !== socket)
|
|
218
|
+
return;
|
|
219
|
+
this.report({ event: "transport_auth_failed", cause: causeOf(error) });
|
|
220
|
+
this.closeSocket();
|
|
221
|
+
this.lostSocket("auth_failed");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** The socket is gone without us closing it: sync on the fallback interval and reconnect with backoff. */
|
|
225
|
+
lostSocket(reason) {
|
|
226
|
+
this.socket = undefined;
|
|
227
|
+
if (this.current === "stopped")
|
|
228
|
+
return;
|
|
229
|
+
this.current = "fallback";
|
|
230
|
+
this.report({ event: "transport_socket_closed", reason });
|
|
231
|
+
if (this.subscribedAt !== undefined && Date.now() - this.subscribedAt >= STABLE_SUBSCRIPTION_MS)
|
|
232
|
+
this.socketFailures = 0;
|
|
233
|
+
this.subscribedAt = undefined;
|
|
234
|
+
this.socketFailures += 1;
|
|
235
|
+
clearTimeout(this.reconnectTimer);
|
|
236
|
+
const reconnectInMs = REJECTED_REASON.test(reason) ? REJECTED_RECONNECT_MS : this.backoff(this.socketFailures);
|
|
237
|
+
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = undefined; this.connect(); }, reconnectInMs);
|
|
238
|
+
// Keep a sync that is already due sooner (the next fallback sync, or a signal waiting out the minimum gap):
|
|
239
|
+
// restarting the timer on every lost socket would stretch the fallback interval past the latency target.
|
|
240
|
+
if (!this.inFlight && this.syncDueAt > Date.now() + this.fallbackSeconds * 1000)
|
|
241
|
+
this.schedule();
|
|
242
|
+
}
|
|
243
|
+
closeSocket() {
|
|
244
|
+
const socket = this.socket;
|
|
245
|
+
this.socket = undefined;
|
|
246
|
+
try {
|
|
247
|
+
socket?.close();
|
|
248
|
+
}
|
|
249
|
+
catch { /* Closing must not stop the transport. */ }
|
|
250
|
+
}
|
|
251
|
+
backoff(failures) {
|
|
252
|
+
const base = Math.min(MAX_BACKOFF_MS, (this.deps.backoffBaseMs ?? 1000) * (2 ** Math.min(5, failures - 1)));
|
|
253
|
+
return base + Math.floor(base * 0.2 * (this.deps.random ?? Math.random)());
|
|
254
|
+
}
|
|
255
|
+
/** Read through a method: stop() may run while a sync awaits, which narrowing on the field cannot see. */
|
|
256
|
+
isStopped() {
|
|
257
|
+
return this.current === "stopped";
|
|
258
|
+
}
|
|
259
|
+
report(event) {
|
|
260
|
+
try {
|
|
261
|
+
this.deps.report?.(event);
|
|
262
|
+
}
|
|
263
|
+
catch { /* Logging cannot stop the transport. */ }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function sideLoops(deps) {
|
|
2
|
+
let stopped = false;
|
|
3
|
+
return {
|
|
4
|
+
async run() {
|
|
5
|
+
await deps.heartbeat.tick();
|
|
6
|
+
if (stopped)
|
|
7
|
+
return;
|
|
8
|
+
await Promise.all([deps.transport.run(), deps.heartbeat.run()]);
|
|
9
|
+
},
|
|
10
|
+
stop() {
|
|
11
|
+
stopped = true;
|
|
12
|
+
try {
|
|
13
|
+
deps.onStop?.();
|
|
14
|
+
}
|
|
15
|
+
catch { /* Stopping must reach both loops. */ }
|
|
16
|
+
deps.transport.stop();
|
|
17
|
+
deps.heartbeat.stop();
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** A heartbeat function that decides for itself when to send (the messenger's), called on a fixed tick. */
|
|
22
|
+
export function tickingHeartbeat(beat, tickMs, signal) {
|
|
23
|
+
let timer;
|
|
24
|
+
let release;
|
|
25
|
+
const stopped = new Promise(resolve => { release = resolve; });
|
|
26
|
+
let ended = false;
|
|
27
|
+
const tick = async () => { try {
|
|
28
|
+
await beat(signal);
|
|
29
|
+
}
|
|
30
|
+
catch { /* The beat reports its own failures. */ } };
|
|
31
|
+
return {
|
|
32
|
+
tick,
|
|
33
|
+
async run() {
|
|
34
|
+
const loop = () => {
|
|
35
|
+
if (ended)
|
|
36
|
+
return;
|
|
37
|
+
timer = setTimeout(() => { void tick().then(loop); }, tickMs);
|
|
38
|
+
};
|
|
39
|
+
loop();
|
|
40
|
+
await stopped;
|
|
41
|
+
},
|
|
42
|
+
stop() {
|
|
43
|
+
if (ended)
|
|
44
|
+
return;
|
|
45
|
+
ended = true;
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
release();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Closures the runner has handled and not yet acknowledged (Work Item 25d8ea74). A closure is listed until the
|
|
3
|
+
* runner acknowledges it on a later sync, so a lost sync response cannot lose it (at-least-once). take() hands the
|
|
4
|
+
* ids to the next sync; restore() puts them back when that sync fails.
|
|
5
|
+
*/
|
|
6
|
+
export class PendingClosureAcks {
|
|
7
|
+
ids = new Set();
|
|
8
|
+
add(ids) {
|
|
9
|
+
for (const id of ids)
|
|
10
|
+
if (typeof id === "string" && id !== "" && this.ids.size < 200)
|
|
11
|
+
this.ids.add(id);
|
|
12
|
+
}
|
|
13
|
+
take() {
|
|
14
|
+
const taken = [...this.ids].slice(0, 50);
|
|
15
|
+
for (const id of taken)
|
|
16
|
+
this.ids.delete(id);
|
|
17
|
+
return taken;
|
|
18
|
+
}
|
|
19
|
+
restore(ids) { this.add(ids); }
|
|
20
|
+
}
|
|
21
|
+
/** Pauses before the second and third report attempts. The Gateway treats a repeated report as idempotent. */
|
|
22
|
+
const REPORT_RETRY_DELAYS_MS = [1000, 2000];
|
|
23
|
+
function causeOf(error) {
|
|
24
|
+
const code = error?.code;
|
|
25
|
+
return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : undefined;
|
|
26
|
+
}
|
|
27
|
+
export function createSyncDispatcher(deps) {
|
|
28
|
+
const fail = (stage, code, error) => {
|
|
29
|
+
const cause = causeOf(error);
|
|
30
|
+
try {
|
|
31
|
+
deps.reportError?.({ stage, code, ...(cause ? { cause } : {}) });
|
|
32
|
+
}
|
|
33
|
+
catch { /* Logging cannot stop dispatch. */ }
|
|
34
|
+
};
|
|
35
|
+
const handleOpen = async (request) => {
|
|
36
|
+
let result;
|
|
37
|
+
try {
|
|
38
|
+
result = await deps.open(request);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (!deps.signal.aborted)
|
|
42
|
+
fail("open", "SESSION_OPEN_OPEN_FAILED", error);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const openId = request?.open_id;
|
|
46
|
+
if (typeof openId !== "string" || openId === "")
|
|
47
|
+
return;
|
|
48
|
+
for (let attempt = 0;; attempt += 1) {
|
|
49
|
+
try {
|
|
50
|
+
await deps.report(openId, result, deps.signal);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (deps.signal.aborted)
|
|
55
|
+
return;
|
|
56
|
+
const retryDelay = REPORT_RETRY_DELAYS_MS[attempt];
|
|
57
|
+
if (retryDelay === undefined) {
|
|
58
|
+
fail("report", "SESSION_OPEN_REPORT_FAILED", error);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
await deps.delay(retryDelay, deps.signal).catch(() => undefined);
|
|
62
|
+
if (deps.signal.aborted)
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return async (result) => {
|
|
68
|
+
// Asks first: they are local and quick, while each open can spend seconds on report retries.
|
|
69
|
+
if (deps.asks) {
|
|
70
|
+
try {
|
|
71
|
+
const handled = await deps.asks.handleWait({
|
|
72
|
+
...(result.askDeliveries !== undefined ? { askDeliveries: result.askDeliveries } : {}),
|
|
73
|
+
...(result.closedDeliveryIds !== undefined ? { closedDeliveryIds: result.closedDeliveryIds } : {}),
|
|
74
|
+
});
|
|
75
|
+
// Acknowledged only once handled: a failed handleWait leaves them for the Gateway to list again.
|
|
76
|
+
if (handled !== false)
|
|
77
|
+
deps.closureAcks?.add(result.closedDeliveryIds ?? []);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
fail("asks", "ASK_DISPATCH_FAILED", error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (result.jobAvailable)
|
|
84
|
+
deps.jobs?.notify();
|
|
85
|
+
for (const request of result.opens) {
|
|
86
|
+
if (deps.signal.aborted)
|
|
87
|
+
return;
|
|
88
|
+
await handleOpen(request);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mtech-ai-local-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.49",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"bin": {
|
|
@@ -34,11 +34,13 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"smol-toml": "1.9.1",
|
|
37
|
-
"uuid": "11.1.1"
|
|
37
|
+
"uuid": "11.1.1",
|
|
38
|
+
"ws": "8.18.3"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"@types/node": "22.19.11",
|
|
41
42
|
"@types/uuid": "10.0.0",
|
|
43
|
+
"@types/ws": "8.18.1",
|
|
42
44
|
"typescript": "5.9.3",
|
|
43
45
|
"vitest": "4.0.18"
|
|
44
46
|
}
|