@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.
@@ -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: { ...(options.env ?? process.env), AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
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");
@@ -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 !== PROTOCOL) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
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
+ }
@@ -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: raw.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
- return ok.find((p) => states[p] === "idle") ?? ok[0]; // idle before busy, otherwise preference order
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
- return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, trace };
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 candidates = [LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL)];
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" });