@staix/agent-hub 0.4.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 +21 -0
- package/README.md +53 -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/acp.ts +10 -2
- package/src/adapters/codex-appserver.ts +11 -1
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +332 -0
- package/src/cli/main.ts +154 -12
- package/src/cli/recovery-package.ts +99 -0
- package/src/cli/statusline-tee.ts +11 -2
- package/src/cli/terminal-recovery.ts +588 -0
- package/src/cli/upgrade-runtime.ts +329 -0
- package/src/cli/upgrade.ts +298 -0
- package/src/hub/budget.ts +32 -1
- package/src/hub/bus.ts +79 -4
- package/src/hub/child-process.ts +11 -0
- package/src/hub/control-client.ts +8 -6
- package/src/hub/daemon.ts +355 -11
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +9 -1
- package/src/hub/manager.ts +15 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/recovery-store.ts +89 -0
- package/src/hub/restart.ts +97 -0
- 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/lifecycle.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
|
|
|
7
7
|
import { startDaemon } from "./daemon.ts";
|
|
8
8
|
import { Registry, type Project } from "./registry.ts";
|
|
9
9
|
import { CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "./ports.ts";
|
|
10
|
+
import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
|
|
10
11
|
|
|
11
12
|
export type ProjectInspection = {
|
|
12
13
|
state: "running" | "stopped" | "stopping" | "unavailable" | "incompatible" | "missing" | "starting";
|
|
@@ -85,6 +86,7 @@ async function available(base: number): Promise<boolean> {
|
|
|
85
86
|
|
|
86
87
|
/** Called only in the detached daemon process. A competing starter exits without touching state. */
|
|
87
88
|
export async function runProjectDaemon(project: Project, unattended = false): Promise<void> {
|
|
89
|
+
assertLifecycleAvailable();
|
|
88
90
|
const registry = new Registry();
|
|
89
91
|
const instanceId = randomUUID();
|
|
90
92
|
let claimed = false;
|
|
@@ -122,6 +124,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv } = {}): Promise<any> {
|
|
127
|
+
assertLifecycleAvailable();
|
|
125
128
|
const registry = new Registry();
|
|
126
129
|
try {
|
|
127
130
|
const current = registry.get(project.id);
|
|
@@ -138,10 +141,14 @@ export async function startProject(project: Project, options: { unattended?: boo
|
|
|
138
141
|
renameSync(temporary, marker);
|
|
139
142
|
const log = openSync(join(project.stateDir, "hub.log"), "a");
|
|
140
143
|
try {
|
|
144
|
+
const launchEnv = { ...(options.env ?? process.env) };
|
|
145
|
+
// Native sessions can outlive the operation which launched them. An expired
|
|
146
|
+
// operation ID must not make a later ordinary startup load a retired snapshot.
|
|
147
|
+
if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock()) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
141
148
|
const child = spawn(process.execPath, [join(import.meta.dir, "../cli/main.ts"), "--project", project.root, "daemon",
|
|
142
149
|
...(options.unattended ? ["--unattended"] : [])], {
|
|
143
150
|
cwd: project.root, detached: true, stdio: ["ignore", log, log],
|
|
144
|
-
env: { ...
|
|
151
|
+
env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
|
|
145
152
|
AGENTHUB_UNATTENDED: options.unattended ? "1" : "0" },
|
|
146
153
|
});
|
|
147
154
|
child.on("error", () => {}); // bounded readiness below reports failure with the project's log path
|
|
@@ -160,6 +167,7 @@ export async function startProject(project: Project, options: { unattended?: boo
|
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
export async function stopProject(project: Project, expectedInstance?: string): Promise<void> {
|
|
170
|
+
assertLifecycleAvailable();
|
|
163
171
|
const inspection = await inspectProject(project);
|
|
164
172
|
if (inspection.state === "stopped") return;
|
|
165
173
|
if (inspection.state !== "running" && inspection.state !== "stopping") throw new Error(inspection.error ?? "hub ownership is not verified; not stopping it");
|
package/src/hub/manager.ts
CHANGED
|
@@ -21,6 +21,7 @@ type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
|
|
|
21
21
|
type Manifest = { port: number; protocol: number; instanceId: string; pid: number };
|
|
22
22
|
const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
|
|
23
23
|
const CONTROL_TIMEOUT = 3000;
|
|
24
|
+
const SUPPORTED_MANAGER_PROTOCOLS = new Set([8, PROTOCOL]);
|
|
24
25
|
const safeError = (error: unknown) => error instanceof Error ? error.message.slice(0, 300) : "operation failed";
|
|
25
26
|
|
|
26
27
|
function files(home: string) {
|
|
@@ -175,7 +176,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
175
176
|
async function managerRequest(home: string, path: "/open" | "/stop"): Promise<{ status: Manifest; body: any }> {
|
|
176
177
|
const status = readManifest(home);
|
|
177
178
|
if (!status) throw new Error("manager manifest is unavailable");
|
|
178
|
-
if (status.protocol
|
|
179
|
+
if (!SUPPORTED_MANAGER_PROTOCOLS.has(status.protocol)) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
|
|
179
180
|
const token = readFileSync(files(home).token, "utf8").trim();
|
|
180
181
|
if (!token) throw new Error("manager token is unavailable");
|
|
181
182
|
const response = await fetch(`http://127.0.0.1:${status.port}${path}`, {
|
|
@@ -215,6 +216,19 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
|
|
|
215
216
|
throw new Error(`manager readiness not confirmed: ${safeError(error)}; see ${join(f.dir, "manager.log")}`);
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
/** Refresh an explicitly supported protocol-8 manager onto the current CLI after upgrade. */
|
|
220
|
+
export async function refreshManager(options: ManagerOptions = {}): Promise<string> {
|
|
221
|
+
const home = options.home ?? hubHome();
|
|
222
|
+
const manifest = readManifest(home);
|
|
223
|
+
if (manifest && manifest.protocol === 8) {
|
|
224
|
+
await managerRequest(home, "/stop");
|
|
225
|
+
const deadline = Date.now() + CONTROL_TIMEOUT;
|
|
226
|
+
while (Date.now() < deadline && readManifest(home)) await Bun.sleep(25);
|
|
227
|
+
if (readManifest(home)) throw new Error("legacy manager shutdown is still pending; refresh was not confirmed");
|
|
228
|
+
}
|
|
229
|
+
return openManager(options);
|
|
230
|
+
}
|
|
231
|
+
|
|
218
232
|
export async function stopManager(options: ManagerOptions = {}): Promise<void> {
|
|
219
233
|
const home = options.home ?? hubHome();
|
|
220
234
|
const local = active.get(home);
|
package/src/hub/peers.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface PeerAdapter {
|
|
|
15
15
|
onState?: (state: PeerState) => void;
|
|
16
16
|
/** Set by the bus. A delivery that had resolved turned out not to reach the agent: put it back. */
|
|
17
17
|
onFailed?: (envs: Envelope[]) => void;
|
|
18
|
+
/** Safe restart metadata only: ids and launch parameters, never prompt/message text. */
|
|
19
|
+
recoveryMetadata?(): Record<string, unknown>;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export const DEFAULT_WATCHDOG_MS = 300_000;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import { hubHome } from "./project.ts";
|
|
6
|
+
|
|
7
|
+
export const operationIdPattern = /^[a-f0-9-]{36}$/;
|
|
8
|
+
export function operationPath(id: string, home = hubHome()): string {
|
|
9
|
+
if (!operationIdPattern.test(id)) throw new Error("invalid recovery operation ID");
|
|
10
|
+
return join(home, "recovery", `${id}.json`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Private state only. Never put tokens or transcript/message bodies in an operation receipt. */
|
|
14
|
+
export function atomicPrivateJSON(file: string, value: unknown): void {
|
|
15
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
16
|
+
try {
|
|
17
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
18
|
+
chmodSync(temporary, 0o600);
|
|
19
|
+
renameSync(temporary, file);
|
|
20
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readOperation<T>(id: string, home = hubHome()): T {
|
|
24
|
+
return JSON.parse(readFileSync(operationPath(id, home), "utf8")) as T;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function writeOperation(id: string, value: unknown, home = hubHome()): void {
|
|
28
|
+
mkdirSync(join(home, "recovery"), { recursive: true, mode: 0o700 });
|
|
29
|
+
atomicPrivateJSON(operationPath(id, home), value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function recoveryLock(home = hubHome()): string | undefined {
|
|
33
|
+
const file = join(home, "recovery.lock");
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
36
|
+
if (!operationIdPattern.test(data.operationId)) throw new Error("invalid recovery lock");
|
|
37
|
+
return data.operationId;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
40
|
+
throw new Error("recovery lock is unreadable; inspect it before changing runtimes", { cause: error });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function acquireRecoveryLock(id: string, home = hubHome()): void {
|
|
45
|
+
operationPath(id, home);
|
|
46
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
47
|
+
const existing = recoveryLock(home);
|
|
48
|
+
if (existing === id) return;
|
|
49
|
+
try { writeFileSync(join(home, "recovery.lock"), JSON.stringify({ operationId: id }), { mode: 0o600, flag: "wx" }); }
|
|
50
|
+
catch { throw new Error(`another recovery operation owns this machine: ${recoveryLock(home) ?? "unknown"}`); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function releaseRecoveryLock(id: string, home = hubHome()): void {
|
|
54
|
+
if (recoveryLock(home) !== id) throw new Error("recovery lock ownership changed");
|
|
55
|
+
rmSync(join(home, "recovery.lock"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Used by CLI and manager lifecycle paths. A detached operation carries its own ID. */
|
|
59
|
+
export function assertLifecycleAvailable(): void {
|
|
60
|
+
const owner = recoveryLock();
|
|
61
|
+
if (owner && process.env.AGENTHUB_RECOVERY_OPERATION !== owner) {
|
|
62
|
+
throw new Error(`recovery operation ${owner} is active; use ahub recovery status|resume ${owner}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** An exclusive runner claim. Never steal a live or uncertain owner on resume. */
|
|
67
|
+
export function claimRunner(id: string, home = hubHome()): () => void {
|
|
68
|
+
const path = `${operationPath(id, home)}.runner.db`;
|
|
69
|
+
const db = new Database(path, { create: true });
|
|
70
|
+
chmodSync(path, 0o600);
|
|
71
|
+
const nonce = randomUUID();
|
|
72
|
+
try {
|
|
73
|
+
db.run("PRAGMA busy_timeout = 3000");
|
|
74
|
+
db.run("CREATE TABLE IF NOT EXISTS runner (slot INTEGER PRIMARY KEY, pid INTEGER NOT NULL, nonce TEXT NOT NULL)");
|
|
75
|
+
db.transaction(() => {
|
|
76
|
+
const row = db.query("SELECT pid FROM runner WHERE slot = 1").get() as { pid: number } | null;
|
|
77
|
+
if (row) {
|
|
78
|
+
if (!Number.isSafeInteger(row.pid) || row.pid < 1) throw new Error("invalid recovery runner ownership");
|
|
79
|
+
try { process.kill(row.pid, 0); throw new Error(`recovery runner ${row.pid} is still alive`); }
|
|
80
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
|
|
81
|
+
}
|
|
82
|
+
db.query("INSERT OR REPLACE INTO runner (slot, pid, nonce) VALUES (1, ?, ?)").run(process.pid, nonce);
|
|
83
|
+
}).immediate();
|
|
84
|
+
} catch (error) { db.close(); throw error; }
|
|
85
|
+
return () => {
|
|
86
|
+
db.query("DELETE FROM runner WHERE slot = 1 AND nonce = ?").run(nonce);
|
|
87
|
+
db.close();
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { BusSnapshot } from "./bus.ts";
|
|
5
|
+
import type { PeerId, PeerState } from "./envelope.ts";
|
|
6
|
+
import { MAX_HOP } from "./envelope.ts";
|
|
7
|
+
|
|
8
|
+
export const RESTART_SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
export type RecoveryPhase = "preparing" | "prepared" | "restored" | "released";
|
|
11
|
+
|
|
12
|
+
export interface RestartPeerSnapshot {
|
|
13
|
+
id: PeerId;
|
|
14
|
+
state: PeerState;
|
|
15
|
+
queueIds: string[];
|
|
16
|
+
launch?: Record<string, unknown>;
|
|
17
|
+
/** Safe native correlation metadata. Never include prompt/message text here. */
|
|
18
|
+
threadId?: string;
|
|
19
|
+
sessionId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RestartSnapshot {
|
|
23
|
+
schemaVersion: 1;
|
|
24
|
+
projectRoot: string;
|
|
25
|
+
projectId: string;
|
|
26
|
+
sourceInstanceId: string;
|
|
27
|
+
operationId: string;
|
|
28
|
+
committedAt: number;
|
|
29
|
+
bus: BusSnapshot;
|
|
30
|
+
manualPaused: PeerId[];
|
|
31
|
+
peers: RestartPeerSnapshot[];
|
|
32
|
+
integrity?: {
|
|
33
|
+
queues: Record<string, string[]>;
|
|
34
|
+
manualPaused: PeerId[];
|
|
35
|
+
boardDigest: string;
|
|
36
|
+
budgetDigest: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RestartReadExpectation {
|
|
41
|
+
projectRoot: string;
|
|
42
|
+
projectId: string;
|
|
43
|
+
operationId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function restartPath(stateDir: string): string {
|
|
47
|
+
return join(stateDir, "restart.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function releasedRestartPath(stateDir: string, operationId: string): string {
|
|
51
|
+
const safe = createHash("sha256").update(operationId).digest("hex");
|
|
52
|
+
return join(stateDir, `restart.released.${safe}.json`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read only a matching controlled-restart snapshot. Unknown or malformed state is ignored and must block recovery upstream. */
|
|
56
|
+
export function readRestartSnapshot(stateDir: string, expected: RestartReadExpectation): RestartSnapshot | undefined {
|
|
57
|
+
let parsed: unknown;
|
|
58
|
+
try { parsed = JSON.parse(readFileSync(restartPath(stateDir), "utf8")); } catch { return undefined; }
|
|
59
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
60
|
+
const s = parsed as Partial<RestartSnapshot>;
|
|
61
|
+
if (s.schemaVersion !== RESTART_SCHEMA_VERSION || s.projectRoot !== expected.projectRoot || s.projectId !== expected.projectId || typeof s.operationId !== "string" || !s.operationId || typeof s.sourceInstanceId !== "string" || !s.sourceInstanceId || !s.bus) return undefined;
|
|
62
|
+
if (expected.operationId !== undefined && s.operationId !== expected.operationId) return undefined;
|
|
63
|
+
const peerId = (value: unknown): value is string => typeof value === "string" && /^(?:hub|user|[a-z][a-z0-9-]{0,31})$/.test(value);
|
|
64
|
+
const isEnvelope = (value: unknown, seen = false): boolean => {
|
|
65
|
+
if (!value || typeof value !== "object") return false;
|
|
66
|
+
const e = value as Record<string, unknown>;
|
|
67
|
+
return typeof e.id === "string" && e.id.length > 0 && e.id.length <= 256 && typeof e.trace === "string" && e.trace.length > 0 && e.trace.length <= 256 && peerId(e.from) && typeof e.body === "string" && Number.isSafeInteger(e.hop) && (e.hop as number) >= 0 && (seen || (e.hop as number) <= MAX_HOP) && Number.isFinite(e.ts) && ["chat", "task", "review", "status", "budget", "presence"].includes(e.kind as string) && ["important", "status", "fyi"].includes(e.priority as string) && (e.to === undefined || (Array.isArray(e.to) && e.to.every(peerId)));
|
|
68
|
+
};
|
|
69
|
+
const bus = s.bus as Partial<BusSnapshot>;
|
|
70
|
+
if (bus.schemaVersion !== 1 || !bus.queues || typeof bus.queues !== "object" || !bus.prefaces || typeof bus.prefaces !== "object" || !Array.isArray(bus.seen) || !bus.attempts || typeof bus.attempts !== "object" || !Array.isArray(bus.withdrawn)) return undefined;
|
|
71
|
+
if (!Object.keys(bus.queues).every(peerId) || !Object.keys(bus.prefaces).every(peerId) || !Object.values(bus.queues).every((queue) => Array.isArray(queue) && queue.every((e) => isEnvelope(e))) || !Object.values(bus.prefaces).every((e) => isEnvelope(e)) || !bus.seen.every((e) => isEnvelope(e, true)) || !bus.withdrawn.every((id) => typeof id === "string" && id.length > 0) || !Object.values(bus.attempts).every((n) => Number.isSafeInteger(n) && n > 0)) return undefined;
|
|
72
|
+
if (!Array.isArray(s.manualPaused) || !s.manualPaused.every((id) => typeof id === "string") || !Array.isArray(s.peers) || !s.peers.every((peer) => peer && typeof peer === "object" && typeof peer.id === "string" && ["idle", "busy", "paused", "offline"].includes(peer.state as string) && Array.isArray(peer.queueIds) && peer.queueIds.every((id) => typeof id === "string"))) return undefined;
|
|
73
|
+
if (s.integrity !== undefined && (!s.integrity || typeof s.integrity !== "object" || typeof s.integrity.boardDigest !== "string" || typeof s.integrity.budgetDigest !== "string" || !Array.isArray(s.integrity.manualPaused) || !s.integrity.manualPaused.every((id) => typeof id === "string") || !s.integrity.queues || typeof s.integrity.queues !== "object" || !Object.values(s.integrity.queues).every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === "string")))) return undefined;
|
|
74
|
+
return s as RestartSnapshot;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Atomic, owner-readable recovery state. The file never carries delivery bodies outside this 0600 path. */
|
|
78
|
+
export function writeRestartSnapshot(stateDir: string, snapshot: RestartSnapshot): void {
|
|
79
|
+
mkdirSync(stateDir, { recursive: true });
|
|
80
|
+
const file = restartPath(stateDir);
|
|
81
|
+
const tmp = `${file}.${snapshot.sourceInstanceId}.tmp`;
|
|
82
|
+
writeFileSync(tmp, `${JSON.stringify(snapshot)}\n`, { mode: 0o600 });
|
|
83
|
+
chmodSync(tmp, 0o600);
|
|
84
|
+
renameSync(tmp, file);
|
|
85
|
+
chmodSync(file, 0o600);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function removeRestartSnapshot(stateDir: string): void {
|
|
89
|
+
try { unlinkSync(restartPath(stateDir)); }
|
|
90
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Preserve an uncertain release outcome for manual reconciliation without making it authoritative on startup. */
|
|
94
|
+
export function archiveRestartSnapshot(stateDir: string, operationId: string): void {
|
|
95
|
+
renameSync(restartPath(stateDir), releasedRestartPath(stateDir, operationId));
|
|
96
|
+
chmodSync(releasedRestartPath(stateDir, operationId), 0o600);
|
|
97
|
+
}
|
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" });
|