@staix/agent-hub 0.5.0 → 0.6.0
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/CHANGELOG.md +8 -0
- package/README.md +46 -3
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10 -8
- package/src/adapters/pi.ts +332 -0
- package/src/cli/main.ts +75 -1
- package/src/cli/terminal-recovery.ts +17 -9
- package/src/cli/upgrade-runtime.ts +46 -16
- package/src/cli/upgrade.ts +3 -0
- package/src/hub/control-client.ts +8 -6
- package/src/hub/daemon.ts +131 -11
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/manager.ts +15 -1
- package/src/hub/routing.ts +25 -3
- package/src/hub/tasks.ts +5 -4
- package/src/models/mlx.ts +419 -0
- package/src/models/relay.ts +334 -0
- package/src/pi/extension.ts +120 -0
- package/src/pi/tool-receipts.ts +52 -0
- package/src/ui/index.html +4 -0
- package/templates/config.json +3 -1
- package/templates/routing.toml +19 -8
package/src/hub/routing.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface ClassPolicy {
|
|
|
15
15
|
escalate_to?: PeerId[];
|
|
16
16
|
/** false: `local` is never owner or reviewer for this class */
|
|
17
17
|
local_allowed?: boolean;
|
|
18
|
+
/** Pi backend for Pi-owned work. */
|
|
19
|
+
pi_backend?: "dgx" | "mlx";
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export interface Routing {
|
|
@@ -24,10 +26,12 @@ export interface Routing {
|
|
|
24
26
|
classes: Partial<Record<TaskClass, ClassPolicy>>;
|
|
25
27
|
signals: { pii_patterns: string[]; long_context_tokens: number };
|
|
26
28
|
constraints: { pii: "local_only" | "off"; long_context: "skip_local" | "off"; budget_paused: "skip_peer" | "off" };
|
|
29
|
+
pi: { dgx_max_context_tokens: number; mlx_max_context_tokens: number };
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const TEMPLATE = join(import.meta.dir, "..", "..", "templates", "routing.toml");
|
|
30
33
|
export const LOCAL: PeerId = "local";
|
|
34
|
+
export const PI: PeerId = "pi";
|
|
31
35
|
|
|
32
36
|
/** `.agenthub/routing.toml`, or the shipped default when the project has none. Throws on a file that does not parse or lacks a fixed model. */
|
|
33
37
|
export function loadRouting(cwd: string): Routing {
|
|
@@ -41,13 +45,20 @@ export function loadRouting(cwd: string): Routing {
|
|
|
41
45
|
if (!raw.local?.fixed_model) throw new Error("routing.toml: [local] fixed_model is required (the path that works without Switchyard)");
|
|
42
46
|
const signals = { pii_patterns: [], long_context_tokens: 120_000, ...raw.signals };
|
|
43
47
|
for (const p of signals.pii_patterns) new RegExp(p); // a bad pattern fails here, at load, not in the middle of an assignment
|
|
48
|
+
const classes = raw.classes ?? {};
|
|
49
|
+
for (const [name, policy] of Object.entries(classes)) {
|
|
50
|
+
if (policy?.pi_backend !== undefined && policy.pi_backend !== "dgx" && policy.pi_backend !== "mlx") throw new Error(`routing.toml: [classes.${name}] pi_backend must be "dgx" or "mlx"`);
|
|
51
|
+
}
|
|
52
|
+
const pi = { dgx_max_context_tokens: 262_144, mlx_max_context_tokens: 16_000, ...(raw as any).pi };
|
|
53
|
+
if (!(Number.isSafeInteger(pi.dgx_max_context_tokens) && pi.dgx_max_context_tokens > 0) || !(Number.isSafeInteger(pi.mlx_max_context_tokens) && pi.mlx_max_context_tokens > 0)) throw new Error("routing.toml: [pi] context limits must be positive integers");
|
|
44
54
|
return {
|
|
45
55
|
local: raw.local,
|
|
46
56
|
targets: raw.targets ?? {},
|
|
47
57
|
routes: raw.routes ?? {},
|
|
48
|
-
classes
|
|
58
|
+
classes,
|
|
49
59
|
signals,
|
|
50
60
|
constraints: { pii: "local_only", long_context: "skip_local", budget_paused: "skip_peer", ...raw.constraints },
|
|
61
|
+
pi,
|
|
51
62
|
};
|
|
52
63
|
}
|
|
53
64
|
|
|
@@ -102,6 +113,7 @@ export interface Assignment {
|
|
|
102
113
|
/** what `local` should ask for on this task: a Switchyard route id and the model to fall back to */
|
|
103
114
|
route?: string;
|
|
104
115
|
fixedModel?: string;
|
|
116
|
+
piBackend?: "dgx" | "mlx";
|
|
105
117
|
trace: string[];
|
|
106
118
|
}
|
|
107
119
|
|
|
@@ -126,6 +138,12 @@ export function assign(
|
|
|
126
138
|
if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return "paused";
|
|
127
139
|
if (pii && peer !== LOCAL) return "pii: on-prem peers only";
|
|
128
140
|
if (peer === LOCAL && policy?.local_allowed === false) return "local_allowed = false for this class";
|
|
141
|
+
if (peer === PI && policy?.local_allowed === false) return "local_allowed = false also excludes pi for this class";
|
|
142
|
+
if (peer === PI && task.signals.includes("long_context")) {
|
|
143
|
+
const backend = policy?.pi_backend ?? "dgx";
|
|
144
|
+
const limit = backend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens;
|
|
145
|
+
if (routing.signals.long_context_tokens > limit) return `pi ${backend} capability limit ${limit} tokens`;
|
|
146
|
+
}
|
|
129
147
|
if (peer === LOCAL && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return "long_context: skip local";
|
|
130
148
|
return undefined;
|
|
131
149
|
};
|
|
@@ -137,7 +155,9 @@ export function assign(
|
|
|
137
155
|
trace.push(` ${role} candidate ${peer}: ${why ? `skipped, ${why}` : states[peer]}`);
|
|
138
156
|
if (!why) ok.push(peer);
|
|
139
157
|
}
|
|
140
|
-
|
|
158
|
+
const localTier = ok.filter((p) => p === LOCAL || p === PI);
|
|
159
|
+
if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
|
|
160
|
+
return ok.find((p) => states[p] === "idle") ?? ok[0];
|
|
141
161
|
};
|
|
142
162
|
|
|
143
163
|
// Never the task's current owner by default: a decline or an escalation has to reach the next peer in the list.
|
|
@@ -155,6 +175,8 @@ export function assign(
|
|
|
155
175
|
}
|
|
156
176
|
const route = policy?.route ?? routing.local.route;
|
|
157
177
|
const fixedModel = policy?.fixed_model ?? routing.local.fixed_model;
|
|
178
|
+
const piBackend = policy?.pi_backend;
|
|
158
179
|
if (owner === LOCAL) trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
|
|
159
|
-
|
|
180
|
+
if (owner === PI) trace.push(`pi decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
|
|
181
|
+
return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), trace };
|
|
160
182
|
}
|
package/src/hub/tasks.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { MemoryClient } from "../memory/client.ts";
|
|
|
3
3
|
import { CLASSES, type Board, type Task, type TaskClass, type TaskRefs } from "./board.ts";
|
|
4
4
|
import type { Bus } from "./bus.ts";
|
|
5
5
|
import { HUB, newEnvelope, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
6
|
-
import { assign, detectSignals, LOCAL, type Assignment, type Routing } from "./routing.ts";
|
|
6
|
+
import { assign, detectSignals, LOCAL, PI, type Assignment, type Routing } from "./routing.ts";
|
|
7
7
|
|
|
8
8
|
export interface TasksDeps {
|
|
9
9
|
board: Board;
|
|
@@ -119,7 +119,7 @@ export class Tasks {
|
|
|
119
119
|
const pii = this.isPii(task);
|
|
120
120
|
const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
|
|
121
121
|
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${h.note ?? ""}`);
|
|
122
|
-
const facts = [`class ${task.class}`, task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : "no reviewer"].filter(Boolean).join("; ");
|
|
122
|
+
const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : "no reviewer"].filter(Boolean).join("; ");
|
|
123
123
|
const body = [
|
|
124
124
|
`Task #${task.id} [${task.class}] ${task.title}`,
|
|
125
125
|
task.detail,
|
|
@@ -216,7 +216,7 @@ export class Tasks {
|
|
|
216
216
|
const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
|
|
217
217
|
if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
|
|
218
218
|
const from = task.owner;
|
|
219
|
-
const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}
|
|
219
|
+
const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}`, context: why });
|
|
220
220
|
if (next.owner && next.owner !== from) {
|
|
221
221
|
this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
|
|
222
222
|
this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
|
|
@@ -239,7 +239,8 @@ export class Tasks {
|
|
|
239
239
|
const routing = this.d.routing();
|
|
240
240
|
for (const task of this.d.board.list()) {
|
|
241
241
|
if (task.owner === peer && OPEN.includes(task.state)) {
|
|
242
|
-
const
|
|
242
|
+
const pii = this.isPii(task);
|
|
243
|
+
const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
|
|
243
244
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
|
|
244
245
|
const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context } : {}) });
|
|
245
246
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createServer, isIP } from "node:net";
|
|
7
|
+
import { Database } from "bun:sqlite";
|
|
8
|
+
|
|
9
|
+
export type MlxState = "disabled" | "starting" | "ready" | "stopped" | "foreign" | "error";
|
|
10
|
+
|
|
11
|
+
export interface MlxOptions {
|
|
12
|
+
runtimeDir?: string;
|
|
13
|
+
modelPath?: string;
|
|
14
|
+
bin?: string;
|
|
15
|
+
host?: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
maxInputTokens?: number;
|
|
18
|
+
maxConcurrency?: number;
|
|
19
|
+
maxTokens?: number;
|
|
20
|
+
/** Test/runtime injection; the default runs the pinned executable directly. */
|
|
21
|
+
spawn?: typeof spawn;
|
|
22
|
+
health?: (url: string, signal: AbortSignal) => Promise<boolean>;
|
|
23
|
+
processInfo?: (pid: number) => ProcessSignature | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MlxStatus {
|
|
27
|
+
state: MlxState;
|
|
28
|
+
url?: string;
|
|
29
|
+
model?: string;
|
|
30
|
+
pid?: number;
|
|
31
|
+
maxInputTokens: number;
|
|
32
|
+
maxConcurrency: number;
|
|
33
|
+
active: number;
|
|
34
|
+
lastError?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MlxHandle {
|
|
38
|
+
readonly status: () => MlxStatus;
|
|
39
|
+
readonly url: string;
|
|
40
|
+
readonly model: string;
|
|
41
|
+
readonly acquire: (signal?: AbortSignal) => Promise<() => void>;
|
|
42
|
+
readonly close: () => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface OwnerRecord {
|
|
46
|
+
schema: 1;
|
|
47
|
+
token: string;
|
|
48
|
+
pid: number;
|
|
49
|
+
bin: string;
|
|
50
|
+
modelPath: string;
|
|
51
|
+
host: string;
|
|
52
|
+
port: number;
|
|
53
|
+
startedAt: number;
|
|
54
|
+
processStart: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ProcessSignature {
|
|
58
|
+
command: string;
|
|
59
|
+
start: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DEFAULT_RUNTIME_DIR = join(homedir(), ".agenthub", "runtimes", "mlx");
|
|
63
|
+
const DEFAULT_MODEL_PATH = join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
|
|
64
|
+
const OWNER_FILE = "owner.json";
|
|
65
|
+
const GENERATION_DB = "generation-slots.db";
|
|
66
|
+
|
|
67
|
+
const ownerPath = (runtimeDir: string) => join(runtimeDir, OWNER_FILE);
|
|
68
|
+
const generationDbPath = (runtimeDir: string) => join(runtimeDir, GENERATION_DB);
|
|
69
|
+
|
|
70
|
+
function assertLoopback(host: string): void {
|
|
71
|
+
const value = host.toLowerCase();
|
|
72
|
+
if (value !== "localhost" && value !== "127.0.0.1" && value !== "::1" && !(isIP(value) === 4 && value.startsWith("127."))) {
|
|
73
|
+
throw new Error("MLX host must be loopback");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readOwner(runtimeDir: string): OwnerRecord | undefined {
|
|
78
|
+
if (!existsSync(ownerPath(runtimeDir))) return undefined;
|
|
79
|
+
try {
|
|
80
|
+
const value = JSON.parse(readFileSync(ownerPath(runtimeDir), "utf8")) as Partial<OwnerRecord>;
|
|
81
|
+
if (value.schema !== 1 || typeof value.token !== "string" || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid < 1 ||
|
|
82
|
+
typeof value.bin !== "string" || typeof value.modelPath !== "string" || typeof value.host !== "string" ||
|
|
83
|
+
!Number.isInteger(value.port) || typeof value.startedAt !== "number" || typeof value.processStart !== "string") throw new Error("MLX owner record is malformed; inspect it before retrying");
|
|
84
|
+
return value as OwnerRecord;
|
|
85
|
+
} catch (error) { throw new Error(`MLX owner record is unreadable: ${(error as Error).message}`); }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function processInfo(pid: number): ProcessSignature | undefined {
|
|
89
|
+
try {
|
|
90
|
+
const result = spawnSync("ps", ["-p", String(pid), "-o", "lstart=", "-o", "command="], { encoding: "utf8" });
|
|
91
|
+
if (result.status !== 0) return undefined;
|
|
92
|
+
const line = String(result.stdout).trim();
|
|
93
|
+
const match = /^(\w{3}\s+\w{3}\s+\d+\s+\d+:\d+:\d+\s+\d{4})\s+(.*)$/.exec(line);
|
|
94
|
+
return match ? { start: match[1]!, command: match[2]! } : undefined;
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ownedProcess(owner: OwnerRecord, readInfo = processInfo): boolean {
|
|
101
|
+
const current = readInfo(owner.pid);
|
|
102
|
+
return !!current && current.start === owner.processStart && current.command.includes(owner.bin) && current.command.includes(owner.modelPath);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function processAlive(pid: number): boolean | undefined {
|
|
106
|
+
try { process.kill(pid, 0); return true; }
|
|
107
|
+
catch (error) {
|
|
108
|
+
return (error as NodeJS.ErrnoException).code === "ESRCH" ? false : undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function acquireStartLock(runtimeDir: string, readInfo: (pid: number) => ProcessSignature | undefined): () => void {
|
|
113
|
+
// A pre-SQLite lock cannot be reclaimed safely by this version. Leave it for
|
|
114
|
+
// explicit inspection instead of allowing two starters to race on the GPU.
|
|
115
|
+
if (existsSync(join(runtimeDir, "start.lock"))) throw new Error("legacy MLX startup lock exists; inspect it before retrying");
|
|
116
|
+
const db = generationDatabase(runtimeDir);
|
|
117
|
+
const self = readInfo(process.pid);
|
|
118
|
+
if (!self) { db.close(); throw new Error("MLX startup owner identity is unavailable"); }
|
|
119
|
+
const token = randomUUID();
|
|
120
|
+
try {
|
|
121
|
+
db.run(`CREATE TABLE IF NOT EXISTS startup_lock (
|
|
122
|
+
slot INTEGER PRIMARY KEY CHECK (slot = 1),
|
|
123
|
+
token TEXT NOT NULL,
|
|
124
|
+
pid INTEGER NOT NULL,
|
|
125
|
+
process_start TEXT NOT NULL
|
|
126
|
+
)`);
|
|
127
|
+
db.transaction(() => {
|
|
128
|
+
const row = db.query("SELECT slot, token, pid, process_start FROM startup_lock WHERE slot = 1").get() as Record<string, unknown> | null;
|
|
129
|
+
if (row) {
|
|
130
|
+
if (row.slot !== 1 || typeof row.token !== "string" || !row.token || !Number.isSafeInteger(row.pid) || (row.pid as number) < 1 ||
|
|
131
|
+
typeof row.process_start !== "string" || !row.process_start) throw new Error("MLX startup lock is malformed; inspect it before retrying");
|
|
132
|
+
const current = readInfo(row.pid as number);
|
|
133
|
+
if (current && current.start === row.process_start) throw new Error("another MLX startup owns the runtime lock");
|
|
134
|
+
if (!current && processAlive(row.pid as number) !== false) throw new Error("MLX startup lock owner cannot be authenticated");
|
|
135
|
+
db.query("DELETE FROM startup_lock WHERE slot = 1 AND token = ? AND pid = ? AND process_start = ?")
|
|
136
|
+
.run(row.token as string, row.pid as number, row.process_start as string);
|
|
137
|
+
}
|
|
138
|
+
db.query("INSERT INTO startup_lock (slot, token, pid, process_start) VALUES (1, ?, ?, ?)").run(token, process.pid, self.start);
|
|
139
|
+
}).immediate();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
db.close();
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
let released = false;
|
|
145
|
+
return () => {
|
|
146
|
+
if (released) return;
|
|
147
|
+
released = true;
|
|
148
|
+
try { db.query("DELETE FROM startup_lock WHERE slot = 1 AND token = ? AND pid = ? AND process_start = ?").run(token, process.pid, self.start); }
|
|
149
|
+
finally { db.close(); }
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function writeOwner(runtimeDir: string, owner: OwnerRecord): void {
|
|
154
|
+
mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
|
|
155
|
+
const temporary = `${ownerPath(runtimeDir)}.${randomUUID()}.tmp`;
|
|
156
|
+
writeFileSync(temporary, `${JSON.stringify(owner)}\n`, { mode: 0o600, flag: "wx" });
|
|
157
|
+
renameSync(temporary, ownerPath(runtimeDir));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface GenerationOwner {
|
|
161
|
+
slot: number;
|
|
162
|
+
token: string;
|
|
163
|
+
pid: number;
|
|
164
|
+
processStart: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
class GenerationSlotBusy extends Error {}
|
|
168
|
+
|
|
169
|
+
function generationDatabase(runtimeDir: string): Database {
|
|
170
|
+
mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
|
|
171
|
+
const db = new Database(generationDbPath(runtimeDir), { create: true });
|
|
172
|
+
try {
|
|
173
|
+
db.run("PRAGMA busy_timeout = 100");
|
|
174
|
+
db.run(`CREATE TABLE IF NOT EXISTS generation_slots (
|
|
175
|
+
slot INTEGER PRIMARY KEY,
|
|
176
|
+
token TEXT NOT NULL,
|
|
177
|
+
pid INTEGER NOT NULL,
|
|
178
|
+
process_start TEXT NOT NULL
|
|
179
|
+
)`);
|
|
180
|
+
return db;
|
|
181
|
+
} catch (error) { db.close(); throw new Error(`MLX generation lock database is unreadable: ${(error as Error).message}`); }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseGenerationOwner(slot: number, row: Record<string, unknown>): GenerationOwner {
|
|
185
|
+
if (row.slot !== slot || typeof row.token !== "string" || !row.token || !Number.isSafeInteger(row.pid) || (row.pid as number) < 1 ||
|
|
186
|
+
typeof row.process_start !== "string" || !row.process_start) {
|
|
187
|
+
throw new Error("MLX generation owner is malformed; inspect it before retrying");
|
|
188
|
+
}
|
|
189
|
+
return { slot, token: row.token, pid: row.pid as number, processStart: row.process_start };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function claimGenerationSlot(db: Database, slot: number, readInfo: (pid: number) => ProcessSignature | undefined): (() => void) | undefined {
|
|
193
|
+
const token = randomUUID();
|
|
194
|
+
const self = readInfo(process.pid);
|
|
195
|
+
if (!self) throw new Error("MLX generation owner identity is unavailable");
|
|
196
|
+
try {
|
|
197
|
+
db.transaction(() => {
|
|
198
|
+
const row = db.query("SELECT slot, token, pid, process_start FROM generation_slots WHERE slot = ?").get(slot) as Record<string, unknown> | null;
|
|
199
|
+
if (row) {
|
|
200
|
+
const owner = parseGenerationOwner(slot, row);
|
|
201
|
+
const current = readInfo(owner.pid);
|
|
202
|
+
if (current && current.start === owner.processStart) throw new GenerationSlotBusy("generation slot is active");
|
|
203
|
+
if (!current && processAlive(owner.pid) !== false) throw new Error("MLX generation owner cannot be authenticated");
|
|
204
|
+
// A different start signature proves PID reuse; a missing process with
|
|
205
|
+
// ESRCH proves the old owner exited. The transaction makes reclamation
|
|
206
|
+
// and replacement one atomic state transition.
|
|
207
|
+
db.query("DELETE FROM generation_slots WHERE slot = ? AND token = ? AND pid = ? AND process_start = ?")
|
|
208
|
+
.run(owner.slot, owner.token, owner.pid, owner.processStart);
|
|
209
|
+
}
|
|
210
|
+
db.query("INSERT INTO generation_slots (slot, token, pid, process_start) VALUES (?, ?, ?, ?)")
|
|
211
|
+
.run(slot, token, process.pid, self.start);
|
|
212
|
+
}).immediate();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error instanceof GenerationSlotBusy) return undefined;
|
|
215
|
+
const message = String((error as Error).message).toLowerCase();
|
|
216
|
+
if (message.includes("database is locked") || message.includes("database is busy")) return undefined;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
let released = false;
|
|
220
|
+
return () => {
|
|
221
|
+
if (released) return;
|
|
222
|
+
released = true;
|
|
223
|
+
try {
|
|
224
|
+
db.query("DELETE FROM generation_slots WHERE slot = ? AND token = ? AND pid = ? AND process_start = ?")
|
|
225
|
+
.run(slot, token, process.pid, self.start);
|
|
226
|
+
} finally { db.close(); }
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function acquireGeneration(runtimeDir: string, maxConcurrency: number, signal?: AbortSignal, readInfo: (pid: number) => ProcessSignature | undefined = processInfo): Promise<() => void> {
|
|
231
|
+
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) throw new Error("MLX maxConcurrency must be a positive integer");
|
|
232
|
+
if (signal?.aborted) throw new Error("MLX generation acquisition cancelled");
|
|
233
|
+
const deadline = Date.now() + 120_000;
|
|
234
|
+
const db = generationDatabase(runtimeDir);
|
|
235
|
+
try {
|
|
236
|
+
for (;;) {
|
|
237
|
+
for (let slot = 0; slot < maxConcurrency; slot++) {
|
|
238
|
+
const release = claimGenerationSlot(db, slot, readInfo);
|
|
239
|
+
if (release) return release;
|
|
240
|
+
}
|
|
241
|
+
if (signal?.aborted) throw new Error("MLX generation acquisition cancelled");
|
|
242
|
+
if (Date.now() >= deadline) throw new Error("MLX generation is busy");
|
|
243
|
+
await new Promise<void>((resolve, reject) => {
|
|
244
|
+
let settled = false;
|
|
245
|
+
const finish = (error?: Error) => {
|
|
246
|
+
if (settled) return;
|
|
247
|
+
settled = true;
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
signal?.removeEventListener("abort", abort);
|
|
250
|
+
error ? reject(error) : resolve();
|
|
251
|
+
};
|
|
252
|
+
const timer = setTimeout(() => finish(), 50);
|
|
253
|
+
const abort = () => finish(new Error("MLX generation acquisition cancelled"));
|
|
254
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
} catch (error) {
|
|
258
|
+
db.close();
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function waitForExit(owner: OwnerRecord, readInfo: (pid: number) => ProcessSignature | undefined): Promise<void> {
|
|
264
|
+
for (let i = 0; i < 60; i++) {
|
|
265
|
+
if (!readInfo(owner.pid) && processAlive(owner.pid) === false) return;
|
|
266
|
+
await Bun.sleep(50);
|
|
267
|
+
}
|
|
268
|
+
throw new Error("MLX process did not terminate after SIGTERM");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function freePort(host: string, requested?: number): Promise<number> {
|
|
272
|
+
const server = createServer();
|
|
273
|
+
await new Promise<void>((resolve, reject) => {
|
|
274
|
+
server.once("error", reject);
|
|
275
|
+
server.listen(requested ?? 0, host, () => resolve());
|
|
276
|
+
});
|
|
277
|
+
const address = server.address();
|
|
278
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
279
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
280
|
+
if (!port) throw new Error("MLX did not provide a usable loopback port");
|
|
281
|
+
return port;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function defaultHealth(url: string, signal: AbortSignal): Promise<boolean> {
|
|
285
|
+
try {
|
|
286
|
+
const response = await fetch(`${url}/models`, { signal });
|
|
287
|
+
return response.ok;
|
|
288
|
+
} catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function argsFor(options: Required<Pick<MlxOptions, "modelPath" | "host" | "port">> & { maxConcurrency: number; maxTokens?: number }): string[] {
|
|
294
|
+
return [
|
|
295
|
+
"--model", options.modelPath,
|
|
296
|
+
"--host", options.host,
|
|
297
|
+
"--port", String(options.port),
|
|
298
|
+
"--allowed-origins", `http://${options.host}:${options.port}`,
|
|
299
|
+
"--decode-concurrency", String(options.maxConcurrency),
|
|
300
|
+
"--prompt-concurrency", String(options.maxConcurrency),
|
|
301
|
+
"--chat-template-args", JSON.stringify({ enable_thinking: false }),
|
|
302
|
+
...(options.maxTokens ? ["--max-tokens", String(options.maxTokens)] : []),
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function inspectMlx(options: MlxOptions = {}): Promise<MlxStatus> {
|
|
307
|
+
const runtimeDir = options.runtimeDir ?? DEFAULT_RUNTIME_DIR;
|
|
308
|
+
const modelPath = options.modelPath ?? DEFAULT_MODEL_PATH;
|
|
309
|
+
const host = options.host ?? "127.0.0.1";
|
|
310
|
+
const maxInputTokens = options.maxInputTokens ?? 16_000;
|
|
311
|
+
const maxConcurrency = options.maxConcurrency ?? 1;
|
|
312
|
+
assertLoopback(host);
|
|
313
|
+
const owner = readOwner(runtimeDir);
|
|
314
|
+
if (!owner) return { state: "stopped", model: modelPath, maxInputTokens, maxConcurrency, active: 0 };
|
|
315
|
+
const current = (options.processInfo ?? processInfo)(owner.pid);
|
|
316
|
+
if (!ownedProcess(owner, options.processInfo ?? processInfo)) {
|
|
317
|
+
if (current || processAlive(owner.pid) !== false) return { state: "foreign", url: `http://${host}:${owner.port}/v1`, model: owner.modelPath, pid: owner.pid, maxInputTokens, maxConcurrency, active: 0, lastError: "runtime record does not match the live process" };
|
|
318
|
+
return { state: "stopped", model: owner.modelPath, maxInputTokens, maxConcurrency, active: 0 };
|
|
319
|
+
}
|
|
320
|
+
const url = `http://${owner.host}:${owner.port}/v1`;
|
|
321
|
+
const healthy = await (options.health ?? defaultHealth)(url, AbortSignal.timeout(1_000));
|
|
322
|
+
return { state: healthy ? "ready" : "starting", url, model: owner.modelPath, pid: owner.pid, maxInputTokens, maxConcurrency, active: 0 };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function ensureMlx(options: MlxOptions = {}): Promise<MlxHandle> {
|
|
326
|
+
const runtimeDir = options.runtimeDir ?? DEFAULT_RUNTIME_DIR;
|
|
327
|
+
const modelPath = options.modelPath ?? DEFAULT_MODEL_PATH;
|
|
328
|
+
const bin = options.bin ?? join(runtimeDir, "bin", "mlx_lm.server");
|
|
329
|
+
const host = options.host ?? "127.0.0.1";
|
|
330
|
+
const maxInputTokens = options.maxInputTokens ?? 16_000;
|
|
331
|
+
const maxConcurrency = options.maxConcurrency ?? 1;
|
|
332
|
+
const readInfo = options.processInfo ?? processInfo;
|
|
333
|
+
assertLoopback(host);
|
|
334
|
+
const health = options.health ?? defaultHealth;
|
|
335
|
+
const existing = await inspectMlx(options);
|
|
336
|
+
const existingOwner = readOwner(runtimeDir);
|
|
337
|
+
if (existingOwner && (existingOwner.modelPath !== modelPath || existingOwner.host !== host || (options.port !== undefined && existingOwner.port !== options.port) || existingOwner.bin !== bin)) {
|
|
338
|
+
throw new Error("MLX runtime is already owned with different host, port, binary or model settings");
|
|
339
|
+
}
|
|
340
|
+
const sharedHandle = (status: MlxStatus): MlxHandle => {
|
|
341
|
+
let active = 0;
|
|
342
|
+
return { url: status.url!, model: modelPath, status: () => ({ ...status, active }), acquire: async (signal) => {
|
|
343
|
+
const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo);
|
|
344
|
+
active++;
|
|
345
|
+
return () => { active = Math.max(0, active - 1); releaseSlot(); };
|
|
346
|
+
}, close: async () => {} };
|
|
347
|
+
};
|
|
348
|
+
if (existing.state === "ready" && existing.url && existing.model === modelPath) return sharedHandle(existing);
|
|
349
|
+
if (existing.state === "starting" && existing.url && existing.model === modelPath) {
|
|
350
|
+
for (let i = 0; i < 100; i++) {
|
|
351
|
+
if (await health(existing.url, AbortSignal.timeout(500))) return sharedHandle({ ...existing, state: "ready" });
|
|
352
|
+
await Bun.sleep(100);
|
|
353
|
+
}
|
|
354
|
+
throw new Error("existing MLX runtime did not become healthy within 10 seconds");
|
|
355
|
+
}
|
|
356
|
+
if (existing.state === "foreign") throw new Error("MLX endpoint is owned by an unknown process; refusing to kill or reuse it");
|
|
357
|
+
mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
|
|
358
|
+
const releaseStartLock = acquireStartLock(runtimeDir, readInfo);
|
|
359
|
+
let child: ChildProcess | undefined;
|
|
360
|
+
let startupError: Error | undefined;
|
|
361
|
+
try {
|
|
362
|
+
const port = await freePort(host, options.port);
|
|
363
|
+
const token = randomUUID();
|
|
364
|
+
const ownerBase: Omit<OwnerRecord, "pid" | "processStart"> = { schema: 1, token, bin, modelPath, host, port, startedAt: Date.now() };
|
|
365
|
+
const spawnProcess = options.spawn ?? spawn;
|
|
366
|
+
const logFd = openSync(join(runtimeDir, "mlx.log"), "a");
|
|
367
|
+
try {
|
|
368
|
+
child = spawnProcess(bin, argsFor({ modelPath, host, port, maxConcurrency, maxTokens: options.maxTokens }), {
|
|
369
|
+
cwd: runtimeDir,
|
|
370
|
+
env: { PATH: process.env.PATH ?? "", HOME: homedir() },
|
|
371
|
+
detached: true,
|
|
372
|
+
stdio: ["ignore", logFd, logFd],
|
|
373
|
+
});
|
|
374
|
+
child.once?.("error", (error) => { startupError = error; });
|
|
375
|
+
} finally { closeSync(logFd); }
|
|
376
|
+
if (!child.pid) throw new Error("MLX process did not provide a PID");
|
|
377
|
+
const signature = readInfo(child.pid);
|
|
378
|
+
if (!signature) throw new Error("could not verify MLX process ownership");
|
|
379
|
+
const owner: OwnerRecord = { ...ownerBase, pid: child.pid, processStart: signature.start };
|
|
380
|
+
writeOwner(runtimeDir, owner);
|
|
381
|
+
const url = `http://${host}:${port}/v1`;
|
|
382
|
+
for (let i = 0; i < 100; i++) {
|
|
383
|
+
if (startupError) throw startupError;
|
|
384
|
+
if (child.exitCode != null || child.signalCode != null) throw new Error("MLX exited before becoming healthy; inspect mlx.log");
|
|
385
|
+
if (await health(url, AbortSignal.timeout(500))) {
|
|
386
|
+
child.unref?.();
|
|
387
|
+
let active = 0;
|
|
388
|
+
// The server is machine-shared. Relay shutdown releases this process's handle;
|
|
389
|
+
// only the explicit stopMlx operation may stop the owned runtime.
|
|
390
|
+
const close = async () => {};
|
|
391
|
+
return { url, model: modelPath, status: () => ({ state: "ready", url, model: modelPath, pid: child?.pid, maxInputTokens, maxConcurrency, active }), acquire: async (signal) => {
|
|
392
|
+
const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo);
|
|
393
|
+
active++;
|
|
394
|
+
return () => { active = Math.max(0, active - 1); releaseSlot(); };
|
|
395
|
+
}, close };
|
|
396
|
+
}
|
|
397
|
+
await Bun.sleep(100);
|
|
398
|
+
}
|
|
399
|
+
throw new Error("MLX did not become healthy within 10 seconds");
|
|
400
|
+
} catch (error) {
|
|
401
|
+
try { child?.kill("SIGTERM"); } catch { /* best effort for our child only */ }
|
|
402
|
+
rmSync(ownerPath(runtimeDir), { force: true });
|
|
403
|
+
throw error;
|
|
404
|
+
} finally { releaseStartLock(); }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export async function stopMlx(options: MlxOptions = {}): Promise<void> {
|
|
408
|
+
const runtimeDir = options.runtimeDir ?? DEFAULT_RUNTIME_DIR;
|
|
409
|
+
const owner = readOwner(runtimeDir);
|
|
410
|
+
if (!owner) return;
|
|
411
|
+
if (!ownedProcess(owner, options.processInfo ?? processInfo)) {
|
|
412
|
+
if ((options.processInfo ?? processInfo)(owner.pid) || processAlive(owner.pid) !== false) throw new Error("MLX owner record does not match the live process; refusing to kill it");
|
|
413
|
+
rmSync(ownerPath(runtimeDir), { force: true });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
process.kill(owner.pid, "SIGTERM");
|
|
417
|
+
await waitForExit(owner, options.processInfo ?? processInfo);
|
|
418
|
+
rmSync(ownerPath(runtimeDir), { force: true });
|
|
419
|
+
}
|