@melaya/runner 1.1.33 → 1.1.35

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/cli.js CHANGED
@@ -52,6 +52,19 @@ async function main() {
52
52
  .parse(process.argv);
53
53
  const opts = program.opts();
54
54
  console.log(BANNER);
55
+ // Single-instance guard: refuse to start if another runner is already running
56
+ // on this machine. Two runners for one user fight over a single server-side
57
+ // session (connect/disconnect churn, turns landing on the wrong host), so we
58
+ // stop the second launch up front rather than let them compete. A stale lock
59
+ // from a crashed runner is reclaimed automatically.
60
+ const { acquireSingleInstanceLock } = await import("./singleInstance.js");
61
+ const lock = acquireSingleInstanceLock();
62
+ if (!lock.ok) {
63
+ console.log(chalk.red(` ✗ A Melaya runner is already running on this machine (pid ${lock.ownerPid}).`));
64
+ console.log(chalk.gray(" Only one runner can run per machine. Stop the other one first"));
65
+ console.log(chalk.gray(` (e.g. \`kill ${lock.ownerPid}\`, or close its terminal), then start again.`));
66
+ process.exit(1);
67
+ }
55
68
  // Detect Python. Fast path: a supported host interpreter (3.10–3.12). If the
56
69
  // host only has an unsupported/newer Python (Homebrew now ships 3.13/3.14 as
57
70
  // `python3`) — or none at all — we PROVISION a managed CPython 3.12 via uv, so
package/dist/pythonEnv.js CHANGED
@@ -496,12 +496,38 @@ export async function ensurePythonEnv(systemPython, expectedVersion, onProgress
496
496
  // this passes, so a half-built venv is never cached as valid: the next
497
497
  // launch re-enters this rebuild path and self-heals instead of returning a
498
498
  // broken venv as ok.
499
- const probe = await runProc(venvPython(), ["-c", "import shortuuid, agentscope, anthropic, openai"], onProgress, { PYTHONPATH: CACHE_DIR });
499
+ // Importing the in-house agentscope eagerly loads its full submodule tree
500
+ // (model/tool/rag/embedding/…), so it drags in every third-party dep at load
501
+ // time. A single broken wheel therefore surfaces here as an "agentscope"
502
+ // failure even though our package is fine. Import each name in turn and, on
503
+ // the FIRST failure, emit a machine-parseable PROBE_FAIL line naming the exact
504
+ // module + error — captured below so the reason names the real culprit instead
505
+ // of the useless "agentscope import failed" (which sent every Mac 3.14→3.12
506
+ // dep breakage back as an unactionable message).
507
+ let probeErr = "";
508
+ const probe = await runProc(venvPython(), ["-c",
509
+ "import sys\n" +
510
+ "for _m in ('shortuuid','agentscope','anthropic','openai'):\n" +
511
+ " try:\n" +
512
+ " __import__(_m)\n" +
513
+ " except Exception as _e:\n" +
514
+ " import traceback\n" +
515
+ " sys.stderr.write('PROBE_FAIL module=%s %s: %s\\n' % (_m, type(_e).__name__, _e))\n" +
516
+ " traceback.print_exc()\n" +
517
+ " sys.exit(1)\n",
518
+ ], (line) => {
519
+ if (/^PROBE_FAIL /.test(line))
520
+ probeErr = line.replace(/^PROBE_FAIL\s+/, "").trim();
521
+ onProgress(line);
522
+ }, { PYTHONPATH: CACHE_DIR });
500
523
  if (probe !== 0) {
524
+ const where = venvMinorVersion() === null ? "" : `Python 3.${venvMinorVersion()} `;
501
525
  return {
502
526
  ok: false,
503
527
  pythonPath: systemPython,
504
- reason: `agentscope import probe failed on the ${venvMinorVersion() === null ? "" : `Python 3.${venvMinorVersion()} `}venv — the deps above did not import. See the pip lines above for the cause; the venv will be rebuilt on next launch.`,
528
+ reason: probeErr
529
+ ? `${where}venv import probe failed — ${probeErr}. Importing agentscope loads its whole dependency tree, so this is the dep that broke (not agentscope itself). See the traceback above; the venv will be rebuilt on next launch.`
530
+ : `agentscope import probe failed on the ${where}venv — a dependency did not import. See the lines above; the venv will be rebuilt on next launch.`,
505
531
  };
506
532
  }
507
533
  writeFileSync(VENV_MARK, venvMarkerValue(expectedVersion), "utf-8");
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Single-instance guard for the runner.
3
+ *
4
+ * Two `@melaya/runner` processes for the same user fight over one server-side
5
+ * session: the server routes an assistant/pipeline turn to ONE socket, so a
6
+ * second runner produces connect/disconnect churn, split state, and turns that
7
+ * land on the wrong host. This lock makes a second launch on the same machine
8
+ * refuse up front ("can't start if another is already open") instead of quietly
9
+ * competing.
10
+ *
11
+ * Machine-scoped by design: it prevents the common footgun (double-launching on
12
+ * one box). It does NOT stop a runner on a *different* machine — that's a
13
+ * legitimate multi-device case, and the server's own per-user limit + the
14
+ * session reattach logic handle cross-machine arbitration.
15
+ *
16
+ * The lock is a small JSON file holding the owning pid. A stale lock (owner pid
17
+ * no longer alive — e.g. a hard kill that skipped cleanup) is reclaimed
18
+ * automatically, so a crash never permanently blocks the next start.
19
+ */
20
+ export type LockResult = {
21
+ ok: true;
22
+ } | {
23
+ ok: false;
24
+ ownerPid: number;
25
+ };
26
+ /**
27
+ * Try to acquire the machine-wide runner lock. Returns {ok:true} and installs
28
+ * process-exit cleanup on success; {ok:false, ownerPid} if a live runner
29
+ * already holds it. A stale lock (dead owner) is reclaimed.
30
+ */
31
+ export declare function acquireSingleInstanceLock(): LockResult;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Single-instance guard for the runner.
3
+ *
4
+ * Two `@melaya/runner` processes for the same user fight over one server-side
5
+ * session: the server routes an assistant/pipeline turn to ONE socket, so a
6
+ * second runner produces connect/disconnect churn, split state, and turns that
7
+ * land on the wrong host. This lock makes a second launch on the same machine
8
+ * refuse up front ("can't start if another is already open") instead of quietly
9
+ * competing.
10
+ *
11
+ * Machine-scoped by design: it prevents the common footgun (double-launching on
12
+ * one box). It does NOT stop a runner on a *different* machine — that's a
13
+ * legitimate multi-device case, and the server's own per-user limit + the
14
+ * session reattach logic handle cross-machine arbitration.
15
+ *
16
+ * The lock is a small JSON file holding the owning pid. A stale lock (owner pid
17
+ * no longer alive — e.g. a hard kill that skipped cleanup) is reclaimed
18
+ * automatically, so a crash never permanently blocks the next start.
19
+ */
20
+ import { mkdirSync, readFileSync, unlinkSync, openSync, writeSync, closeSync } from "fs";
21
+ import { join } from "path";
22
+ import { homedir } from "os";
23
+ const CACHE_DIR = join(homedir(), ".melaya-runner");
24
+ const LOCK_PATH = join(CACHE_DIR, "runner.lock");
25
+ function pidAlive(pid) {
26
+ if (!Number.isInteger(pid) || pid <= 0)
27
+ return false;
28
+ try {
29
+ // Signal 0 does not send a signal — it only checks the process exists.
30
+ // Throws ESRCH if gone, EPERM if alive but owned by another user (still
31
+ // "alive" for our purposes). Works on Windows too.
32
+ process.kill(pid, 0);
33
+ return true;
34
+ }
35
+ catch (e) {
36
+ return e?.code === "EPERM";
37
+ }
38
+ }
39
+ /**
40
+ * Try to acquire the machine-wide runner lock. Returns {ok:true} and installs
41
+ * process-exit cleanup on success; {ok:false, ownerPid} if a live runner
42
+ * already holds it. A stale lock (dead owner) is reclaimed.
43
+ */
44
+ export function acquireSingleInstanceLock() {
45
+ mkdirSync(CACHE_DIR, { recursive: true });
46
+ const writeOwn = () => {
47
+ // O_EXCL create: fails if the file already exists, which makes the
48
+ // check-and-claim atomic against another runner racing to start.
49
+ const fd = openSync(LOCK_PATH, "wx");
50
+ try {
51
+ writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
52
+ }
53
+ finally {
54
+ closeSync(fd);
55
+ }
56
+ };
57
+ try {
58
+ writeOwn();
59
+ }
60
+ catch (e) {
61
+ if (e?.code !== "EEXIST")
62
+ throw e;
63
+ // A lock exists — is its owner still alive?
64
+ let ownerPid = 0;
65
+ try {
66
+ ownerPid = Number(JSON.parse(readFileSync(LOCK_PATH, "utf-8"))?.pid) || 0;
67
+ }
68
+ catch {
69
+ ownerPid = 0;
70
+ }
71
+ if (ownerPid && ownerPid !== process.pid && pidAlive(ownerPid)) {
72
+ return { ok: false, ownerPid };
73
+ }
74
+ // Stale (dead owner) or unreadable — reclaim it.
75
+ try {
76
+ unlinkSync(LOCK_PATH);
77
+ }
78
+ catch { /* someone else may have just cleaned it */ }
79
+ try {
80
+ writeOwn();
81
+ }
82
+ catch (e2) {
83
+ // Lost a reclaim race to another starting runner.
84
+ if (e2?.code === "EEXIST") {
85
+ let pid2 = 0;
86
+ try {
87
+ pid2 = Number(JSON.parse(readFileSync(LOCK_PATH, "utf-8"))?.pid) || 0;
88
+ }
89
+ catch {
90
+ pid2 = 0;
91
+ }
92
+ return { ok: false, ownerPid: pid2 || -1 };
93
+ }
94
+ throw e2;
95
+ }
96
+ }
97
+ // Release the lock on every normal or signalled exit. Only unlink if WE still
98
+ // own it, so a reclaimed-after-crash successor never deletes the live owner's.
99
+ let released = false;
100
+ const release = () => {
101
+ if (released)
102
+ return;
103
+ released = true;
104
+ try {
105
+ const held = Number(JSON.parse(readFileSync(LOCK_PATH, "utf-8"))?.pid) || 0;
106
+ if (held === process.pid)
107
+ unlinkSync(LOCK_PATH);
108
+ }
109
+ catch { /* already gone */ }
110
+ };
111
+ process.on("exit", release);
112
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
113
+ process.on(sig, () => { release(); process.exit(0); });
114
+ }
115
+ return { ok: true };
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.33",
3
+ "version": "1.1.35",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,