@melaya/runner 1.1.47 → 1.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.
@@ -230,21 +230,47 @@ class _RedactingStdout:
230
230
  def _scrub(self, s: str) -> str:
231
231
  return self._B64.sub(lambda m: f"<redacted base64 {len(m.group(0))} bytes>", s)
232
232
 
233
+ def _flush_transcript(self):
234
+ # Send any buffered agentscope transcript to STDERR (redacted). Keeping it
235
+ # off stdout means the runner's stdout channel carries ONLY `MELASSIST `
236
+ # protocol frames: a transcript print can never glue onto a frame and be
237
+ # dropped (the missing-delta / garbled-fragment bug), and frame parsing on
238
+ # the runner stays cheap — no multi-KB base64 transcript lines flooding its
239
+ # event loop (which was spiking lag and dropping the socket heartbeat).
240
+ if self._buf:
241
+ try:
242
+ sys.stderr.write(self._scrub(self._buf))
243
+ except Exception:
244
+ pass
245
+ self._buf = ""
246
+
233
247
  def write(self, s):
234
248
  try:
249
+ # A protocol frame is emitted in ONE write() call, whole and
250
+ # newline-terminated (see _emit). Route it to the REAL stdout byte for
251
+ # byte (no scrub — frames are short structured JSON), after flushing any
252
+ # pending partial transcript so nothing can glue onto it.
253
+ if s.startswith(_EVENT_PREFIX):
254
+ self._flush_transcript()
255
+ self._real.write(s)
256
+ self._real.flush()
257
+ return len(s)
258
+ # Everything else = agentscope's verbatim message transcript → stderr,
259
+ # line-buffered + base64-redacted, so it never pollutes the protocol.
235
260
  self._buf += s
236
261
  while "\n" in self._buf:
237
262
  line, self._buf = self._buf.split("\n", 1)
238
- self._real.write(self._scrub(line) + "\n")
263
+ try:
264
+ sys.stderr.write(self._scrub(line) + "\n")
265
+ except Exception:
266
+ pass
239
267
  return len(s)
240
268
  except Exception:
241
269
  return self._real.write(s)
242
270
 
243
271
  def flush(self):
244
272
  try:
245
- if self._buf:
246
- self._real.write(self._scrub(self._buf))
247
- self._buf = ""
273
+ self._flush_transcript()
248
274
  self._real.flush()
249
275
  except Exception:
250
276
  pass
@@ -55,6 +55,11 @@ try {
55
55
  }
56
56
  catch { /* dev tree layout differs — non-fatal */ }
57
57
  const activeAssistants = new Map();
58
+ // Line prefix the assistant host uses to frame protocol events on stdout
59
+ // (mirror of assistantHost.py `_EVENT_PREFIX`). Everything else on stdout is
60
+ // raw agentscope transcript (now routed to stderr host-side, but we still parse
61
+ // defensively in case a frame arrives glued to a raw print).
62
+ const _EVENT_PREFIX = "MELASSIST ";
58
63
  // Warm pool (size 1): a fully-booted host held in reserve, ready to be ADOPTED
59
64
  // by the next new session whose config+generation match — skipping the ~7s cold
60
65
  // boot (import + build_agent). Pure optimization: any mismatch or unhealthy
@@ -72,6 +77,11 @@ let warmSpareSeed = null;
72
77
  // SYNCHRONOUSLY here closes that window.
73
78
  const bootingAssistants = new Set();
74
79
  const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
80
+ // OOM backstop for the assistant stdout line-framer: if a single unterminated
81
+ // line (no newline) grows past this, flush + reset rather than let `+=` build an
82
+ // unbounded string. Generous — legitimate frames (tool results, transcripts) are
83
+ // far smaller; a line this large would exceed the socket buffer anyway.
84
+ const ASSISTANT_STDOUT_MAX_CARRY = 64 * 1024 * 1024;
75
85
  export async function connect(opts) {
76
86
  const spinner = ora("Connecting to Melaya...").start();
77
87
  // Connect to the /api/v1/runner NAMESPACE on the existing Socket.IO server.
@@ -1319,15 +1329,37 @@ export async function connect(opts) {
1319
1329
  console.log(chalk.hex("#7c6ff0")(` ◆ Assistant ${a.forward ? "session" : "spare"} ${a.sid.slice(0, 12)}… (${a.providerLabel})`));
1320
1330
  proc.stdout?.on("data", (data) => {
1321
1331
  session.lastActivity = Date.now();
1332
+ // O(n) line framing: accumulate, split ONCE, carry the trailing partial.
1333
+ // The old `buf += …; while(indexOf/slice)` loop re-sliced the WHOLE buffer
1334
+ // per line — O(n²) on a big burst (a large tool result / browser tree in
1335
+ // the transcript). That blocked the Node event loop long enough that
1336
+ // Socket.IO couldn't service the connection (send the engine.io pong), so
1337
+ // the server's pingTimeout dropped the socket MID-TURN — the "heartbeat
1338
+ // stops while working" failure. Keeping this O(n) means the heartbeat and
1339
+ // pong keep flowing no matter how much the host streams.
1322
1340
  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);
1341
+ const parts = session.stdoutBuf.split("\n");
1342
+ session.stdoutBuf = parts.pop() ?? "";
1343
+ // OOM backstop: an unterminated line that keeps growing across chunks would
1344
+ // make the `+=` above quadratic again. Flush it as its own line and reset.
1345
+ if (session.stdoutBuf.length > ASSISTANT_STDOUT_MAX_CARRY) {
1346
+ parts.push(session.stdoutBuf);
1347
+ session.stdoutBuf = "";
1348
+ console.log(chalk.yellow(` [assistant-stdout] flushed oversized line (${(ASSISTANT_STDOUT_MAX_CARRY / 1048576) | 0}MB+) to protect the event loop`));
1349
+ }
1350
+ for (const line of parts) {
1327
1351
  const t = line.trim();
1328
- if (t.startsWith("MELASSIST ")) {
1352
+ // A protocol frame can arrive GLUED to a preceding raw print that had no
1353
+ // trailing newline (agentscope prints the agent transcript to the same
1354
+ // stdout), e.g. `Assistant: GotMELASSIST {"kind":"delta",…}`. Find the
1355
+ // prefix ANYWHERE in the line, not just at the start, or that delta is
1356
+ // dropped as raw output (the missing-deltas / garbled-fragment bug).
1357
+ const mi = t.indexOf(_EVENT_PREFIX);
1358
+ if (mi >= 0) {
1359
+ if (mi > 0 && opts.verbose)
1360
+ console.log(chalk.gray(` [assistant-stdout] ${t.slice(0, mi)}`));
1329
1361
  try {
1330
- const parsed = JSON.parse(t.slice("MELASSIST ".length));
1362
+ const parsed = JSON.parse(t.slice(mi + _EVENT_PREFIX.length));
1331
1363
  if (typeof parsed?.memoryWatermark === "number")
1332
1364
  session.memoryWatermark = parsed.memoryWatermark;
1333
1365
  if (parsed?.kind === "ready") {
@@ -1343,7 +1375,12 @@ export async function connect(opts) {
1343
1375
  if (session.forward)
1344
1376
  socket.emit("runner:assistant_event", { sessionId: session.sid, ...parsed });
1345
1377
  }
1346
- catch { /* skip malformed */ }
1378
+ catch {
1379
+ // The literal "MELASSIST " appeared inside raw text (not a real frame)
1380
+ // — the JSON tail won't parse; treat the whole line as raw output.
1381
+ if (t && opts.verbose)
1382
+ console.log(chalk.gray(` [assistant-stdout] ${t}`));
1383
+ }
1347
1384
  }
1348
1385
  else if (t && opts.verbose) {
1349
1386
  console.log(chalk.gray(` [assistant-stdout] ${t}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.47",
3
+ "version": "1.1.49",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,