@melaya/runner 1.0.97 → 1.0.98

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.
@@ -37,6 +37,52 @@ import traceback
37
37
  _EVENT_PREFIX = "MELASSIST "
38
38
  _IDLE_EXIT_SECONDS = int(os.environ.get("MEL_ASSISTANT_IDLE_SECONDS", "900")) # 15 min
39
39
 
40
+ # PR4 rehydration: this host instance id + the DB generation it booted under.
41
+ import uuid as _uuid
42
+ _HOST_ID = _uuid.uuid4().hex
43
+ try:
44
+ _GENERATION = int(os.environ.get("MEL_ASSISTANT_GENERATION", "0") or 0)
45
+ except Exception:
46
+ _GENERATION = 0
47
+
48
+
49
+ def _run_async(coro):
50
+ """Run a coroutine to completion from the synchronous main loop (no running
51
+ loop here — turns use their own asyncio.run)."""
52
+ import asyncio
53
+ return asyncio.run(coro)
54
+
55
+
56
+ def _agent_memory(agent):
57
+ """Best-effort handle to the agent's BoundedMemory (for watermark + restore)."""
58
+ try:
59
+ mem = getattr(agent, "memory", None)
60
+ if mem is not None and hasattr(mem, "memory_watermark"):
61
+ return mem
62
+ except Exception:
63
+ pass
64
+ return None
65
+
66
+
67
+ def _memory_watermark(agent) -> int:
68
+ mem = _agent_memory(agent)
69
+ try:
70
+ return int(mem.memory_watermark()) if mem is not None else 0
71
+ except Exception:
72
+ return 0
73
+
74
+
75
+ def _render_summary_text(summary) -> str:
76
+ """Render a server AssistantSummary ({facts, provenance}) to a data-only text
77
+ block for the compressed-summary slot. Never policy — reference only."""
78
+ if not isinstance(summary, dict):
79
+ return ""
80
+ facts = summary.get("facts") or []
81
+ lines = [f"- {str(f)[:240]}" for f in facts if str(f).strip()]
82
+ if not lines:
83
+ return ""
84
+ return "[Earlier conversation summary — reference only]\n" + "\n".join(lines)
85
+
40
86
  # Streaming state for the CURRENT turn — the pre_print / post_acting hooks read
41
87
  # this to emit delta / tool events keyed to the turn in flight. `cancel` is the
42
88
  # STOP flag: set by the stdin-reader thread, polled by the running turn.
@@ -502,7 +548,20 @@ def main() -> int:
502
548
  _emit("", "error", message=f"assistant host failed to start: {exc}")
503
549
  return 1
504
550
 
505
- _emit("", "ready")
551
+ # PR4: report memory watermark + host identity + generation so the server can
552
+ # decide whether to offer a generation-bound rehydrate snapshot.
553
+ # Surface context compaction to the chat UI on the RUNNER path too: the
554
+ # BoundedMemory fires pre_compact / post_compact around a summarise pass.
555
+ try:
556
+ from shared.orchestration.hooks import pipeline_hooks
557
+ pipeline_hooks.register("pre_compact", lambda ctx: _emit(str(_stream.get("turnId") or ""), "compacting"))
558
+ pipeline_hooks.register("post_compact", lambda ctx: _emit(
559
+ str(_stream.get("turnId") or ""), "compacted",
560
+ dropped=int((ctx or {}).get("messages_compressed") or 0)))
561
+ except Exception:
562
+ _log("compaction hooks unavailable (non-fatal)")
563
+
564
+ _emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION)
506
565
  _log("ready")
507
566
 
508
567
  q: "queue.Queue[str]" = queue.Queue()
@@ -525,6 +584,38 @@ def main() -> int:
525
584
  except Exception:
526
585
  _log(f"bad stdin line (not json): {line[:120]}")
527
586
  continue
587
+ # PR4: generation-bound rehydrate. Seed a FRESH host's memory from the
588
+ # server snapshot (task pin + summary + recent tail) via restoreSnapshot —
589
+ # watermark-guarded so a re-offer never double-seeds. Never memory.add().
590
+ if isinstance(req, dict) and req.get("kind") == "restore":
591
+ try:
592
+ gen = int(req.get("generation") or 0)
593
+ except Exception:
594
+ gen = 0
595
+ if _GENERATION and gen and gen != _GENERATION:
596
+ _log(f"restore for stale generation {gen} (mine={_GENERATION}) — ignored")
597
+ continue
598
+ mem = _agent_memory(agent)
599
+ if mem is None:
600
+ continue
601
+ try:
602
+ from agentscope.message import Msg as _Msg
603
+ recent = req.get("recent") or []
604
+ recent_msgs = [
605
+ _Msg(str(m.get("role") or "user"), str(m.get("content") or ""), str(m.get("role") or "user"))
606
+ for m in recent if isinstance(m, dict) and str(m.get("content") or "").strip()
607
+ ]
608
+ applied = _run_async(mem.restore_snapshot(
609
+ task_pin=str(req.get("taskPin") or ""),
610
+ summary_text=_render_summary_text(req.get("summary")),
611
+ recent_msgs=recent_msgs,
612
+ watermark=int(req.get("watermark") or 0),
613
+ ))
614
+ _emit("", "rehydrated", applied=bool(applied), watermark=_memory_watermark(agent))
615
+ _log(f"restore applied={applied} watermark={_memory_watermark(agent)}")
616
+ except Exception:
617
+ _log("restore failed:\n" + traceback.format_exc())
618
+ continue
528
619
  turn_id = str(req.get("turnId") or "")
529
620
  message = str(req.get("message") or "")
530
621
  # Per-turn autonomy mode: the TS side carries an updated `hitl_mode`
@@ -29,6 +29,13 @@ import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
29
29
  const HEARTBEAT_INTERVAL = 30_000;
30
30
  const activeProcesses = new Map();
31
31
  const activeAssistants = new Map();
32
+ // PR4: sid-keyed boot reservation. The guard-then-spawn in runner:assistant_start
33
+ // has an async gap (ensureSharedModules / ensurePythonEnv awaits) between the
34
+ // activeAssistants.has() check and the .set(). A second assistant_start in that
35
+ // window — a concurrent SSE, or a RESTARTED server re-emitting start while the
36
+ // host is still booting — would double-spawn two python hosts. Reserving the sid
37
+ // SYNCHRONOUSLY here closes that window.
38
+ const bootingAssistants = new Set();
32
39
  const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
33
40
  export async function connect(opts) {
34
41
  const spinner = ora("Connecting to Melaya...").start();
@@ -876,11 +883,19 @@ export async function connect(opts) {
876
883
  const sid = String(payload.sessionId || "");
877
884
  if (!sid)
878
885
  return;
886
+ const generation = Number(payload.generation ?? 0) || 0;
879
887
  const emitEv = (ev) => socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", ...ev });
888
+ // A live host already exists — report ready (with its watermark so the server
889
+ // can decide whether to re-offer a rehydrate). No second spawn.
880
890
  if (activeAssistants.has(sid)) {
881
891
  emitEv({ kind: "ready" });
882
892
  return;
883
893
  }
894
+ // A boot is already in flight for this sid (concurrent start / server restart
895
+ // mid-boot). Drop this duplicate — the in-flight boot will emit `ready`.
896
+ if (bootingAssistants.has(sid))
897
+ return;
898
+ bootingAssistants.add(sid); // SYNCHRONOUS reservation before any await
884
899
  try {
885
900
  const sharedVersion = String(payload.sharedVersion ?? "latest");
886
901
  await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
@@ -946,6 +961,10 @@ export async function connect(opts) {
946
961
  // reads this at start; each turn's frame can flip it (see assistant_turn).
947
962
  // Fail-safe: unknown ⇒ "safe".
948
963
  MEL_ASSISTANT_HITL_MODE: (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe",
964
+ // PR4: the DB-authoritative generation this host boots under. The host
965
+ // echoes it in `ready` and stamps events; an older boot self-terminates
966
+ // on a generation mismatch.
967
+ MEL_ASSISTANT_GENERATION: String(generation),
949
968
  // Run context for phone-driving assistant turns. The assistant host has
950
969
  // no pipeline MEL_RUN_ID, so without this phone.py._cmd omits `run_id`,
951
970
  // the server's arm block (phone.ts, `if (runId)`) never fires, and the
@@ -968,7 +987,7 @@ export async function connect(opts) {
968
987
  ...(payload.credentials || {}),
969
988
  };
970
989
  const proc = spawn(envResult.pythonPath, ["-u", stagedHost], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
971
- const session = { proc, lastActivity: Date.now(), stdoutBuf: "" };
990
+ const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation };
972
991
  activeAssistants.set(sid, session);
973
992
  console.log(chalk.hex("#7c6ff0")(` ◆ Assistant session ${sid.slice(0, 10)}… (${payload.provider})`));
974
993
  proc.stdout?.on("data", (data) => {
@@ -1012,6 +1031,39 @@ export async function connect(opts) {
1012
1031
  catch (e) {
1013
1032
  emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
1014
1033
  }
1034
+ finally {
1035
+ // Release the boot reservation on EVERY path (spawned, or errored/returned
1036
+ // early). By now the host is either in activeAssistants or dead.
1037
+ bootingAssistants.delete(sid);
1038
+ }
1039
+ });
1040
+ // PR4: generation-bound rehydrate. The server sends this AFTER `ready`, only
1041
+ // when the DB watermark is newer than the host's. Forward it to the host as a
1042
+ // `restore` frame; the host applies it via restoreSnapshot ONLY if its own
1043
+ // memory watermark is older (idempotent — a re-offer never double-seeds).
1044
+ socket.on("runner:assistant_rehydrate", (payload) => {
1045
+ const sid = String(payload.sessionId || "");
1046
+ const s = activeAssistants.get(sid);
1047
+ if (!s)
1048
+ return;
1049
+ // Bind to the boot generation — a frame for an older/newer generation than the
1050
+ // live host is dropped (the host also re-checks).
1051
+ const gen = Number(payload.generation ?? 0) || 0;
1052
+ if (gen && s.generation && gen !== s.generation)
1053
+ return;
1054
+ s.lastActivity = Date.now();
1055
+ try {
1056
+ s.proc.stdin?.write(JSON.stringify({
1057
+ kind: "restore",
1058
+ generation: gen,
1059
+ watermark: Number(payload.watermark ?? 0) || 0,
1060
+ taskPin: payload.taskPin ?? null,
1061
+ summary: payload.summary ?? null,
1062
+ summaryUptoSeq: Number(payload.summaryUptoSeq ?? 0) || 0,
1063
+ recent: Array.isArray(payload.recent) ? payload.recent : [],
1064
+ }) + "\n");
1065
+ }
1066
+ catch { /* best-effort — host stays as-is on a write failure */ }
1015
1067
  });
1016
1068
  socket.on("runner:assistant_turn", (payload) => {
1017
1069
  const s = activeAssistants.get(String(payload.sessionId || ""));
package/package.json CHANGED
@@ -1,42 +1,42 @@
1
- {
2
- "name": "@melaya/runner",
3
- "version": "1.0.97",
4
- "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
- "license": "UNLICENSED",
6
- "private": false,
7
- "type": "module",
8
- "bin": {
9
- "melaya-runner": "dist/cli.js"
10
- },
11
- "main": "dist/index.js",
12
- "files": [
13
- "dist/**/*.js",
14
- "dist/**/*.d.ts",
15
- "dist/**/*.py",
16
- "localRagIngest.py",
17
- "localRagRetrieve.py",
18
- "nltk_data/**",
19
- "README.md"
20
- ],
21
- "scripts": {
22
- "build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
23
- "prepublishOnly": "npm run build"
24
- },
25
- "dependencies": {
26
- "chalk": "^5.3.0",
27
- "commander": "^12.0.0",
28
- "ora": "^8.0.0",
29
- "playwright": "^1.47.0",
30
- "socket.io-client": "^4.8.0"
31
- },
32
- "devDependencies": {
33
- "@types/node": "^20.0.0",
34
- "typescript": "^5.5.0"
35
- },
36
- "engines": {
37
- "node": ">=18"
38
- },
39
- "publishConfig": {
40
- "access": "public"
41
- }
42
- }
1
+ {
2
+ "name": "@melaya/runner",
3
+ "version": "1.0.98",
4
+ "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
+ "license": "UNLICENSED",
6
+ "private": false,
7
+ "type": "module",
8
+ "bin": {
9
+ "melaya-runner": "dist/cli.js"
10
+ },
11
+ "main": "dist/index.js",
12
+ "files": [
13
+ "dist/**/*.js",
14
+ "dist/**/*.d.ts",
15
+ "dist/**/*.py",
16
+ "localRagIngest.py",
17
+ "localRagRetrieve.py",
18
+ "nltk_data/**",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "dependencies": {
26
+ "chalk": "^5.3.0",
27
+ "commander": "^12.0.0",
28
+ "ora": "^8.0.0",
29
+ "playwright": "^1.47.0",
30
+ "socket.io-client": "^4.8.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^20.0.0",
34
+ "typescript": "^5.5.0"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }