mtech-ai-local-runner 0.1.48 → 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 CHANGED
@@ -1,4 +1,37 @@
1
- ## Unreleased: AI-first ask delivery and messenger mode (disabled)
1
+ ## 0.1.49: one push transport instead of two long-polls
2
+
3
+ The daemon no longer long-polls the Gateway (Work Item 25d8ea74). It keeps one Pusher protocol 7 WebSocket to the
4
+ ELSA development Reverb server, which only rings, and calls `local_agent_runner_sync` once, with no wait: on start,
5
+ after each subscribe, on each signal, every 30 s while subscribed, and every 8 s while the socket is down or the
6
+ Gateway gives no push block. A job is claimed with `local_agent_jobs_wait` at `wait_seconds` 0, and only when the
7
+ sync says one is claimable. Session opens, ask deliveries and closures arrive on the sync result, and a closure is
8
+ acknowledged on the next sync. The full heartbeat goes out on a change or every 10 minutes. While the daemon takes no
9
+ jobs (reconciling at start, or suspended for recovery), it reports every adapter blocked with
10
+ `RUNNER_NOT_ACCEPTING_JOBS`, so the Gateway routes no job to it.
11
+
12
+ The socket sends `User-Agent: mtec-ai-local-runner/<version>`; the server's firewall refuses an upgrade without one.
13
+ The runner needs a Gateway with `local_agent_runner_sync` and `local_agent_runner_channel_auth`, so deploy the
14
+ Gateway before upgrading a runner. Push stays off until the Gateway's push switch names the employee; until then the
15
+ runner syncs every 8 s.
16
+
17
+ ## 0.1.48: local_edit takes the complete file, not a patch
18
+
19
+ Read the existing file first, then pass its complete desired new contents in
20
+ `local_edit.content`, including unchanged text. The operation replaces the whole
21
+ file or creates it; it never applies a diff. Recognizable apply_patch directives,
22
+ unified hunk lines and diff headers return `DENIED_PATCH_CONTENT` before a write.
23
+ The isolated writer enforces the same guard. Literal patch blocks in a document
24
+ are also rejected by this conservative API; ordinary source string literals and
25
+ YAML separators remain accepted. No partial old/new edit tool is introduced.
26
+
27
+ Gateway and Runner must agree on the new tool inventory hash. Package runtime
28
+ receipts change, while the reviewed Codex CLI release manifest identity stays
29
+ unchanged. Keep existing -2G allowed paths; deploy the matching Gateway registry
30
+ and exact runtime pins before switching a daemon. The candidate contains the
31
+ newer development ask/messenger code documented below and
32
+ the 0.1.46/0.1.47 fixes; no integration is installed merely by preparing it.
33
+
34
+ ### Also in 0.1.48: AI-first ask delivery and messenger mode (disabled)
2
35
 
3
36
  The runner can put an AI Gateway ask (a Work Decision, a named review or a work request), and the answer to it,
4
37
  into the employee's own running Claude Code or Codex session before any Slack message is sent (Work Item e1c08b33,
@@ -70,23 +103,6 @@ its pilot list. Until then an existing runner works as before: setup never turns
70
103
  installs no integration it was not asked for. The one change without the switch is that setup on a fresh Mac without an
71
104
  admitted executor completes as a messenger instead of failing. Deploy the Gateway receivers before upgrading a runner.
72
105
 
73
- ## 0.1.48: local_edit takes the complete file, not a patch
74
-
75
- Read the existing file first, then pass its complete desired new contents in
76
- `local_edit.content`, including unchanged text. The operation replaces the whole
77
- file or creates it; it never applies a diff. Recognizable apply_patch directives,
78
- unified hunk lines and diff headers return `DENIED_PATCH_CONTENT` before a write.
79
- The isolated writer enforces the same guard. Literal patch blocks in a document
80
- are also rejected by this conservative API; ordinary source string literals and
81
- YAML separators remain accepted. No partial old/new edit tool is introduced.
82
-
83
- Gateway and Runner must agree on the new tool inventory hash. Package runtime
84
- receipts change, while the reviewed Codex CLI release manifest identity stays
85
- unchanged. Keep existing -2G allowed paths; deploy the matching Gateway registry
86
- and exact runtime pins before switching a daemon. The candidate contains the
87
- newer development ask/messenger code documented in the Unreleased section and
88
- the 0.1.46/0.1.47 fixes; no integration is installed merely by preparing it.
89
-
90
106
  ## 0.1.47: Claude security host wiring remains gated
91
107
 
92
108
  Claude now shares the job security session and checks a live opaque executable
@@ -5,16 +5,22 @@ import { Daemon } from "../daemon/daemon.js";
5
5
  import { ProcessLock } from "../daemon/process-lock.js";
6
6
  import { runnerDisplayName } from "../daemon/runtime.js";
7
7
  import { SessionOpener, sessionOpenAdvertisement } from "../daemon/session-opener.js";
8
- import { SessionOpenPoller, abortableDelay } from "../daemon/session-open-poller.js";
8
+ import { RunnerTransport } from "../transport/runner-transport.js";
9
+ import { ReverbSocket } from "../transport/reverb-socket.js";
10
+ import { PendingClosureAcks, createSyncDispatcher } from "../transport/sync-dispatch.js";
11
+ import { abortableDelay } from "../transport/abortable-delay.js";
12
+ import { sideLoops, tickingHeartbeat } from "../transport/side-loops.js";
9
13
  import { packageVersion } from "../package-version.js";
10
14
  import { AskRuntime, withAsks } from "./runtime.js";
11
15
  /*
12
16
  * Messenger mode (Work Item e1c08b33, spec section 8.1). Setup chose it because no executor is admitted on
13
- * this Mac. The daemon runs the session-open loop only: there is no jobs loop, no adapter probe, no PreToolUse
14
- * self-test and no executor host, so nothing built here can start an executor.
17
+ * this Mac. The daemon runs the push transport only (Work Item 25d8ea74): there is no jobs loop, no adapter probe,
18
+ * no PreToolUse self-test and no executor host, so nothing built here can start an executor.
15
19
  */
16
- /** A messenger has no jobs loop, so its session-open loop sends the heartbeat: at daemon start, then every 600 s. */
20
+ /** A messenger has no jobs loop, so it sends the heartbeat itself: at daemon start, then every 600 s. */
17
21
  export const MESSENGER_HEARTBEAT_MS = 600_000;
22
+ /** How often the heartbeat decides whether a beat is due (Work Item 25d8ea74); it used to run on every wait. */
23
+ export const MESSENGER_HEARTBEAT_TICK_MS = 30_000;
18
24
  const HEARTBEAT_RETRY_MS = 60_000;
19
25
  /**
20
26
  * The waits before resending a heartbeat that had to leave session_open out, one after another: 60, 120, 240 s, then the
@@ -108,21 +114,42 @@ export async function buildMessengerDaemon(input) {
108
114
  }, { now: input.now, reportError: code => log({ event: "messenger_heartbeat_failed", code }),
109
115
  advertised: async () => JSON.stringify(await asks.advertisement() ?? null) });
110
116
  const opener = new SessionOpener({ home });
111
- const sessionOpens = new SessionOpenPoller({
112
- wait: async (signal) => {
113
- await heartbeat(signal);
114
- const result = await gateway.waitForSessionOpens(25, signal);
115
- await asks.handleWait(result);
116
- return result;
117
- },
117
+ const stopping = new AbortController();
118
+ const closureAcks = new PendingClosureAcks();
119
+ const dispatch = createSyncDispatcher({
118
120
  open: request => opener.open(request),
119
121
  report: async (openId, result, signal) => {
120
122
  await gateway.reportSessionOpen({ openId, status: result.status, surface: result.surface,
121
123
  failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
122
124
  },
125
+ asks,
126
+ closureAcks,
123
127
  delay: abortableDelay,
128
+ signal: stopping.signal,
124
129
  reportError: diagnostic => log({ event: "session_open_poller_failed", ...diagnostic }),
125
130
  });
131
+ // No executor: the messenger sends no adapter versions, and the Gateway never says a job is claimable.
132
+ const transport = new RunnerTransport({
133
+ sync: async (reason, signal) => {
134
+ const acknowledged = closureAcks.take();
135
+ try {
136
+ return await gateway.syncRunner({ adapterVersions: {}, reason, acknowledgedClosureIds: acknowledged }, signal);
137
+ }
138
+ catch (error) {
139
+ closureAcks.restore(acknowledged);
140
+ throw error;
141
+ }
142
+ },
143
+ authorize: (socketId, signal) => gateway.authorizeRunnerChannel(socketId, signal),
144
+ dispatch,
145
+ openSocket: (target, events) => new ReverbSocket(target, events, { packageVersion: packageVersion() }),
146
+ report: log,
147
+ });
148
+ const sessionOpens = sideLoops({
149
+ heartbeat: tickingHeartbeat(heartbeat, input.heartbeatTickMs ?? MESSENGER_HEARTBEAT_TICK_MS, stopping.signal),
150
+ transport,
151
+ onStop: () => stopping.abort(),
152
+ });
126
153
  const daemon = new Daemon({
127
154
  lock: new ProcessLock(join(input.stateRoot, "daemon.lock")),
128
155
  // Job state belongs to a full runner: a messenger never reads, reconciles or suspends on it.
@@ -102,17 +102,24 @@ export class AskRuntime {
102
102
  * the runner's person is a pilot actor and it has stored this runner's ask_inbox. A result without ask_deliveries
103
103
  * therefore means asks are off, and every stored delivery ends here with no report: the Gateway's fallback owns them.
104
104
  * A result with the key present but empty changes nothing.
105
+ *
106
+ * Resolves false when the result's closures could not be handled (the hub is down, or closing failed), so the push
107
+ * transport does not acknowledge them and the Gateway lists them again (Work Item 25d8ea74).
105
108
  */
106
109
  async handleWait(result) {
107
110
  if (!this.started)
108
- return;
111
+ return false;
112
+ let closuresHandled = true;
109
113
  try {
110
114
  if (result.askDeliveries === undefined) {
111
115
  await this.inbox.endAll();
112
- return;
116
+ return true;
113
117
  }
114
- if (result.closedDeliveryIds !== undefined && result.closedDeliveryIds.length > 0)
118
+ if (result.closedDeliveryIds !== undefined && result.closedDeliveryIds.length > 0) {
119
+ closuresHandled = false;
115
120
  await this.inbox.close(result.closedDeliveryIds);
121
+ closuresHandled = true;
122
+ }
116
123
  if (result.askDeliveries.length > 0)
117
124
  await this.inbox.receive(result.askDeliveries);
118
125
  }
@@ -120,6 +127,7 @@ export class AskRuntime {
120
127
  this.log({ event: "ask_delivery_failed", code: errorCode(error) });
121
128
  }
122
129
  this.flushSoon();
130
+ return closuresHandled;
123
131
  }
124
132
  /**
125
133
  * heartbeat runner.session_open.ask_inbox, or undefined when the hub is down or nothing is installed. While a start is
@@ -0,0 +1,63 @@
1
+ export class HeartbeatScheduler {
2
+ deps;
3
+ lastKey;
4
+ lastSentAt = Number.NEGATIVE_INFINITY;
5
+ timer;
6
+ stopped = false;
7
+ chain = Promise.resolve();
8
+ release;
9
+ done = new Promise(resolve => { this.release = resolve; });
10
+ constructor(deps) {
11
+ this.deps = deps;
12
+ }
13
+ /** One heartbeat check now, after any check in progress; never throws. */
14
+ tick() {
15
+ this.chain = this.chain.then(() => this.check());
16
+ return this.chain;
17
+ }
18
+ async check() {
19
+ let snapshot;
20
+ try {
21
+ snapshot = await this.deps.snapshot();
22
+ }
23
+ catch {
24
+ this.report("HEARTBEAT_SNAPSHOT_FAILED");
25
+ return;
26
+ }
27
+ const key = JSON.stringify(snapshot);
28
+ const now = (this.deps.now ?? Date.now)();
29
+ if (key === this.lastKey && now - this.lastSentAt < (this.deps.maxAgeMs ?? 600_000))
30
+ return;
31
+ try {
32
+ await this.deps.send(snapshot);
33
+ this.lastKey = key;
34
+ this.lastSentAt = now;
35
+ }
36
+ catch {
37
+ this.report("HEARTBEAT_SEND_FAILED");
38
+ }
39
+ }
40
+ /** Keeps ticking until stop(). The first tick comes one interval in: the caller sends the first heartbeat itself. */
41
+ async run() {
42
+ const loop = () => {
43
+ if (this.stopped)
44
+ return;
45
+ this.timer = setTimeout(() => { void this.tick().then(loop); }, this.deps.intervalMs ?? 60_000);
46
+ };
47
+ loop();
48
+ await this.done;
49
+ }
50
+ stop() {
51
+ if (this.stopped)
52
+ return;
53
+ this.stopped = true;
54
+ clearTimeout(this.timer);
55
+ this.release();
56
+ }
57
+ report(code) {
58
+ try {
59
+ this.deps.reportError?.(code);
60
+ }
61
+ catch { /* Logging cannot stop heartbeats. */ }
62
+ }
63
+ }
@@ -0,0 +1,99 @@
1
+ export class JobRunner {
2
+ deps;
3
+ armed = false;
4
+ busy = false;
5
+ pending = false;
6
+ stopped = false;
7
+ inFlight;
8
+ release;
9
+ done = new Promise(resolve => { this.release = resolve; });
10
+ constructor(deps) {
11
+ this.deps = deps;
12
+ }
13
+ /** True while a claim or a job is in progress. */
14
+ get running() {
15
+ return this.busy;
16
+ }
17
+ /**
18
+ * True while this runner may claim: after run() started and before stop(). A runner that does not accept jobs
19
+ * (still reconciling, or suspended after an unknown outcome) must not look ready to the Gateway.
20
+ */
21
+ get accepting() {
22
+ return this.armed && !this.stopped;
23
+ }
24
+ notify() {
25
+ if (this.stopped)
26
+ return;
27
+ if (!this.armed || this.busy) {
28
+ this.pending = true;
29
+ return;
30
+ }
31
+ this.start();
32
+ }
33
+ async run() {
34
+ // A pending promise alone does not keep Node alive; this referenced timer does until stop().
35
+ const keepAlive = setInterval(() => undefined, 60_000);
36
+ try {
37
+ this.armed = true;
38
+ if (!this.stopped) {
39
+ try {
40
+ this.deps.onArmed?.();
41
+ }
42
+ catch { /* Telling the Gateway is best effort; claims still start. */ }
43
+ }
44
+ if (this.pending && !this.stopped) {
45
+ this.pending = false;
46
+ this.start();
47
+ }
48
+ await this.done;
49
+ // Like the old poll loop, run() ends only after the claim or job in progress has finished.
50
+ await this.inFlight;
51
+ }
52
+ finally {
53
+ clearInterval(keepAlive);
54
+ }
55
+ }
56
+ stop() {
57
+ if (this.stopped)
58
+ return;
59
+ this.stopped = true;
60
+ this.release();
61
+ }
62
+ start() {
63
+ const cycle = this.cycle();
64
+ this.inFlight = cycle;
65
+ void cycle.finally(() => { if (this.inFlight === cycle)
66
+ this.inFlight = undefined; });
67
+ }
68
+ async cycle() {
69
+ this.busy = true;
70
+ let stage = "claim";
71
+ try {
72
+ const claim = await this.deps.claim();
73
+ if (claim.status === "claimed" && claim.job !== undefined && !this.stopped) {
74
+ stage = "execute";
75
+ await this.deps.execute(claim.job);
76
+ this.deps.requestSync("after_job");
77
+ }
78
+ }
79
+ catch {
80
+ try {
81
+ this.deps.reportError?.({ stage, code: stage === "execute" ? "EXECUTION_UNACKNOWLEDGED" : "GATEWAY_UNAVAILABLE" });
82
+ }
83
+ catch { /* Observational logging cannot bypass the stop below or discard recovery state. */ }
84
+ // Unknown execution or terminal outcomes must reconcile before any new claim.
85
+ if (stage === "execute") {
86
+ this.busy = false;
87
+ this.stop();
88
+ return;
89
+ }
90
+ }
91
+ finally {
92
+ this.busy = false;
93
+ }
94
+ if (this.pending && !this.stopped) {
95
+ this.pending = false;
96
+ this.start();
97
+ }
98
+ }
99
+ }
@@ -17,11 +17,16 @@ import { WorkspaceManager } from "../workspace/workspace-manager.js";
17
17
  import { GitClient } from "../workspace/git-client.js";
18
18
  import { FileDaemonStateStore } from "../state-store.js";
19
19
  import { ExecutionCoordinator } from "./execution-coordinator.js";
20
- import { JobPoller } from "./job-poller.js";
20
+ import { JobRunner } from "./job-runner.js";
21
+ import { HeartbeatScheduler } from "./heartbeat-scheduler.js";
21
22
  import { ProcessLock } from "./process-lock.js";
22
23
  import { Daemon } from "./daemon.js";
23
24
  import { SessionOpener, sessionOpenAdvertisement } from "./session-opener.js";
24
- import { SessionOpenPoller, abortableDelay } from "./session-open-poller.js";
25
+ import { RunnerTransport } from "../transport/runner-transport.js";
26
+ import { ReverbSocket } from "../transport/reverb-socket.js";
27
+ import { PendingClosureAcks, createSyncDispatcher } from "../transport/sync-dispatch.js";
28
+ import { abortableDelay } from "../transport/abortable-delay.js";
29
+ import { sideLoops } from "../transport/side-loops.js";
25
30
  import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
26
31
  import { packagedHookCommandParts } from "../safety/hook-command.js";
27
32
  import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
@@ -115,60 +120,15 @@ export async function buildDaemon(input) {
115
120
  workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient() }),
116
121
  state,
117
122
  });
118
- const poller = new JobPoller({
123
+ let transport;
124
+ /** The adapter probe in progress, if any: a claim waits for it, so versions never change under a claim. */
125
+ let probing = Promise.resolve();
126
+ const poller = new JobRunner({
119
127
  reportError: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "poller_failed", ...diagnostic })}\n`); },
120
- heartbeat: async () => {
121
- if (input.claudeSupportedProbe) {
122
- let current;
123
- try {
124
- current = await plugin.probe();
125
- }
126
- catch {
127
- current = { available: false, authenticated: false, capabilities: [] };
128
- }
129
- const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
130
- const index = probes.findIndex(p => p.id === 'claude_code_cli');
131
- if (index >= 0)
132
- probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
133
- if (ready)
134
- versions.claude_code_cli = '1';
135
- else
136
- delete versions.claude_code_cli;
137
- }
138
- let current;
139
- try {
140
- current = await codex.probe();
141
- }
142
- catch {
143
- current = { available: false, authenticated: false, capabilities: [] };
144
- }
145
- const ready = adapterStatus(current).authenticated && !!current.executableVersion && codex.capabilities.every(c => current.capabilities.includes(c));
146
- const index = probes.findIndex(p => p.id === 'codex_cli');
147
- if (index >= 0)
148
- probes[index] = { id: 'codex_cli', version: '1', ...current, available: ready };
149
- if (ready)
150
- versions.codex_cli = '1';
151
- else
152
- delete versions.codex_cli;
153
- let sessionOpen;
154
- // A detection error must not stop job claims: the heartbeat simply omits the field.
155
- try {
156
- sessionOpen = await sessionOpenAdvertisement({ home: input.env.HOME ?? "" });
157
- }
158
- catch {
159
- sessionOpen = undefined;
160
- }
161
- const askInbox = await asks.advertisement();
162
- await gateway.heartbeatLocalRunner({
163
- deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
164
- displayName: runnerDisplayName(), packageVersion: packageVersion(),
165
- capabilities: localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationMode),
166
- mode: "full",
167
- ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
168
- });
169
- },
170
- wait: async () => {
171
- const claim = await gateway.waitForLocalJob(versions, 25);
128
+ // Push transport (Work Item 25d8ea74): claimed only when a sync says a job is claimable, with no wait.
129
+ claim: async () => {
130
+ await probing;
131
+ const claim = await gateway.waitForLocalJob(versions, 0);
172
132
  if (claim.status !== "claimed" || !claim.job || !claim.task_package || !claim.lease_token || !claim.launch_grant)
173
133
  return { status: "no_job" };
174
134
  if (production && claim.task_package.runtime_adapter === 'codex_cli')
@@ -186,23 +146,107 @@ export async function buildDaemon(input) {
186
146
  execute: async (job) => {
187
147
  await coordinator.execute(job);
188
148
  },
189
- delay: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
149
+ requestSync: reason => { transport?.requestSync(reason); },
150
+ // Until now the heartbeat said this runner takes no jobs; tell the Gateway at once, and ask whether one waits.
151
+ onArmed: () => { void heartbeats.tick(); transport?.requestSync("start"); },
190
152
  });
191
- const opener = new SessionOpener({ home: input.env.HOME ?? "" });
192
- const sessionOpens = new SessionOpenPoller({
193
- wait: async (signal) => {
194
- const result = await gateway.waitForSessionOpens(25, signal);
195
- await asks.handleWait(result);
196
- return result;
153
+ const heartbeats = new HeartbeatScheduler({
154
+ intervalMs: 30_000,
155
+ snapshot: async () => {
156
+ // Adapter probes run only between jobs, as they did when the job loop sent the heartbeat.
157
+ if (!poller.running) {
158
+ const probe = (async () => {
159
+ if (input.claudeSupportedProbe) {
160
+ let current;
161
+ try {
162
+ current = await plugin.probe();
163
+ }
164
+ catch {
165
+ current = { available: false, authenticated: false, capabilities: [] };
166
+ }
167
+ const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
168
+ const index = probes.findIndex(p => p.id === 'claude_code_cli');
169
+ if (index >= 0)
170
+ probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
171
+ if (ready)
172
+ versions.claude_code_cli = '1';
173
+ else
174
+ delete versions.claude_code_cli;
175
+ }
176
+ let current;
177
+ try {
178
+ current = await codex.probe();
179
+ }
180
+ catch {
181
+ current = { available: false, authenticated: false, capabilities: [] };
182
+ }
183
+ const ready = adapterStatus(current).authenticated && !!current.executableVersion && codex.capabilities.every(c => current.capabilities.includes(c));
184
+ const index = probes.findIndex(p => p.id === 'codex_cli');
185
+ if (index >= 0)
186
+ probes[index] = { id: 'codex_cli', version: '1', ...current, available: ready };
187
+ if (ready)
188
+ versions.codex_cli = '1';
189
+ else
190
+ delete versions.codex_cli;
191
+ })();
192
+ probing = probe.catch(() => undefined);
193
+ await probe;
194
+ }
195
+ let sessionOpen;
196
+ // A detection error must not stop job claims: the heartbeat simply omits the field.
197
+ try {
198
+ sessionOpen = await sessionOpenAdvertisement({ home: input.env.HOME ?? "" });
199
+ }
200
+ catch {
201
+ sessionOpen = undefined;
202
+ }
203
+ const askInbox = await asks.advertisement();
204
+ return {
205
+ deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
206
+ displayName: runnerDisplayName(), packageVersion: packageVersion(),
207
+ capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationMode), poller.accepting),
208
+ mode: "full",
209
+ ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
210
+ };
197
211
  },
212
+ send: async (payload) => { await gateway.heartbeatLocalRunner(payload); },
213
+ reportError: code => { process.stderr.write(`${JSON.stringify({ event: "heartbeat_failed", code })}\n`); },
214
+ });
215
+ const stopping = new AbortController();
216
+ const closureAcks = new PendingClosureAcks();
217
+ const opener = new SessionOpener({ home: input.env.HOME ?? "" });
218
+ const dispatch = createSyncDispatcher({
198
219
  open: request => opener.open(request),
199
220
  report: async (openId, result, signal) => {
200
221
  await gateway.reportSessionOpen({ openId, status: result.status, surface: result.surface,
201
222
  failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
202
223
  },
224
+ asks,
225
+ closureAcks,
226
+ jobs: poller,
203
227
  delay: abortableDelay,
228
+ signal: stopping.signal,
204
229
  reportError: diagnostic => { process.stderr.write(`${JSON.stringify({ event: "session_open_poller_failed", ...diagnostic })}\n`); },
205
230
  });
231
+ const pushTransport = new RunnerTransport({
232
+ sync: async (reason, signal) => {
233
+ const acknowledged = closureAcks.take();
234
+ // A runner that takes no jobs right now (reconciling, or suspended for recovery) names no adapter.
235
+ try {
236
+ return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged }, signal);
237
+ }
238
+ catch (error) {
239
+ closureAcks.restore(acknowledged);
240
+ throw error;
241
+ }
242
+ },
243
+ authorize: (socketId, signal) => gateway.authorizeRunnerChannel(socketId, signal),
244
+ dispatch,
245
+ openSocket: (target, events) => new ReverbSocket(target, events, { packageVersion: packageVersion() }),
246
+ report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); },
247
+ });
248
+ transport = pushTransport;
249
+ const sessionOpens = sideLoops({ heartbeat: heartbeats, transport: pushTransport, onStop: () => stopping.abort() });
206
250
  const daemon = new Daemon({
207
251
  lock: new ProcessLock(join(input.stateRoot, "daemon.lock")), state,
208
252
  reconcile: async (stored) => {
@@ -222,6 +266,20 @@ export async function buildDaemon(input) {
222
266
  return { daemon, close: async () => { daemon.stop(); await production?.close(); } };
223
267
  }
224
268
  export function runnerDisplayName() { return `${hostname()} local AI`; }
269
+ /** The execution blocker a full runner reports for every adapter while it takes no jobs. */
270
+ export const RUNNER_NOT_ACCEPTING_JOBS = "RUNNER_NOT_ACCEPTING_JOBS";
271
+ /*
272
+ * Work Item 25d8ea74: sync keeps presence fresh even while the daemon reconciles or is suspended for recovery, which
273
+ * the old job loop's heartbeat never did. So the heartbeat itself must say the runner takes no jobs, or the Gateway
274
+ * would route a job to a runner that never claims it. The Gateway requires at least one capability from a full
275
+ * runner, so each adapter is reported blocked, with this code, instead of being left out.
276
+ */
277
+ export function acceptingJobs(capabilities, accepting) {
278
+ if (accepting)
279
+ return capabilities;
280
+ return capabilities.map(capability => ({ ...capability, authenticated: false, execution_status: "blocked",
281
+ execution_reason_codes: [...new Set([RUNNER_NOT_ACCEPTING_JOBS, ...capability.execution_reason_codes])].slice(0, 20) }));
282
+ }
225
283
  /** Unknown mutation outcomes halt polling; known failures replay only their terminal receipt. */
226
284
  export async function reconcilePreparationState(previous, deps) {
227
285
  if (["publication_pending", "publication_terminal_pending"].includes(previous.phase ?? ""))
@@ -12,6 +12,51 @@ export class GatewayToolError extends Error {
12
12
  this.code = code;
13
13
  }
14
14
  }
15
+ const PUSH_CHANNEL = /^private-ai-gateway\.[a-z0-9_-]{1,32}\.runner\.[a-z0-9:_-]{1,128}$/;
16
+ const PUSH_APP_KEY = /^[A-Za-z0-9_-]{1,128}$/;
17
+ const CHANNEL_AUTH = /^[^:\s]{1,128}:[a-f0-9]{64}$/;
18
+ /*
19
+ * A sync result without the ask keys ends every stored delivery (the e1c08b33 kill switch), so a result that is not
20
+ * clearly a sync answer is refused instead of being read as "asks off": the transport retries it with backoff.
21
+ */
22
+ export function parseRunnerSync(result, runnerId) {
23
+ const body = result && typeof result === "object" && !Array.isArray(result) ? result : undefined;
24
+ const malformed = (key) => body !== undefined && body[key] !== undefined && !Array.isArray(body[key]);
25
+ if (body === undefined || !Array.isArray(body.opens) || malformed("ask_deliveries") || malformed("closed_delivery_ids")) {
26
+ throw new GatewayToolError("Gateway returned an invalid runner sync result", "local_agent_runner_sync", "GATEWAY_INVALID_TOOL_RESULT");
27
+ }
28
+ return {
29
+ opens: body.opens,
30
+ ...(Array.isArray(body.ask_deliveries) ? { askDeliveries: body.ask_deliveries } : {}),
31
+ ...(Array.isArray(body.closed_delivery_ids) ? { closedDeliveryIds: body.closed_delivery_ids } : {}),
32
+ jobAvailable: body.job_available === true,
33
+ push: parsePush(body.push, runnerId),
34
+ syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
35
+ fallbackSyncSeconds: clampSeconds(body.fallback_sync_seconds, 5, 30, 8),
36
+ };
37
+ }
38
+ function clampSeconds(value, min, max, fallback) {
39
+ return typeof value === "number" && Number.isInteger(value) ? Math.min(max, Math.max(min, value)) : fallback;
40
+ }
41
+ function parsePush(value, runnerId) {
42
+ if (!value || typeof value !== "object")
43
+ return null;
44
+ const { url, app_key: appKey, channel, event } = value;
45
+ if (typeof url !== "string" || typeof appKey !== "string" || typeof channel !== "string" || event !== "runner.sync")
46
+ return null;
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(url);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ if (parsed.protocol !== "wss:" || !PUSH_APP_KEY.test(appKey) || !PUSH_CHANNEL.test(channel))
55
+ return null;
56
+ if (!channel.endsWith(`.runner.${runnerId.toLowerCase()}`))
57
+ return null;
58
+ return { url, appKey, channel, event };
59
+ }
15
60
  function nested(value, path) {
16
61
  return path.split(".").reduce((current, key) => {
17
62
  if (!current || typeof current !== "object")
@@ -479,6 +524,27 @@ export class McpGatewayClient {
479
524
  ...(input.failureCode ? { failure_code: input.failureCode } : {}),
480
525
  }, signal);
481
526
  }
527
+ /** acknowledgedClosureIds: closures handled since the last sync; the Gateway lists each again until acknowledged. */
528
+ async syncRunner(input, signal) {
529
+ const acknowledged = (input.acknowledgedClosureIds ?? []).slice(0, 50);
530
+ const result = await this.callTool("local_agent_runner_sync", {
531
+ runner_id: this.runnerId,
532
+ adapter_versions: input.adapterVersions,
533
+ reason: input.reason,
534
+ ...(acknowledged.length > 0 ? { acknowledged_closure_ids: acknowledged } : {}),
535
+ }, signal);
536
+ return parseRunnerSync(result, this.runnerId);
537
+ }
538
+ async authorizeRunnerChannel(socketId, signal) {
539
+ const result = await this.callTool("local_agent_runner_channel_auth", {
540
+ runner_id: this.runnerId,
541
+ socket_id: socketId,
542
+ }, signal);
543
+ if (typeof result?.channel !== "string" || typeof result.auth !== "string" || !CHANNEL_AUTH.test(result.auth)) {
544
+ throw new GatewayToolError("Gateway returned an invalid channel authorization", "local_agent_runner_channel_auth", "GATEWAY_INVALID_TOOL_RESULT");
545
+ }
546
+ return { channel: result.channel, auth: result.auth };
547
+ }
482
548
  async localRunnerStatus() {
483
549
  return this.callTool("local_agent_runner_status", { runner_id: this.runnerId });
484
550
  }
@@ -0,0 +1,12 @@
1
+ /** Resolves after the delay or at once on abort, and never leaves a listener on a long-lived signal. */
2
+ export function abortableDelay(milliseconds, signal) {
3
+ return new Promise(resolve => {
4
+ if (signal.aborted) {
5
+ resolve();
6
+ return;
7
+ }
8
+ const onAbort = () => { clearTimeout(timer); resolve(); };
9
+ const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, milliseconds);
10
+ signal.addEventListener("abort", onAbort, { once: true });
11
+ });
12
+ }
@@ -0,0 +1,66 @@
1
+ const SOCKET_ID = /^\d{1,20}\.\d{1,20}$/;
2
+ const DEFAULT_ACTIVITY_TIMEOUT_SECONDS = 30;
3
+ export function subscribeFrame(channel, auth) {
4
+ return JSON.stringify({ event: "pusher:subscribe", data: { channel, auth } });
5
+ }
6
+ export function pingFrame() {
7
+ return JSON.stringify({ event: "pusher:ping", data: {} });
8
+ }
9
+ export function pongFrame() {
10
+ return JSON.stringify({ event: "pusher:pong", data: {} });
11
+ }
12
+ export function decodeFrame(raw, expected) {
13
+ let frame;
14
+ try {
15
+ frame = JSON.parse(raw);
16
+ }
17
+ catch {
18
+ return { kind: "ignored" };
19
+ }
20
+ if (!frame || typeof frame !== "object" || Array.isArray(frame))
21
+ return { kind: "ignored" };
22
+ const { event, channel, data } = frame;
23
+ if (typeof event !== "string")
24
+ return { kind: "ignored" };
25
+ switch (event) {
26
+ case "pusher:connection_established": {
27
+ const payload = parseData(data);
28
+ const socketId = payload?.socket_id;
29
+ if (typeof socketId !== "string" || !SOCKET_ID.test(socketId))
30
+ return { kind: "error" };
31
+ const timeout = payload?.activity_timeout;
32
+ const activityTimeoutSeconds = typeof timeout === "number" && Number.isInteger(timeout) && timeout >= 5 && timeout <= 600
33
+ ? timeout : DEFAULT_ACTIVITY_TIMEOUT_SECONDS;
34
+ return { kind: "connected", socketId, activityTimeoutSeconds };
35
+ }
36
+ case "pusher_internal:subscription_succeeded":
37
+ return channel === expected.channel ? { kind: "subscribed" } : { kind: "ignored" };
38
+ case "pusher:ping":
39
+ return { kind: "ping" };
40
+ case "pusher:pong":
41
+ return { kind: "pong" };
42
+ case "pusher:error": {
43
+ const code = parseData(data)?.code;
44
+ return typeof code === "number" ? { kind: "error", code } : { kind: "error" };
45
+ }
46
+ case "pusher:subscription_error":
47
+ return { kind: "error" };
48
+ default:
49
+ break;
50
+ }
51
+ if (event.startsWith("client-") || event.startsWith("pusher"))
52
+ return { kind: "ignored" };
53
+ return channel === expected.channel && event === expected.event ? { kind: "signal" } : { kind: "ignored" };
54
+ }
55
+ function parseData(data) {
56
+ if (typeof data === "string") {
57
+ try {
58
+ const parsed = JSON.parse(data);
59
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ return data && typeof data === "object" && !Array.isArray(data) ? data : undefined;
66
+ }
@@ -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.48",
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
  }