@beeos-ai/device-agent 0.9.1 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/transport-bridge-ws.d.ts +12 -0
- package/dist/adapters/transport-bridge-ws.js +27 -3
- package/dist/adapters/transport-bridge-ws.js.map +1 -1
- package/dist/agent-runtime/core.d.ts +1 -1
- package/dist/agent-runtime/core.js +41 -0
- package/dist/agent-runtime/core.js.map +1 -1
- package/dist/app/session-manager.d.ts +14 -11
- package/dist/app/session-manager.js +8 -9
- package/dist/app/session-manager.js.map +1 -1
- package/dist/bootstrap.js +140 -11
- package/dist/bootstrap.js.map +1 -1
- package/dist/chat.js +11 -1
- package/dist/chat.js.map +1 -1
- package/dist/config/loader.js +3 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +3 -0
- package/dist/config/schema.js +51 -15
- package/dist/config/schema.js.map +1 -1
- package/dist/fleet/adapters/process/node-process-manager.d.ts +115 -13
- package/dist/fleet/adapters/process/node-process-manager.js +412 -64
- package/dist/fleet/adapters/process/node-process-manager.js.map +1 -1
- package/dist/fleet/adapters/state/port-allocator.d.ts +12 -0
- package/dist/fleet/adapters/state/port-allocator.js +39 -5
- package/dist/fleet/adapters/state/port-allocator.js.map +1 -1
- package/dist/fleet/domain/events.d.ts +23 -1
- package/dist/fleet/domain/fsm.d.ts +13 -1
- package/dist/fleet/domain/fsm.js +70 -0
- package/dist/fleet/domain/fsm.js.map +1 -1
- package/dist/fleet/supervisor.js +30 -0
- package/dist/fleet/supervisor.js.map +1 -1
- package/dist/health/adb-track-devices.d.ts +103 -0
- package/dist/health/adb-track-devices.js +171 -0
- package/dist/health/adb-track-devices.js.map +1 -0
- package/dist/health/health-reporter.d.ts +67 -0
- package/dist/health/health-reporter.js +123 -0
- package/dist/health/health-reporter.js.map +1 -0
- package/dist/health/index.d.ts +6 -0
- package/dist/health/index.js +4 -0
- package/dist/health/index.js.map +1 -0
- package/dist/health/mcp-health-monitor.d.ts +190 -0
- package/dist/health/mcp-health-monitor.js +267 -0
- package/dist/health/mcp-health-monitor.js.map +1 -0
- package/dist/inbound/cli/main.js +8 -1
- package/dist/inbound/cli/main.js.map +1 -1
- package/dist/inbound/cli/run-command.js +11 -1
- package/dist/inbound/cli/run-command.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-client/action-mapping.d.ts +45 -0
- package/dist/mcp-client/action-mapping.js +224 -0
- package/dist/mcp-client/action-mapping.js.map +1 -0
- package/dist/mcp-client/autospawn.d.ts +15 -2
- package/dist/mcp-client/autospawn.js +82 -4
- package/dist/mcp-client/autospawn.js.map +1 -1
- package/dist/mcp-client/client.d.ts +134 -38
- package/dist/mcp-client/client.js +394 -87
- package/dist/mcp-client/client.js.map +1 -1
- package/dist/mcp-client/index.d.ts +5 -3
- package/dist/mcp-client/index.js +2 -1
- package/dist/mcp-client/index.js.map +1 -1
- package/dist/mcp-client/inproc.d.ts +2 -2
- package/dist/mcp-client/inproc.js.map +1 -1
- package/dist/mcp-client/runtime.d.ts +124 -21
- package/dist/mcp-client/runtime.js +18 -7
- package/dist/mcp-client/runtime.js.map +1 -1
- package/dist/model/adapters/autoglm.js +44 -45
- package/dist/model/adapters/autoglm.js.map +1 -1
- package/dist/plugins/acp/index.d.ts +35 -0
- package/dist/plugins/acp/index.js +121 -0
- package/dist/plugins/acp/index.js.map +1 -1
- package/dist/plugins/message-bus/busy-reply.js +13 -5
- package/dist/plugins/message-bus/busy-reply.js.map +1 -1
- package/dist/plugins/message-bus/inbound.js +12 -6
- package/dist/plugins/message-bus/inbound.js.map +1 -1
- package/dist/plugins/message-bus/index.js +14 -2
- package/dist/plugins/message-bus/index.js.map +1 -1
- package/dist/plugins/message-bus/ms-channel-notifier.d.ts +19 -7
- package/dist/plugins/message-bus/ms-channel-notifier.js +33 -8
- package/dist/plugins/message-bus/ms-channel-notifier.js.map +1 -1
- package/dist/ports/transport.d.ts +11 -0
- package/package.json +4 -3
|
@@ -1,23 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Adapter — `IProcessManager` implementation backed by `child_process.spawn`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 0.4.0 SINGLETON MCP TOPOLOGY:
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Pre-0.4 the manager spawned **one mcp + one daemon per device** (1:1) so
|
|
7
|
+
* the daemon could pin its `--mcp-url` to a per-device port. With the new
|
|
8
|
+
* SDK-backed device-mcp-server (which is itself a singleton multi-device
|
|
9
|
+
* BackendRegistry process), the manager keeps the per-device `IProcessManager`
|
|
10
|
+
* API the FSM/supervisor expect but internally collapses every `spawnMcp`
|
|
11
|
+
* call onto a **single shared MCP child**:
|
|
12
|
+
*
|
|
13
|
+
* - The first `spawnMcp` call actually starts `device-mcp-server` on the
|
|
14
|
+
* supplied port, runs the `initialize` JSON-RPC readiness handshake,
|
|
15
|
+
* and remembers that port as the global MCP port.
|
|
16
|
+
* - Subsequent `spawnMcp` calls (for additional devices) skip the spawn,
|
|
17
|
+
* immediately emit a synthetic per-device `mcp_started` for the
|
|
18
|
+
* existing global port, and add the serial to a refcount set.
|
|
19
|
+
* - `killMcp(serial)` removes the serial from the refcount; the actual
|
|
20
|
+
* `device-mcp-server` child is only killed when the last device drops
|
|
21
|
+
* out (or `stop()` is called).
|
|
22
|
+
* - When the global MCP exits unexpectedly, every refcounted serial
|
|
23
|
+
* receives a synthetic `mcp_exited` so each per-device FSM transitions
|
|
24
|
+
* coherently and triggers a respawn (whose first call rebuilds the
|
|
25
|
+
* singleton).
|
|
26
|
+
*
|
|
27
|
+
* The daemon's `--mcp-url` always points at the global port (recorded in
|
|
28
|
+
* `globalMcpPort`), so per-device daemons all share the same backend
|
|
29
|
+
* registry. Tool calls route by the `device` argument the daemon's
|
|
30
|
+
* `McpClient` injects from its `defaultDevice = serial`. The MCP server's
|
|
31
|
+
* own `--adb-serial` per-device pin flag is gone; the registry discovers
|
|
32
|
+
* every connected device on its own.
|
|
33
|
+
*
|
|
34
|
+
* Spawn surface:
|
|
35
|
+
*
|
|
36
|
+
* - **mcp** `device-mcp-server --backend <kind>
|
|
37
|
+
* --host 127.0.0.1 --port <globalMcpPort>` (singleton)
|
|
8
38
|
* - **daemon** `device-agent --key <keyFile> --agent-gateway-url <url>
|
|
9
|
-
* --mcp-url http://127.0.0.1:<
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
39
|
+
* --mcp-url http://127.0.0.1:<globalMcpPort>
|
|
40
|
+
* --device <serial> [--adb <serial> for ADB backends]
|
|
41
|
+
* [--http --http-port <httpPort>]` (per device)
|
|
42
|
+
* - **scrcpy** `scrcpy-bridge` (no args; per device, optional —
|
|
43
|
+
* only spawned when the binding's `videoMode === "scrcpy"`).
|
|
14
44
|
*
|
|
15
45
|
* Lifecycle responsibilities:
|
|
16
46
|
*
|
|
17
|
-
* - `spawnMcp` waits for
|
|
18
|
-
* before emitting `mcp_started`. If readiness times out
|
|
19
|
-
* killed and
|
|
20
|
-
* attempt as a crash
|
|
47
|
+
* - `spawnMcp` (first call) waits for the MCP `initialize` handshake to
|
|
48
|
+
* return 200/202 before emitting `mcp_started`. If readiness times out
|
|
49
|
+
* the global child is killed and a synthetic `mcp_exited` is reported
|
|
50
|
+
* so the FSM treats the attempt as a crash. Subsequent (refcount-bump)
|
|
51
|
+
* calls emit `mcp_started` synchronously without re-probing.
|
|
21
52
|
* - `spawnDaemon` emits `daemon_started` as soon as the child has a PID
|
|
22
53
|
* (the daemon's own readiness is observable through ACP traffic which is
|
|
23
54
|
* out of fleet scope; the daemon process itself dying is what we react to
|
|
@@ -79,6 +110,42 @@ export class NodeProcessManager {
|
|
|
79
110
|
slots = new Map();
|
|
80
111
|
listeners = new Set();
|
|
81
112
|
started = false;
|
|
113
|
+
// ── Singleton MCP bookkeeping ───────────────────────────────────────────
|
|
114
|
+
/**
|
|
115
|
+
* The lone `device-mcp-server` child shared across all devices, or
|
|
116
|
+
* `undefined` when no device currently needs the MCP. First `spawnMcp`
|
|
117
|
+
* call spawns it; the last `killMcp` (or `stop()`) tears it down.
|
|
118
|
+
*/
|
|
119
|
+
globalMcpSlot;
|
|
120
|
+
/** Port the singleton is bound to. 0 when not running. */
|
|
121
|
+
globalMcpPort = 0;
|
|
122
|
+
/**
|
|
123
|
+
* Port the most recent successfully-probed singleton was bound to.
|
|
124
|
+
* Survives `globalMcpPort` being reset to 0 on exit; consulted by
|
|
125
|
+
* the next `startGlobalMcp` to detect port drift across respawn.
|
|
126
|
+
* 0 means "no prior cycle has succeeded" (suppresses the false-
|
|
127
|
+
* positive `mcp_port_changed` on the very first spawn).
|
|
128
|
+
*/
|
|
129
|
+
previousGlobalMcpPort = 0;
|
|
130
|
+
/** Serials currently refcounting the global MCP. */
|
|
131
|
+
mcpRefs = new Set();
|
|
132
|
+
/**
|
|
133
|
+
* Single-flight readiness: when two devices land on `spawnMcp` at the
|
|
134
|
+
* same time we want only one of them to actually probe `initialize`;
|
|
135
|
+
* the other just awaits this promise.
|
|
136
|
+
*/
|
|
137
|
+
mcpReadyPromise;
|
|
138
|
+
/**
|
|
139
|
+
* Single-flight first-spawn: protects the
|
|
140
|
+
* (`globalMcpSlot === undefined` → `spawnFn` → `globalMcpSlot = …`)
|
|
141
|
+
* critical section. Without this, two concurrent first-time
|
|
142
|
+
* `spawnMcp(A)` / `spawnMcp(B)` calls could each call `spawnFn`
|
|
143
|
+
* (the `globalMcpSlot` write is post-spawn, so the second caller
|
|
144
|
+
* sees `undefined` and overwrites with its own child, leaking
|
|
145
|
+
* the first one). When non-undefined, every other caller waits
|
|
146
|
+
* here instead of starting a second mcp.
|
|
147
|
+
*/
|
|
148
|
+
globalMcpStartPromise;
|
|
82
149
|
constructor(opts = {}) {
|
|
83
150
|
this.mcpBin =
|
|
84
151
|
opts.mcpBin ?? process.env.BEEOS_DEVICE_MCP_BIN ?? "device-mcp-server";
|
|
@@ -110,6 +177,16 @@ export class NodeProcessManager {
|
|
|
110
177
|
this.killDaemon(s, { expected: true }),
|
|
111
178
|
this.killScrcpy(s, { expected: true }),
|
|
112
179
|
]));
|
|
180
|
+
// Belt + braces: if any refs remain (shouldn't, but defensive), tear
|
|
181
|
+
// down the singleton MCP explicitly. Otherwise the last killMcp above
|
|
182
|
+
// already cleared it.
|
|
183
|
+
if (this.globalMcpSlot) {
|
|
184
|
+
await this.killGlobalMcp(true);
|
|
185
|
+
}
|
|
186
|
+
// Forget the prior port so a future `start()` doesn't fire a
|
|
187
|
+
// spurious `mcp_port_changed` against a slate that's been torn
|
|
188
|
+
// down completely.
|
|
189
|
+
this.previousGlobalMcpPort = 0;
|
|
113
190
|
}
|
|
114
191
|
on(listener) {
|
|
115
192
|
this.listeners.add(listener);
|
|
@@ -153,24 +230,77 @@ export class NodeProcessManager {
|
|
|
153
230
|
});
|
|
154
231
|
return;
|
|
155
232
|
}
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
233
|
+
// Singleton refcount path — when the global MCP is already running,
|
|
234
|
+
// we just attach this serial as a ref and emit the per-device
|
|
235
|
+
// `mcp_started` synchronously. No new process spawn, no second
|
|
236
|
+
// `initialize` probe.
|
|
237
|
+
if (this.globalMcpSlot) {
|
|
238
|
+
this.attachToGlobalMcp(slot, opts.serial);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// Concurrent first-spawn: another caller is already driving
|
|
242
|
+
// `startGlobalMcp`. Wait for it instead of starting a duplicate
|
|
243
|
+
// child (which would leak the first one — `globalMcpSlot` is
|
|
244
|
+
// overwritten post-spawn, so a naive concurrent path could
|
|
245
|
+
// double-spawn the binary).
|
|
246
|
+
if (this.globalMcpStartPromise) {
|
|
247
|
+
await this.globalMcpStartPromise;
|
|
248
|
+
if (this.globalMcpSlot) {
|
|
249
|
+
this.attachToGlobalMcp(slot, opts.serial);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// The driver's start failed — emit our own per-device synthetic
|
|
253
|
+
// `mcp_exited` so this caller's FSM transitions out of `spawning`
|
|
254
|
+
// (the driver only emitted for ITS serial).
|
|
255
|
+
this.emit({
|
|
256
|
+
kind: "mcp_exited",
|
|
257
|
+
serial: opts.serial,
|
|
258
|
+
code: null,
|
|
259
|
+
signal: null,
|
|
260
|
+
expected: false,
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// First device — actually spawn the MCP child. We use this device's
|
|
265
|
+
// allocated port (`opts.mcpPort`) as the global port; later devices
|
|
266
|
+
// will share it irrespective of what the allocator handed them.
|
|
267
|
+
this.globalMcpStartPromise = this.startGlobalMcp(opts).finally(() => {
|
|
268
|
+
this.globalMcpStartPromise = undefined;
|
|
269
|
+
});
|
|
270
|
+
await this.globalMcpStartPromise;
|
|
271
|
+
if (!this.globalMcpSlot) {
|
|
272
|
+
// startGlobalMcp already emitted a synthetic `mcp_exited` for this
|
|
273
|
+
// serial; nothing else to do — the FSM will retry per its policy.
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
this.attachToGlobalMcp(slot, opts.serial);
|
|
277
|
+
}
|
|
278
|
+
// ── Singleton helpers ────────────────────────────────────────────────────
|
|
279
|
+
/**
|
|
280
|
+
* Spawn the singleton `device-mcp-server`. Driven by the FIRST device
|
|
281
|
+
* to enter the spawn pipeline — its `opts.mcpPort` becomes the global
|
|
282
|
+
* port, and its `opts.backend` (or the constructor default) selects
|
|
283
|
+
* the backend kind. On readiness failure, emits a synthetic per-device
|
|
284
|
+
* `mcp_exited` for `opts.serial` so the caller's FSM transitions
|
|
285
|
+
* cleanly; on success leaves `globalMcpSlot` populated for refcount
|
|
286
|
+
* attachment.
|
|
287
|
+
*/
|
|
288
|
+
async startGlobalMcp(opts) {
|
|
160
289
|
const backend = opts.backend ?? this.mcpBackend;
|
|
161
|
-
const args = [
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
290
|
+
const args = [
|
|
291
|
+
"--backend",
|
|
292
|
+
backend,
|
|
293
|
+
// 0.4.0: device-mcp-server discovers every connected ADB device on
|
|
294
|
+
// its own — no `--adb-serial` per-device pin flag.
|
|
295
|
+
"--host",
|
|
296
|
+
this.mcpHost,
|
|
297
|
+
"--port",
|
|
298
|
+
String(opts.mcpPort),
|
|
299
|
+
];
|
|
169
300
|
const env = {
|
|
170
301
|
...process.env,
|
|
171
302
|
...this.extraEnv,
|
|
172
303
|
DEVICEAGENT_BACKEND: backend,
|
|
173
|
-
ANDROID_SERIAL: opts.serial,
|
|
174
304
|
MCP_HOST: this.mcpHost,
|
|
175
305
|
MCP_PORT: String(opts.mcpPort),
|
|
176
306
|
};
|
|
@@ -179,7 +309,7 @@ export class NodeProcessManager {
|
|
|
179
309
|
child = this.spawnFn(this.mcpBin, args, env);
|
|
180
310
|
}
|
|
181
311
|
catch (e) {
|
|
182
|
-
this.logger.error("failed to spawn mcp", {
|
|
312
|
+
this.logger.error("failed to spawn singleton mcp", {
|
|
183
313
|
serial: opts.serial,
|
|
184
314
|
error: String(e),
|
|
185
315
|
});
|
|
@@ -192,54 +322,202 @@ export class NodeProcessManager {
|
|
|
192
322
|
});
|
|
193
323
|
return;
|
|
194
324
|
}
|
|
195
|
-
const
|
|
325
|
+
const globalSlot = {
|
|
196
326
|
child,
|
|
197
327
|
expectingKill: false,
|
|
198
328
|
spawnedAt: Date.now(),
|
|
199
329
|
bin: this.mcpBin,
|
|
200
330
|
args,
|
|
201
331
|
};
|
|
202
|
-
|
|
203
|
-
this.
|
|
204
|
-
|
|
332
|
+
this.globalMcpSlot = globalSlot;
|
|
333
|
+
this.globalMcpPort = opts.mcpPort;
|
|
334
|
+
this.logger.info("spawned singleton device-mcp-server", {
|
|
205
335
|
pid: child.pid ?? null,
|
|
206
336
|
port: opts.mcpPort,
|
|
337
|
+
backend,
|
|
207
338
|
});
|
|
208
|
-
this.
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
339
|
+
this.attachGlobalExitListener(globalSlot);
|
|
340
|
+
// Single-flight initialize probe.
|
|
341
|
+
if (!this.mcpReadyPromise) {
|
|
342
|
+
const url = `http://${this.mcpHost}:${opts.mcpPort}/mcp`;
|
|
343
|
+
this.mcpReadyPromise = this.httpProbeFn(url, this.mcpHealthTimeoutMs);
|
|
344
|
+
}
|
|
345
|
+
let ok = false;
|
|
346
|
+
try {
|
|
347
|
+
ok = await this.mcpReadyPromise;
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
this.mcpReadyPromise = undefined;
|
|
351
|
+
}
|
|
212
352
|
if (!ok) {
|
|
213
|
-
this.logger.warn("mcp
|
|
353
|
+
this.logger.warn("singleton mcp initialize handshake did not succeed within grace", {
|
|
214
354
|
serial: opts.serial,
|
|
215
|
-
|
|
355
|
+
port: opts.mcpPort,
|
|
216
356
|
});
|
|
217
|
-
// Treat
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
357
|
+
// Treat readiness failure as a crash. Fan-out synthetic
|
|
358
|
+
// `mcp_exited` to every serial that attached during the probe
|
|
359
|
+
// (concurrent waiters who saw `globalMcpSlot` truthy and joined
|
|
360
|
+
// as refs before the probe resolved) PLUS the driver itself if
|
|
361
|
+
// it isn't already a ref. Without this fan-out, those waiters
|
|
362
|
+
// would only see the eventual real-child exit (fired by the
|
|
363
|
+
// async SIGTERM listener), and we'd race the listener against
|
|
364
|
+
// the test / supervisor's expected immediate transition.
|
|
365
|
+
globalSlot.expectingKill = true;
|
|
221
366
|
try {
|
|
222
367
|
child.kill("SIGTERM");
|
|
223
368
|
}
|
|
224
369
|
catch {
|
|
225
370
|
/* ignore */
|
|
226
371
|
}
|
|
227
|
-
this.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
372
|
+
const refsToFanOut = Array.from(this.mcpRefs);
|
|
373
|
+
this.globalMcpSlot = undefined;
|
|
374
|
+
this.globalMcpPort = 0;
|
|
375
|
+
this.mcpRefs.clear();
|
|
376
|
+
for (const serial of refsToFanOut) {
|
|
377
|
+
const refSlot = this.slots.get(serial);
|
|
378
|
+
if (refSlot)
|
|
379
|
+
refSlot.mcp = undefined;
|
|
380
|
+
this.emit({
|
|
381
|
+
kind: "mcp_exited",
|
|
382
|
+
serial,
|
|
383
|
+
code: null,
|
|
384
|
+
signal: "SIGTERM",
|
|
385
|
+
expected: false,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
if (!refsToFanOut.includes(opts.serial)) {
|
|
389
|
+
this.emit({
|
|
390
|
+
kind: "mcp_exited",
|
|
391
|
+
serial: opts.serial,
|
|
392
|
+
code: null,
|
|
393
|
+
signal: "SIGTERM",
|
|
394
|
+
expected: false,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// Probe succeeded — the singleton is healthy on `globalMcpPort`. If
|
|
400
|
+
// the previous cycle was on a different port, every still-alive
|
|
401
|
+
// daemon is now wired to a stale `--mcp-url`. Emit per-serial
|
|
402
|
+
// `mcp_port_changed` so the supervisor's FSM (`mcp_port_changed`
|
|
403
|
+
// case in fromHealthy / fromSpawning) can issue a serialized
|
|
404
|
+
// `restart_daemon` for each. The very first successful spawn (no
|
|
405
|
+
// prior port) is silent.
|
|
406
|
+
const previous = this.previousGlobalMcpPort;
|
|
407
|
+
this.previousGlobalMcpPort = this.globalMcpPort;
|
|
408
|
+
if (previous > 0 && previous !== this.globalMcpPort) {
|
|
409
|
+
for (const [serial, slotEntry] of this.slots) {
|
|
410
|
+
if (slotEntry.daemon) {
|
|
411
|
+
this.emit({
|
|
412
|
+
kind: "mcp_port_changed",
|
|
413
|
+
serial,
|
|
414
|
+
previousPort: previous,
|
|
415
|
+
newPort: this.globalMcpPort,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Wire `serial` into the running global MCP: store a ref-pointing
|
|
423
|
+
* `ChildSlot` on the device's slots, register the ref, and emit a
|
|
424
|
+
* per-device `mcp_started` event using the global port. Idempotent —
|
|
425
|
+
* a serial already in `mcpRefs` becomes a no-op.
|
|
426
|
+
*/
|
|
427
|
+
attachToGlobalMcp(slot, serial) {
|
|
428
|
+
if (!this.globalMcpSlot)
|
|
429
|
+
return;
|
|
430
|
+
if (this.mcpRefs.has(serial)) {
|
|
431
|
+
this.logger.debug("mcp ref already present — skipping", { serial });
|
|
234
432
|
return;
|
|
235
433
|
}
|
|
434
|
+
// The per-device slot points at the SHARED child reference so
|
|
435
|
+
// pid()/isAlive() return sensible values, but kill()/exit listeners
|
|
436
|
+
// are managed at the global level. We do NOT call `attachExitListener`
|
|
437
|
+
// here — the global exit listener fans out to every ref.
|
|
438
|
+
slot.mcp = {
|
|
439
|
+
child: this.globalMcpSlot.child,
|
|
440
|
+
expectingKill: false,
|
|
441
|
+
spawnedAt: this.globalMcpSlot.spawnedAt,
|
|
442
|
+
bin: this.globalMcpSlot.bin,
|
|
443
|
+
args: this.globalMcpSlot.args,
|
|
444
|
+
};
|
|
445
|
+
this.mcpRefs.add(serial);
|
|
236
446
|
this.emit({
|
|
237
447
|
kind: "mcp_started",
|
|
238
|
-
serial
|
|
239
|
-
pid: child.pid ?? 0,
|
|
240
|
-
port:
|
|
448
|
+
serial,
|
|
449
|
+
pid: this.globalMcpSlot.child.pid ?? 0,
|
|
450
|
+
port: this.globalMcpPort,
|
|
241
451
|
});
|
|
242
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* Attach a single global exit listener to the singleton MCP child. When
|
|
455
|
+
* it dies, fan-out a synthetic `mcp_exited` to every refcounted serial
|
|
456
|
+
* (clearing their slots) so each per-device FSM transitions cleanly.
|
|
457
|
+
* The `expected` flag is taken from `globalMcpSlot.expectingKill`, which
|
|
458
|
+
* is set by `killGlobalMcp(expected=true)` during graceful shutdown.
|
|
459
|
+
*/
|
|
460
|
+
attachGlobalExitListener(globalSlot) {
|
|
461
|
+
globalSlot.child.once("exit", (code, signal) => {
|
|
462
|
+
const expected = globalSlot.expectingKill;
|
|
463
|
+
const refs = Array.from(this.mcpRefs);
|
|
464
|
+
this.logger.warn("singleton mcp exited", {
|
|
465
|
+
code,
|
|
466
|
+
signal,
|
|
467
|
+
expected,
|
|
468
|
+
refs: refs.length,
|
|
469
|
+
});
|
|
470
|
+
// Clear bookkeeping BEFORE re-entry so a respawn-driving FSM can
|
|
471
|
+
// call `spawnMcp` and find `globalMcpSlot === undefined`.
|
|
472
|
+
this.globalMcpSlot = undefined;
|
|
473
|
+
this.globalMcpPort = 0;
|
|
474
|
+
this.mcpRefs.clear();
|
|
475
|
+
for (const serial of refs) {
|
|
476
|
+
const slot = this.slots.get(serial);
|
|
477
|
+
if (slot)
|
|
478
|
+
slot.mcp = undefined;
|
|
479
|
+
this.emit({ kind: "mcp_exited", serial, code, signal, expected });
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
globalSlot.child.once("error", (err) => {
|
|
483
|
+
this.logger.error("singleton mcp child error", { error: String(err) });
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Tear down the singleton MCP child unconditionally. `expected=true`
|
|
488
|
+
* marks the upcoming exit so FSMs don't penalise restartCount; the
|
|
489
|
+
* actual `*_exited` events are emitted by the global exit listener.
|
|
490
|
+
* A no-op when the singleton is already gone.
|
|
491
|
+
*/
|
|
492
|
+
async killGlobalMcp(expected) {
|
|
493
|
+
const globalSlot = this.globalMcpSlot;
|
|
494
|
+
if (!globalSlot)
|
|
495
|
+
return;
|
|
496
|
+
globalSlot.expectingKill = expected;
|
|
497
|
+
try {
|
|
498
|
+
globalSlot.child.kill("SIGTERM");
|
|
499
|
+
}
|
|
500
|
+
catch (e) {
|
|
501
|
+
this.logger.warn("singleton mcp SIGTERM failed", { error: String(e) });
|
|
502
|
+
}
|
|
503
|
+
const start = Date.now();
|
|
504
|
+
while (this.globalMcpSlot && Date.now() - start < this.shutdownGraceMs) {
|
|
505
|
+
await sleep(50);
|
|
506
|
+
}
|
|
507
|
+
if (this.globalMcpSlot) {
|
|
508
|
+
this.logger.warn("singleton mcp SIGTERM grace expired — sending SIGKILL");
|
|
509
|
+
try {
|
|
510
|
+
globalSlot.child.kill("SIGKILL");
|
|
511
|
+
}
|
|
512
|
+
catch (e) {
|
|
513
|
+
this.logger.warn("singleton mcp SIGKILL failed", { error: String(e) });
|
|
514
|
+
}
|
|
515
|
+
const killStart = Date.now();
|
|
516
|
+
while (this.globalMcpSlot && Date.now() - killStart < 2_000) {
|
|
517
|
+
await sleep(50);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
243
521
|
async spawnDaemon(opts) {
|
|
244
522
|
const slot = this.ensureSlot(opts.serial);
|
|
245
523
|
if (slot.daemon) {
|
|
@@ -249,7 +527,17 @@ export class NodeProcessManager {
|
|
|
249
527
|
});
|
|
250
528
|
return;
|
|
251
529
|
}
|
|
252
|
-
|
|
530
|
+
// 0.4.0 SINGLETON MCP TOPOLOGY: every daemon connects to the SAME
|
|
531
|
+
// shared `device-mcp-server`, so `--mcp-url` MUST point at
|
|
532
|
+
// `globalMcpPort`, not at this device's allocator-allocated port.
|
|
533
|
+
// Pre-singleton each device had its own MCP, so `opts.mcpPort` was
|
|
534
|
+
// authoritative; with the singleton refactor only the FIRST device's
|
|
535
|
+
// port is bound — for every subsequent device `opts.mcpPort` is the
|
|
536
|
+
// allocator-leased number that nothing actually listens on. Falling
|
|
537
|
+
// back to `opts.mcpPort` only when the singleton is not up keeps
|
|
538
|
+
// direct test invocations (no surrounding `spawnMcp` call) working.
|
|
539
|
+
const effectiveMcpPort = this.globalMcpPort > 0 ? this.globalMcpPort : opts.mcpPort;
|
|
540
|
+
const mcpUrl = `http://${this.mcpHost}:${effectiveMcpPort}`;
|
|
253
541
|
// P1-K of the install-link review: only ADB devices need
|
|
254
542
|
// `--adb <serial>` — the device-agent uses the flag to pick the
|
|
255
543
|
// physical Android device. For desktop / self-attach backends
|
|
@@ -263,6 +551,12 @@ export class NodeProcessManager {
|
|
|
263
551
|
? opts.backend === "adb"
|
|
264
552
|
: true;
|
|
265
553
|
const args = ["--key", opts.keyFile, "--mcp-url", mcpUrl];
|
|
554
|
+
// 0.4.0: route every tool call to the right backend by setting the
|
|
555
|
+
// McpClient's `defaultDevice`. `--adb` is kept as an alias for ADB
|
|
556
|
+
// backends because the daemon's CLI still maps both `--adb` and
|
|
557
|
+
// `--device` to `adbSerial` for backward compat — but `--device`
|
|
558
|
+
// is the canonical 0.4.0 flag.
|
|
559
|
+
args.push("--device", opts.serial);
|
|
266
560
|
if (isAdbBackend) {
|
|
267
561
|
args.push("--adb", opts.serial);
|
|
268
562
|
}
|
|
@@ -467,7 +761,32 @@ export class NodeProcessManager {
|
|
|
467
761
|
}
|
|
468
762
|
// ── Kill paths ───────────────────────────────────────────────────────────
|
|
469
763
|
async killMcp(serial, opts = {}) {
|
|
470
|
-
|
|
764
|
+
const expected = opts.expected ?? true;
|
|
765
|
+
const slot = this.slots.get(serial);
|
|
766
|
+
if (!slot)
|
|
767
|
+
return;
|
|
768
|
+
// Singleton MCP: this serial may or may not currently hold a ref. Always
|
|
769
|
+
// clear the per-device pointer + emit per-device `mcp_exited`, then
|
|
770
|
+
// tear down the global child only when the LAST ref drops.
|
|
771
|
+
const hadRef = this.mcpRefs.delete(serial);
|
|
772
|
+
if (slot.mcp) {
|
|
773
|
+
slot.mcp = undefined;
|
|
774
|
+
// Per-device exit so the FSM transitions cleanly. The global child
|
|
775
|
+
// is still alive for any remaining refs (or being torn down below).
|
|
776
|
+
this.emit({
|
|
777
|
+
kind: "mcp_exited",
|
|
778
|
+
serial,
|
|
779
|
+
code: null,
|
|
780
|
+
signal: null,
|
|
781
|
+
expected,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
if (hadRef && this.mcpRefs.size === 0 && this.globalMcpSlot) {
|
|
785
|
+
// Last ref gone — tear down the singleton. The global exit listener
|
|
786
|
+
// will be a no-op for `mcp_exited` fan-out (refs is empty), so this
|
|
787
|
+
// is safe even with `expected=true` already on the slot.
|
|
788
|
+
await this.killGlobalMcp(expected);
|
|
789
|
+
}
|
|
471
790
|
}
|
|
472
791
|
async killDaemon(serial, opts = {}) {
|
|
473
792
|
await this.killChild(serial, "daemon", opts.expected ?? true);
|
|
@@ -600,20 +919,57 @@ const defaultSpawnFn = (bin, args, env) => {
|
|
|
600
919
|
});
|
|
601
920
|
return child;
|
|
602
921
|
};
|
|
922
|
+
/**
|
|
923
|
+
* MCP readiness probe — POSTs an `initialize` JSON-RPC request to the
|
|
924
|
+
* given URL (which is the singleton-MCP `/mcp` endpoint) and treats a
|
|
925
|
+
* 200 / 202 response as readiness. Replaces the pre-0.4 `/healthz` GET
|
|
926
|
+
* because device-mcp-server 0.4 only speaks the official MCP wire
|
|
927
|
+
* protocol over `/mcp`.
|
|
928
|
+
*/
|
|
603
929
|
const defaultHttpProbeFn = async (url, timeoutMs) => {
|
|
604
930
|
const deadline = Date.now() + timeoutMs;
|
|
605
931
|
while (Date.now() < deadline) {
|
|
606
|
-
const ok = await probeOnce(url,
|
|
932
|
+
const ok = await probeOnce(url, 1_500);
|
|
607
933
|
if (ok)
|
|
608
934
|
return true;
|
|
609
935
|
await sleep(250);
|
|
610
936
|
}
|
|
611
937
|
return false;
|
|
612
938
|
};
|
|
939
|
+
const PROBE_INITIALIZE_BODY = JSON.stringify({
|
|
940
|
+
jsonrpc: "2.0",
|
|
941
|
+
id: 1,
|
|
942
|
+
method: "initialize",
|
|
943
|
+
params: {
|
|
944
|
+
protocolVersion: "2025-06-18",
|
|
945
|
+
capabilities: {},
|
|
946
|
+
clientInfo: { name: "fleet-readiness-probe", version: "0.4.0" },
|
|
947
|
+
},
|
|
948
|
+
});
|
|
613
949
|
function probeOnce(url, timeoutMs) {
|
|
614
950
|
return new Promise((resolve) => {
|
|
615
|
-
const
|
|
616
|
-
|
|
951
|
+
const parsed = (() => {
|
|
952
|
+
try {
|
|
953
|
+
return new URL(url);
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
})();
|
|
959
|
+
if (!parsed)
|
|
960
|
+
return resolve(false);
|
|
961
|
+
const req = http.request({
|
|
962
|
+
method: "POST",
|
|
963
|
+
host: parsed.hostname,
|
|
964
|
+
port: parsed.port,
|
|
965
|
+
path: parsed.pathname || "/mcp",
|
|
966
|
+
headers: {
|
|
967
|
+
"content-type": "application/json",
|
|
968
|
+
accept: "application/json, text/event-stream",
|
|
969
|
+
"content-length": Buffer.byteLength(PROBE_INITIALIZE_BODY),
|
|
970
|
+
},
|
|
971
|
+
}, (res) => {
|
|
972
|
+
const ok = res.statusCode === 200 || res.statusCode === 202;
|
|
617
973
|
res.resume();
|
|
618
974
|
resolve(ok);
|
|
619
975
|
});
|
|
@@ -622,6 +978,7 @@ function probeOnce(url, timeoutMs) {
|
|
|
622
978
|
resolve(false);
|
|
623
979
|
});
|
|
624
980
|
req.on("error", () => resolve(false));
|
|
981
|
+
req.end(PROBE_INITIALIZE_BODY);
|
|
625
982
|
});
|
|
626
983
|
}
|
|
627
984
|
/**
|
|
@@ -719,15 +1076,6 @@ async function materializeInlineAgentConfig(instanceId) {
|
|
|
719
1076
|
return null;
|
|
720
1077
|
}
|
|
721
1078
|
}
|
|
722
|
-
/**
|
|
723
|
-
* `device-mcp-server` `--adb-serial <serial>` is only meaningful when the
|
|
724
|
-
* backend talks to ADB. Currently that's just `adb` (canonical) and the
|
|
725
|
-
* `android` alias; desktop / mock backends ignore the flag entirely so we
|
|
726
|
-
* skip it to keep argv readable in `ps`.
|
|
727
|
-
*/
|
|
728
|
-
function isAdbBackend(name) {
|
|
729
|
-
return name === "adb" || name === "android";
|
|
730
|
-
}
|
|
731
1079
|
function sleep(ms) {
|
|
732
1080
|
return new Promise((res) => {
|
|
733
1081
|
const t = setTimeout(res, ms);
|