@melaya/runner 1.1.45 → 1.1.47
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/assistantHost.py +4 -1
- package/dist/connection.js +227 -97
- package/dist/pythonEnv.js +101 -11
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -1177,6 +1177,7 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
|
|
|
1177
1177
|
|
|
1178
1178
|
|
|
1179
1179
|
def main() -> int:
|
|
1180
|
+
_boot_t0 = time.perf_counter()
|
|
1180
1181
|
_log(f"booting (provider={os.environ.get('MEL_ASSISTANT_PROVIDER')})")
|
|
1181
1182
|
# Wire OpenTelemetry → Melaya event relay so agentscope's @trace_llm actually
|
|
1182
1183
|
# exports token spans to agents.spans — the SAME thing pipelines get via
|
|
@@ -1195,7 +1196,9 @@ def main() -> int:
|
|
|
1195
1196
|
# in-process phone tools (phone.py._cmd) see it from turn one.
|
|
1196
1197
|
_apply_hitl_mode(None)
|
|
1197
1198
|
try:
|
|
1199
|
+
_t_agent = time.perf_counter()
|
|
1198
1200
|
agent = _build_agent()
|
|
1201
|
+
_log(f"[timing] _build_agent {time.perf_counter() - _t_agent:.2f}s")
|
|
1199
1202
|
except Exception as exc:
|
|
1200
1203
|
_log("boot failed:\n" + traceback.format_exc())
|
|
1201
1204
|
_emit("", "error", message="The assistant could not start on this runner. " + _turn_error_message(exc))
|
|
@@ -1218,7 +1221,7 @@ def main() -> int:
|
|
|
1218
1221
|
_log("compaction hooks unavailable (non-fatal)")
|
|
1219
1222
|
|
|
1220
1223
|
_emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION, configHash=_config_hash())
|
|
1221
|
-
_log("ready")
|
|
1224
|
+
_log(f"[timing] ready — {time.perf_counter() - _boot_t0:.2f}s (main start → ready). Import cost = gap from process spawn to the 'booting' line above.")
|
|
1222
1225
|
|
|
1223
1226
|
q: "queue.Queue[str]" = queue.Queue()
|
|
1224
1227
|
threading.Thread(target=_stdin_reader, args=(q,), daemon=True).start()
|
package/dist/connection.js
CHANGED
|
@@ -14,7 +14,7 @@ import { io } from "socket.io-client";
|
|
|
14
14
|
import chalk from "chalk";
|
|
15
15
|
import ora from "ora";
|
|
16
16
|
import { spawn } from "child_process";
|
|
17
|
-
import { writeFileSync, mkdirSync, existsSync, readFileSync } from "fs";
|
|
17
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync, copyFileSync } from "fs";
|
|
18
18
|
import { dirname, join } from "path";
|
|
19
19
|
import { fileURLToPath } from "url";
|
|
20
20
|
import { tmpdir, homedir } from "os";
|
|
@@ -55,6 +55,15 @@ try {
|
|
|
55
55
|
}
|
|
56
56
|
catch { /* dev tree layout differs — non-fatal */ }
|
|
57
57
|
const activeAssistants = new Map();
|
|
58
|
+
// Warm pool (size 1): a fully-booted host held in reserve, ready to be ADOPTED
|
|
59
|
+
// by the next new session whose config+generation match — skipping the ~7s cold
|
|
60
|
+
// boot (import + build_agent). Pure optimization: any mismatch or unhealthy
|
|
61
|
+
// spare falls through to a normal fresh boot, so it can never break a session.
|
|
62
|
+
let warmSpare = null;
|
|
63
|
+
// The (env, config, generation) to prewarm the next spare with — learned from
|
|
64
|
+
// the most recent real assistant_start so the spare matches the user's actual
|
|
65
|
+
// usage. null until the first real boot teaches us a config.
|
|
66
|
+
let warmSpareSeed = null;
|
|
58
67
|
// PR4: sid-keyed boot reservation. The guard-then-spawn in runner:assistant_start
|
|
59
68
|
// has an async gap (ensureSharedModules / ensurePythonEnv awaits) between the
|
|
60
69
|
// activeAssistants.has() check and the .set(). A second assistant_start in that
|
|
@@ -78,6 +87,27 @@ export async function connect(opts) {
|
|
|
78
87
|
reconnectionAttempts: Infinity,
|
|
79
88
|
});
|
|
80
89
|
let relay = null;
|
|
90
|
+
// ── Disconnect diagnostics (transport-close root-cause) ──────────────
|
|
91
|
+
// A "transport close" disconnect can be a NETWORK blip OR the Node event loop
|
|
92
|
+
// being BLOCKED (e.g. relaying big browser screenshots/trees) long enough that
|
|
93
|
+
// Socket.IO can't service the connection. These need OPPOSITE fixes, so we
|
|
94
|
+
// sample event-loop lag continuously and print the recent PEAK on disconnect:
|
|
95
|
+
// a multi-second spike ⇒ event-loop block (in-process work); ~0 ⇒ network/proxy.
|
|
96
|
+
let connectedAt = 0;
|
|
97
|
+
let lastHeartbeatAt = 0;
|
|
98
|
+
let peakLagMs = 0;
|
|
99
|
+
{
|
|
100
|
+
let last = Date.now();
|
|
101
|
+
const TICK = 500;
|
|
102
|
+
const t = setInterval(() => {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const lag = now - last - TICK; // how much longer than TICK the loop actually took
|
|
105
|
+
last = now;
|
|
106
|
+
if (lag > peakLagMs)
|
|
107
|
+
peakLagMs = lag;
|
|
108
|
+
}, TICK);
|
|
109
|
+
t.unref?.();
|
|
110
|
+
}
|
|
81
111
|
// ── Melaya Browser bridge state (plan Sections 3/4) ──────────────────
|
|
82
112
|
// The governed browser control bridge is started lazily on the first
|
|
83
113
|
// browser run. Grant public keys arrive over the socket (server push)
|
|
@@ -209,6 +239,8 @@ export async function connect(opts) {
|
|
|
209
239
|
};
|
|
210
240
|
socket.on("connect", async () => {
|
|
211
241
|
spinner.succeed(chalk.green("Connected to Melaya"));
|
|
242
|
+
connectedAt = Date.now();
|
|
243
|
+
peakLagMs = 0; // fresh window each connection so the disconnect line reflects THIS session
|
|
212
244
|
// New socket lifetime → new generation. Grants minted for an earlier
|
|
213
245
|
// generation are refused (plan Section 4). Any browser runs from the
|
|
214
246
|
// previous connection are already torn down on the disconnect path.
|
|
@@ -269,7 +301,16 @@ export async function connect(opts) {
|
|
|
269
301
|
}
|
|
270
302
|
});
|
|
271
303
|
socket.on("disconnect", (reason) => {
|
|
272
|
-
|
|
304
|
+
const upS = connectedAt ? Math.round((Date.now() - connectedAt) / 1000) : 0;
|
|
305
|
+
const hbS = lastHeartbeatAt ? Math.round((Date.now() - lastHeartbeatAt) / 1000) : -1;
|
|
306
|
+
console.log(chalk.yellow(`\n Disconnected: ${reason} (up ${upS}s, last heartbeat ${hbS}s ago, peak event-loop lag ${peakLagMs}ms)`));
|
|
307
|
+
// The discriminator: a big peak lag (or a stale heartbeat) means the Node
|
|
308
|
+
// event loop was BLOCKED while connected — heavy in-process work (big browser
|
|
309
|
+
// screenshot/tree relay), NOT a network drop → fix is to offload/chunk that
|
|
310
|
+
// work. A small lag with "transport close" points at the network/proxy path.
|
|
311
|
+
if (peakLagMs > 2000 || hbS > 45) {
|
|
312
|
+
console.log(chalk.yellow(` ↳ event loop was blocked ~${peakLagMs}ms (heartbeat ${hbS}s ago) — this is in-process backpressure (likely big browser payload relay), not a network drop.`));
|
|
313
|
+
}
|
|
273
314
|
// Melaya Browser: a dropped socket invalidates every browser grant
|
|
274
315
|
// bound to this socket generation. Tear down ALL browser runs
|
|
275
316
|
// (cancels in-flight ops, releases leases, closes owned browsers);
|
|
@@ -430,8 +471,10 @@ export async function connect(opts) {
|
|
|
430
471
|
});
|
|
431
472
|
// ── Heartbeat ──────────────────────────────────────────────────────
|
|
432
473
|
setInterval(() => {
|
|
433
|
-
if (socket.connected)
|
|
474
|
+
if (socket.connected) {
|
|
434
475
|
socket.emit("runner:heartbeat");
|
|
476
|
+
lastHeartbeatAt = Date.now();
|
|
477
|
+
}
|
|
435
478
|
}, HEARTBEAT_INTERVAL);
|
|
436
479
|
// ── Run command ────────────────────────────────────────────────────
|
|
437
480
|
socket.on("runner:run", async (payload) => {
|
|
@@ -1259,6 +1302,159 @@ export async function connect(opts) {
|
|
|
1259
1302
|
// codex / ollama / lmstudio: assistant_start boots a long-lived host,
|
|
1260
1303
|
// assistant_turn feeds one user message, and the host streams events back as
|
|
1261
1304
|
// runner:assistant_event frames the server relays into the browser SSE.
|
|
1305
|
+
// ── Warm-pool: spawn + wire an assistant host ────────────────────────────
|
|
1306
|
+
// Used for a REAL session boot (forward=true) AND for a pooled spare
|
|
1307
|
+
// (forward=false → the host's frames are suppressed until adoption re-points
|
|
1308
|
+
// session.sid + flips forward). Reading session.sid/forward (not a captured
|
|
1309
|
+
// constant) is what lets one process be adopted by a later sid. The `error`
|
|
1310
|
+
// listener is registered UNCONDITIONALLY here — the old code nested it inside
|
|
1311
|
+
// `exit`, so a spawn 'error' (which never fires 'exit') had no listener and
|
|
1312
|
+
// took down the whole runner.
|
|
1313
|
+
const launchAssistantHost = (a) => {
|
|
1314
|
+
const proc = spawn(a.pythonPath, ["-u", a.stagedHost], { cwd: a.workDir, env: a.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
1315
|
+
const session = {
|
|
1316
|
+
proc, sid: a.sid, forward: a.forward, ready: false, lastActivity: Date.now(),
|
|
1317
|
+
stdoutBuf: "", generation: a.generation, memoryWatermark: 0, hostInstanceId: "", configHash: "", stderrTail: [],
|
|
1318
|
+
};
|
|
1319
|
+
console.log(chalk.hex("#7c6ff0")(` ◆ Assistant ${a.forward ? "session" : "spare"} ${a.sid.slice(0, 12)}… (${a.providerLabel})`));
|
|
1320
|
+
proc.stdout?.on("data", (data) => {
|
|
1321
|
+
session.lastActivity = Date.now();
|
|
1322
|
+
session.stdoutBuf += data.toString();
|
|
1323
|
+
let idx;
|
|
1324
|
+
while ((idx = session.stdoutBuf.indexOf("\n")) >= 0) {
|
|
1325
|
+
const line = session.stdoutBuf.slice(0, idx);
|
|
1326
|
+
session.stdoutBuf = session.stdoutBuf.slice(idx + 1);
|
|
1327
|
+
const t = line.trim();
|
|
1328
|
+
if (t.startsWith("MELASSIST ")) {
|
|
1329
|
+
try {
|
|
1330
|
+
const parsed = JSON.parse(t.slice("MELASSIST ".length));
|
|
1331
|
+
if (typeof parsed?.memoryWatermark === "number")
|
|
1332
|
+
session.memoryWatermark = parsed.memoryWatermark;
|
|
1333
|
+
if (parsed?.kind === "ready") {
|
|
1334
|
+
if (typeof parsed.hostInstanceId === "string")
|
|
1335
|
+
session.hostInstanceId = parsed.hostInstanceId;
|
|
1336
|
+
if (typeof parsed.configHash === "string")
|
|
1337
|
+
session.configHash = parsed.configHash;
|
|
1338
|
+
session.ready = true;
|
|
1339
|
+
parsed.protocol = ASSISTANT_PROTOCOL_VERSION;
|
|
1340
|
+
}
|
|
1341
|
+
// A spare's frames are NOT forwarded (no session yet); adoption flips
|
|
1342
|
+
// session.forward and re-emits `ready` with the real sid.
|
|
1343
|
+
if (session.forward)
|
|
1344
|
+
socket.emit("runner:assistant_event", { sessionId: session.sid, ...parsed });
|
|
1345
|
+
}
|
|
1346
|
+
catch { /* skip malformed */ }
|
|
1347
|
+
}
|
|
1348
|
+
else if (t && opts.verbose) {
|
|
1349
|
+
console.log(chalk.gray(` [assistant-stdout] ${t}`));
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
proc.stderr?.on("data", (data) => {
|
|
1354
|
+
for (const ln of data.toString().split("\n")) {
|
|
1355
|
+
const t = ln.trimEnd();
|
|
1356
|
+
if (!t)
|
|
1357
|
+
continue;
|
|
1358
|
+
if (opts.verbose)
|
|
1359
|
+
console.log(chalk.gray(` [assistant-stderr] ${t}`));
|
|
1360
|
+
session.stderrTail.push(t);
|
|
1361
|
+
if (session.stderrTail.length > 40)
|
|
1362
|
+
session.stderrTail.shift();
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
proc.on("error", (err) => {
|
|
1366
|
+
if (warmSpare === session)
|
|
1367
|
+
warmSpare = null;
|
|
1368
|
+
if (activeAssistants.get(session.sid) === session)
|
|
1369
|
+
activeAssistants.delete(session.sid);
|
|
1370
|
+
bootingAssistants.delete(session.sid);
|
|
1371
|
+
if (!session.forward) {
|
|
1372
|
+
if (opts.verbose)
|
|
1373
|
+
console.log(chalk.gray(` [prewarm] spare spawn error: ${err?.message || err}`));
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
browserBridge?.teardownRunsForSession(session.sid, "assistant_spawn_error").catch(() => { });
|
|
1377
|
+
socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: "", kind: "error", message: `assistant host spawn failed: ${err?.message || err}` });
|
|
1378
|
+
});
|
|
1379
|
+
proc.on("exit", (code) => {
|
|
1380
|
+
if (warmSpare === session)
|
|
1381
|
+
warmSpare = null;
|
|
1382
|
+
const weOwned = activeAssistants.get(session.sid) === session;
|
|
1383
|
+
if (weOwned)
|
|
1384
|
+
activeAssistants.delete(session.sid);
|
|
1385
|
+
if (weOwned)
|
|
1386
|
+
browserBridge?.teardownRunsForSession(session.sid, `assistant_exit:${code}`).catch(() => { });
|
|
1387
|
+
if (!session.forward) {
|
|
1388
|
+
if (opts.verbose)
|
|
1389
|
+
console.log(chalk.gray(` ■ Assistant spare exited (${code}) pre-adoption`));
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (session.killed) {
|
|
1393
|
+
console.log(chalk.gray(` ■ Assistant host ${session.sid.slice(0, 10)}… replaced (config drift, exit ${code})`));
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
const detail = code !== 0 && session.stderrTail.length ? session.stderrTail.slice(-12).join("\n") : "";
|
|
1397
|
+
socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: "", kind: "session_closed", code, detail });
|
|
1398
|
+
console.log(chalk.gray(` ■ Assistant session ${session.sid.slice(0, 10)}… closed (exit ${code})`));
|
|
1399
|
+
});
|
|
1400
|
+
return session;
|
|
1401
|
+
};
|
|
1402
|
+
// Prewarm ONE spare from the most-recent real config so the next matching new
|
|
1403
|
+
// session adopts it and skips the ~7s cold boot. Best-effort, ≤1 spare, kills a
|
|
1404
|
+
// stale spare (config changed) first, self-destructs after 10 min if unused.
|
|
1405
|
+
// Off by default: the adoption MECHANISM is proven, but a CLI-session provider
|
|
1406
|
+
// (claude_code/codex keep a per-user CLI/OAuth session) can contend when a
|
|
1407
|
+
// spare runs a turn alongside a live host — needs per-provider validation
|
|
1408
|
+
// before it's on for everyone. Set MEL_ASSISTANT_WARM_POOL=1 to enable.
|
|
1409
|
+
const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL === "1";
|
|
1410
|
+
let _prewarmInFlight = false;
|
|
1411
|
+
const schedulePrewarm = () => {
|
|
1412
|
+
if (!WARM_POOL_ENABLED || !warmSpareSeed || _prewarmInFlight)
|
|
1413
|
+
return;
|
|
1414
|
+
if (warmSpare) {
|
|
1415
|
+
if (warmSpare.configHash && warmSpare.configHash !== warmSpareSeed.configHash) {
|
|
1416
|
+
try {
|
|
1417
|
+
warmSpare.proc.kill();
|
|
1418
|
+
}
|
|
1419
|
+
catch { /* gone */ }
|
|
1420
|
+
warmSpare = null;
|
|
1421
|
+
}
|
|
1422
|
+
else {
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
_prewarmInFlight = true;
|
|
1427
|
+
try {
|
|
1428
|
+
const seed = warmSpareSeed;
|
|
1429
|
+
const stamp = String(Date.now());
|
|
1430
|
+
const wd = join(seed.workDirBase, `melaya-assistant-spare-${stamp}`);
|
|
1431
|
+
mkdirSync(wd, { recursive: true });
|
|
1432
|
+
const staged = join(wd, "assistantHost.py");
|
|
1433
|
+
copyFileSync(seed.hostScript, staged);
|
|
1434
|
+
// Keep seed.env's MEL_RUN_ID (`assistant:<userId>`, per-user, shared by all
|
|
1435
|
+
// of a user's sessions) so an adopted spare's run context / tracing / phone
|
|
1436
|
+
// arm plumbing matches the session that adopts it — overriding it to a
|
|
1437
|
+
// spare-specific id broke turn execution on adoption.
|
|
1438
|
+
const spareEnv = { ...seed.env };
|
|
1439
|
+
const spare = launchAssistantHost({ env: spareEnv, pythonPath: seed.pythonPath, workDir: wd, stagedHost: staged, sid: `__spare__:${stamp}`, generation: seed.generation, forward: false, providerLabel: "prewarm" });
|
|
1440
|
+
warmSpare = spare;
|
|
1441
|
+
const t = setTimeout(() => { if (warmSpare === spare && !spare.forward) {
|
|
1442
|
+
try {
|
|
1443
|
+
spare.proc.kill();
|
|
1444
|
+
}
|
|
1445
|
+
catch { /* gone */ }
|
|
1446
|
+
warmSpare = null;
|
|
1447
|
+
} }, 10 * 60_000);
|
|
1448
|
+
t.unref?.();
|
|
1449
|
+
}
|
|
1450
|
+
catch (e) {
|
|
1451
|
+
if (opts.verbose)
|
|
1452
|
+
console.log(chalk.gray(` [prewarm] launch failed: ${e?.message || e}`));
|
|
1453
|
+
}
|
|
1454
|
+
finally {
|
|
1455
|
+
_prewarmInFlight = false;
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1262
1458
|
socket.on("runner:assistant_start", async (payload) => {
|
|
1263
1459
|
const sid = String(payload.sessionId || "");
|
|
1264
1460
|
if (!sid)
|
|
@@ -1294,6 +1490,25 @@ export async function connect(opts) {
|
|
|
1294
1490
|
return;
|
|
1295
1491
|
}
|
|
1296
1492
|
}
|
|
1493
|
+
// Warm-pool ADOPTION (fast path). A fully-booted spare with the SAME config
|
|
1494
|
+
// AND generation is waiting — re-point it to this sid and report ready
|
|
1495
|
+
// immediately, skipping the ~7s cold boot (import + build_agent). Fail-safe:
|
|
1496
|
+
// exact match of a READY, un-killed spare ONLY; anything else falls straight
|
|
1497
|
+
// through to the fresh-boot path below, so the pool can never break a session.
|
|
1498
|
+
if (!activeAssistants.has(sid) && !bootingAssistants.has(sid)
|
|
1499
|
+
&& warmSpare && warmSpare.ready && !warmSpare.killed && expectedConfig
|
|
1500
|
+
&& warmSpare.configHash === expectedConfig && warmSpare.generation === generation) {
|
|
1501
|
+
const spare = warmSpare;
|
|
1502
|
+
warmSpare = null;
|
|
1503
|
+
spare.sid = sid;
|
|
1504
|
+
spare.forward = true;
|
|
1505
|
+
spare.lastActivity = Date.now();
|
|
1506
|
+
activeAssistants.set(sid, spare);
|
|
1507
|
+
console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted warm spare — cold boot skipped`));
|
|
1508
|
+
emitEv({ kind: "ready", memoryWatermark: spare.memoryWatermark || 0, generation: spare.generation, hostInstanceId: spare.hostInstanceId, configHash: spare.configHash, protocol: ASSISTANT_PROTOCOL_VERSION });
|
|
1509
|
+
schedulePrewarm(); // refill the pool for the next session
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1297
1512
|
// A boot is already in flight for this sid (concurrent start / server restart
|
|
1298
1513
|
// mid-boot). Drop this duplicate — the in-flight boot will emit `ready`.
|
|
1299
1514
|
if (bootingAssistants.has(sid))
|
|
@@ -1420,101 +1635,16 @@ export async function connect(opts) {
|
|
|
1420
1635
|
// Per-user creds (incl. MELAYA_API_KEY + the selected connectors' env).
|
|
1421
1636
|
...(payload.credentials || {}),
|
|
1422
1637
|
};
|
|
1423
|
-
const
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
// server-aligned. Start empty; the stdout parser captures them.
|
|
1427
|
-
const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation, memoryWatermark: 0, hostInstanceId: "", configHash: "" };
|
|
1428
|
-
activeAssistants.set(sid, session);
|
|
1429
|
-
console.log(chalk.hex("#7c6ff0")(` ◆ Assistant session ${sid.slice(0, 10)}… (${payload.provider})`));
|
|
1430
|
-
proc.stdout?.on("data", (data) => {
|
|
1431
|
-
// ANY host output means the turn is actively working — refresh the idle
|
|
1432
|
-
// clock. Without this, lastActivity was set ONLY at turn start (assistant_turn),
|
|
1433
|
-
// so a SINGLE long turn (a phone task is one turn with ~100 tool calls streaming
|
|
1434
|
-
// for >15 min) was reaped mid-action by the idle sweep despite continuous
|
|
1435
|
-
// activity ("idle-swept" while properly working). Now only a genuinely silent
|
|
1436
|
-
// host (no output for ASSISTANT_IDLE_MS) is swept.
|
|
1437
|
-
session.lastActivity = Date.now();
|
|
1438
|
-
session.stdoutBuf += data.toString();
|
|
1439
|
-
let idx;
|
|
1440
|
-
while ((idx = session.stdoutBuf.indexOf("\n")) >= 0) {
|
|
1441
|
-
const line = session.stdoutBuf.slice(0, idx);
|
|
1442
|
-
session.stdoutBuf = session.stdoutBuf.slice(idx + 1);
|
|
1443
|
-
const t = line.trim();
|
|
1444
|
-
if (t.startsWith("MELASSIST ")) {
|
|
1445
|
-
try {
|
|
1446
|
-
const parsed = JSON.parse(t.slice("MELASSIST ".length));
|
|
1447
|
-
// Track the host's latest memory watermark (ready / rehydrated
|
|
1448
|
-
// frames report it) so a re-`start` of the live host reports a
|
|
1449
|
-
// truthful watermark and the server skips a needless rehydrate.
|
|
1450
|
-
if (typeof parsed?.memoryWatermark === "number")
|
|
1451
|
-
session.memoryWatermark = parsed.memoryWatermark;
|
|
1452
|
-
// CAPTURE the host's authoritative identity off its `ready` frame
|
|
1453
|
-
// (_HOST_ID / _config_hash) so the live-host ready path can re-report
|
|
1454
|
-
// them without the host re-emitting; ADD only the wire protocol.
|
|
1455
|
-
if (parsed?.kind === "ready") {
|
|
1456
|
-
if (typeof parsed.hostInstanceId === "string")
|
|
1457
|
-
session.hostInstanceId = parsed.hostInstanceId;
|
|
1458
|
-
if (typeof parsed.configHash === "string")
|
|
1459
|
-
session.configHash = parsed.configHash;
|
|
1460
|
-
parsed.protocol = ASSISTANT_PROTOCOL_VERSION;
|
|
1461
|
-
}
|
|
1462
|
-
socket.emit("runner:assistant_event", { sessionId: sid, ...parsed });
|
|
1463
|
-
}
|
|
1464
|
-
catch { /* skip malformed */ }
|
|
1465
|
-
}
|
|
1466
|
-
else if (t && opts.verbose) {
|
|
1467
|
-
console.log(chalk.gray(` [assistant-stdout] ${t}`));
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
});
|
|
1471
|
-
const stderrTail = [];
|
|
1472
|
-
proc.stderr?.on("data", (data) => {
|
|
1473
|
-
for (const ln of data.toString().split("\n")) {
|
|
1474
|
-
const t = ln.trimEnd();
|
|
1475
|
-
if (!t)
|
|
1476
|
-
continue;
|
|
1477
|
-
if (opts.verbose)
|
|
1478
|
-
console.log(chalk.gray(` [assistant-stderr] ${t}`));
|
|
1479
|
-
stderrTail.push(t);
|
|
1480
|
-
if (stderrTail.length > 40)
|
|
1481
|
-
stderrTail.shift();
|
|
1482
|
-
}
|
|
1483
|
-
});
|
|
1484
|
-
proc.on("exit", (code) => {
|
|
1485
|
-
// Only evict if WE are still the registered host. A config-drift reboot
|
|
1486
|
-
// (e.g. the user added a connector) kills this host and spawns a REPLACEMENT
|
|
1487
|
-
// under the same sid; if this late exit deleted the map entry blindly it
|
|
1488
|
-
// would orphan the newcomer and make the next turn "session_not_found".
|
|
1489
|
-
proc.on("error", (err) => {
|
|
1490
|
-
// A spawn that fails (python deleted post-check, EACCES/AV) emits "error"
|
|
1491
|
-
// and never "exit"; with no listener this took down the WHOLE runner.
|
|
1492
|
-
if (activeAssistants.get(sid) === session)
|
|
1493
|
-
activeAssistants.delete(sid);
|
|
1494
|
-
bootingAssistants.delete(sid);
|
|
1495
|
-
browserBridge?.teardownRunsForSession(sid, "assistant_spawn_error").catch(() => { });
|
|
1496
|
-
socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "error", message: `assistant host spawn failed: ${err?.message || err}` });
|
|
1497
|
-
});
|
|
1498
|
-
const weOwned = activeAssistants.get(sid) === session;
|
|
1499
|
-
if (weOwned)
|
|
1500
|
-
activeAssistants.delete(sid);
|
|
1501
|
-
// Tear down any per-turn bridge runs this host leaked (a turn that never
|
|
1502
|
-
// sent `done` before the host exited/crashed) so its lease + enforceOnPage
|
|
1503
|
-
// handlers don't linger on the user's open tab. Only if a replacement
|
|
1504
|
-
// host hasn't already taken over the sid.
|
|
1505
|
-
if (weOwned)
|
|
1506
|
-
browserBridge?.teardownRunsForSession(sid, `assistant_exit:${code}`).catch(() => { });
|
|
1507
|
-
// A host we INTENTIONALLY killed for a config-drift reboot must NOT emit
|
|
1508
|
-
// session_closed — that frame would fail the fresh boot's ready waiter
|
|
1509
|
-
// (the server can't pre-mark it superseded here, the kill is runner-side).
|
|
1510
|
-
if (session.killed) {
|
|
1511
|
-
console.log(chalk.gray(` ■ Assistant host ${sid.slice(0, 10)}… replaced (config drift, exit ${code})`));
|
|
1512
|
-
return;
|
|
1513
|
-
}
|
|
1514
|
-
const detail = code !== 0 && stderrTail.length ? stderrTail.slice(-12).join("\n") : "";
|
|
1515
|
-
socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "session_closed", code, detail });
|
|
1516
|
-
console.log(chalk.gray(` ■ Assistant session ${sid.slice(0, 10)}… closed (exit ${code})`));
|
|
1638
|
+
const session = launchAssistantHost({
|
|
1639
|
+
env, pythonPath: envResult.pythonPath, workDir, stagedHost,
|
|
1640
|
+
sid, generation, forward: true, providerLabel: String(payload.provider || ""),
|
|
1517
1641
|
});
|
|
1642
|
+
activeAssistants.set(sid, session);
|
|
1643
|
+
// Teach the pool this exact (env, config, generation) so it prewarms a
|
|
1644
|
+
// matching spare for the NEXT new session — which then adopts it and skips
|
|
1645
|
+
// the cold boot (see the ADOPTION fast path + schedulePrewarm above).
|
|
1646
|
+
warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript };
|
|
1647
|
+
schedulePrewarm();
|
|
1518
1648
|
}
|
|
1519
1649
|
catch (e) {
|
|
1520
1650
|
emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
|
package/dist/pythonEnv.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createHash } from "crypto";
|
|
|
15
15
|
import { join } from "path";
|
|
16
16
|
import { homedir, platform } from "os";
|
|
17
17
|
import chalk from "chalk";
|
|
18
|
+
import { localUvPath } from "./uvBootstrap.js";
|
|
18
19
|
const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
19
20
|
const VENV_DIR = join(CACHE_DIR, "venv");
|
|
20
21
|
const VENV_MARK = join(CACHE_DIR, "venv-version.txt");
|
|
@@ -151,13 +152,18 @@ const PIP_DEPS = [
|
|
|
151
152
|
// ── Template-validation sweep deps (2026-07-06) — tools that hard-error
|
|
152
153
|
// "pip install X" on the runner without these, observed in live runs:
|
|
153
154
|
// yt-dlp — youtube_search_channel / youtube_get_transcript (1221 YouTube Intel)
|
|
154
|
-
// sentence-transformers — build_knowledge_from_text embedder (1221)
|
|
155
155
|
// flights — flight_price_alert / flight_search live Google Flights ("fli" import; 1261/1265)
|
|
156
156
|
// pymupdf4llm — pdf_to_text markdown extraction (1227 PDF Contract Analyser)
|
|
157
157
|
"yt-dlp",
|
|
158
|
-
"sentence-transformers",
|
|
159
158
|
"flights",
|
|
160
159
|
"pymupdf4llm",
|
|
160
|
+
// sentence-transformers REMOVED 2026-09-09 — it pulls torch (~2GB), which
|
|
161
|
+
// dominated the venv install/reinstall for EVERY runner, yet only
|
|
162
|
+
// build_knowledge_from_text (+ guarded fallbacks in rag/memory_search/aiml)
|
|
163
|
+
// uses it, and DocsTab local RAG embedding runs on OLLAMA/LM-Studio
|
|
164
|
+
// (localRagIngest.py), not sentence-transformers. All import sites are
|
|
165
|
+
// try/except-guarded, so the niche tool degrades to a "pip install
|
|
166
|
+
// sentence-transformers" message instead of shipping 2GB to everyone.
|
|
161
167
|
// ── Vector RAG (retrieval mode) ────────────────────────────────────────
|
|
162
168
|
// ollama — Python client used by agentscope.embedding.OllamaTextEmbedding.
|
|
163
169
|
// Talks to localhost:11434 (the user's local Ollama daemon) to embed
|
|
@@ -215,12 +221,74 @@ const PIP_DEPS = [
|
|
|
215
221
|
"asyncpg",
|
|
216
222
|
"PyMySQL",
|
|
217
223
|
"cryptography",
|
|
224
|
+
// ── Cloud-infra connector SDKs (shared/tools/{aws,route53,alibaba,gcp}.py) ──
|
|
225
|
+
// These modules LAZY-import their SDK inside the tool call, so the toolkit
|
|
226
|
+
// builds + creds.test passes GREEN without them — then the FIRST real tool
|
|
227
|
+
// call ModuleNotFoundErrors on the runner (AWS/Alibaba incident 2026-08-16;
|
|
228
|
+
// Alibaba re-hit 2026-09-09). They ship in requirements.lock (the cloud image)
|
|
229
|
+
// but MUST be mirrored HERE too — the runner venv installs from THIS list, not
|
|
230
|
+
// the lock. Surfaced by the runner dep audit (packages/runner/scripts/
|
|
231
|
+
// audit_runner_deps.py); keep the pins in step with requirements.lock.
|
|
232
|
+
// PURE-PYTHON WHEELS ONLY here: never add a dep that needs a system binary
|
|
233
|
+
// (libzbar / cairo+pango / tesseract / …) — a wheel-less dep fails the WHOLE
|
|
234
|
+
// venv bootstrap and bricks the runner; leave those lazy-guarded in the tool.
|
|
235
|
+
"boto3==1.42.42", // aws.py + route53.py
|
|
236
|
+
"alibabacloud-tea-openapi==0.4.5", // alibaba.py — generic Tea OpenAPI client
|
|
237
|
+
"alibabacloud-tea-util==0.3.15", // alibaba.py
|
|
238
|
+
"alibabacloud-openapi-util==0.2.4", // alibaba.py (pip resolves the rest of the Tea closure)
|
|
239
|
+
"google-api-python-client==2.198.0", // gcp.py — googleapiclient.discovery
|
|
240
|
+
"google-auth-httplib2==0.4.1", // gcp.py — AuthorizedHttp (pulls httplib2 + google-auth)
|
|
241
|
+
// ── Additional shared-tool deps (SOFT gaps from audit_runner_deps.py 2026-09-09)
|
|
242
|
+
// All lazy-imported inside the tool AND pure-python wheels, so they do NOT slow
|
|
243
|
+
// spawn (module import stays light) — only the one-time venv install. Without
|
|
244
|
+
// them the tool degrades with a "pip install X" message; cheap + common enough
|
|
245
|
+
// to ship. DELIBERATELY EXCLUDED (stay lazy-guarded, never in PIP_DEPS): the
|
|
246
|
+
// system-binary deps opencv/cv2, weasyprint (cairo+pango), pytesseract
|
|
247
|
+
// (tesseract), pyzbar (libzbar) — a wheel-less dep fails the whole venv
|
|
248
|
+
// bootstrap; and web3 / fastapi (heavy tree / odd for a tool runner).
|
|
249
|
+
"redis", // crew_blackout_watcher / crew_halts / hitl_middleware
|
|
250
|
+
"youtube-transcript-api", // youtube_tools
|
|
251
|
+
"icalendar", // ics_tools
|
|
252
|
+
"Unidecode", // translation_tools (imports as `unidecode`)
|
|
253
|
+
"pdfplumber", // core (PDF text extraction)
|
|
254
|
+
"segno", // qr_tools (QR generation)
|
|
255
|
+
"PyGithub", // github tool (imports as `github`)
|
|
256
|
+
"python-gitlab", // github tool (imports as `gitlab`)
|
|
257
|
+
"pandas-ta", // alphavantage_tools (TA indicators; pandas already present)
|
|
258
|
+
"python-telegram-bot", // messaging (bot-API rich path; pulls httpx, already present)
|
|
259
|
+
"aiomysql", // database (async MySQL; sync PyMySQL already present)
|
|
218
260
|
];
|
|
219
261
|
export function venvPython() {
|
|
220
262
|
return platform() === "win32"
|
|
221
263
|
? join(VENV_DIR, "Scripts", "python.exe")
|
|
222
264
|
: join(VENV_DIR, "bin", "python");
|
|
223
265
|
}
|
|
266
|
+
/** Prefer the runner-managed uv (localUvPath), else uv on PATH, else null.
|
|
267
|
+
* uv installs far faster than pip (parallel downloads + a SAFE content-
|
|
268
|
+
* addressed, per-hash-validated cache), turning the "1-2 min" venv reinstall on
|
|
269
|
+
* a deps/version change into seconds. Pure lookup — never downloads uv here
|
|
270
|
+
* (the pip fallback covers uv's absence, so the venv build can't be blocked). */
|
|
271
|
+
function _resolveUvBin() {
|
|
272
|
+
try {
|
|
273
|
+
const p = localUvPath();
|
|
274
|
+
if (existsSync(p))
|
|
275
|
+
return p;
|
|
276
|
+
}
|
|
277
|
+
catch { /* ignore */ }
|
|
278
|
+
const bin = platform() === "win32" ? "uv.exe" : "uv";
|
|
279
|
+
const sep = platform() === "win32" ? ";" : ":";
|
|
280
|
+
for (const dir of (process.env.PATH || "").split(sep)) {
|
|
281
|
+
if (!dir)
|
|
282
|
+
continue;
|
|
283
|
+
try {
|
|
284
|
+
const cand = join(dir, bin);
|
|
285
|
+
if (existsSync(cand))
|
|
286
|
+
return cand;
|
|
287
|
+
}
|
|
288
|
+
catch { /* ignore */ }
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
224
292
|
// ── certifi CA-bundle path resolution + cache ────────────────────────────
|
|
225
293
|
// Set as SSL_CERT_FILE + REQUESTS_CA_BUNDLE when spawning pipeline Python
|
|
226
294
|
// so that ANY HTTPS-using tool (gmail_send, slack_post_text, anthropic /
|
|
@@ -488,15 +556,37 @@ async function _ensurePythonEnvImpl(systemPython, expectedVersion, onProgress =
|
|
|
488
556
|
// only *.py files (no pyproject.toml), so PYTHONPATH-based import is
|
|
489
557
|
// the only path. The cached agentscope source under
|
|
490
558
|
// ~/.melaya-runner/agentscope/ resolves via PYTHONPATH at spawn time.
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
|
|
559
|
+
// Prefer uv when available (the runner ships it — localUvPath — or provisions
|
|
560
|
+
// it for managed CPython). uv is ~10-100x faster than pip: parallel downloads,
|
|
561
|
+
// a resolver written in Rust, and a content-addressed cache that is validated
|
|
562
|
+
// per wheel hash — so unlike pip's SHARED, cross-Python-version cache (the very
|
|
563
|
+
// reason the pip path below passes --no-cache-dir) uv's cache can never install
|
|
564
|
+
// a corrupt wheel. We therefore get caching AND safety, which turns the "1-2
|
|
565
|
+
// min" reinstall on a deps/version change into seconds. UV_SYSTEM_CERTS mirrors
|
|
566
|
+
// pip's --use-feature=truststore (verify TLS against the OS trust store — vital
|
|
567
|
+
// behind intercepting proxies / AV, else CERTIFICATE_VERIFY_FAILED). Any uv
|
|
568
|
+
// failure falls straight back to the proven pip path so the venv build is never
|
|
569
|
+
// blocked by a uv edge case.
|
|
570
|
+
const _uvBin = _resolveUvBin();
|
|
571
|
+
let acode;
|
|
572
|
+
if (_uvBin) {
|
|
573
|
+
onProgress(`installing ${PIP_DEPS.length} python deps via uv (fast; cached reinstalls take seconds)`);
|
|
574
|
+
acode = await runProc(_uvBin, ["pip", "install", "--python", venvPython(), "--quiet", ...PIP_DEPS], onProgress, { UV_SYSTEM_CERTS: "1" });
|
|
575
|
+
if (acode !== 0) {
|
|
576
|
+
onProgress("uv install failed — falling back to pip (--no-cache-dir)");
|
|
577
|
+
acode = await runProc(venvPython(), ["-m", "pip", "install", "--use-feature=truststore", "--no-cache-dir", "--quiet", "--disable-pip-version-check", ...PIP_DEPS], onProgress);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
onProgress(`installing ${PIP_DEPS.length} python deps (anthropic, openai, opentelemetry, …) — first run can take 1-2 min`);
|
|
582
|
+
// --no-cache-dir: skip pip's on-disk HTTP/wheel cache. It silences the
|
|
583
|
+
// "Cache entry deserialization failed, entry ignored" warning flood (stale
|
|
584
|
+
// entries left by other Python versions in the shared user cache) and, more
|
|
585
|
+
// importantly, guarantees a corrupt cached wheel can never install a subtly
|
|
586
|
+
// broken package into a fresh runner venv. Costs a re-download on rebuild,
|
|
587
|
+
// which is rare (only on a deps change or a manual clear).
|
|
588
|
+
acode = await runProc(venvPython(), ["-m", "pip", "install", "--use-feature=truststore", "--no-cache-dir", "--quiet", "--disable-pip-version-check", ...PIP_DEPS], onProgress);
|
|
589
|
+
}
|
|
500
590
|
if (acode !== 0) {
|
|
501
591
|
return {
|
|
502
592
|
ok: false,
|