@melaya/runner 1.0.82 → 1.0.84

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.
@@ -38,8 +38,12 @@ _EVENT_PREFIX = "MELASSIST "
38
38
  _IDLE_EXIT_SECONDS = int(os.environ.get("MEL_ASSISTANT_IDLE_SECONDS", "900")) # 15 min
39
39
 
40
40
  # Streaming state for the CURRENT turn — the pre_print / post_acting hooks read
41
- # this to emit delta / tool events keyed to the turn in flight.
42
- _stream = {"turnId": "", "lens": {}}
41
+ # this to emit delta / tool events keyed to the turn in flight. `cancel` is the
42
+ # STOP flag: set by the stdin-reader thread, polled by the running turn.
43
+ _stream = {"turnId": "", "lens": {}, "cancel": False}
44
+
45
+ # Sentinel returned by a turn's coroutine when the user pressed STOP.
46
+ _CANCELLED = object()
43
47
 
44
48
 
45
49
  def _emit(turn_id: str, kind: str, **fields) -> None:
@@ -219,9 +223,24 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
219
223
  _emit(turn_id, "round", n=1)
220
224
  _stream["turnId"] = turn_id
221
225
  _stream["lens"] = {}
226
+ _stream["cancel"] = False # fresh turn — clear any stale STOP
222
227
 
228
+ # Run the agent as a cancellable task and poll the STOP flag (flipped by the
229
+ # stdin-reader thread when the user presses STOP in chat). asyncio.cancel()
230
+ # unwinds the agent loop wherever it is awaiting — including mid phone-command
231
+ # HTTP wait — so the run stops promptly instead of finishing the step first.
223
232
  async def _go():
224
- return await agent(Msg("user", message, "user"))
233
+ task = asyncio.ensure_future(agent(Msg("user", message, "user")))
234
+ while not task.done():
235
+ if _stream.get("cancel"):
236
+ task.cancel()
237
+ try:
238
+ await task
239
+ except BaseException: # CancelledError + any teardown error
240
+ pass
241
+ return _CANCELLED
242
+ await asyncio.sleep(0.12)
243
+ return task.result()
225
244
 
226
245
  try:
227
246
  result = asyncio.run(_go())
@@ -232,6 +251,13 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
232
251
  _emit(turn_id, "done")
233
252
  return
234
253
 
254
+ if result is _CANCELLED:
255
+ _stream["turnId"] = ""
256
+ _log("turn cancelled by user STOP")
257
+ _emit(turn_id, "text", content="Stopped.")
258
+ _emit(turn_id, "done")
259
+ return
260
+
235
261
  _stream["turnId"] = ""
236
262
  # Authoritative final answer — the client swaps the streamed plain text for
237
263
  # this markdown-rendered version.
@@ -242,9 +268,26 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
242
268
 
243
269
  def _stdin_reader(q: "queue.Queue[str]") -> None:
244
270
  """Feed stdin lines into a queue so the main loop can apply an idle timeout
245
- (blocking readline can't be interrupted portably)."""
271
+ (blocking readline can't be interrupted portably).
272
+
273
+ STOP is special: a `{"cancel": true}` line must take effect WHILE a turn is
274
+ running, but the main thread is blocked in _run_turn and won't drain the
275
+ queue until it finishes. So handle cancel HERE, on the reader thread — flip
276
+ the shared flag the running turn polls — and never enqueue it as a new turn.
277
+ """
246
278
  try:
247
279
  for line in sys.stdin:
280
+ s = line.strip()
281
+ if s:
282
+ try:
283
+ req = json.loads(s)
284
+ except Exception:
285
+ req = None
286
+ if isinstance(req, dict) and req.get("cancel"):
287
+ tid = str(req.get("turnId") or "")
288
+ if not tid or tid == _stream.get("turnId"):
289
+ _stream["cancel"] = True
290
+ continue # consumed — do not queue as a turn
248
291
  q.put(line)
249
292
  except Exception:
250
293
  pass
@@ -64,6 +64,30 @@ export async function connect(opts) {
64
64
  console.log(chalk.yellow(` ⚠ Luma browser bridge failed to start: ${e?.message || e}`));
65
65
  }
66
66
  };
67
+ // Background warm-up of the assistant Python env, once per process, so the
68
+ // first assistant chat spawns near-instantly. No-op on a fresh runner (no
69
+ // shared bundle on disk yet) — the real assistant_start installs it then.
70
+ let _assistantPrewarmed = false;
71
+ const _prewarmAssistantEnv = async () => {
72
+ if (_assistantPrewarmed)
73
+ return;
74
+ _assistantPrewarmed = true;
75
+ try {
76
+ const { getLocalSharedVersion } = await import("./sharedVendor.js");
77
+ const localVersion = getLocalSharedVersion();
78
+ if (!localVersion)
79
+ return; // nothing cached yet — let assistant_start do it
80
+ const { ensurePythonEnv } = await import("./pythonEnv.js");
81
+ await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
82
+ console.log(chalk.gray(` [assistant prewarm] ${m}`)); });
83
+ if (opts.verbose)
84
+ console.log(chalk.gray(" [assistant prewarm] ready"));
85
+ }
86
+ catch (e) {
87
+ if (opts.verbose)
88
+ console.log(chalk.gray(` [assistant prewarm] skipped: ${e?.message || e}`));
89
+ }
90
+ };
67
91
  socket.on("connect", async () => {
68
92
  spinner.succeed(chalk.green("Connected to Melaya"));
69
93
  // Report detected models
@@ -83,6 +107,12 @@ export async function connect(opts) {
83
107
  // effort — failure here means Luma POSTs fall back to aiohttp
84
108
  // (which 403s under the bot rule) but reads still work.
85
109
  await _ensureLumaBridge();
110
+ // Pre-warm the assistant Python env in the BACKGROUND so the first chat
111
+ // message doesn't pay the venv check + one-time NLTK/cert self-heal inline
112
+ // (the biggest chunk of "◆ Assistant session … takes forever to spawn").
113
+ // We warm with the version already on disk so it hits the cache instead of
114
+ // reinstalling; skipped on a fresh runner (no bundle yet) to avoid churn.
115
+ void _prewarmAssistantEnv();
86
116
  console.log(chalk.gray(" Waiting for pipeline runs...\n"));
87
117
  });
88
118
  socket.on("connect_error", (err) => {
@@ -936,6 +966,23 @@ export async function connect(opts) {
936
966
  activeAssistants.delete(String(payload.sessionId));
937
967
  }
938
968
  });
969
+ // Cooperative cancel: the user pressed STOP in chat. Write a cancel line to the
970
+ // host's stdin — its stdin reader thread flips a flag the running turn's
971
+ // hooks poll, so the agent loop aborts BETWEEN steps (right where the next
972
+ // phone action would fire) WITHOUT killing the warm session. The server-side
973
+ // phone:kill gate independently halts any in-flight phone action, so even a
974
+ // host that predates this line stops driving the phone; this just also stops
975
+ // it burning model tokens and keeps the session warm for the next message.
976
+ socket.on("runner:assistant_cancel", (payload) => {
977
+ const s = activeAssistants.get(String(payload.sessionId || ""));
978
+ if (!s)
979
+ return;
980
+ s.lastActivity = Date.now();
981
+ try {
982
+ s.proc.stdin?.write(JSON.stringify({ turnId: payload.turnId, cancel: true }) + "\n");
983
+ }
984
+ catch { /* best-effort */ }
985
+ });
939
986
  // Idle sweep — reap assistant hosts with no turn in ASSISTANT_IDLE_MS.
940
987
  setInterval(() => {
941
988
  const now = Date.now();
package/dist/pythonEnv.js CHANGED
@@ -320,13 +320,21 @@ async function ensureNltkData(onProgress) {
320
320
  onProgress(`(nltk check exited ${code} — Mode B vector RAG ingest may fail with "Resource 'punkt_tab' not found")`);
321
321
  }
322
322
  }
323
+ // One-time-per-process guard for the cache-hit self-heal (NLTK + cert bundle).
324
+ let _selfHealedThisProcess = false;
323
325
  export async function ensurePythonEnv(systemPython, expectedVersion, onProgress = (m) => console.log(chalk.gray(` [venv] ${m}`))) {
324
326
  if (venvIsValid(expectedVersion)) {
325
- // Self-heal: ensure NLTK data is present even when the venv marker
326
- // says we're up to date. This is what previously broke for boxes
327
- // that upgraded the runner without invalidating the marker.
328
- await ensureNltkData(onProgress);
329
- await _resolveAndCacheCertBundle(onProgress);
327
+ // Self-heal: ensure NLTK data + the cert bundle are present even when the
328
+ // venv marker says we're up to date (this is what previously broke for
329
+ // boxes that upgraded the runner without invalidating the marker). Both are
330
+ // stable for the process lifetime, so run them ONCE per runner process — the
331
+ // old code spawned two extra Python processes on EVERY assistant/pipeline
332
+ // start (~0.5-1.5s of pure latency on the assistant-spawn critical path).
333
+ if (!_selfHealedThisProcess) {
334
+ _selfHealedThisProcess = true;
335
+ await ensureNltkData(onProgress);
336
+ await _resolveAndCacheCertBundle(onProgress);
337
+ }
330
338
  return { ok: true, pythonPath: venvPython() };
331
339
  }
332
340
  if (!existsSync(AGENTSCOPE)) {
@@ -7,5 +7,12 @@
7
7
  * runtime from the server's /api/v1/agents/shared-bundle endpoint,
8
8
  * integrity-verified via SHA-256, and cached at ~/.melaya-runner/shared/.
9
9
  */
10
+ /**
11
+ * The shared-bundle version currently installed on disk (from shared-version.txt),
12
+ * or null if none/incomplete. Used to pre-warm the Python env on connect with the
13
+ * SAME version the cache already holds, so the warm-up hits the cache instead of
14
+ * triggering a reinstall.
15
+ */
16
+ export declare function getLocalSharedVersion(): string | null;
10
17
  export declare function getSharedDir(): string;
11
18
  export declare function ensureSharedModules(serverUrl: string, expectedVersion: string, runnerToken: string): Promise<void>;
@@ -14,6 +14,22 @@ import { createHash, createHmac, timingSafeEqual } from "crypto";
14
14
  const CACHE_DIR = join(homedir(), ".melaya-runner");
15
15
  const SHARED_DIR = join(CACHE_DIR, "shared");
16
16
  const VERSION_FILE = join(CACHE_DIR, "shared-version.txt");
17
+ /**
18
+ * The shared-bundle version currently installed on disk (from shared-version.txt),
19
+ * or null if none/incomplete. Used to pre-warm the Python env on connect with the
20
+ * SAME version the cache already holds, so the warm-up hits the cache instead of
21
+ * triggering a reinstall.
22
+ */
23
+ export function getLocalSharedVersion() {
24
+ try {
25
+ if (existsSync(VERSION_FILE) && cacheLooksValid()) {
26
+ const v = readFileSync(VERSION_FILE, "utf-8").trim();
27
+ return v || null;
28
+ }
29
+ }
30
+ catch { /* best-effort */ }
31
+ return null;
32
+ }
17
33
  export function getSharedDir() {
18
34
  // Return the CACHE_DIR root (not shared/ subdir) because the bundle
19
35
  // now includes agentscope/ and crews/ alongside shared/. PYTHONPATH
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.82",
3
+ "version": "1.0.84",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,