@staix/agent-hub 0.12.30 → 0.12.31
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 +21 -7
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +4 -0
- package/docs/agent-notes/daemon.md +5 -2
- package/docs/agent-notes/local-worker.md +2 -0
- package/docs/agent-notes/tasks.md +5 -0
- package/docs/agent-notes/tests.md +5 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +129 -22
- package/docs/quickstart.md +17 -2
- package/docs/security.md +30 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +37 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +66 -19
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +5 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +56 -12
- package/src/cli/console.ts +19 -6
- package/src/cli/identity.ts +3 -2
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.ts +8 -1
- package/src/cli/output.ts +13 -2
- package/src/cli/upgrade-interactive.ts +2 -1
- package/src/cli/upgrade-restore-state.ts +133 -0
- package/src/cli/upgrade-runtime.ts +162 -58
- package/src/cli/upgrade.ts +49 -24
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +78 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/control-client.ts +5 -1
- package/src/hub/daemon.ts +400 -67
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/peer-catalog.ts +3 -0
- package/src/hub/routing.ts +35 -16
- package/src/hub/settings.ts +2 -1
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +21 -6
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +173 -34
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +12 -9
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
|
|
2
|
+
import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
|
|
3
|
+
import { isDenied } from "../local/deny.ts";
|
|
4
|
+
import { supersededDeliveryReason, deliveryTaskContext } from "./delivery-superseded.ts";
|
|
5
|
+
import { renderHeldHint } from "./delivery-hints.ts";
|
|
1
6
|
import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
|
|
2
7
|
import { MemoryObservations } from "./memory-observations.ts";
|
|
3
8
|
import { createStalenessProbe } from "./memory-staleness.ts";
|
|
@@ -16,7 +21,7 @@ import { JcodePeer } from "../adapters/jcode.ts";
|
|
|
16
21
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
17
22
|
import { loadSdlc } from "./sdlc.ts";
|
|
18
23
|
import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
|
|
19
|
-
import { KNOWN_PEERS, OPTIONAL_PEERS, SUPPORTED_PEERS, isSupportedPeer } from "./peer-catalog.ts";
|
|
24
|
+
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer } from "./peer-catalog.ts";
|
|
20
25
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
21
26
|
import { buildKimiLaunch, buildQwenLaunch } from "../cli/launch.ts";
|
|
22
27
|
import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
|
|
@@ -51,16 +56,16 @@ import { Capture, skipTools } from "../memory/capture.ts";
|
|
|
51
56
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
52
57
|
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
53
58
|
import { Briefs } from "../memory/brief.ts";
|
|
54
|
-
import { Board, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
59
|
+
import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
55
60
|
import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
56
61
|
import { HUB } from "./envelope.ts";
|
|
57
62
|
import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
|
|
58
63
|
import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
|
|
59
64
|
import { basename, isAbsolute } from "node:path";
|
|
60
65
|
import { fileURLToPath } from "node:url";
|
|
61
|
-
import { realPath } from "./project.ts";
|
|
66
|
+
import { realPath, hubHome } from "./project.ts";
|
|
62
67
|
import type { BusEvent } from "./bus.ts";
|
|
63
|
-
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
68
|
+
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
64
69
|
import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
|
|
65
70
|
import { SupervisionFeed } from "./supervision.ts";
|
|
66
71
|
import { drainCliAudits } from "../cli/identity-audit.ts";
|
|
@@ -74,7 +79,7 @@ import { Bus } from "./bus.ts";
|
|
|
74
79
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
75
80
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
76
81
|
import { startDashboard, type DashboardSession } from "./ui.ts";
|
|
77
|
-
import { PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
82
|
+
import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
78
83
|
import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
79
84
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
80
85
|
import { MemoryClient, workerUrl } from "../memory/client.ts";
|
|
@@ -104,7 +109,7 @@ import { syncMemory } from "@staix/agent-mem";
|
|
|
104
109
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
|
|
105
110
|
import { memoryProjectKey } from "@staix/agent-mem";
|
|
106
111
|
import { nativeRecall } from "../memory/recall.ts";
|
|
107
|
-
import { promotionScreen, withoutPii } from "./memory-policy.ts";
|
|
112
|
+
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
108
113
|
|
|
109
114
|
export interface HubConfig {
|
|
110
115
|
watchdog_ms: number;
|
|
@@ -125,6 +130,8 @@ export interface HubConfig {
|
|
|
125
130
|
memory: { enabled: boolean; worker_url?: string; inject_tokens: number; brief_items: number; native?: boolean; short_ttl_days?: number; sync_interval_min?: number; suggest_edges?: boolean };
|
|
126
131
|
roster: PeerRoster;
|
|
127
132
|
roles: Record<string, string[]>;
|
|
133
|
+
/** #455: the one peer that holds the planner role; unset, the `roles` planners stand, or the hub assigns one. */
|
|
134
|
+
planner?: string;
|
|
128
135
|
profile?: ProfileName;
|
|
129
136
|
profile_overrides?: Record<string, unknown>;
|
|
130
137
|
pii?: { paths: string[] };
|
|
@@ -147,7 +154,7 @@ export interface HubConfig {
|
|
|
147
154
|
/** Between-turn task escalation, disabled unless explicitly enabled (#186). */
|
|
148
155
|
task_sweep: TaskSweepConfig;
|
|
149
156
|
/** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
|
|
150
|
-
checks: { timeout_s: number; [cls: string]: string | number };
|
|
157
|
+
checks: { timeout_s: number; runner?: "container"; [cls: string]: string | number | undefined };
|
|
151
158
|
/** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
|
|
152
159
|
snapshots: { enabled: boolean; keep: number };
|
|
153
160
|
/** #247: opt-in research records of each approved task in ~/.agenthub/research (ids, counts and tokens only). */
|
|
@@ -231,14 +238,19 @@ const CONFIG_FILES = ["config.json", "config.local.json"] as const;
|
|
|
231
238
|
const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", "local", "pi", "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal"];
|
|
232
239
|
|
|
233
240
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
234
|
-
export function loadConductorPolicy(cwd: string): { roles: Record<string, string[]>; conductor: HubConfig["conductor"] } {
|
|
241
|
+
export function loadConductorPolicy(cwd: string): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
|
|
235
242
|
const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
|
|
236
243
|
const conductor = { ...DEFAULT_CONFIG.conductor };
|
|
244
|
+
let planner: string | undefined;
|
|
237
245
|
for (const name of CONFIG_FILES) {
|
|
238
246
|
let file: Record<string, unknown>;
|
|
239
247
|
try { file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")); }
|
|
240
248
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
241
249
|
if (file.roles !== undefined) { conductorPeer(file.roles); Object.assign(roles, file.roles); }
|
|
250
|
+
if (file.planner !== undefined) {
|
|
251
|
+
if (!(PLANNER_PEERS as readonly unknown[]).includes(file.planner)) throw new Error(`planner must be one of ${PLANNER_PEERS.join(", ")}`);
|
|
252
|
+
planner = file.planner as string;
|
|
253
|
+
}
|
|
242
254
|
if (file.conductor !== undefined) {
|
|
243
255
|
if (!file.conductor || typeof file.conductor !== "object" || Array.isArray(file.conductor)) throw new Error("conductor must be an object");
|
|
244
256
|
const block = file.conductor as Record<string, unknown>;
|
|
@@ -253,7 +265,7 @@ export function loadConductorPolicy(cwd: string): { roles: Record<string, string
|
|
|
253
265
|
}
|
|
254
266
|
}
|
|
255
267
|
conductorPeer(roles);
|
|
256
|
-
return { roles, conductor };
|
|
268
|
+
return { roles, conductor, ...(planner ? { planner } : {}) };
|
|
257
269
|
}
|
|
258
270
|
|
|
259
271
|
/** `memory.short_ttl_days`: a whole number of days from 1 to 3650; anything else would make every short-term deadline invalid. */
|
|
@@ -345,6 +357,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
345
357
|
...(file.memory?.sync_interval_min !== undefined ? { sync_interval_min: syncMinutes(file.memory.sync_interval_min) } : {}) }, // the native store is on with any project config (#258)
|
|
346
358
|
roster: peerRosterConfig(file.roster),
|
|
347
359
|
roles: conductorPolicy.roles,
|
|
360
|
+
planner: conductorPolicy.planner,
|
|
348
361
|
conductor: conductorPolicy.conductor,
|
|
349
362
|
budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
|
|
350
363
|
context: { ...DEFAULT_CONTEXT, ...file.context },
|
|
@@ -358,7 +371,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
358
371
|
},
|
|
359
372
|
tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks, ...(file.tasks?.wip_limits !== undefined ? { wip_limits: planningWipLimits(file.tasks.wip_limits) } : {}) },
|
|
360
373
|
task_sweep: taskSweepConfig(file.task_sweep),
|
|
361
|
-
checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
|
|
374
|
+
checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "container" ? "container" : undefined },
|
|
362
375
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
363
376
|
research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
|
|
364
377
|
bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
|
|
@@ -423,6 +436,12 @@ export interface DaemonOptions {
|
|
|
423
436
|
researchGraceMs?: number;
|
|
424
437
|
/** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
|
|
425
438
|
memorySync?: typeof syncMemory;
|
|
439
|
+
/** Host-only test/embedding transports. Never read from config or tool input. */
|
|
440
|
+
boundedTools?: {
|
|
441
|
+
specAvailable?: typeof specAvailable;
|
|
442
|
+
fetchSpec?: typeof fetchSpec;
|
|
443
|
+
runner?: (options: RunnerOptions) => Pick<ContainerRunner, "available" | "start" | "result" | "consumeOutput" | "cancel" | "sweep" | "stopAll" | "markStale"> & Partial<Pick<ContainerRunner, "unknownReceipts" | "maintenanceUnavailable">>;
|
|
444
|
+
};
|
|
426
445
|
}
|
|
427
446
|
|
|
428
447
|
interface Client {
|
|
@@ -560,6 +579,39 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
560
579
|
const logFile = join(opts.stateDir, "hub.log");
|
|
561
580
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
562
581
|
const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
|
|
582
|
+
// #455: the planner in force. `policyRoles` is what the config files say; settlePlanner derives config.roles from it in
|
|
583
|
+
// place (Tasks holds the same object): an explicit `planner` key is exclusive, and with none and no rostered planner
|
|
584
|
+
// the first rostered PLANNER_PEERS entry plans. Never written to disk: a person pins it with the `planner` setting.
|
|
585
|
+
let policyRoles = structuredClone(config.roles);
|
|
586
|
+
let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
|
|
587
|
+
let plannerLogged = "";
|
|
588
|
+
const settlePlanner = () => {
|
|
589
|
+
const roles = structuredClone(policyRoles);
|
|
590
|
+
const pinned = (PLANNER_PEERS as readonly string[]).includes(config.planner ?? "") ? config.planner! : undefined;
|
|
591
|
+
let source: typeof plannerSource;
|
|
592
|
+
if (pinned) {
|
|
593
|
+
for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
|
|
594
|
+
roles[pinned] = ["planner", ...(roles[pinned] ?? [])];
|
|
595
|
+
source = "setting";
|
|
596
|
+
} else if (Object.entries(roles).some(([peer, list]) => list.includes("planner") && onRoster(peer))) source = "roles";
|
|
597
|
+
else {
|
|
598
|
+
const auto = PLANNER_PEERS.find(onRoster);
|
|
599
|
+
if (auto) { // the off-roster holders give the role up while the automatic planner holds it
|
|
600
|
+
for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
|
|
601
|
+
roles[auto] = [...(roles[auto] ?? []), "planner"];
|
|
602
|
+
}
|
|
603
|
+
source = auto ? "automatic" : "none";
|
|
604
|
+
}
|
|
605
|
+
conductorPeer(roles);
|
|
606
|
+
for (const key of Object.keys(config.roles)) delete config.roles[key];
|
|
607
|
+
Object.assign(config.roles, roles);
|
|
608
|
+
plannerSource = source;
|
|
609
|
+
const line = `${plannerPeers().join(", ") || "nobody"} (${source})`;
|
|
610
|
+
if (line !== plannerLogged) { log(`planner: ${line}${source === "automatic" ? "; pin it with ahub settings set planner <peer>" : ""}`); plannerLogged = line; }
|
|
611
|
+
};
|
|
612
|
+
const plannerPeers = () => Object.keys(config.roles).filter(peer => config.roles[peer]!.includes("planner"));
|
|
613
|
+
if (config.planner !== undefined && !(PLANNER_PEERS as readonly string[]).includes(config.planner)) log(`planner: ${JSON.stringify(config.planner)} is not one of ${PLANNER_PEERS.join(", ")}; ignored`);
|
|
614
|
+
settlePlanner();
|
|
563
615
|
const catalog = loadRoleCatalog(opts.cwd);
|
|
564
616
|
for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
|
|
565
617
|
const resolvedProfile = resolveProfile(config.profile, config.profile_overrides);
|
|
@@ -718,12 +770,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
718
770
|
if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]);
|
|
719
771
|
// Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
|
|
720
772
|
// A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
|
|
721
|
-
const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important"): boolean => {
|
|
773
|
+
const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run"): boolean => {
|
|
722
774
|
if (peer === USER || peer === HUB || !Object.hasOwn(config.capabilities, peer)) return true;
|
|
723
775
|
const list = config.capabilities[peer];
|
|
724
776
|
return Array.isArray(list) && list.includes(cap);
|
|
725
777
|
};
|
|
726
|
-
const CAPABILITIES = ["propose", "assign", "remember", "memory_correct", "memory_read", "important"];
|
|
778
|
+
const CAPABILITIES = ["propose", "assign", "remember", "memory_correct", "memory_read", "important", "spec_read", "check_run"];
|
|
727
779
|
for (const [peer, list] of Object.entries(config.capabilities)) {
|
|
728
780
|
if (!PEER_ID.test(peer)) log(`capabilities.${peer} is not a peer id; ignored (capabilities is an object of lists, one per peer)`);
|
|
729
781
|
else if (!Array.isArray(list)) log(`capabilities.${peer} is not a list: ${peer} gets no capabilities`);
|
|
@@ -738,7 +790,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
738
790
|
// silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
|
|
739
791
|
// together. Cached until the board changes: it is asked per recipient of every message.
|
|
740
792
|
let turnFreeNow: boolean | undefined;
|
|
741
|
-
const
|
|
793
|
+
const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
|
|
794
|
+
const turnFree = (): boolean => ![...turns.values()].some(turn => turn.private) && (turnFreeNow ??= coordination === "turn-free" && !board.list().some((t) => !isClosedTaskState(t.state) && tasks.isPii(t)));
|
|
742
795
|
/**
|
|
743
796
|
* Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
|
|
744
797
|
* has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
|
|
@@ -786,7 +839,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
786
839
|
const staleOff = config.experiments?.stale_notices === "deliver";
|
|
787
840
|
if (staleOff) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
|
|
788
841
|
const requestedPeerStops = new Set<string>();
|
|
789
|
-
const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff || relevantNotice(peer, env), silence,
|
|
842
|
+
const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff || relevantNotice(peer, env), silence,
|
|
790
843
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
|
|
791
844
|
startupCleanup.push(() => bus.closeJournal());
|
|
792
845
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
@@ -1012,9 +1065,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1012
1065
|
if (opts.config) return; // explicit embedding/test configuration remains its authority
|
|
1013
1066
|
const before = conductorPeer(config.roles);
|
|
1014
1067
|
const policy = loadConductorPolicy(opts.cwd);
|
|
1015
|
-
|
|
1016
|
-
|
|
1068
|
+
policyRoles = structuredClone(policy.roles);
|
|
1069
|
+
config.planner = policy.planner;
|
|
1017
1070
|
config.conductor = policy.conductor;
|
|
1071
|
+
settlePlanner();
|
|
1018
1072
|
const after = conductorPeer(config.roles);
|
|
1019
1073
|
if (before && (before !== after || config.conductor.feed === "off")) { bus.revokeSupervision(before); supervision.resetPending(before); budgetFeedSeen.clear(); }
|
|
1020
1074
|
if (config.conductor.feed === "off" && after) bus.revokeSupervision(after);
|
|
@@ -1023,8 +1077,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1023
1077
|
startupCleanup.push(() => executionBudget.close());
|
|
1024
1078
|
// Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
|
|
1025
1079
|
const allowedChecks = new Set([...CLASSES, ...Object.keys(resolvedProfile.classes)]);
|
|
1026
|
-
const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => allowedChecks.has(k) && typeof v === "string" && v.trim())) as Record<string, string>;
|
|
1027
|
-
const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && !(k in checkCommands));
|
|
1080
|
+
const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => k !== "runner" && allowedChecks.has(k) && typeof v === "string" && v.trim())) as Record<string, string>;
|
|
1081
|
+
const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && k !== "runner" && !(k in checkCommands));
|
|
1028
1082
|
if (strayChecks.length) log(`checks ignored for ${strayChecks.join(", ")}: not a task class with a command (classes: ${CLASSES.join(", ")})`);
|
|
1029
1083
|
// Commands and the other machine-local fields came only from a file git vouched for (loadConfig, issue #17).
|
|
1030
1084
|
for (const line of config.ignored ?? []) log(`${line}; only a config file nobody committed may set it`);
|
|
@@ -1034,7 +1088,96 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1034
1088
|
const runningChecks = new Set<() => void>();
|
|
1035
1089
|
let checksClosed = false; // set on stop: a check still queued then must not start and outlive the hub
|
|
1036
1090
|
const interrupted = { code: null, timedOut: false, interrupted: true, tail: "" };
|
|
1091
|
+
// Bounded capabilities (#418): inactive sandboxed peers need no host account probe.
|
|
1092
|
+
// A config-file daemon probes configured Pi/runner at startup; embeddings opt in or probe lazily on use.
|
|
1093
|
+
let specState: Awaited<ReturnType<typeof specAvailable>> = { available: false, reason: "gh unavailable: not probed" };
|
|
1094
|
+
let specProbe: Promise<void> | undefined, specProbeSettled = false;
|
|
1095
|
+
const ensureSpec = (refresh = false): Promise<void> => {
|
|
1096
|
+
if (specProbe && (!refresh || !specProbeSettled)) return specProbe;
|
|
1097
|
+
specProbeSettled = false;
|
|
1098
|
+
specProbe = Promise.resolve().then(() => (opts.boundedTools?.specAvailable ?? specAvailable)({ projectDir: opts.cwd }))
|
|
1099
|
+
.then(value => { specState = value; }, () => { specState = { available: false, reason: "gh unavailable" }; })
|
|
1100
|
+
.finally(() => { specProbeSettled = true; });
|
|
1101
|
+
return specProbe;
|
|
1102
|
+
};
|
|
1103
|
+
const checkPending = new Set<string>();
|
|
1104
|
+
const checkConnections = new WeakMap<object, string>();
|
|
1105
|
+
const checkBindings = new Map<string, Pick<RunReceipt, "task" | "owner" | "generation" | "updated" | "revision">>();
|
|
1106
|
+
const connectionGeneration = (id: string): string | undefined => {
|
|
1107
|
+
const peer = bus.peers.get(id);
|
|
1108
|
+
if (!peer || peer.state === "offline") return undefined;
|
|
1109
|
+
let token = checkConnections.get(peer);
|
|
1110
|
+
if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
|
|
1111
|
+
const native = peer instanceof WsPeer || peer instanceof CodexPeer || peer instanceof PiPeer ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
|
|
1112
|
+
if (peer instanceof PiPeer && native === undefined) return undefined;
|
|
1113
|
+
return `${instanceId}:${token}:${String(native ?? "")}`;
|
|
1114
|
+
};
|
|
1115
|
+
const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
|
|
1116
|
+
const task = board.get(receipt.task);
|
|
1117
|
+
return !!task && task.owner === receipt.owner && task.state === "in_progress" && task.updated === receipt.updated && task.history.length === receipt.revision
|
|
1118
|
+
&& connectionGeneration(receipt.owner) === receipt.generation && may(receipt.owner, "check_run") && membershipAllowed(receipt.owner) && !tasks.isPii(task)
|
|
1119
|
+
&& !turns.get(receipt.owner)?.private && !contextPii(receipt.owner);
|
|
1120
|
+
};
|
|
1121
|
+
const boundedSafe = (text: string) => !looksSecret(text) && screenMemory([{ title: null, text }]).length === 1
|
|
1122
|
+
&& !mentionsPrivatePath(text, [], [...(config.pii?.paths ?? []), ...config.local.deny], opts.cwd);
|
|
1123
|
+
const publicCheck = (receipt: RunReceipt) => {
|
|
1124
|
+
const safeTail = boundedSafe(receipt.tail), safeError = !receipt.error || boundedSafe(receipt.error);
|
|
1125
|
+
return { run: receipt.run, task: receipt.task, status: receipt.status, sha: receipt.sha, tree: receipt.tree, lockfile: receipt.lockfile,
|
|
1126
|
+
excluded: receipt.excluded, runner: receipt.runner, image: receipt.image, platform: receipt.platform,
|
|
1127
|
+
check: receipt.check, ...(receipt.files ? { files: receipt.files } : {}), exit: receipt.exit, timed_out: receipt.timed_out,
|
|
1128
|
+
cancelled: receipt.cancelled, interrupted: receipt.interrupted, duration_ms: receipt.duration_ms,
|
|
1129
|
+
tail: safeTail ? receipt.tail : "", output_bytes: receipt.output_bytes, truncated: receipt.truncated,
|
|
1130
|
+
output_withheld: receipt.output_withheld || !safeTail || !safeError, ...(safeError && receipt.error ? { error: receipt.error } : {}) };
|
|
1131
|
+
};
|
|
1132
|
+
const checkOptions: RunnerOptions = {
|
|
1133
|
+
projectDir: opts.cwd, stateDir: opts.stateDir, storageDir: join(hubHome(), "checks"),
|
|
1134
|
+
projectKey: createHash("sha256").update(realPath(opts.cwd)).digest("hex").slice(0, 24),
|
|
1135
|
+
enabled: () => config.checks.runner === "container",
|
|
1136
|
+
isDenied: path => isDenied(path, config.local.deny) || matchesPolicy(opts.cwd, path, config.pii?.paths ?? [], true),
|
|
1137
|
+
isCurrent: currentCheck,
|
|
1138
|
+
onResult: async receipt => {
|
|
1139
|
+
if (!currentCheck(receipt)) { await checkRunner.markStale(receipt.run); return; }
|
|
1140
|
+
// Only a screened, current-owner result is delivered; no tail enters capture or diagnostic logs.
|
|
1141
|
+
const view = publicCheck(receipt);
|
|
1142
|
+
checkBindings.set(receipt.run, { task: receipt.task, owner: receipt.owner, generation: receipt.generation, updated: receipt.updated, revision: receipt.revision });
|
|
1143
|
+
while (checkBindings.size > 200) checkBindings.delete(checkBindings.keys().next().value!);
|
|
1144
|
+
if (stopping || !currentCheck(receipt)) { await checkRunner.markStale(receipt.run); return; }
|
|
1145
|
+
bus.publish(newEnvelope(HUB, sanitize(JSON.stringify(view)), { to: [receipt.owner], kind: "task", refs: { task: String(receipt.task), boundedCheck: receipt.run } }));
|
|
1146
|
+
log(`check ${receipt.run}: exit ${receipt.exit ?? "unknown"}, bytes ${receipt.output_bytes}`);
|
|
1147
|
+
},
|
|
1148
|
+
};
|
|
1149
|
+
const checkRunner = opts.boundedTools?.runner?.(checkOptions) ?? new ContainerRunner(checkOptions);
|
|
1150
|
+
const runnerRefreshAbort = new AbortController();
|
|
1151
|
+
startupCleanup.push(() => { runnerRefreshAbort.abort(); void checkRunner.stopAll(); });
|
|
1152
|
+
let runnerState = await checkRunner.available(runnerRefreshAbort.signal);
|
|
1153
|
+
const orphanChecks = (await checkRunner.sweep()).map(line => boundedSafe(line) ? sanitize(line) : "orphan check: details withheld by privacy policy");
|
|
1154
|
+
if (opts.boundedTools?.specAvailable || !opts.config && (config.pi.enabled || config.checks.runner === "container")) await ensureSpec();
|
|
1155
|
+
let runnerStateAt = runnerState.checkedAt ?? Date.now(), refreshingRunner: Promise<void> | undefined;
|
|
1156
|
+
const refreshRunner = (): Promise<void> => {
|
|
1157
|
+
if (stopping || runnerRefreshAbort.signal.aborted) return Promise.resolve();
|
|
1158
|
+
if (Date.now() - runnerStateAt < 60_000) return Promise.resolve();
|
|
1159
|
+
if (refreshingRunner) return refreshingRunner;
|
|
1160
|
+
refreshingRunner = checkRunner.available(runnerRefreshAbort.signal).then(value => {
|
|
1161
|
+
if (stopping || runnerRefreshAbort.signal.aborted) return;
|
|
1162
|
+
runnerState = value; runnerStateAt = value.checkedAt ?? Date.now();
|
|
1163
|
+
}, () => {
|
|
1164
|
+
if (stopping || runnerRefreshAbort.signal.aborted) return;
|
|
1165
|
+
runnerState = { available: false, reason: "container unavailable" }; runnerStateAt = Date.now();
|
|
1166
|
+
}).finally(() => { refreshingRunner = undefined; });
|
|
1167
|
+
return refreshingRunner;
|
|
1168
|
+
};
|
|
1169
|
+
const peerCapabilities = () => {
|
|
1170
|
+
// Keep the last confirmed capability during a bounded, single-flight refresh; replace it only on a result.
|
|
1171
|
+
if (Date.now() - runnerStateAt >= 60_000) void refreshRunner();
|
|
1172
|
+
return Object.fromEntries([...new Set([...SUPPORTED_PEERS, ...bus.peers.keys()])].map(peer => {
|
|
1173
|
+
if (peer !== "pi" && peer !== "local") return [peer, { tests: true, spec: true }];
|
|
1174
|
+
return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"),
|
|
1175
|
+
testsReason: !may(peer, "check_run") ? `capabilities.${peer}` : `runner unavailable: ${runnerState.reason ?? "unknown"}`,
|
|
1176
|
+
specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
|
|
1177
|
+
}));
|
|
1178
|
+
};
|
|
1037
1179
|
const tasks: Tasks = new Tasks({
|
|
1180
|
+
capabilities: peerCapabilities, repository: memoryRepo,
|
|
1038
1181
|
recordPlanning: change => event({ type: "planning", ...change }),
|
|
1039
1182
|
wipLimit: peer => Object.hasOwn(config.tasks.wip_limits ?? {}, peer) ? config.tasks.wip_limits![peer]! : 1,
|
|
1040
1183
|
profile: profileName(config.profile),
|
|
@@ -1094,6 +1237,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1094
1237
|
review: config.review,
|
|
1095
1238
|
sweep: taskSweepConfig(config.task_sweep),
|
|
1096
1239
|
sweepHeld: () => stopping || recoveryActive(),
|
|
1240
|
+
cancellationHeld: () => stopping || recoveryActive(),
|
|
1241
|
+
offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
|
|
1242
|
+
offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer instanceof WsPeer && peer.pullOnly; },
|
|
1243
|
+
stallNotice: stall => { supervision.taskStalled(stall); },
|
|
1097
1244
|
roles: config.roles,
|
|
1098
1245
|
turnFree,
|
|
1099
1246
|
capable: (peer) => capable.has(peer),
|
|
@@ -1116,6 +1263,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1116
1263
|
held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
|
|
1117
1264
|
});
|
|
1118
1265
|
relevantNotice = (peer, env) => {
|
|
1266
|
+
if (env.from === HUB && env.refs?.boundedCheck) {
|
|
1267
|
+
const run = env.refs.boundedCheck, binding = checkBindings.get(run);
|
|
1268
|
+
let outputSafe = false;
|
|
1269
|
+
try {
|
|
1270
|
+
const value = JSON.parse(env.body) as { tail?: unknown; error?: unknown };
|
|
1271
|
+
outputSafe = typeof value.tail === "string" && boundedSafe(value.tail)
|
|
1272
|
+
&& (value.error === undefined || typeof value.error === "string" && boundedSafe(value.error));
|
|
1273
|
+
} catch { /* An unknown projection never reaches a model. */ }
|
|
1274
|
+
if (!binding || binding.owner !== peer || !currentCheck(binding) || !outputSafe) {
|
|
1275
|
+
checkBindings.delete(run);
|
|
1276
|
+
void checkRunner.markStale(run).catch(() => {});
|
|
1277
|
+
return false;
|
|
1278
|
+
}
|
|
1279
|
+
return true;
|
|
1280
|
+
}
|
|
1119
1281
|
if (env.from !== HUB || !env.refs?.supervision) return tasks.relevant(peer, env);
|
|
1120
1282
|
if (conductorPeer(config.roles) !== peer || config.conductor.feed === "off") return false;
|
|
1121
1283
|
const key = env.refs.supervisionKey ?? "";
|
|
@@ -1139,7 +1301,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1139
1301
|
const facts: Facts = new Facts({ root: opts.cwd, repo: factsRepo, tmp: join(opts.stateDir, "facts"), instance: instanceId.slice(0, 8), scope: (peer) => repositoryFacts ? (scopes.has(peer) ? scopes.get(peer) : scopes.set(peer, tasks.factScope(peer)).get(peer)) : undefined, peers: () => [...bus.peers.keys()], nameable: tasks.nameable, deny: config.local.deny, privatePath: path => matchesPolicy(opts.cwd, path, config.pii?.paths ?? []), projection: file => projections.project(file) });
|
|
1140
1302
|
const progress = new ProgressObserver({
|
|
1141
1303
|
tasks: () => board.list(),
|
|
1142
|
-
isPrivate: () => board.list().some(task => task.state
|
|
1304
|
+
isPrivate: () => [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task)),
|
|
1143
1305
|
inference: { escalate: (conversation, turn) => inference?.escalate(conversation, turn) ?? Promise.resolve(undefined) },
|
|
1144
1306
|
emit: conductorProgressSink(event, supervision),
|
|
1145
1307
|
notify,
|
|
@@ -1322,7 +1484,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1322
1484
|
return undefined;
|
|
1323
1485
|
};
|
|
1324
1486
|
const activityRings = { kimi: new ActivityRing(), qwen: new ActivityRing() };
|
|
1325
|
-
const contextPii = (peer: PeerId) => board.list().some(t => t.state
|
|
1487
|
+
const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some(t => !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
|
|
1326
1488
|
const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
|
|
1327
1489
|
// One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
|
|
1328
1490
|
if (checkpointWaits.has(peer) || recoveryActive() || stopping) return Promise.resolve(undefined);
|
|
@@ -1416,7 +1578,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1416
1578
|
// Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
|
|
1417
1579
|
const runId = Date.now().toString(36);
|
|
1418
1580
|
let turnSeq = 0;
|
|
1419
|
-
const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
|
|
1420
1581
|
const pendingDeliveryTask = new Map<PeerId, number>();
|
|
1421
1582
|
const taskAttribution = (peer: PeerId, delivery = turns.get(peer)?.deliveryTask): TaskAttribution => {
|
|
1422
1583
|
const result = attribute(delivery, board.list("in_progress").filter(t => t.owner === peer).map(t => t.id));
|
|
@@ -1448,7 +1609,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1448
1609
|
const turnLog = repo ? new Turns(join(opts.stateDir, "hub.db")) : undefined;
|
|
1449
1610
|
if (turnLog) startupCleanup.push(() => turnLog.close());
|
|
1450
1611
|
/** Whether a peer has a PII task open in any state: assigned and not yet accepted counts. */
|
|
1451
|
-
const holdsPii = (peer: PeerId) => board.list().some((t) => t.owner === peer && t.state
|
|
1612
|
+
const holdsPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some((t) => t.owner === peer && !isClosedTaskState(t.state) && t.state !== "in_review" && tasks.isPii(t));
|
|
1452
1613
|
const memoryObservations = memoryTelemetry ? new MemoryObservations(memoryTelemetry, (peer, envs) => {
|
|
1453
1614
|
if (turns.get(peer)?.private || contextPii(peer) || envs.some(env => env.private)) return;
|
|
1454
1615
|
for (const env of envs) {
|
|
@@ -1460,7 +1621,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1460
1621
|
if (row && tasks.isPii(row)) return;
|
|
1461
1622
|
return { ...(row ? { task: taskKey(memoryProject, row) } : {}), turn: turns.get(peer)?.id ?? null, session: contextSession(peer) ?? null };
|
|
1462
1623
|
}) : undefined;
|
|
1463
|
-
bus.onMemoryDelivery = (peer, id, out, field) => memoryObservations?.delivery(peer, id, out, field);
|
|
1624
|
+
bus.onMemoryDelivery = (peer, id, out, field) => { if (!out.some(env => env.refs?.boundedCheck)) memoryObservations?.delivery(peer, id, out, field); };
|
|
1464
1625
|
const citeMemory = (peer: PeerId, text: string, id: string, envs: readonly Envelope[] = []) => {
|
|
1465
1626
|
if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
|
|
1466
1627
|
memoryObservations?.cite(peer, text, id, envs);
|
|
@@ -1482,7 +1643,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1482
1643
|
return { tree, ms: Math.round(performance.now() - t0) };
|
|
1483
1644
|
};
|
|
1484
1645
|
function privateSnapshotWindow(): boolean {
|
|
1485
|
-
return [...turns.values()].some(turn => turn.private) || board.list().some(task => task.state
|
|
1646
|
+
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
1486
1647
|
}
|
|
1487
1648
|
function reviewProjection(task: Task): string | undefined {
|
|
1488
1649
|
if (!projections.enabled || tasks.isPii(task)) return undefined;
|
|
@@ -1525,6 +1686,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1525
1686
|
const contextTailSeen = new Map<PeerId, string>();
|
|
1526
1687
|
const intervals = [
|
|
1527
1688
|
setInterval(() => {
|
|
1689
|
+
void refreshRunner().catch(() => {});
|
|
1528
1690
|
try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
|
|
1529
1691
|
for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
|
|
1530
1692
|
for (const [id, request] of permissions) if (request.expiresAt > Date.now()) supervision.approvalWaiting({ id, peer: request.peer, tool: request.tool ?? "unknown", createdAt: request.createdAt });
|
|
@@ -1607,7 +1769,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1607
1769
|
// names are answered by the hub; every call still passes daemon role/capability checks before its effects.
|
|
1608
1770
|
const HUB_TOOLS = ["hub_send", ...TASK_TOOLS.map((t) => t.name), ...CONDUCTOR_TOOLS.map((t) => t.name)];
|
|
1609
1771
|
const HUB_TOOL_TITLES = new Set(HUB_TOOLS.map((name) => `mcp__agent-hub__${name}`));
|
|
1610
|
-
const toolEnv = (peer: PeerId) => ({ AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
|
|
1772
|
+
const toolEnv = (peer: PeerId, deliveryOnly = false) => ({ ...(deliveryOnly ? { AGENTHUB_DELIVERY_ONLY: "true" } : {}), AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
|
|
1611
1773
|
|
|
1612
1774
|
/** One entry point for the task tools, whoever calls them: MCP clients, the local worker, the console. */
|
|
1613
1775
|
// A task op can write the board across awaits (triage, briefs, the dependents an approval releases): a recovery
|
|
@@ -1627,11 +1789,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1627
1789
|
const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
|
|
1628
1790
|
auditConduct({ kind: "conduct", actor: by, action: op.slice(4), ...(id === undefined ? {} : { task: id }) });
|
|
1629
1791
|
}
|
|
1630
|
-
|
|
1792
|
+
const hint = args[3] ? ownHeldHint(by) : "";
|
|
1793
|
+
return hint ? `${text}\n\n${hint}` : text;
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
// Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
|
|
1796
|
+
const hint = args[3] ? ownHeldHint(by) : "";
|
|
1797
|
+
if (hint && error instanceof Error) error.message += `\n\n${hint}`;
|
|
1798
|
+
throw error;
|
|
1631
1799
|
} finally {
|
|
1632
1800
|
taskOpsInFlight--;
|
|
1633
1801
|
}
|
|
1634
1802
|
};
|
|
1803
|
+
function ownHeldHint(peer: PeerId): string {
|
|
1804
|
+
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(bus.peers.get(peer) instanceof LocalPeer || bus.peers.get(peer) instanceof AcpPeer)); }
|
|
1805
|
+
catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
|
|
1806
|
+
}
|
|
1635
1807
|
/** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
|
|
1636
1808
|
const peerArg = (v: unknown, name: string): PeerId | undefined => {
|
|
1637
1809
|
if (v == null || v === "") return undefined;
|
|
@@ -1640,6 +1812,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1640
1812
|
return id;
|
|
1641
1813
|
};
|
|
1642
1814
|
async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
|
|
1815
|
+
if (BOUNDED_TASK_TOOLS.has(op)) {
|
|
1816
|
+
const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
|
|
1817
|
+
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !keys.includes(key))
|
|
1818
|
+
|| (keys.includes("id") ? !Number.isSafeInteger(a.id) || a.id < 1 : typeof a.run !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(a.run))) throw new PreEffectToolRefusal("invalid bounded task request");
|
|
1819
|
+
if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("bounded task tools unavailable during a PII turn");
|
|
1820
|
+
}
|
|
1643
1821
|
if (MEMORY_READ_TOOLS.has(op)) validateMemoryRead(op, a);
|
|
1644
1822
|
if (op === "hub_memory_correct") correctionInput(a);
|
|
1645
1823
|
if (CONDUCTOR_TOOL_NAMES.has(op)) {
|
|
@@ -1660,7 +1838,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1660
1838
|
// console reads a PII task's text deliberately, with `ahub task show <id>`.
|
|
1661
1839
|
const onPrem = inProcess && by === "local" && piiTurn;
|
|
1662
1840
|
const line = (t: { id: number; state: string; owner: PeerId | null; reviewer: PeerId | null }) => { const task = board.get(t.id); return task && by === USER ? tasks.resultLine(task) : `task #${t.id}: ${t.state}, owner ${t.owner ?? "none"}, reviewer ${t.reviewer ?? "none"}`; };
|
|
1663
|
-
const need = (cap: "propose" | "assign" | "remember" | "memory_read" | "memory_correct", what: string) => {
|
|
1841
|
+
const need = (cap: "propose" | "assign" | "remember" | "memory_read" | "memory_correct" | "spec_read" | "check_run", what: string) => {
|
|
1664
1842
|
if (may(by, cap)) return;
|
|
1665
1843
|
log(`capabilities: ${by} may not ${what} (${op})`);
|
|
1666
1844
|
throw new PreEffectToolRefusal(`${by} may not ${what} (no "${cap}" in capabilities.${by} in .agenthub/config.json)`);
|
|
@@ -1678,7 +1856,88 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1678
1856
|
}
|
|
1679
1857
|
if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
|
|
1680
1858
|
if (op === "hub_remember") need("remember", "save notes to shared memory");
|
|
1859
|
+
if (op === "hub_task_spec") {
|
|
1860
|
+
need("spec_read", "read assigned specifications");
|
|
1861
|
+
const authorized = () => {
|
|
1862
|
+
const task = board.get(a.id);
|
|
1863
|
+
if (!task || task.state === "backlog" || task.owner !== by && task.reviewer !== by || tasks.isPii(task) || turns.get(by)?.private || contextPii(by)) throw new PreEffectToolRefusal("spec unavailable");
|
|
1864
|
+
return task;
|
|
1865
|
+
};
|
|
1866
|
+
const task = authorized(), generation = connectionGeneration(by), updated = task.updated, revision = task.history.length;
|
|
1867
|
+
const deadline = Date.now() + 25_000; // retain a response margin inside the control client's 30-second cap
|
|
1868
|
+
const issues = specRefs(task, memoryRepo);
|
|
1869
|
+
if (!issues.length) throw new PreEffectToolRefusal("no assigned spec references");
|
|
1870
|
+
await ensureSpec();
|
|
1871
|
+
authorized();
|
|
1872
|
+
if (!specState.available) throw new PreEffectToolRefusal(specState.reason ?? "gh unauthenticated");
|
|
1873
|
+
const specs = [];
|
|
1874
|
+
for (const issue of issues) {
|
|
1875
|
+
const remaining = deadline - Date.now();
|
|
1876
|
+
if (remaining <= 0) throw new PreEffectToolRefusal("timeout");
|
|
1877
|
+
specs.push(await (opts.boundedTools?.fetchSpec ?? fetchSpec)(memoryRepo, issue, { screen: boundedSafe, projectDir: opts.cwd, timeoutMs: Math.min(15_000, remaining) }));
|
|
1878
|
+
const current = authorized();
|
|
1879
|
+
if (current.updated !== updated || current.history.length !== revision || connectionGeneration(by) !== generation || !may(by, "spec_read")) throw new PreEffectToolRefusal("spec unavailable");
|
|
1880
|
+
}
|
|
1881
|
+
const result = JSON.stringify({ specs });
|
|
1882
|
+
if (Buffer.byteLength(result) > 80 * 1024) throw new PreEffectToolRefusal("too large");
|
|
1883
|
+
if (!specs.every(spec => boundedSafe(`${spec.title}\n${spec.body}`))) throw new PreEffectToolRefusal("spec withheld by privacy policy");
|
|
1884
|
+
return result;
|
|
1885
|
+
}
|
|
1886
|
+
if (op === "hub_check_start") {
|
|
1887
|
+
need("check_run", "run isolated checks");
|
|
1888
|
+
if (stopping || recoveryActive()) throw new PreEffectToolRefusal("runner unavailable: recovery or shutdown");
|
|
1889
|
+
const authorized = () => {
|
|
1890
|
+
const task = board.get(a.id);
|
|
1891
|
+
if (!task || task.owner !== by || task.state !== "in_progress" || tasks.isPii(task) || turns.get(by)?.private || contextPii(by)) throw new PreEffectToolRefusal("check unavailable");
|
|
1892
|
+
return task;
|
|
1893
|
+
};
|
|
1894
|
+
const task = authorized(), updated = task.updated, revision = task.history.length, generation = connectionGeneration(by);
|
|
1895
|
+
if (!generation) throw new PreEffectToolRefusal("check requires an attached owner");
|
|
1896
|
+
if (a.check !== "test" && a.check !== "typecheck"
|
|
1897
|
+
|| a.check === "test" && (!Array.isArray(a.files) || a.files.length < 1 || a.files.length > 32 || a.files.some((file: unknown) => typeof file !== "string" || !/^test\/[A-Za-z0-9._/-]+\.test\.ts$/.test(file) || file.split("/").some(part => !part || part === "." || part === "..")))
|
|
1898
|
+
|| a.check === "typecheck" && a.files !== undefined) throw new PreEffectToolRefusal("invalid check request");
|
|
1899
|
+
await refreshRunner();
|
|
1900
|
+
const current = authorized();
|
|
1901
|
+
if (current.updated !== updated || current.history.length !== revision || connectionGeneration(by) !== generation || !may(by, "check_run") || stopping || recoveryActive()) throw new PreEffectToolRefusal("check unavailable");
|
|
1902
|
+
if (!runnerState.available) throw new PreEffectToolRefusal(`runner unavailable: ${runnerState.reason ?? "unknown"}`);
|
|
1903
|
+
const ref = current.refs.commit ?? current.refs.branch;
|
|
1904
|
+
if (!ref) throw new PreEffectToolRefusal("check requires task branch or commit");
|
|
1905
|
+
const handle = checkRunner.start({ task: current.id, owner: by, generation, updated, revision: current.history.length, ref, check: a.check, ...(a.files === undefined ? {} : { files: a.files }) });
|
|
1906
|
+
checkPending.add(handle.run);
|
|
1907
|
+
void handle.done.finally(() => checkPending.delete(handle.run)).catch(() => {});
|
|
1908
|
+
return JSON.stringify({ run: handle.run });
|
|
1909
|
+
}
|
|
1910
|
+
if (op === "hub_check_result" || op === "hub_check_cancel") {
|
|
1911
|
+
need("check_run", "read or cancel isolated checks");
|
|
1912
|
+
let receipt = await checkRunner.result(a.run);
|
|
1913
|
+
const task = receipt ? board.get(receipt.task) : undefined;
|
|
1914
|
+
if (!receipt || by !== USER && (receipt.owner !== by || !task || tasks.isPii(task))) throw new PreEffectToolRefusal("check unavailable");
|
|
1915
|
+
if (receipt.status === "stale" && by !== USER) throw new PreEffectToolRefusal("check unavailable: stale binding");
|
|
1916
|
+
if (!currentCheck(receipt)) {
|
|
1917
|
+
await checkRunner.markStale(receipt.run); receipt = { ...receipt, status: "stale" };
|
|
1918
|
+
// markStale persists and can abort a run: a refusal after it is never proof of absent effects.
|
|
1919
|
+
if (by !== USER) throw new Error("check unavailable: stale binding");
|
|
1920
|
+
}
|
|
1921
|
+
if (op === "hub_check_cancel") {
|
|
1922
|
+
await checkRunner.cancel(receipt.run);
|
|
1923
|
+
const actual = await checkRunner.result(receipt.run);
|
|
1924
|
+
if (!actual) throw new Error("check receipt unavailable after cancellation");
|
|
1925
|
+
return JSON.stringify({ run: actual.run, status: actual.status, cancelled: actual.cancelled });
|
|
1926
|
+
}
|
|
1927
|
+
if (by !== USER && !currentCheck(receipt)) throw new PreEffectToolRefusal("check unavailable: stale binding");
|
|
1928
|
+
if (by === receipt.owner) await checkRunner.consumeOutput(receipt.run);
|
|
1929
|
+
if (by !== USER && (!currentCheck(receipt) || turns.get(by)?.private || contextPii(by))) throw new Error("check unavailable: stale binding");
|
|
1930
|
+
// Policy can change during output cleanup, so form the final projection again.
|
|
1931
|
+
return JSON.stringify(publicCheck(receipt));
|
|
1932
|
+
}
|
|
1681
1933
|
switch (op) {
|
|
1934
|
+
case "hub_delivery_retry": {
|
|
1935
|
+
if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "delivery_id") || typeof a.delivery_id !== "string" || a.delivery_id.length < 1 || a.delivery_id.length > 256) throw new PreEffectToolRefusal("hub_delivery_retry accepts only your delivery_id; completed and discard require the person");
|
|
1936
|
+
if (bus.peers.get(by) instanceof LocalPeer || bus.peers.get(by) instanceof AcpPeer) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
|
|
1937
|
+
if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
|
|
1938
|
+
bus.retryDelivery(by, a.delivery_id);
|
|
1939
|
+
return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
|
|
1940
|
+
}
|
|
1682
1941
|
case "hub_backlog_list":
|
|
1683
1942
|
return JSON.stringify(tasks.backlogList(by));
|
|
1684
1943
|
case "hub_release_list":
|
|
@@ -1710,6 +1969,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1710
1969
|
if (silent && factsOn()) facts.sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
|
|
1711
1970
|
return [line(t), overlap, tasks.planGuardText(t)].filter(Boolean).join("\n");
|
|
1712
1971
|
}
|
|
1972
|
+
case "hub_task_cancel": {
|
|
1973
|
+
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
|
|
1974
|
+
|| !Number.isSafeInteger(a.id) || a.id < 1 || typeof a.reason !== "string" || !a.reason.trim() || a.reason.length > 2000
|
|
1975
|
+
|| a.superseded_by !== undefined && (typeof a.superseded_by !== "string" || !a.superseded_by.trim() || a.superseded_by.length > 2000)
|
|
1976
|
+
|| a.revision !== undefined && (!Number.isSafeInteger(a.revision) || a.revision < 0)) throw new PreEffectToolRefusal("invalid cancellation request");
|
|
1977
|
+
if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("cancellation unavailable during a PII turn");
|
|
1978
|
+
return JSON.stringify(tasks.publicView(await tasks.cancel(a.id, by, { reason: a.reason, supersededBy: a.superseded_by, expectedFlowRevision: a.revision })));
|
|
1979
|
+
}
|
|
1713
1980
|
case "hub_task_decline":
|
|
1714
1981
|
return line(await tasks.decline(by, a.id, a.reason));
|
|
1715
1982
|
case "hub_task_done": {
|
|
@@ -1814,8 +2081,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1814
2081
|
}
|
|
1815
2082
|
case "task_escalate":
|
|
1816
2083
|
return line(await tasks.escalate(USER, a.id, undefined, undefined, true));
|
|
1817
|
-
case "route_explain":
|
|
1818
|
-
|
|
2084
|
+
case "route_explain": {
|
|
2085
|
+
const trace = tasks.explain(a.id !== undefined ? Number(a.id) : { title: String(a.title ?? ""), class: a.class as TaskClass });
|
|
2086
|
+
return [...trace, ...bus.knownPeers().flatMap(peer => {
|
|
2087
|
+
if (trace.some(line => line.startsWith(` hold: ${peer}'s queue is held:`))) return [];
|
|
2088
|
+
const hold = queueHold(peer); return hold ? [`${peer} queue: ${hold}`] : [];
|
|
2089
|
+
})].join("\n");
|
|
2090
|
+
}
|
|
1819
2091
|
case "turn_revert": {
|
|
1820
2092
|
// `ahub undo --context` (issue #33): the conversation half of an undo; the CLI restores the files itself.
|
|
1821
2093
|
const turn = turnLog?.get(String(a.turn));
|
|
@@ -1840,7 +2112,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1840
2112
|
}
|
|
1841
2113
|
function recoveryTaskPreface(peer: PeerId): void {
|
|
1842
2114
|
if (recoveryPhase !== "restored") return;
|
|
1843
|
-
const open = board.list().filter((task) => (task.owner === peer || task.reviewer === peer) && !
|
|
2115
|
+
const open = board.list().filter((task) => (task.owner === peer || task.reviewer === peer) && !isClosedTaskState(task.state));
|
|
1844
2116
|
if (!open.length) return;
|
|
1845
2117
|
const lines = open.map((task) => {
|
|
1846
2118
|
const view = tasks.publicView(task) as { title?: unknown; detail?: unknown; state?: unknown; owner?: unknown; reviewer?: unknown };
|
|
@@ -1875,6 +2147,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1875
2147
|
let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
|
|
1876
2148
|
const recoveryReady = () => {
|
|
1877
2149
|
if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || rosterChanging !== undefined || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
|
|
2150
|
+
if (checkPending.size !== 0) return false;
|
|
1878
2151
|
if (!recoveryPeerSnapshot) return true;
|
|
1879
2152
|
const current = recoveryPeers();
|
|
1880
2153
|
return recoveryPeerSnapshot.every((saved) => {
|
|
@@ -2029,7 +2302,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2029
2302
|
}
|
|
2030
2303
|
return now;
|
|
2031
2304
|
};
|
|
2032
|
-
const queueHoldStatus = (peer: PeerId, heldBy?: string) => heldBy ? { heldBy, holdNote: queueHold(peer) } : {};
|
|
2305
|
+
const queueHoldStatus = (peer: PeerId, heldBy?: string) => heldBy ? { heldBy, holdNote: queueHold(peer), heldRows: heldRows(peer) } : {};
|
|
2033
2306
|
const logPermissionStart = (peer: string) => {
|
|
2034
2307
|
const requested = defaultSources[peer];
|
|
2035
2308
|
const selected = permissionMode(peer), pending = pendingPermissionDefaults.has(peer);
|
|
@@ -2062,6 +2335,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2062
2335
|
};
|
|
2063
2336
|
const status = (includeAttentionItems = false, includeAttentionInput = false) => {
|
|
2064
2337
|
const taskNeeds = board.statusTasks();
|
|
2338
|
+
const taskStalls = tasks.stalled();
|
|
2339
|
+
const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
|
|
2065
2340
|
const peerIds = statusPeerIds(taskNeeds);
|
|
2066
2341
|
const view = {
|
|
2067
2342
|
...(crashReport.length ? { crash: crashReport } : {}),
|
|
@@ -2071,6 +2346,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2071
2346
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2072
2347
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
2073
2348
|
protocol: PROTOCOL,
|
|
2349
|
+
features: DAEMON_FEATURES,
|
|
2074
2350
|
stopping,
|
|
2075
2351
|
pid: process.pid,
|
|
2076
2352
|
cwd: opts.cwd,
|
|
@@ -2082,11 +2358,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2082
2358
|
),
|
|
2083
2359
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2084
2360
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
2085
|
-
roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id) }])),
|
|
2361
|
+
roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
|
|
2362
|
+
planner: { peers: plannerPeers(), source: plannerSource },
|
|
2086
2363
|
...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
|
|
2087
2364
|
...(sidecar ? { switchyard: sidecar.status } : {}),
|
|
2088
2365
|
...(modelRelay ? { models: modelRelay.status() } : {}),
|
|
2366
|
+
isolatedChecks: { pending: checkPending.size, orphans: orphanChecks, unknownReceipts: checkRunner.unknownReceipts ?? [], maintenanceUnavailable: checkRunner.maintenanceUnavailable === true },
|
|
2367
|
+
taskCapabilities: peerCapabilities(),
|
|
2089
2368
|
tasks: board.counts(),
|
|
2369
|
+
taskStalls, pendingTaskOffers,
|
|
2090
2370
|
...(bus.storageError ? { deliveryError: bus.storageError } : {}),
|
|
2091
2371
|
...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
|
|
2092
2372
|
};
|
|
@@ -2103,6 +2383,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2103
2383
|
storageBlocked: !!bus.storageError,
|
|
2104
2384
|
recovery: recoveryOperationId ? { id: recoveryOperationId, running: true } : undefined,
|
|
2105
2385
|
tasks: taskNeeds,
|
|
2386
|
+
facts: [...taskStalls.map(stall => ({ kind: "stall" as const, id: stall.task })), ...pendingTaskOffers.map(offer => ({ kind: "offer" as const, id: offer.task, peer: offer.peer }))],
|
|
2106
2387
|
budget: Object.fromEntries(Object.entries(budget.status()).map(([peer, row]) => [peer, row.paused ? { paused: { since: row.paused.since, resetsAt: row.paused.resetsAt } } : {}])),
|
|
2107
2388
|
};
|
|
2108
2389
|
const attention = needs(attentionInput, Date.now());
|
|
@@ -2122,37 +2403,58 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2122
2403
|
const taskChanged = board.onChange;
|
|
2123
2404
|
board.onChange = (task, entry) => {
|
|
2124
2405
|
try { taskChanged?.(task, entry); }
|
|
2125
|
-
finally {
|
|
2406
|
+
finally {
|
|
2407
|
+
try { settleSupersededDeliveries(); }
|
|
2408
|
+
finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
|
|
2409
|
+
}
|
|
2126
2410
|
};
|
|
2127
2411
|
|
|
2128
2412
|
// A queue change is not a bus event: without this, status.json reports the queue as it was before the last
|
|
2129
2413
|
// message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
|
|
2130
|
-
function
|
|
2131
|
-
return
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
}))
|
|
2414
|
+
function heldRows(peer: PeerId) {
|
|
2415
|
+
return bus.queueList(peer).filter(row => row.state === "needs_review").map(row => ({
|
|
2416
|
+
id: row.id, senders: [...new Set(row.originals.map(env => sanitize(env.from)))],
|
|
2417
|
+
kinds: [...new Set(row.originals.map(env => env.kind))], createdAt: row.createdAt,
|
|
2418
|
+
context: deliveryTaskContext(row.originals, board, peer),
|
|
2419
|
+
}));
|
|
2136
2420
|
}
|
|
2137
2421
|
function queueHold(peer: PeerId): string | undefined {
|
|
2138
|
-
const
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2422
|
+
const rows = heldRows(peer);
|
|
2423
|
+
if (!rows.length) return undefined;
|
|
2424
|
+
return rows.map(row => `held by needs_review ${row.id} (from ${row.senders.join(",")}; kind ${row.kinds.join(",")}; age ${Math.max(0, Math.floor((Date.now() - row.createdAt) / 1000))}s${row.context ? `; ${row.context}` : ""}); ahub queue resolve ${row.id} --action completed|retry|discard --reason <text>`).join("; ");
|
|
2425
|
+
}
|
|
2426
|
+
let settlingSuperseded = false;
|
|
2427
|
+
function settleSupersededDeliveries() {
|
|
2428
|
+
if (settlingSuperseded || stopping || recoveryActive() || bus.isRecoveryHeld) return;
|
|
2429
|
+
settlingSuperseded = true;
|
|
2430
|
+
try {
|
|
2431
|
+
for (const row of bus.queueList()) {
|
|
2432
|
+
if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
|
|
2433
|
+
const reason = supersededDeliveryReason(row.originals, board, row.peer);
|
|
2434
|
+
if (!reason) continue;
|
|
2435
|
+
try { bus.resolveDelivery(row.id, row.revision, "completed", reason, "hub"); }
|
|
2436
|
+
catch { /* Busy peers, storage failures and changed revisions remain held for the next queue/board change. */ }
|
|
2437
|
+
}
|
|
2438
|
+
} finally { settlingSuperseded = false; }
|
|
2143
2439
|
}
|
|
2144
2440
|
const noticedHolds = new Set<string>();
|
|
2441
|
+
let reportingQueues = false;
|
|
2145
2442
|
bus.onQueues = () => {
|
|
2146
|
-
if (stopping) return;
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2443
|
+
if (stopping || settlingSuperseded || reportingQueues) return;
|
|
2444
|
+
reportingQueues = true;
|
|
2445
|
+
try {
|
|
2446
|
+
settleSupersededDeliveries();
|
|
2447
|
+
for (const peer of bus.knownPeers()) {
|
|
2448
|
+
const rows = heldRows(peer);
|
|
2449
|
+
if (!rows.some(row => !noticedHolds.has(row.id))) continue;
|
|
2450
|
+
for (const row of rows) { noticedHolds.add(row.id); supervision.needsReview(peer, row.id); }
|
|
2451
|
+
notify(`${peer} queue ${queueHold(peer)}`);
|
|
2452
|
+
}
|
|
2453
|
+
writeStatus();
|
|
2454
|
+
} finally { reportingQueues = false; }
|
|
2155
2455
|
};
|
|
2456
|
+
// Opening the journal/restoring the snapshot preceded callbacks; classify those holds too.
|
|
2457
|
+
settleSupersededDeliveries();
|
|
2156
2458
|
|
|
2157
2459
|
// When each peer went offline; a peer never seen attached counts from hub start (issue #6).
|
|
2158
2460
|
const offlineSince = new Map<PeerId, number>();
|
|
@@ -2580,6 +2882,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2580
2882
|
if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
|
|
2581
2883
|
if (recoveryActive()) return { ok: false, error: "recovery is holding mutations" };
|
|
2582
2884
|
const mode = startMode(peer);
|
|
2885
|
+
if (mode === "tui" && (peer === "pi" || peer === "local")) { await ensureSpec(true); await refreshRunner(); }
|
|
2583
2886
|
const owner = bus.peers.get(peer);
|
|
2584
2887
|
if (mode === "headless") {
|
|
2585
2888
|
// A Pi a person runs in a terminal is theirs: a start made for them never takes it over as a headless one.
|
|
@@ -2662,6 +2965,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2662
2965
|
async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }): Promise<Record<string, unknown>> {
|
|
2663
2966
|
let unmute: (() => void) | undefined;
|
|
2664
2967
|
try {
|
|
2968
|
+
if (peer === "pi" || peer === "local") { await ensureSpec(true); await refreshRunner(); }
|
|
2665
2969
|
return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
|
|
2666
2970
|
} finally {
|
|
2667
2971
|
unmute?.();
|
|
@@ -2776,7 +3080,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2776
3080
|
: { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null }, current);
|
|
2777
3081
|
},
|
|
2778
3082
|
onTurnFailure: () => { supervisionTurns.delete("qwen"); },
|
|
2779
|
-
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("qwen")).map(([name, value]) => ({ name, value })) }],
|
|
3083
|
+
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("qwen", true)).map(([name, value]) => ({ name, value })) }],
|
|
2780
3084
|
preamble: sessionRoleContract("qwen"),
|
|
2781
3085
|
});
|
|
2782
3086
|
recoveryTaskPreface("qwen"); await ensurePreface("qwen");
|
|
@@ -2828,7 +3132,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2828
3132
|
current);
|
|
2829
3133
|
},
|
|
2830
3134
|
onTurnFailure: () => { supervisionTurns.delete("kimi"); },
|
|
2831
|
-
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi")).map(([name, value]) => ({ name, value })) }],
|
|
3135
|
+
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi", true)).map(([name, value]) => ({ name, value })) }],
|
|
2832
3136
|
preamble: sessionRoleContract("kimi"),
|
|
2833
3137
|
});
|
|
2834
3138
|
recoveryTaskPreface("kimi");
|
|
@@ -3364,7 +3668,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3364
3668
|
tasks: boardTasks.map((task) => {
|
|
3365
3669
|
const view = tasks.publicView(task);
|
|
3366
3670
|
// Refs and history can themselves quote private text. The dashboard needs neither.
|
|
3367
|
-
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
3671
|
+
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
3368
3672
|
}),
|
|
3369
3673
|
links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
|
|
3370
3674
|
budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[id] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
|
|
@@ -3399,6 +3703,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3399
3703
|
return storedConfigCache.config;
|
|
3400
3704
|
};
|
|
3401
3705
|
/** Stored start modes and the scoped instruction policy follow a settings write or undo. */
|
|
3706
|
+
/** #455: the stored planner key, applied at once; planning authority and backlog notices follow config.roles. */
|
|
3707
|
+
const applyPlanner = () => { const stored = storedConfig(); if (stored) { config.planner = stored.planner; settlePlanner(); writeStatus(); } };
|
|
3402
3708
|
const applyStartModes = () => { const stored = storedConfig(); if (stored) { config.peers = stored.peers; config.protect_instruction_files = stored.protect_instruction_files; } };
|
|
3403
3709
|
/** Apply only the named peer; unrelated hand edits retain their prior publication scope. */
|
|
3404
3710
|
const applyRoleSelection = (peer: string) => {
|
|
@@ -3435,7 +3741,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3435
3741
|
},
|
|
3436
3742
|
});
|
|
3437
3743
|
const undo = pendingUndo(opts.stateDir);
|
|
3438
|
-
|
|
3744
|
+
const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
|
|
3745
|
+
return { rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
3439
3746
|
}
|
|
3440
3747
|
/** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
|
|
3441
3748
|
const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false) => {
|
|
@@ -3444,7 +3751,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3444
3751
|
};
|
|
3445
3752
|
const settingSource = (authority: SettingAuthority) => authority === "terminal" ? "terminal" as const : "dashboard" as const;
|
|
3446
3753
|
function rosterView() {
|
|
3447
|
-
const open = board.list().filter(task => task.state
|
|
3754
|
+
const open = board.list().filter(task => !isClosedTaskState(task.state));
|
|
3448
3755
|
return [...KNOWN_PEERS, ...OPTIONAL_PEERS.filter(peer => config.roster?.[peer] !== undefined)].map(peer => ({ peer, on: onRoster(peer), startMode: startMode(peer), state: bus.stateOf(peer), queued: bus.queued(peer), tasks: open.filter(task => task.owner === peer || task.reviewer === peer).length }));
|
|
3449
3756
|
}
|
|
3450
3757
|
function rosterCandidate(def: SettingDef, value: SettingValue, undo = false): PeerRoster {
|
|
@@ -3494,6 +3801,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3494
3801
|
else writeConfigSetting(opts.cwd, opts.stateDir, def, value, (scratch) => loadConfig(scratch));
|
|
3495
3802
|
// Apply exactly the authorized key. Other file edits stay pending until the next hub start.
|
|
3496
3803
|
config.roster = next;
|
|
3804
|
+
settlePlanner(); // #455: an automatic planner follows the roster
|
|
3497
3805
|
saved = true;
|
|
3498
3806
|
recordSetting(def.key, before, after, source, undo);
|
|
3499
3807
|
bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
|
|
@@ -3518,7 +3826,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3518
3826
|
if (owner instanceof PiPeer && owner.mode === "tui") await owner.resumeFromRoster();
|
|
3519
3827
|
}
|
|
3520
3828
|
writeStatus();
|
|
3521
|
-
const stayed = !after ? board.list().filter(task => task.state
|
|
3829
|
+
const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
|
|
3522
3830
|
return { ok: true, text: [`${peer}: ${after ? "on" : "off"} roster (in force now)`, ...(!after && terminalNotice ? [terminalNotice] : []), ...(after && peer === "codex" ? [`Codex 0.146.0 needs a manual return: resume the previous native session with ${codexResumeCommand()}.`] : []), ...(!after ? handoff.map(move => sanitize(`task ${move.title}: ${move.role} assignment moved to ${move.to}`)) : []), ...stayed.map(task => sanitize(`task ${tasks.publicTitle(task)} stays with ${peer} as ${[task.owner === peer ? "owner" : "", task.reviewer === peer ? "reviewer" : ""].filter(Boolean).join(" and ")} (${task.state})`)), ...(!after && !handoff.length && !stayed.length ? ["No open tasks to hand over."] : [])].join("\n"), ...(!after ? { handoff } : {}) };
|
|
3523
3831
|
} catch (error) {
|
|
3524
3832
|
if (saved) writeStatus();
|
|
@@ -3568,7 +3876,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3568
3876
|
if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value, (scratch) => def.type === "roles" ? validateRoleSetting(scratch, def.path[1]!) : loadConfig(scratch))) return { ok: true, text: `${def.key}: config.local.json already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
|
|
3569
3877
|
storedConfigCache = undefined;
|
|
3570
3878
|
const to = settingScalar(valueAt(storedConfig(), def.path));
|
|
3571
|
-
if (def.type === "roles") applyRoleSelection(def.path[1]!); else applyStartModes();
|
|
3879
|
+
if (def.type === "roles") applyRoleSelection(def.path[1]!); else if (def.key === "planner") applyPlanner(); else applyStartModes();
|
|
3572
3880
|
recordSetting(def.key, from, to, source);
|
|
3573
3881
|
if (def.type === "roles") event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) });
|
|
3574
3882
|
// Config trust (#17) can drop a machine-local field at load: the write happened, and the person is told it will not count.
|
|
@@ -3595,7 +3903,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3595
3903
|
const from = read();
|
|
3596
3904
|
undoSetting(opts.cwd, opts.stateDir, (scratch) => def.type === "roles" ? validateRoleSetting(scratch, def.path[1]!) : loadConfig(scratch), routingCheck);
|
|
3597
3905
|
storedConfigCache = undefined;
|
|
3598
|
-
if (def.type === "roles") applyRoleSelection(def.path[1]!); else applyStartModes();
|
|
3906
|
+
if (def.type === "roles") applyRoleSelection(def.path[1]!); else if (def.key === "planner") applyPlanner(); else applyStartModes();
|
|
3599
3907
|
recordSetting(def.key, from, read(), settingSource(authority), true);
|
|
3600
3908
|
if (def.type === "roles") event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) });
|
|
3601
3909
|
return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
|
|
@@ -3619,10 +3927,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3619
3927
|
const peer = def.path.at(-1)!;
|
|
3620
3928
|
return { ok: true, lines: [permissionBoundary(peer), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value)}`] : ["inherit: the shared file's default, or ask"])] };
|
|
3621
3929
|
}
|
|
3622
|
-
if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "peer start" ? "applies at the peer's next start" : "read at the next hub start"}`] };
|
|
3930
|
+
if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "peer start" ? "applies at the peer's next start" : def.applies === "live" ? "in force as soon as it is saved" : "read at the next hub start"}`] };
|
|
3623
3931
|
try {
|
|
3624
3932
|
const after = parseRouting(routingText(opts.cwd), routingCandidate(readOverlay(opts.cwd), def, value));
|
|
3625
|
-
const open = board.list().filter((task) => task.state
|
|
3933
|
+
const open = board.list().filter((task) => !isClosedTaskState(task.state));
|
|
3626
3934
|
// A PII task's routing is fixed by its constraint and its text is private: it is counted, not shown.
|
|
3627
3935
|
const shown = open.filter((task) => !tasks.isPii(task)).slice(0, 20);
|
|
3628
3936
|
return { ok: true, lines: [`${def.key}: ${settingText(routingValue(currentRouting(opts.cwd, log), def))} -> ${settingText(routingValue(after, def))}`, ...(open.length ? [] : ["no open task: nothing is routed differently now"])],
|
|
@@ -3735,6 +4043,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3735
4043
|
// #272: the rest of what recoveryReady() waits for, so the upgrade screen can name the wait. Display only.
|
|
3736
4044
|
...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
|
|
3737
4045
|
...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
|
|
4046
|
+
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
3738
4047
|
...((piReceipts?.inFlight ?? 0) !== 0 ? ["Pi tool calls in flight"] : []),
|
|
3739
4048
|
...(releasing ? ["ready tasks being released"] : []),
|
|
3740
4049
|
...([...bus.peers].filter(([, peer]) => peer instanceof PiPeer && peer.state !== "busy" && !peer.recoveryReady).map(([id, peer]) => !membershipAllowed(id) && peer instanceof PiPeer && peer.mode === "tui" ? `${id} is not settled; end the retained Pi TUI, then prepare again` : `${id} is not settled`)),
|
|
@@ -3750,10 +4059,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3750
4059
|
async function recoveryOp(msg: any): Promise<Record<string, unknown>> {
|
|
3751
4060
|
if (typeof msg.op !== "string" || !["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) return recoveryError("unknown recovery operation");
|
|
3752
4061
|
if (typeof msg.expectedInstanceId !== "string" || msg.expectedInstanceId !== instanceId) return recoveryError("expected daemon instance does not match");
|
|
4062
|
+
const incompatibleCancellationTarget = () => {
|
|
4063
|
+
const ids = board.list().filter(task => task.state === "cancelled").map(task => task.id);
|
|
4064
|
+
return ids.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_cancel"))
|
|
4065
|
+
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
4066
|
+
};
|
|
4067
|
+
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
4068
|
+
if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
4069
|
+
|
|
3753
4070
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
3754
4071
|
const pi = bus.peers.get("pi");
|
|
3755
4072
|
if (pi instanceof PiPeer && pi.state === "idle") {
|
|
3756
4073
|
try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
4074
|
+
if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
3757
4075
|
}
|
|
3758
4076
|
}
|
|
3759
4077
|
if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
|
|
@@ -3769,6 +4087,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3769
4087
|
recoveryPeerSnapshot = undefined;
|
|
3770
4088
|
}
|
|
3771
4089
|
if (recoveryOperationId && recoveryOperationId !== op) return recoveryError("another recovery operation is active");
|
|
4090
|
+
{ const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
3772
4091
|
recoveryOperationId = op;
|
|
3773
4092
|
if (!recoveryPhase) recoveryPhase = "preparing";
|
|
3774
4093
|
armRecoveryLease();
|
|
@@ -3776,6 +4095,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3776
4095
|
budget.setRecoveryHold(true);
|
|
3777
4096
|
bus.setRecoveryHold(true);
|
|
3778
4097
|
await bus.fenceRecovery();
|
|
4098
|
+
{ const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
3779
4099
|
const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
|
|
3780
4100
|
if (!blocked.length && permissions.size === 0 && recoveryReady()) {
|
|
3781
4101
|
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
@@ -3823,6 +4143,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3823
4143
|
peers,
|
|
3824
4144
|
integrity: integrity(),
|
|
3825
4145
|
};
|
|
4146
|
+
{ const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
3826
4147
|
try { writeRestartSnapshot(opts.stateDir, snapshot); } catch { return recoveryError("could not persist restart state"); }
|
|
3827
4148
|
recoveryCommitted = true;
|
|
3828
4149
|
clearTimeout(recoveryLeaseTimer);
|
|
@@ -4009,7 +4330,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4009
4330
|
if (important) ws.notifyQueued({ queued: bus.queued(c.peer!), important });
|
|
4010
4331
|
});
|
|
4011
4332
|
}
|
|
4012
|
-
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL });
|
|
4333
|
+
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
|
|
4013
4334
|
}
|
|
4014
4335
|
if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
|
|
4015
4336
|
if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
|
|
@@ -4048,11 +4369,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4048
4369
|
return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
|
|
4049
4370
|
} catch { return void reply({ ok: false, error: "resolution refused: refresh the delivery revision and inspect active or already resolved work" }); }
|
|
4050
4371
|
}
|
|
4372
|
+
case "delivery_holds": {
|
|
4373
|
+
if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
|
|
4374
|
+
try {
|
|
4375
|
+
const held = bus.heldDeliveryHints(c.peer);
|
|
4376
|
+
return void reply(held ? { ok: true, held } : { ok: false, error: "held metadata unavailable" });
|
|
4377
|
+
} catch { return void reply({ ok: false, error: "held metadata unavailable" }); }
|
|
4378
|
+
}
|
|
4051
4379
|
case "delivery_receipt": {
|
|
4052
4380
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
4053
|
-
if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
|
|
4381
|
+
if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review", "failed_safe"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
|
|
4382
|
+
if (msg.state === "failed_safe" && (!peer.pullOnly || bus.queueShow(msg.deliveryId)?.state !== "dispatching")) return void reply({ ok: false, error: "safe failure requires an unseen push to a pull-only session" });
|
|
4054
4383
|
const reason = typeof msg.reason === "string" ? sanitize(msg.reason).replace(/\s+/g, " ").trim().slice(0, 300) : "";
|
|
4055
|
-
bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state
|
|
4384
|
+
bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state !== "accepted" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
|
|
4056
4385
|
return void reply({ ok: true });
|
|
4057
4386
|
}
|
|
4058
4387
|
case "inbox": {
|
|
@@ -4501,6 +4830,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4501
4830
|
}
|
|
4502
4831
|
async function stopOnce(): Promise<void> {
|
|
4503
4832
|
stopping = true;
|
|
4833
|
+
runnerRefreshAbort.abort();
|
|
4504
4834
|
// A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
|
|
4505
4835
|
try { removeSessions(opts.stateDir, instanceId); } catch { /* the state dir is gone */ }
|
|
4506
4836
|
checksClosed = true;
|
|
@@ -4514,6 +4844,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4514
4844
|
try {
|
|
4515
4845
|
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
4516
4846
|
// Drain those starts before taking the final owned-process snapshot.
|
|
4847
|
+
// Fence/abort the runner now, but slow container teardown must not consume the peer-stop deadline.
|
|
4848
|
+
const runnerStopped = Promise.allSettled([checkRunner.stopAll(), refreshingRunner ?? Promise.resolve()]);
|
|
4517
4849
|
await Promise.allSettled([...starting.values()]);
|
|
4518
4850
|
// A fingerprint still being detected belongs in the research records written below; its peer must still be
|
|
4519
4851
|
// attached when it settles, so this comes before the peers stop (each detection is bounded by its probe deadline).
|
|
@@ -4522,6 +4854,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4522
4854
|
// A peer whose stop rejects must not strand the daemon (issue #56): its failure is
|
|
4523
4855
|
// logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
|
|
4524
4856
|
for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
|
|
4857
|
+
await runnerStopped;
|
|
4525
4858
|
await modelRelay?.close();
|
|
4526
4859
|
await egress?.close();
|
|
4527
4860
|
await piReceipts?.close();
|
|
@@ -4566,7 +4899,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4566
4899
|
writeFileSync(tokenFile, token, { mode: 0o600 });
|
|
4567
4900
|
chmodSync(tokenFile, 0o600);
|
|
4568
4901
|
budget.restore(); // pauses recorded by an earlier hub run stay in force; unfinished handoffs wait for peers to attach
|
|
4569
|
-
|
|
4902
|
+
bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
|
|
4570
4903
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
4571
4904
|
ready = true;
|
|
4572
4905
|
// #258: approvals whose settle entry a crash cut off are settled now (decisions, contracts and fails reach long-term).
|