@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 +13 -0
- package/dist/pythonEnv.js +28 -2
- package/dist/singleInstance.d.ts +31 -0
- package/dist/singleInstance.js +116 -0
- package/package.json +1 -1
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
|
-
|
|
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:
|
|
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
|
+
}
|