@melaya/runner 1.1.68 → 1.1.70

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.
@@ -13,6 +13,7 @@ import { homedir, setPriority } from "os";
13
13
  import { spawn } from "child_process";
14
14
  import { createHash, createHmac, timingSafeEqual } from "crypto";
15
15
  import { venvPython } from "./pythonEnv.js";
16
+ import { trackChild } from "./processTree.js";
16
17
  const CACHE_DIR = join(homedir(), ".melaya-runner");
17
18
  const SHARED_DIR = join(CACHE_DIR, "shared");
18
19
  const VERSION_FILE = join(CACHE_DIR, "shared-version.txt");
@@ -162,7 +163,8 @@ function precompileInBackground() {
162
163
  const dirs = ["shared", "agentscope", "crews"].map((d) => join(CACHE_DIR, d)).filter((d) => existsSync(d));
163
164
  if (!dirs.length)
164
165
  return;
165
- const child = spawn(py, ["-m", "compileall", "-q", "-j", "2", ...dirs], { stdio: "ignore", windowsHide: true, detached: false });
166
+ // Tracked: runner shutdown stops it instead of leaving it compiling.
167
+ const child = trackChild(spawn(py, ["-m", "compileall", "-q", "-j", "2", ...dirs], { stdio: "ignore", windowsHide: true, detached: false }));
166
168
  child.on("error", () => { });
167
169
  if (child.pid) {
168
170
  try {
@@ -20,6 +20,7 @@
20
20
  import { mkdirSync, readFileSync, unlinkSync, openSync, writeSync, closeSync } from "fs";
21
21
  import { join } from "path";
22
22
  import { homedir } from "os";
23
+ import { gracefulShutdownClaimed } from "./processTree.js";
23
24
  const CACHE_DIR = join(homedir(), ".melaya-runner");
24
25
  const LOCK_PATH = join(CACHE_DIR, "runner.lock");
25
26
  function pidAlive(pid) {
@@ -110,7 +111,15 @@ export function acquireSingleInstanceLock() {
110
111
  };
111
112
  process.on("exit", release);
112
113
  for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
113
- process.on(sig, () => { release(); process.exit(0); });
114
+ // Once connection.ts owns a graceful shutdown (SIGINT/SIGTERM), let it
115
+ // finish: it exits on its own and "exit" above releases the lock then, so
116
+ // a successor cannot start while this runner's children are still stopping.
117
+ process.on(sig, () => {
118
+ if (sig !== "SIGHUP" && gracefulShutdownClaimed())
119
+ return;
120
+ release();
121
+ process.exit(0);
122
+ });
114
123
  }
115
124
  return { ok: true };
116
125
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Last good startup facts, so the next start can connect at once:
3
+ * ~/.melaya-runner/startup-cache.json holds the last published model list and
4
+ * the Python interpreter that worked. The runner connects with these, then
5
+ * publishes fresh discovery through `runner:models` when it lands.
6
+ *
7
+ * Model names only (no credentials, no tokens). A missing or malformed file
8
+ * reads as empty; writes are best-effort and atomic (temp file + rename).
9
+ */
10
+ import type { DetectedModel } from "./detect.js";
11
+ export interface StartupCache {
12
+ models?: DetectedModel[];
13
+ modelsAt?: number;
14
+ python?: {
15
+ cmd: string;
16
+ minor: number;
17
+ provisioned?: boolean;
18
+ };
19
+ }
20
+ export declare function startupCachePath(dir?: string): string;
21
+ export declare function readStartupCache(path?: string): StartupCache;
22
+ export declare function writeStartupCache(patch: StartupCache, path?: string): void;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Last good startup facts, so the next start can connect at once:
3
+ * ~/.melaya-runner/startup-cache.json holds the last published model list and
4
+ * the Python interpreter that worked. The runner connects with these, then
5
+ * publishes fresh discovery through `runner:models` when it lands.
6
+ *
7
+ * Model names only (no credentials, no tokens). A missing or malformed file
8
+ * reads as empty; writes are best-effort and atomic (temp file + rename).
9
+ */
10
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+ const PROVIDERS = new Set(["lmstudio", "ollama", "claude_code", "codex", "github_copilot"]);
14
+ export function startupCachePath(dir = join(homedir(), ".melaya-runner")) {
15
+ return join(dir, "startup-cache.json");
16
+ }
17
+ export function readStartupCache(path = startupCachePath()) {
18
+ try {
19
+ const j = JSON.parse(readFileSync(path, "utf-8"));
20
+ const out = {};
21
+ if (Array.isArray(j.models)) {
22
+ out.models = j.models
23
+ .filter((m) => m && PROVIDERS.has(String(m.provider)) && typeof m.name === "string" && m.name.length > 0 && m.name.length < 200)
24
+ .slice(0, 500);
25
+ out.modelsAt = Number(j.modelsAt) || 0;
26
+ }
27
+ if (j.python && typeof j.python.cmd === "string" && Number.isInteger(j.python.minor)) {
28
+ out.python = { cmd: j.python.cmd, minor: j.python.minor, ...(j.python.provisioned ? { provisioned: true } : {}) };
29
+ }
30
+ return out;
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ export function writeStartupCache(patch, path = startupCachePath()) {
37
+ try {
38
+ const next = { ...readStartupCache(path), ...patch };
39
+ mkdirSync(join(path, ".."), { recursive: true });
40
+ const tmp = `${path}.${process.pid}.tmp`;
41
+ writeFileSync(tmp, JSON.stringify(next), { encoding: "utf-8", mode: 0o600 });
42
+ renameSync(tmp, path);
43
+ }
44
+ catch { /* best-effort: the next start just discovers again */ }
45
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Startup stage timestamps: where the time goes between process start and a
3
+ * runner that is online with its models and Python ready.
4
+ *
5
+ * Stages (first mark wins, epoch ms):
6
+ * process_start the Node process began (performance.timeOrigin)
7
+ * config options parsed and the token is in hand
8
+ * socket_connected the Socket.IO handshake succeeded
9
+ * online hello sent, relay up: the server can route work here
10
+ * python_ready a supported Python interpreter is resolved
11
+ * discovery_done the local model scan finished and was published
12
+ *
13
+ * The socket no longer waits for Python or discovery, so the last two usually
14
+ * land after `online`. Reported to the desktop app (log line + a "timings"
15
+ * host message) and printed with --diagnostics.
16
+ */
17
+ export declare const STARTUP_STAGES: readonly ["process_start", "config", "socket_connected", "online", "python_ready", "discovery_done"];
18
+ export type StartupStage = (typeof STARTUP_STAGES)[number];
19
+ /** Record a stage once. Later marks of the same stage are ignored. */
20
+ export declare function markStage(stage: StartupStage, at?: number): void;
21
+ /** Called after every new stage mark. Returns an unsubscribe function. */
22
+ export declare function onStage(cb: (stage: StartupStage, at: number) => void): () => void;
23
+ export declare function stageMarked(stage: StartupStage): boolean;
24
+ /** True once every stage has been recorded. */
25
+ export declare function startupComplete(): boolean;
26
+ /** Epoch timestamps and offsets from process start, in stage order. */
27
+ export declare function startupTimings(): {
28
+ at: Partial<Record<StartupStage, number>>;
29
+ sinceStartMs: Partial<Record<StartupStage, number>>;
30
+ };
31
+ /** Human-readable timeline, one stage per line. */
32
+ export declare function formatStartupTimings(): string;
33
+ /** Test seam. */
34
+ export declare function _resetStartupTimings(): void;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Startup stage timestamps: where the time goes between process start and a
3
+ * runner that is online with its models and Python ready.
4
+ *
5
+ * Stages (first mark wins, epoch ms):
6
+ * process_start the Node process began (performance.timeOrigin)
7
+ * config options parsed and the token is in hand
8
+ * socket_connected the Socket.IO handshake succeeded
9
+ * online hello sent, relay up: the server can route work here
10
+ * python_ready a supported Python interpreter is resolved
11
+ * discovery_done the local model scan finished and was published
12
+ *
13
+ * The socket no longer waits for Python or discovery, so the last two usually
14
+ * land after `online`. Reported to the desktop app (log line + a "timings"
15
+ * host message) and printed with --diagnostics.
16
+ */
17
+ import { sendToHost } from "./hostChannel.js";
18
+ export const STARTUP_STAGES = ["process_start", "config", "socket_connected", "online", "python_ready", "discovery_done"];
19
+ const marks = new Map();
20
+ const listeners = new Set();
21
+ function processStart() {
22
+ const origin = typeof performance !== "undefined" && performance.timeOrigin ? performance.timeOrigin : 0;
23
+ return Math.round(origin || Date.now() - process.uptime() * 1000);
24
+ }
25
+ /** Record a stage once. Later marks of the same stage are ignored. */
26
+ export function markStage(stage, at = Date.now()) {
27
+ if (!marks.has("process_start"))
28
+ marks.set("process_start", processStart());
29
+ if (marks.has(stage))
30
+ return;
31
+ marks.set(stage, at);
32
+ const start = marks.get("process_start");
33
+ sendToHost({ type: "log", level: "info", line: `startup: ${stage} +${Math.max(0, at - start)}ms` });
34
+ sendToHost({ type: "timings", stages: startupTimings().sinceStartMs });
35
+ for (const l of listeners) {
36
+ try {
37
+ l(stage, at);
38
+ }
39
+ catch { /* listener errors never matter */ }
40
+ }
41
+ }
42
+ /** Called after every new stage mark. Returns an unsubscribe function. */
43
+ export function onStage(cb) {
44
+ listeners.add(cb);
45
+ return () => { listeners.delete(cb); };
46
+ }
47
+ export function stageMarked(stage) {
48
+ return marks.has(stage);
49
+ }
50
+ /** True once every stage has been recorded. */
51
+ export function startupComplete() {
52
+ return STARTUP_STAGES.every((s) => marks.has(s));
53
+ }
54
+ /** Epoch timestamps and offsets from process start, in stage order. */
55
+ export function startupTimings() {
56
+ if (!marks.has("process_start"))
57
+ marks.set("process_start", processStart());
58
+ const start = marks.get("process_start");
59
+ const at = {};
60
+ const sinceStartMs = {};
61
+ for (const s of STARTUP_STAGES) {
62
+ const v = marks.get(s);
63
+ if (v === undefined)
64
+ continue;
65
+ at[s] = v;
66
+ sinceStartMs[s] = Math.max(0, v - start);
67
+ }
68
+ return { at, sinceStartMs };
69
+ }
70
+ /** Human-readable timeline, one stage per line. */
71
+ export function formatStartupTimings() {
72
+ const { sinceStartMs } = startupTimings();
73
+ return STARTUP_STAGES
74
+ .map((s) => `${s.padEnd(17)} ${sinceStartMs[s] === undefined ? "pending" : `+${sinceStartMs[s]}ms`}`)
75
+ .join("\n");
76
+ }
77
+ /** Test seam. */
78
+ export function _resetStartupTimings() {
79
+ marks.clear();
80
+ listeners.clear();
81
+ }
@@ -18,7 +18,8 @@
18
18
  import { existsSync, mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, statSync, copyFileSync } from "fs";
19
19
  import { join } from "path";
20
20
  import { homedir, platform, arch } from "os";
21
- import { spawn, execSync } from "child_process";
21
+ import { execFile } from "child_process";
22
+ import { spawnTracked as spawn, registerSetupChild, terminateTree } from "./processTree.js";
22
23
  const CACHE_DIR = join(homedir(), ".melaya-runner");
23
24
  const BIN_DIR = join(CACHE_DIR, "bin");
24
25
  const isWin = platform() === "win32";
@@ -40,27 +41,28 @@ function assetName() {
40
41
  return a === "arm64" ? "uv-aarch64-pc-windows-msvc.zip" : "uv-x86_64-pc-windows-msvc.zip";
41
42
  return null;
42
43
  }
43
- function tryExec(cmd) {
44
- try {
45
- return execSync(cmd, { encoding: "utf-8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] }).trim() || null;
46
- }
47
- catch {
48
- return null;
49
- }
50
- }
44
+ // PATH lookup without a shell or a blocking subprocess: Python provisioning
45
+ // now runs after the socket connects, so nothing here may stall the event loop.
51
46
  function findOnPath(bin) {
52
- const out = tryExec(isWin ? `where ${bin}` : `command -v ${bin}`);
53
- if (!out)
54
- return null;
55
- const first = out.split(/\r?\n/)[0].trim();
56
- return first || null;
47
+ const sep = isWin ? ";" : ":";
48
+ for (const dir of (process.env.PATH || process.env.Path || "").split(sep)) {
49
+ if (!dir)
50
+ continue;
51
+ const cand = join(dir, bin);
52
+ try {
53
+ if (existsSync(cand) && statSync(cand).isFile())
54
+ return cand;
55
+ }
56
+ catch { /* unreadable dir */ }
57
+ }
58
+ return null;
57
59
  }
58
60
  // Inactivity-bounded child runner (mirrors pythonEnv.runProc): a managed-CPython
59
61
  // download legitimately takes a while but streams progress, so we bound silence.
60
62
  function run(cmd, args, onLine, opts = {}) {
61
63
  const inactivityMs = opts.inactivityMs ?? 5 * 60_000;
62
64
  return new Promise((resolve) => {
63
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...opts.env }, cwd: opts.cwd });
65
+ const child = registerSetupChild(spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...opts.env }, cwd: opts.cwd }));
64
66
  let done = false;
65
67
  let timer;
66
68
  const finish = (c) => { if (done)
@@ -69,10 +71,7 @@ function run(cmd, args, onLine, opts = {}) {
69
71
  const bump = () => {
70
72
  if (timer)
71
73
  clearTimeout(timer);
72
- timer = setTimeout(() => { try {
73
- child.kill("SIGKILL");
74
- }
75
- catch { /* gone */ } onLine(`(no output for ${Math.round(inactivityMs / 60000)}m — killed ${cmd})`); finish(1); }, inactivityMs);
74
+ timer = setTimeout(() => { void terminateTree(child, { graceMs: 0 }); onLine(`(no output for ${Math.round(inactivityMs / 60000)}m — killed ${cmd})`); finish(1); }, inactivityMs);
76
75
  };
77
76
  const chew = (b) => { bump(); for (const ln of b.toString().split("\n")) {
78
77
  const t = ln.trimEnd();
@@ -88,11 +87,14 @@ function run(cmd, args, onLine, opts = {}) {
88
87
  }
89
88
  function capture(cmd, args, env = {}) {
90
89
  return new Promise((resolve) => {
91
- let out = "";
92
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
93
- child.stdout?.on("data", (b) => { out += b.toString(); });
94
- child.on("exit", () => resolve(out.trim()));
95
- child.on("error", () => resolve(""));
90
+ try {
91
+ execFile(cmd, args, { env: { ...process.env, ...env }, timeout: 30_000, windowsHide: true, encoding: "utf-8" }, (err, stdout) => {
92
+ resolve(err ? "" : String(stdout || "").trim());
93
+ });
94
+ }
95
+ catch {
96
+ resolve("");
97
+ }
96
98
  });
97
99
  }
98
100
  // Extract the downloaded uv archive. uv ships .tar.gz for macOS/Linux and .zip
@@ -223,6 +225,12 @@ export async function provisionPython(version, onLine) {
223
225
  const uv = await ensureUv(onLine);
224
226
  if (!uv)
225
227
  return null;
228
+ // Fast path: a Python of this version uv already knows (provisioned on an
229
+ // earlier start): no install step, no network.
230
+ // --system: never a virtual environment that happens to be active or in cwd.
231
+ const known = await capture(uv, ["python", "find", "--system", version], { UV_SYSTEM_CERTS: "1" });
232
+ if (known && existsSync(known))
233
+ return known;
226
234
  onLine(`installing a managed Python ${version} (one-time; ~30-60s)`);
227
235
  // UV_SYSTEM_CERTS makes uv verify against the OS trust store instead of its
228
236
  // bundled webpki roots — required behind TLS-intercepting proxies/antivirus
package/dist/vaults.js CHANGED
@@ -21,6 +21,7 @@
21
21
  import { createPublicKey, verify as cryptoVerify, randomBytes } from "crypto";
22
22
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, watch as fsWatch, writeFileSync, } from "fs";
23
23
  import { spawn } from "child_process";
24
+ import { trackChild } from "./processTree.js";
24
25
  import { homedir, platform as osPlatform } from "os";
25
26
  import path from "path";
26
27
  import { Readable } from "stream";
@@ -482,7 +483,8 @@ export class VaultHostProcess {
482
483
  return this.starting;
483
484
  this.starting = (async () => {
484
485
  const l = await this.o.launch();
485
- const p = spawn(l.pythonPath, ["-u", l.scriptPath], { env: l.env, cwd: l.cwd, stdio: ["pipe", "pipe", "pipe"] });
486
+ // Tracked: runner shutdown stops it even if stop()'s delayed kill never fires.
487
+ const p = trackChild(spawn(l.pythonPath, ["-u", l.scriptPath], { env: l.env, cwd: l.cwd, stdio: ["pipe", "pipe", "pipe"] }));
486
488
  // EPIPE after a crash surfaces here; the exit handler does the cleanup.
487
489
  p.stdin?.on("error", () => { });
488
490
  p.stdout?.setEncoding("utf-8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.68",
3
+ "version": "1.1.70",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Melaya Labs LLC",