@staix/agent-hub 0.12.30 → 0.12.32
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 +33 -0
- package/README.md +29 -9
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +5 -0
- package/docs/agent-notes/daemon.md +14 -8
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +2 -2
- package/docs/operations.md +236 -37
- package/docs/quickstart.md +24 -5
- package/docs/security.md +42 -10
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -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/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verified.json +20 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1361 -44
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/definitions/builtin-metadata.ts +121 -0
- package/src/adapters/definitions/kimi-native.ts +4 -0
- package/src/adapters/definitions/qwen-native.ts +24 -0
- package/src/adapters/definitions/qwen.ts +71 -0
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +11 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +130 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +16 -5
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +208 -40
- package/src/cli/output.ts +15 -2
- package/src/cli/preview.ts +8 -5
- package/src/cli/recovery-guidance.ts +137 -0
- package/src/cli/recovery-presentation-options.ts +8 -0
- package/src/cli/recovery-session-file.ts +18 -0
- package/src/cli/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -74
- package/src/cli/upgrade-labels.ts +30 -0
- package/src/cli/upgrade-presentation-data.ts +41 -0
- package/src/cli/upgrade-presentation-flags.ts +31 -0
- package/src/cli/upgrade-presentation.ts +217 -0
- package/src/cli/upgrade-restore-state.ts +137 -0
- package/src/cli/upgrade-runtime.ts +430 -175
- package/src/cli/upgrade.ts +325 -49
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +89 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/cohorts.ts +2 -2
- package/src/hub/config-apply.ts +242 -0
- package/src/hub/config-reload.ts +120 -0
- package/src/hub/config-trust.ts +11 -2
- package/src/hub/control-client.ts +5 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +931 -209
- 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/events.ts +2 -2
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +13 -13
- package/src/hub/peers.ts +12 -2
- package/src/hub/permission-mode.ts +5 -3
- package/src/hub/recovery-continuity.ts +152 -0
- package/src/hub/recovery-store.ts +10 -4
- package/src/hub/routing.ts +35 -16
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +52 -37
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +24 -8
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +199 -40
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/memory/capture.ts +2 -1
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +35 -16
- 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";
|
|
@@ -14,22 +19,26 @@ import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from
|
|
|
14
19
|
import { ActivityRing } from "./activity.ts";
|
|
15
20
|
import { JcodePeer } from "../adapters/jcode.ts";
|
|
16
21
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
17
|
-
import { loadSdlc } from "./sdlc.ts";
|
|
22
|
+
import { loadSdlc, sdlcGh } 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
|
-
import { buildKimiLaunch
|
|
26
|
+
import { buildKimiLaunch } from "../cli/launch.ts";
|
|
27
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
22
28
|
import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
|
|
23
29
|
import type { ToolObservation } from "../models/route/signals.ts";
|
|
24
30
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
25
|
-
import { appendFileSync, chmodSync, existsSync, fstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { appendFileSync, chmodSync, existsSync, fstatSync, mkdirSync, readFileSync, renameSync, rmSync, watchFile, unwatchFile, writeFileSync } from "node:fs";
|
|
26
32
|
import { join, relative, resolve } from "node:path";
|
|
27
33
|
import { homedir } from "node:os";
|
|
28
34
|
import { spawn } from "node:child_process";
|
|
29
35
|
import { childEnv } from "./child-process.ts";
|
|
30
36
|
import { runCheck } from "./checks.ts";
|
|
31
37
|
import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
|
|
32
|
-
import { stripUntrusted } from "./config-trust.ts";
|
|
38
|
+
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
|
|
39
|
+
import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig } from "./config-apply.ts";
|
|
40
|
+
import { resolveSettledRoles } from "./settled-roles.ts";
|
|
41
|
+
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
|
|
33
42
|
import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
|
|
34
43
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
35
44
|
import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
@@ -42,7 +51,7 @@ import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
|
|
|
42
51
|
import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
|
|
43
52
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
44
53
|
import { processSignature } from "../pi/process-signature.ts";
|
|
45
|
-
import { recordPiReplacement, validatePiSession } from "../pi/session.ts";
|
|
54
|
+
import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
|
|
46
55
|
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
47
56
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
48
57
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
@@ -51,16 +60,16 @@ import { Capture, skipTools } from "../memory/capture.ts";
|
|
|
51
60
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
52
61
|
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
53
62
|
import { Briefs } from "../memory/brief.ts";
|
|
54
|
-
import { Board, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
63
|
+
import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
55
64
|
import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
56
65
|
import { HUB } from "./envelope.ts";
|
|
57
66
|
import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
|
|
58
67
|
import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
|
|
59
68
|
import { basename, isAbsolute } from "node:path";
|
|
60
69
|
import { fileURLToPath } from "node:url";
|
|
61
|
-
import { realPath } from "./project.ts";
|
|
70
|
+
import { realPath, hubHome } from "./project.ts";
|
|
62
71
|
import type { BusEvent } from "./bus.ts";
|
|
63
|
-
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
72
|
+
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
64
73
|
import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
|
|
65
74
|
import { SupervisionFeed } from "./supervision.ts";
|
|
66
75
|
import { drainCliAudits } from "../cli/identity-audit.ts";
|
|
@@ -68,13 +77,13 @@ import { launcherPreview } from "../cli/preview.ts";
|
|
|
68
77
|
import { AssignmentUndeliveredError, Tasks } from "./tasks.ts";
|
|
69
78
|
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
|
|
70
79
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
71
|
-
import { currentRouting, detectSignals, OVERLAY_FILE, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
72
|
-
import { checkSettingValue, ignoredLine, pendingUndo, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue } from "./settings.ts";
|
|
80
|
+
import { currentRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
81
|
+
import { SETTINGS, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
73
82
|
import { Bus } from "./bus.ts";
|
|
74
83
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
75
84
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
76
85
|
import { startDashboard, type DashboardSession } from "./ui.ts";
|
|
77
|
-
import { PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
86
|
+
import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
78
87
|
import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
79
88
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
80
89
|
import { MemoryClient, workerUrl } from "../memory/client.ts";
|
|
@@ -90,6 +99,8 @@ import type { JournalDelivery } from "./delivery-journal.ts";
|
|
|
90
99
|
import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
|
|
91
100
|
import { changedPaths, hasTree, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
|
|
92
101
|
import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
102
|
+
import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
|
|
103
|
+
import { shellQuote } from "../cli/terminal-recovery.ts";
|
|
93
104
|
|
|
94
105
|
import { isStartablePeer, peerStartConfig, PEER_START_RETRY_MS, STARTABLE_PEERS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
|
|
95
106
|
import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
|
|
@@ -104,7 +115,7 @@ import { syncMemory } from "@staix/agent-mem";
|
|
|
104
115
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
|
|
105
116
|
import { memoryProjectKey } from "@staix/agent-mem";
|
|
106
117
|
import { nativeRecall } from "../memory/recall.ts";
|
|
107
|
-
import { promotionScreen, withoutPii } from "./memory-policy.ts";
|
|
118
|
+
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
108
119
|
|
|
109
120
|
export interface HubConfig {
|
|
110
121
|
watchdog_ms: number;
|
|
@@ -125,6 +136,8 @@ export interface HubConfig {
|
|
|
125
136
|
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
137
|
roster: PeerRoster;
|
|
127
138
|
roles: Record<string, string[]>;
|
|
139
|
+
/** #455: the one peer that holds the planner role; unset, the `roles` planners stand, or the hub assigns one. */
|
|
140
|
+
planner?: string;
|
|
128
141
|
profile?: ProfileName;
|
|
129
142
|
profile_overrides?: Record<string, unknown>;
|
|
130
143
|
pii?: { paths: string[] };
|
|
@@ -147,7 +160,7 @@ export interface HubConfig {
|
|
|
147
160
|
/** Between-turn task escalation, disabled unless explicitly enabled (#186). */
|
|
148
161
|
task_sweep: TaskSweepConfig;
|
|
149
162
|
/** 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 };
|
|
163
|
+
checks: { timeout_s: number; runner?: "container"; [cls: string]: string | number | undefined };
|
|
151
164
|
/** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
|
|
152
165
|
snapshots: { enabled: boolean; keep: number };
|
|
153
166
|
/** #247: opt-in research records of each approved task in ~/.agenthub/research (ids, counts and tokens only). */
|
|
@@ -228,17 +241,24 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
228
241
|
|
|
229
242
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
230
243
|
const CONFIG_FILES = ["config.json", "config.local.json"] as const;
|
|
244
|
+
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
231
245
|
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
246
|
|
|
233
247
|
/** 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"] } {
|
|
248
|
+
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
|
|
235
249
|
const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
|
|
236
250
|
const conductor = { ...DEFAULT_CONFIG.conductor };
|
|
251
|
+
let planner: string | undefined;
|
|
237
252
|
for (const name of CONFIG_FILES) {
|
|
238
253
|
let file: Record<string, unknown>;
|
|
239
|
-
|
|
254
|
+
if (layers && !layers.some(layer => layer.name === name)) continue;
|
|
255
|
+
try { file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")); }
|
|
240
256
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
241
257
|
if (file.roles !== undefined) { conductorPeer(file.roles); Object.assign(roles, file.roles); }
|
|
258
|
+
if (file.planner !== undefined) {
|
|
259
|
+
if (!(PLANNER_PEERS as readonly unknown[]).includes(file.planner)) throw new Error(`planner must be one of ${PLANNER_PEERS.join(", ")}`);
|
|
260
|
+
planner = file.planner as string;
|
|
261
|
+
}
|
|
242
262
|
if (file.conductor !== undefined) {
|
|
243
263
|
if (!file.conductor || typeof file.conductor !== "object" || Array.isArray(file.conductor)) throw new Error("conductor must be an object");
|
|
244
264
|
const block = file.conductor as Record<string, unknown>;
|
|
@@ -253,7 +273,7 @@ export function loadConductorPolicy(cwd: string): { roles: Record<string, string
|
|
|
253
273
|
}
|
|
254
274
|
}
|
|
255
275
|
conductorPeer(roles);
|
|
256
|
-
return { roles, conductor };
|
|
276
|
+
return { roles, conductor, ...(planner ? { planner } : {}) };
|
|
257
277
|
}
|
|
258
278
|
|
|
259
279
|
/** `memory.short_ttl_days`: a whole number of days from 1 to 3650; anything else would make every short-term deadline invalid. */
|
|
@@ -267,13 +287,14 @@ function syncMinutes(v: unknown): number {
|
|
|
267
287
|
return v;
|
|
268
288
|
}
|
|
269
289
|
|
|
270
|
-
export function loadConfig(cwd: string): HubConfig {
|
|
290
|
+
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubConfig {
|
|
271
291
|
const ignored: string[] = [];
|
|
272
292
|
const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
273
293
|
const files = CONFIG_FILES.flatMap((name) => {
|
|
274
294
|
let file: Record<string, any>; // parsed JSON, checked field by field below
|
|
295
|
+
if (layers && !layers.some(layer => layer.name === name)) return [];
|
|
275
296
|
try {
|
|
276
|
-
file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
|
|
297
|
+
file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
|
|
277
298
|
} catch (error) {
|
|
278
299
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
279
300
|
throw error;
|
|
@@ -328,7 +349,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
328
349
|
throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
|
|
329
350
|
}
|
|
330
351
|
const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
|
|
331
|
-
const conductorPolicy = loadConductorPolicy(cwd);
|
|
352
|
+
const conductorPolicy = loadConductorPolicy(cwd, layers);
|
|
332
353
|
if (!mlx.enabled && file.pi?.backend === "mlx") throw new Error("Pi backend mlx conflicts with mlx.enabled=false");
|
|
333
354
|
if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
|
|
334
355
|
if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
|
|
@@ -345,6 +366,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
345
366
|
...(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
367
|
roster: peerRosterConfig(file.roster),
|
|
347
368
|
roles: conductorPolicy.roles,
|
|
369
|
+
planner: conductorPolicy.planner,
|
|
348
370
|
conductor: conductorPolicy.conductor,
|
|
349
371
|
budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
|
|
350
372
|
context: { ...DEFAULT_CONTEXT, ...file.context },
|
|
@@ -358,7 +380,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
358
380
|
},
|
|
359
381
|
tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks, ...(file.tasks?.wip_limits !== undefined ? { wip_limits: planningWipLimits(file.tasks.wip_limits) } : {}) },
|
|
360
382
|
task_sweep: taskSweepConfig(file.task_sweep),
|
|
361
|
-
checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
|
|
383
|
+
checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "container" ? "container" : undefined },
|
|
362
384
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
363
385
|
research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
|
|
364
386
|
bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
|
|
@@ -423,6 +445,12 @@ export interface DaemonOptions {
|
|
|
423
445
|
researchGraceMs?: number;
|
|
424
446
|
/** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
|
|
425
447
|
memorySync?: typeof syncMemory;
|
|
448
|
+
/** Host-only test/embedding transports. Never read from config or tool input. */
|
|
449
|
+
boundedTools?: {
|
|
450
|
+
specAvailable?: typeof specAvailable;
|
|
451
|
+
fetchSpec?: typeof fetchSpec;
|
|
452
|
+
runner?: (options: RunnerOptions) => Pick<ContainerRunner, "available" | "start" | "result" | "consumeOutput" | "cancel" | "sweep" | "stopAll" | "markStale"> & Partial<Pick<ContainerRunner, "unknownReceipts" | "maintenanceUnavailable">>;
|
|
453
|
+
};
|
|
426
454
|
}
|
|
427
455
|
|
|
428
456
|
interface Client {
|
|
@@ -544,7 +572,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
544
572
|
try {
|
|
545
573
|
const loadedConfig = opts.config ?? loadConfig(opts.cwd);
|
|
546
574
|
const lifecycleConfig = loadSdlc(opts.cwd);
|
|
547
|
-
|
|
575
|
+
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
576
|
+
const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
|
|
577
|
+
const config = { ...ownedConfig, roster: peerRosterConfig(loadedConfig.roster), permission_modes: permissionDefaults(loadedConfig.permission_modes), roles: { ...loadedConfig.roles }, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
|
|
548
578
|
let rosterChanging: string | undefined;
|
|
549
579
|
let kimiSessionMode: StartMode | undefined;
|
|
550
580
|
let kimiAdmissionId: string | undefined;
|
|
@@ -560,6 +590,39 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
560
590
|
const logFile = join(opts.stateDir, "hub.log");
|
|
561
591
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
562
592
|
const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
|
|
593
|
+
let runningRouting = currentRouting(opts.cwd, log);
|
|
594
|
+
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting);
|
|
595
|
+
const startupSources = structuredClone(acceptedSources.trustLayers);
|
|
596
|
+
const embeddedConfig = structuredClone(config);
|
|
597
|
+
let desiredConfig = structuredClone(config);
|
|
598
|
+
const startupRouting = structuredClone(runningRouting);
|
|
599
|
+
let desiredRouting = structuredClone(runningRouting);
|
|
600
|
+
let configRevision = 0;
|
|
601
|
+
let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
|
|
602
|
+
let pendingRestart: { key: string; reason: string }[] = [];
|
|
603
|
+
let lastReloadFailure: string | undefined;
|
|
604
|
+
let reloadDebounce: ReturnType<typeof setTimeout> | undefined;
|
|
605
|
+
let configWatchPending = false;
|
|
606
|
+
const configWatchers: { path: string; listener: (current: import("node:fs").Stats, previous: import("node:fs").Stats) => void }[] = [];
|
|
607
|
+
const stopConfigWatch = () => { configWatchPending = false; clearTimeout(reloadDebounce); for (const item of configWatchers.splice(0)) unwatchFile(item.path, item.listener); };
|
|
608
|
+
const activeRouting = () => runningRouting;
|
|
609
|
+
// #455: the planner in force. `policyRoles` is what the config files say; settlePlanner derives config.roles from it in
|
|
610
|
+
// place (Tasks holds the same object): an explicit `planner` key is exclusive, and with none and no rostered planner
|
|
611
|
+
// the first rostered PLANNER_PEERS entry plans. Never written to disk: a person pins it with the `planner` setting.
|
|
612
|
+
let policyRoles = structuredClone(config.roles);
|
|
613
|
+
let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
|
|
614
|
+
let plannerLogged = "";
|
|
615
|
+
const settlePlanner = () => {
|
|
616
|
+
const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
|
|
617
|
+
for (const key of Object.keys(config.roles)) delete config.roles[key];
|
|
618
|
+
Object.assign(config.roles, roles);
|
|
619
|
+
plannerSource = source;
|
|
620
|
+
const line = `${plannerPeers().join(", ") || "nobody"} (${source})`;
|
|
621
|
+
if (line !== plannerLogged) { log(`planner: ${line}${source === "automatic" ? "; pin it with ahub settings set planner <peer>" : ""}`); plannerLogged = line; }
|
|
622
|
+
};
|
|
623
|
+
const plannerPeers = () => Object.keys(config.roles).filter(peer => config.roles[peer]!.includes("planner"));
|
|
624
|
+
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`);
|
|
625
|
+
settlePlanner();
|
|
563
626
|
const catalog = loadRoleCatalog(opts.cwd);
|
|
564
627
|
for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
|
|
565
628
|
const resolvedProfile = resolveProfile(config.profile, config.profile_overrides);
|
|
@@ -579,16 +642,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
579
642
|
try { configured = configuredRoles(peer); } catch { note = "catalog selection is incompatible or unavailable"; }
|
|
580
643
|
const prepared = preparedSessionRoles.get(peer);
|
|
581
644
|
const active = bus.peers.get(peer)?.state !== "offline" && bus.peers.has(peer);
|
|
645
|
+
let desired = configured;
|
|
646
|
+
try { desired = selectedRoles(peer, config.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(config.catalog_roles) }, catalog).map(role => role.id); }
|
|
647
|
+
catch { note = "catalog selection is incompatible or unavailable"; }
|
|
582
648
|
const value = active ? prepared ?? null : configured;
|
|
583
|
-
return { value, values: compatibleRoles(peer), ...(
|
|
584
|
-
}
|
|
585
|
-
function validateRoleSetting(scratch: string, peer: string) {
|
|
586
|
-
const candidate = loadConfig(scratch);
|
|
587
|
-
selectedRoles(peer, config.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(candidate.catalog_roles) }, catalog);
|
|
588
|
-
return candidate;
|
|
649
|
+
return { value, values: compatibleRoles(peer), ...(JSON.stringify(value) !== JSON.stringify(desired) ? { pending: desired } : {}), ...(note ? { note } : active && !prepared ? { note: "Current session guidance is unconfirmed; configuration is not receipt of model consumption." } : {}) };
|
|
589
650
|
}
|
|
651
|
+
const prepareRoleSelection = (peer: string) => { roleAssignments[peer] = catalogAssignments(config.catalog_roles)[peer] ?? resolvedProfile.catalog_roles[peer] ?? []; };
|
|
590
652
|
function sessionRoleContract(peer: string): string {
|
|
591
653
|
try {
|
|
654
|
+
prepareRoleSelection(peer);
|
|
592
655
|
const selected = selectedRoles(peer, config.roles, roleAssignments, catalog);
|
|
593
656
|
preparedSessionRoles.set(peer, selected.map(role => role.id));
|
|
594
657
|
preparedSessionRoleIdentities.set(peer, selected.map(roleIdentity));
|
|
@@ -694,8 +757,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
694
757
|
|
|
695
758
|
// A session record left by a run that never shut down means it crashed (issue #37). A controlled restart has its own.
|
|
696
759
|
const crashed = !recoveryOperation && !restartFilePresent ? readSessions(opts.stateDir) : undefined;
|
|
760
|
+
let piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
761
|
+
// A disposed operation's exact session takes precedence over a stale crash record for this whole startup.
|
|
762
|
+
const archivedPiContinuity = piContinuity.state !== "absent";
|
|
697
763
|
if (config.mlx.enabled === false) {
|
|
698
|
-
const launches = [...(restored?.peers ?? []).filter(peer => peer.id === "pi").map(peer => peer.launch), ...(crashed?.peers ?? []).filter(peer => peer.peer === "pi").map(peer => peer.meta.launch)];
|
|
764
|
+
const launches = [...(restored?.peers ?? []).filter(peer => peer.id === "pi").map(peer => peer.launch), ...(crashed?.peers ?? []).filter(peer => peer.peer === "pi" && !archivedPiContinuity).map(peer => peer.meta.launch)];
|
|
699
765
|
for (const launch of launches) {
|
|
700
766
|
const problem = mlxLaunchProblem(config, (launch ?? {}) as { backend?: unknown; model?: unknown });
|
|
701
767
|
if (problem) throw new Error(`recorded Pi recovery: ${problem}`);
|
|
@@ -704,7 +770,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
704
770
|
// A controlled restart's source may have been cut short before it removed its record: this run is not a crash, and
|
|
705
771
|
// a record left now would make the next ordinary start look like one.
|
|
706
772
|
if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
|
|
707
|
-
const autoResume = config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
|
|
773
|
+
const autoResume = () => config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
|
|
708
774
|
const piAutoStart = () => onRoster("pi") && config.pi.auto_start;
|
|
709
775
|
const startedAt = Date.now();
|
|
710
776
|
/** What crash recovery did or asks the user to do, for `ahub status`. */
|
|
@@ -718,12 +784,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
718
784
|
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
785
|
// Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
|
|
720
786
|
// 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 => {
|
|
787
|
+
const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run"): boolean => {
|
|
722
788
|
if (peer === USER || peer === HUB || !Object.hasOwn(config.capabilities, peer)) return true;
|
|
723
789
|
const list = config.capabilities[peer];
|
|
724
790
|
return Array.isArray(list) && list.includes(cap);
|
|
725
791
|
};
|
|
726
|
-
const CAPABILITIES = ["propose", "assign", "remember", "memory_correct", "memory_read", "important"];
|
|
792
|
+
const CAPABILITIES = ["propose", "assign", "remember", "memory_correct", "memory_read", "important", "spec_read", "check_run"];
|
|
727
793
|
for (const [peer, list] of Object.entries(config.capabilities)) {
|
|
728
794
|
if (!PEER_ID.test(peer)) log(`capabilities.${peer} is not a peer id; ignored (capabilities is an object of lists, one per peer)`);
|
|
729
795
|
else if (!Array.isArray(list)) log(`capabilities.${peer} is not a list: ${peer} gets no capabilities`);
|
|
@@ -738,7 +804,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
738
804
|
// silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
|
|
739
805
|
// together. Cached until the board changes: it is asked per recipient of every message.
|
|
740
806
|
let turnFreeNow: boolean | undefined;
|
|
741
|
-
const
|
|
807
|
+
const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
|
|
808
|
+
const turnFree = (): boolean => (config.coordination === "turn-free" || tasks.cohorts.list().some(cohort => cohort.silent)) && ![...turns.values()].some(turn => turn.private) && (turnFreeNow ??= !board.list().some((t) => !isClosedTaskState(t.state) && tasks.isPii(t)));
|
|
742
809
|
/**
|
|
743
810
|
* Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
|
|
744
811
|
* has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
|
|
@@ -783,10 +850,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
783
850
|
const hubStartedAt = Date.now();
|
|
784
851
|
// The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
|
|
785
852
|
let relevantNotice: (peer: PeerId, env: Envelope) => boolean = () => true;
|
|
786
|
-
const staleOff = config.experiments?.stale_notices === "deliver";
|
|
787
|
-
if (staleOff) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
|
|
853
|
+
const staleOff = () => config.experiments?.stale_notices === "deliver";
|
|
854
|
+
if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
|
|
788
855
|
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,
|
|
856
|
+
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
857
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
|
|
791
858
|
startupCleanup.push(() => bus.closeJournal());
|
|
792
859
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
@@ -842,7 +909,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
842
909
|
memoryNotice(`hub native memory disabled: ${reason}`);
|
|
843
910
|
};
|
|
844
911
|
if (config.memory.native) {
|
|
845
|
-
try { const opened = openProfileMemory({ ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () =>
|
|
912
|
+
try { const opened = openProfileMemory({ ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () => activeRouting(), () => config.pii?.paths ?? [], (key) => {
|
|
846
913
|
const m = /#(\d+)@(\d+)$/.exec(key), t = m ? board.get(Number(m[1])) : undefined;
|
|
847
914
|
return t && String(t.created) === m![2] ? [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])] : [];
|
|
848
915
|
}) }, memoryUnavailable); memoryStore = opened.store; memoryBinding = opened.binding; startupCleanup.push(() => opened.store.close()); }
|
|
@@ -867,7 +934,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
867
934
|
* Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
|
|
868
935
|
* policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
|
|
869
936
|
*/
|
|
870
|
-
const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs,
|
|
937
|
+
const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs, activeRouting(), opts.cwd, config.pii?.paths ?? []);
|
|
871
938
|
let candidates: CandidateStore | undefined;
|
|
872
939
|
if (memoryStore) {
|
|
873
940
|
try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
|
|
@@ -978,7 +1045,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
978
1045
|
const onCampus = async (): Promise<boolean> => (await omni.onCampus()) && !(sidecar?.upstream && omni.isAccessHost(sidecar.upstream));
|
|
979
1046
|
let sidecar: Sidecar | undefined; // L2, started by the first hub-owned model call, stopped with the hub
|
|
980
1047
|
let sidecarRouting = "";
|
|
981
|
-
inference = new Inference(config.inference, { omni, sidecar: () => sidecar, route: "sy/fast", fixedModel: () =>
|
|
1048
|
+
inference = new Inference(config.inference, { omni, sidecar: () => sidecar, route: "sy/fast", fixedModel: () => activeRouting().local.fixed_model, log });
|
|
982
1049
|
|
|
983
1050
|
let dashboard: ReturnType<typeof startDashboard> | undefined;
|
|
984
1051
|
const uiEvents: { seq: number; event: BusEvent }[] = [];
|
|
@@ -1000,7 +1067,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1000
1067
|
conductor: () => conductorPeer(config.roles) ?? undefined,
|
|
1001
1068
|
scope: () => config.conductor?.feed ?? "own",
|
|
1002
1069
|
tasks: () => board.list(), publicTitle: (task) => tasks.publicTitle(task), isPrivate: (task) => tasks.isPii(task),
|
|
1003
|
-
approvalAgeMs: (config.conductor?.approval_wait_s ?? 30) * 1000,
|
|
1070
|
+
approvalAgeMs: () => (config.conductor?.approval_wait_s ?? 30) * 1000,
|
|
1004
1071
|
readRound: (peer) => conductorHolds.readRound(peer), writeRound: (peer, signature) => conductorHolds.writeRound(peer, signature),
|
|
1005
1072
|
emit: (notice) => {
|
|
1006
1073
|
if (stopping || recoveryActive()) return false;
|
|
@@ -1011,10 +1078,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1011
1078
|
const refreshConductorPolicy = () => {
|
|
1012
1079
|
if (opts.config) return; // explicit embedding/test configuration remains its authority
|
|
1013
1080
|
const before = conductorPeer(config.roles);
|
|
1014
|
-
const policy =
|
|
1015
|
-
|
|
1016
|
-
|
|
1081
|
+
const policy = { roles: policyRoles, planner: config.planner, conductor: config.conductor };
|
|
1082
|
+
policyRoles = structuredClone(policy.roles);
|
|
1083
|
+
config.planner = policy.planner;
|
|
1017
1084
|
config.conductor = policy.conductor;
|
|
1085
|
+
settlePlanner();
|
|
1018
1086
|
const after = conductorPeer(config.roles);
|
|
1019
1087
|
if (before && (before !== after || config.conductor.feed === "off")) { bus.revokeSupervision(before); supervision.resetPending(before); budgetFeedSeen.clear(); }
|
|
1020
1088
|
if (config.conductor.feed === "off" && after) bus.revokeSupervision(after);
|
|
@@ -1023,8 +1091,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1023
1091
|
startupCleanup.push(() => executionBudget.close());
|
|
1024
1092
|
// Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
|
|
1025
1093
|
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));
|
|
1094
|
+
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>;
|
|
1095
|
+
const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && k !== "runner" && !(k in checkCommands));
|
|
1028
1096
|
if (strayChecks.length) log(`checks ignored for ${strayChecks.join(", ")}: not a task class with a command (classes: ${CLASSES.join(", ")})`);
|
|
1029
1097
|
// Commands and the other machine-local fields came only from a file git vouched for (loadConfig, issue #17).
|
|
1030
1098
|
for (const line of config.ignored ?? []) log(`${line}; only a config file nobody committed may set it`);
|
|
@@ -1034,7 +1102,96 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1034
1102
|
const runningChecks = new Set<() => void>();
|
|
1035
1103
|
let checksClosed = false; // set on stop: a check still queued then must not start and outlive the hub
|
|
1036
1104
|
const interrupted = { code: null, timedOut: false, interrupted: true, tail: "" };
|
|
1105
|
+
// Bounded capabilities (#418): inactive sandboxed peers need no host account probe.
|
|
1106
|
+
// A config-file daemon probes configured Pi/runner at startup; embeddings opt in or probe lazily on use.
|
|
1107
|
+
let specState: Awaited<ReturnType<typeof specAvailable>> = { available: false, reason: "gh unavailable: not probed" };
|
|
1108
|
+
let specProbe: Promise<void> | undefined, specProbeSettled = false;
|
|
1109
|
+
const ensureSpec = (refresh = false): Promise<void> => {
|
|
1110
|
+
if (specProbe && (!refresh || !specProbeSettled)) return specProbe;
|
|
1111
|
+
specProbeSettled = false;
|
|
1112
|
+
specProbe = Promise.resolve().then(() => (opts.boundedTools?.specAvailable ?? specAvailable)({ projectDir: opts.cwd }))
|
|
1113
|
+
.then(value => { specState = value; }, () => { specState = { available: false, reason: "gh unavailable" }; })
|
|
1114
|
+
.finally(() => { specProbeSettled = true; });
|
|
1115
|
+
return specProbe;
|
|
1116
|
+
};
|
|
1117
|
+
const checkPending = new Set<string>();
|
|
1118
|
+
const checkConnections = new WeakMap<object, string>();
|
|
1119
|
+
const checkBindings = new Map<string, Pick<RunReceipt, "task" | "owner" | "generation" | "updated" | "revision">>();
|
|
1120
|
+
const connectionGeneration = (id: string): string | undefined => {
|
|
1121
|
+
const peer = bus.peers.get(id);
|
|
1122
|
+
if (!peer || peer.state === "offline") return undefined;
|
|
1123
|
+
let token = checkConnections.get(peer);
|
|
1124
|
+
if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
|
|
1125
|
+
const native = peer instanceof WsPeer || peer instanceof CodexPeer || peer instanceof PiPeer ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
|
|
1126
|
+
if (peer instanceof PiPeer && native === undefined) return undefined;
|
|
1127
|
+
return `${instanceId}:${token}:${String(native ?? "")}`;
|
|
1128
|
+
};
|
|
1129
|
+
const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
|
|
1130
|
+
const task = board.get(receipt.task);
|
|
1131
|
+
return !!task && task.owner === receipt.owner && task.state === "in_progress" && task.updated === receipt.updated && task.history.length === receipt.revision
|
|
1132
|
+
&& connectionGeneration(receipt.owner) === receipt.generation && may(receipt.owner, "check_run") && membershipAllowed(receipt.owner) && !tasks.isPii(task)
|
|
1133
|
+
&& !turns.get(receipt.owner)?.private && !contextPii(receipt.owner);
|
|
1134
|
+
};
|
|
1135
|
+
const boundedSafe = (text: string) => !looksSecret(text) && screenMemory([{ title: null, text }]).length === 1
|
|
1136
|
+
&& !mentionsPrivatePath(text, [], [...(config.pii?.paths ?? []), ...config.local.deny], opts.cwd);
|
|
1137
|
+
const publicCheck = (receipt: RunReceipt) => {
|
|
1138
|
+
const safeTail = boundedSafe(receipt.tail), safeError = !receipt.error || boundedSafe(receipt.error);
|
|
1139
|
+
return { run: receipt.run, task: receipt.task, status: receipt.status, sha: receipt.sha, tree: receipt.tree, lockfile: receipt.lockfile,
|
|
1140
|
+
excluded: receipt.excluded, runner: receipt.runner, image: receipt.image, platform: receipt.platform,
|
|
1141
|
+
check: receipt.check, ...(receipt.files ? { files: receipt.files } : {}), exit: receipt.exit, timed_out: receipt.timed_out,
|
|
1142
|
+
cancelled: receipt.cancelled, interrupted: receipt.interrupted, duration_ms: receipt.duration_ms,
|
|
1143
|
+
tail: safeTail ? receipt.tail : "", output_bytes: receipt.output_bytes, truncated: receipt.truncated,
|
|
1144
|
+
output_withheld: receipt.output_withheld || !safeTail || !safeError, ...(safeError && receipt.error ? { error: receipt.error } : {}) };
|
|
1145
|
+
};
|
|
1146
|
+
const checkOptions: RunnerOptions = {
|
|
1147
|
+
projectDir: opts.cwd, stateDir: opts.stateDir, storageDir: join(hubHome(), "checks"),
|
|
1148
|
+
projectKey: createHash("sha256").update(realPath(opts.cwd)).digest("hex").slice(0, 24),
|
|
1149
|
+
enabled: () => config.checks.runner === "container",
|
|
1150
|
+
isDenied: path => isDenied(path, config.local.deny) || matchesPolicy(opts.cwd, path, config.pii?.paths ?? [], true),
|
|
1151
|
+
isCurrent: currentCheck,
|
|
1152
|
+
onResult: async receipt => {
|
|
1153
|
+
if (!currentCheck(receipt)) { await checkRunner.markStale(receipt.run); return; }
|
|
1154
|
+
// Only a screened, current-owner result is delivered; no tail enters capture or diagnostic logs.
|
|
1155
|
+
const view = publicCheck(receipt);
|
|
1156
|
+
checkBindings.set(receipt.run, { task: receipt.task, owner: receipt.owner, generation: receipt.generation, updated: receipt.updated, revision: receipt.revision });
|
|
1157
|
+
while (checkBindings.size > 200) checkBindings.delete(checkBindings.keys().next().value!);
|
|
1158
|
+
if (stopping || !currentCheck(receipt)) { await checkRunner.markStale(receipt.run); return; }
|
|
1159
|
+
bus.publish(newEnvelope(HUB, sanitize(JSON.stringify(view)), { to: [receipt.owner], kind: "task", refs: { task: String(receipt.task), boundedCheck: receipt.run } }));
|
|
1160
|
+
log(`check ${receipt.run}: exit ${receipt.exit ?? "unknown"}, bytes ${receipt.output_bytes}`);
|
|
1161
|
+
},
|
|
1162
|
+
};
|
|
1163
|
+
const checkRunner = opts.boundedTools?.runner?.(checkOptions) ?? new ContainerRunner(checkOptions);
|
|
1164
|
+
const runnerRefreshAbort = new AbortController();
|
|
1165
|
+
startupCleanup.push(() => { runnerRefreshAbort.abort(); void checkRunner.stopAll(); });
|
|
1166
|
+
let runnerState = await checkRunner.available(runnerRefreshAbort.signal);
|
|
1167
|
+
const orphanChecks = (await checkRunner.sweep()).map(line => boundedSafe(line) ? sanitize(line) : "orphan check: details withheld by privacy policy");
|
|
1168
|
+
if (opts.boundedTools?.specAvailable || !opts.config && (config.pi.enabled || config.checks.runner === "container")) await ensureSpec();
|
|
1169
|
+
let runnerStateAt = runnerState.checkedAt ?? Date.now(), refreshingRunner: Promise<void> | undefined;
|
|
1170
|
+
const refreshRunner = (): Promise<void> => {
|
|
1171
|
+
if (stopping || runnerRefreshAbort.signal.aborted) return Promise.resolve();
|
|
1172
|
+
if (Date.now() - runnerStateAt < 60_000) return Promise.resolve();
|
|
1173
|
+
if (refreshingRunner) return refreshingRunner;
|
|
1174
|
+
refreshingRunner = checkRunner.available(runnerRefreshAbort.signal).then(value => {
|
|
1175
|
+
if (stopping || runnerRefreshAbort.signal.aborted) return;
|
|
1176
|
+
runnerState = value; runnerStateAt = value.checkedAt ?? Date.now();
|
|
1177
|
+
}, () => {
|
|
1178
|
+
if (stopping || runnerRefreshAbort.signal.aborted) return;
|
|
1179
|
+
runnerState = { available: false, reason: "container unavailable" }; runnerStateAt = Date.now();
|
|
1180
|
+
}).finally(() => { refreshingRunner = undefined; });
|
|
1181
|
+
return refreshingRunner;
|
|
1182
|
+
};
|
|
1183
|
+
const peerCapabilities = () => {
|
|
1184
|
+
// Keep the last confirmed capability during a bounded, single-flight refresh; replace it only on a result.
|
|
1185
|
+
if (Date.now() - runnerStateAt >= 60_000) void refreshRunner();
|
|
1186
|
+
return Object.fromEntries([...new Set([...SUPPORTED_PEERS, ...bus.peers.keys()])].map(peer => {
|
|
1187
|
+
if (peer !== "pi" && peer !== "local") return [peer, { tests: true, spec: true }];
|
|
1188
|
+
return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"),
|
|
1189
|
+
testsReason: !may(peer, "check_run") ? `capabilities.${peer}` : `runner unavailable: ${runnerState.reason ?? "unknown"}`,
|
|
1190
|
+
specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
|
|
1191
|
+
}));
|
|
1192
|
+
};
|
|
1037
1193
|
const tasks: Tasks = new Tasks({
|
|
1194
|
+
capabilities: peerCapabilities, repository: memoryRepo,
|
|
1038
1195
|
recordPlanning: change => event({ type: "planning", ...change }),
|
|
1039
1196
|
wipLimit: peer => Object.hasOwn(config.tasks.wip_limits ?? {}, peer) ? config.tasks.wip_limits![peer]! : 1,
|
|
1040
1197
|
profile: profileName(config.profile),
|
|
@@ -1043,17 +1200,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1043
1200
|
catalog,
|
|
1044
1201
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1045
1202
|
projectedReview: task => reviewProjection(task),
|
|
1046
|
-
sdlc: { config: () => lifecycleConfig },
|
|
1203
|
+
sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
|
|
1047
1204
|
board,
|
|
1048
1205
|
bus,
|
|
1049
1206
|
executionBudget,
|
|
1050
|
-
routing: () =>
|
|
1207
|
+
routing: () => activeRouting(), // only the complete validated reload can publish routing changes
|
|
1051
1208
|
cwd: opts.cwd,
|
|
1052
1209
|
project: chain.at(-1)!,
|
|
1053
1210
|
...(config.memory.enabled ? { memory } : {}),
|
|
1054
1211
|
candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
|
|
1055
1212
|
memoryCheck: checkMemory,
|
|
1056
|
-
...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, config.memory.brief_items,
|
|
1213
|
+
...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
|
|
1057
1214
|
memoryStore ? (t) => { checkMemory(); return screenMemory(memoryStore!.related(memoryProject, taskNodes(t), Number.MAX_SAFE_INTEGER)).slice(0, config.memory.brief_items).map((v) => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about })); } : undefined,
|
|
1058
1215
|
(peer, task, counts) => event({ type: "memory_brief", peer, task, ...counts }),
|
|
1059
1216
|
items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
|
|
@@ -1088,14 +1245,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1088
1245
|
},
|
|
1089
1246
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
1090
1247
|
// The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
|
|
1091
|
-
piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () =>
|
|
1248
|
+
piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model }),
|
|
1092
1249
|
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
1093
1250
|
quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
|
|
1094
1251
|
review: config.review,
|
|
1095
1252
|
sweep: taskSweepConfig(config.task_sweep),
|
|
1096
1253
|
sweepHeld: () => stopping || recoveryActive(),
|
|
1254
|
+
cancellationHeld: () => stopping || recoveryActive(),
|
|
1255
|
+
offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
|
|
1256
|
+
offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer instanceof WsPeer && peer.pullOnly; },
|
|
1257
|
+
stallNotice: stall => { supervision.taskStalled(stall); },
|
|
1097
1258
|
roles: config.roles,
|
|
1098
1259
|
turnFree,
|
|
1260
|
+
coordination: () => config.coordination === "turn-free",
|
|
1099
1261
|
capable: (peer) => capable.has(peer),
|
|
1100
1262
|
idle: (peer) => idle(peer),
|
|
1101
1263
|
treeHash: (paths, windows) => facts.tree(paths, windows),
|
|
@@ -1116,6 +1278,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1116
1278
|
held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
|
|
1117
1279
|
});
|
|
1118
1280
|
relevantNotice = (peer, env) => {
|
|
1281
|
+
if (env.from === HUB && env.refs?.boundedCheck) {
|
|
1282
|
+
const run = env.refs.boundedCheck, binding = checkBindings.get(run);
|
|
1283
|
+
let outputSafe = false;
|
|
1284
|
+
try {
|
|
1285
|
+
const value = JSON.parse(env.body) as { tail?: unknown; error?: unknown };
|
|
1286
|
+
outputSafe = typeof value.tail === "string" && boundedSafe(value.tail)
|
|
1287
|
+
&& (value.error === undefined || typeof value.error === "string" && boundedSafe(value.error));
|
|
1288
|
+
} catch { /* An unknown projection never reaches a model. */ }
|
|
1289
|
+
if (!binding || binding.owner !== peer || !currentCheck(binding) || !outputSafe) {
|
|
1290
|
+
checkBindings.delete(run);
|
|
1291
|
+
void checkRunner.markStale(run).catch(() => {});
|
|
1292
|
+
return false;
|
|
1293
|
+
}
|
|
1294
|
+
return true;
|
|
1295
|
+
}
|
|
1119
1296
|
if (env.from !== HUB || !env.refs?.supervision) return tasks.relevant(peer, env);
|
|
1120
1297
|
if (conductorPeer(config.roles) !== peer || config.conductor.feed === "off") return false;
|
|
1121
1298
|
const key = env.refs.supervisionKey ?? "";
|
|
@@ -1139,7 +1316,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1139
1316
|
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
1317
|
const progress = new ProgressObserver({
|
|
1141
1318
|
tasks: () => board.list(),
|
|
1142
|
-
isPrivate: () => board.list().some(task => task.state
|
|
1319
|
+
isPrivate: () => [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task)),
|
|
1143
1320
|
inference: { escalate: (conversation, turn) => inference?.escalate(conversation, turn) ?? Promise.resolve(undefined) },
|
|
1144
1321
|
emit: conductorProgressSink(event, supervision),
|
|
1145
1322
|
notify,
|
|
@@ -1322,7 +1499,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1322
1499
|
return undefined;
|
|
1323
1500
|
};
|
|
1324
1501
|
const activityRings = { kimi: new ActivityRing(), qwen: new ActivityRing() };
|
|
1325
|
-
const contextPii = (peer: PeerId) => board.list().some(t => t.state
|
|
1502
|
+
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
1503
|
const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
|
|
1327
1504
|
// One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
|
|
1328
1505
|
if (checkpointWaits.has(peer) || recoveryActive() || stopping) return Promise.resolve(undefined);
|
|
@@ -1416,7 +1593,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1416
1593
|
// Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
|
|
1417
1594
|
const runId = Date.now().toString(36);
|
|
1418
1595
|
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
1596
|
const pendingDeliveryTask = new Map<PeerId, number>();
|
|
1421
1597
|
const taskAttribution = (peer: PeerId, delivery = turns.get(peer)?.deliveryTask): TaskAttribution => {
|
|
1422
1598
|
const result = attribute(delivery, board.list("in_progress").filter(t => t.owner === peer).map(t => t.id));
|
|
@@ -1444,11 +1620,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1444
1620
|
event({ type: "supervision_turn", peer, turn: receipt.id, ...(receipt.tokens === undefined ? {} : { tokens: receipt.tokens }), ms: Math.max(0, Date.now() - receipt.at) });
|
|
1445
1621
|
};
|
|
1446
1622
|
// Per-turn snapshots (issue #33): a git tree at both turn boundaries, recorded in hub.db for `ahub turns` and `ahub undo`.
|
|
1447
|
-
const repo =
|
|
1448
|
-
|
|
1449
|
-
|
|
1623
|
+
const repo = factsRepo;
|
|
1624
|
+
let turnLog = config.snapshots.enabled && repo ? new Turns(join(opts.stateDir, "hub.db")) : undefined;
|
|
1625
|
+
startupCleanup.push(() => turnLog?.close());
|
|
1450
1626
|
/** 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
|
|
1627
|
+
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
1628
|
const memoryObservations = memoryTelemetry ? new MemoryObservations(memoryTelemetry, (peer, envs) => {
|
|
1453
1629
|
if (turns.get(peer)?.private || contextPii(peer) || envs.some(env => env.private)) return;
|
|
1454
1630
|
for (const env of envs) {
|
|
@@ -1460,7 +1636,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1460
1636
|
if (row && tasks.isPii(row)) return;
|
|
1461
1637
|
return { ...(row ? { task: taskKey(memoryProject, row) } : {}), turn: turns.get(peer)?.id ?? null, session: contextSession(peer) ?? null };
|
|
1462
1638
|
}) : undefined;
|
|
1463
|
-
bus.onMemoryDelivery = (peer, id, out, field) => memoryObservations?.delivery(peer, id, out, field);
|
|
1639
|
+
bus.onMemoryDelivery = (peer, id, out, field) => { if (!out.some(env => env.refs?.boundedCheck)) memoryObservations?.delivery(peer, id, out, field); };
|
|
1464
1640
|
const citeMemory = (peer: PeerId, text: string, id: string, envs: readonly Envelope[] = []) => {
|
|
1465
1641
|
if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
|
|
1466
1642
|
memoryObservations?.cite(peer, text, id, envs);
|
|
@@ -1470,7 +1646,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1470
1646
|
* runs inside the state change): three git calls at a turn's start and four at its end, each with the 10 s timeout
|
|
1471
1647
|
* in snapshots.ts.
|
|
1472
1648
|
*/
|
|
1473
|
-
const snap = (what: string): { tree?: string; ms: number } => {
|
|
1649
|
+
const snap = (what: string, capture = config.snapshots.enabled): { tree?: string; ms: number } => {
|
|
1650
|
+
if (!capture) return { ms: 0 };
|
|
1474
1651
|
const t0 = performance.now();
|
|
1475
1652
|
let tree: string | undefined;
|
|
1476
1653
|
try {
|
|
@@ -1482,11 +1659,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1482
1659
|
return { tree, ms: Math.round(performance.now() - t0) };
|
|
1483
1660
|
};
|
|
1484
1661
|
function privateSnapshotWindow(): boolean {
|
|
1485
|
-
return [...turns.values()].some(turn => turn.private) || board.list().some(task => task.state
|
|
1662
|
+
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
1486
1663
|
}
|
|
1487
1664
|
function reviewProjection(task: Task): string | undefined {
|
|
1488
1665
|
if (!projections.enabled || tasks.isPii(task)) return undefined;
|
|
1489
|
-
if (!repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
|
|
1666
|
+
if (!config.snapshots.enabled || !repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
|
|
1490
1667
|
if (privateSnapshotWindow()) return "(projected review unavailable: private snapshot window)";
|
|
1491
1668
|
const created = taskProposalAt(task);
|
|
1492
1669
|
const from = created === undefined ? undefined : turnLog.taskStart(task.id, created);
|
|
@@ -1525,6 +1702,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1525
1702
|
const contextTailSeen = new Map<PeerId, string>();
|
|
1526
1703
|
const intervals = [
|
|
1527
1704
|
setInterval(() => {
|
|
1705
|
+
void refreshRunner().catch(() => {});
|
|
1528
1706
|
try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
|
|
1529
1707
|
for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
|
|
1530
1708
|
for (const [id, request] of permissions) if (request.expiresAt > Date.now()) supervision.approvalWaiting({ id, peer: request.peer, tool: request.tool ?? "unknown", createdAt: request.createdAt });
|
|
@@ -1587,10 +1765,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1587
1765
|
}
|
|
1588
1766
|
}, 5_000),
|
|
1589
1767
|
];
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1768
|
+
let sweepTimer: ReturnType<typeof setInterval> | undefined;
|
|
1769
|
+
const rescheduleSweep = () => {
|
|
1770
|
+
if (sweepTimer) clearInterval(sweepTimer);
|
|
1771
|
+
sweepTimer = undefined;
|
|
1772
|
+
const sweep = taskSweepConfig(config.task_sweep);
|
|
1773
|
+
tasks.configureSweep(sweep);
|
|
1774
|
+
if (sweep.enabled) {
|
|
1775
|
+
sweepTimer = setInterval(() => {
|
|
1776
|
+
void tasks.sweep(opts.taskSweepNow?.() ?? Date.now()).catch(() => notify("task idle sweep failed; inspect hub task history before manual action"));
|
|
1777
|
+
}, sweep.interval_s * 1000);
|
|
1778
|
+
sweepTimer.unref?.();
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
rescheduleSweep();
|
|
1782
|
+
startupCleanup.push(() => clearInterval(sweepTimer));
|
|
1594
1783
|
for (const i of intervals) i.unref?.();
|
|
1595
1784
|
startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
|
|
1596
1785
|
|
|
@@ -1607,7 +1796,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1607
1796
|
// names are answered by the hub; every call still passes daemon role/capability checks before its effects.
|
|
1608
1797
|
const HUB_TOOLS = ["hub_send", ...TASK_TOOLS.map((t) => t.name), ...CONDUCTOR_TOOLS.map((t) => t.name)];
|
|
1609
1798
|
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 });
|
|
1799
|
+
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
1800
|
|
|
1612
1801
|
/** One entry point for the task tools, whoever calls them: MCP clients, the local worker, the console. */
|
|
1613
1802
|
// A task op can write the board across awaits (triage, briefs, the dependents an approval releases): a recovery
|
|
@@ -1627,11 +1816,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1627
1816
|
const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
|
|
1628
1817
|
auditConduct({ kind: "conduct", actor: by, action: op.slice(4), ...(id === undefined ? {} : { task: id }) });
|
|
1629
1818
|
}
|
|
1630
|
-
|
|
1819
|
+
const hint = args[3] ? ownHeldHint(by) : "";
|
|
1820
|
+
return hint ? `${text}\n\n${hint}` : text;
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
// Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
|
|
1823
|
+
const hint = args[3] ? ownHeldHint(by) : "";
|
|
1824
|
+
if (hint && error instanceof Error) error.message += `\n\n${hint}`;
|
|
1825
|
+
throw error;
|
|
1631
1826
|
} finally {
|
|
1632
1827
|
taskOpsInFlight--;
|
|
1633
1828
|
}
|
|
1634
1829
|
};
|
|
1830
|
+
function ownHeldHint(peer: PeerId): string {
|
|
1831
|
+
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(bus.peers.get(peer) instanceof LocalPeer || bus.peers.get(peer) instanceof AcpPeer)); }
|
|
1832
|
+
catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
|
|
1833
|
+
}
|
|
1635
1834
|
/** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
|
|
1636
1835
|
const peerArg = (v: unknown, name: string): PeerId | undefined => {
|
|
1637
1836
|
if (v == null || v === "") return undefined;
|
|
@@ -1640,6 +1839,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1640
1839
|
return id;
|
|
1641
1840
|
};
|
|
1642
1841
|
async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
|
|
1842
|
+
if (BOUNDED_TASK_TOOLS.has(op)) {
|
|
1843
|
+
const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
|
|
1844
|
+
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !keys.includes(key))
|
|
1845
|
+
|| (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");
|
|
1846
|
+
if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("bounded task tools unavailable during a PII turn");
|
|
1847
|
+
}
|
|
1643
1848
|
if (MEMORY_READ_TOOLS.has(op)) validateMemoryRead(op, a);
|
|
1644
1849
|
if (op === "hub_memory_correct") correctionInput(a);
|
|
1645
1850
|
if (CONDUCTOR_TOOL_NAMES.has(op)) {
|
|
@@ -1660,7 +1865,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1660
1865
|
// console reads a PII task's text deliberately, with `ahub task show <id>`.
|
|
1661
1866
|
const onPrem = inProcess && by === "local" && piiTurn;
|
|
1662
1867
|
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) => {
|
|
1868
|
+
const need = (cap: "propose" | "assign" | "remember" | "memory_read" | "memory_correct" | "spec_read" | "check_run", what: string) => {
|
|
1664
1869
|
if (may(by, cap)) return;
|
|
1665
1870
|
log(`capabilities: ${by} may not ${what} (${op})`);
|
|
1666
1871
|
throw new PreEffectToolRefusal(`${by} may not ${what} (no "${cap}" in capabilities.${by} in .agenthub/config.json)`);
|
|
@@ -1678,7 +1883,88 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1678
1883
|
}
|
|
1679
1884
|
if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
|
|
1680
1885
|
if (op === "hub_remember") need("remember", "save notes to shared memory");
|
|
1886
|
+
if (op === "hub_task_spec") {
|
|
1887
|
+
need("spec_read", "read assigned specifications");
|
|
1888
|
+
const authorized = () => {
|
|
1889
|
+
const task = board.get(a.id);
|
|
1890
|
+
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");
|
|
1891
|
+
return task;
|
|
1892
|
+
};
|
|
1893
|
+
const task = authorized(), generation = connectionGeneration(by), updated = task.updated, revision = task.history.length;
|
|
1894
|
+
const deadline = Date.now() + 25_000; // retain a response margin inside the control client's 30-second cap
|
|
1895
|
+
const issues = specRefs(task, memoryRepo);
|
|
1896
|
+
if (!issues.length) throw new PreEffectToolRefusal("no assigned spec references");
|
|
1897
|
+
await ensureSpec();
|
|
1898
|
+
authorized();
|
|
1899
|
+
if (!specState.available) throw new PreEffectToolRefusal(specState.reason ?? "gh unauthenticated");
|
|
1900
|
+
const specs = [];
|
|
1901
|
+
for (const issue of issues) {
|
|
1902
|
+
const remaining = deadline - Date.now();
|
|
1903
|
+
if (remaining <= 0) throw new PreEffectToolRefusal("timeout");
|
|
1904
|
+
specs.push(await (opts.boundedTools?.fetchSpec ?? fetchSpec)(memoryRepo, issue, { screen: boundedSafe, projectDir: opts.cwd, timeoutMs: Math.min(15_000, remaining) }));
|
|
1905
|
+
const current = authorized();
|
|
1906
|
+
if (current.updated !== updated || current.history.length !== revision || connectionGeneration(by) !== generation || !may(by, "spec_read")) throw new PreEffectToolRefusal("spec unavailable");
|
|
1907
|
+
}
|
|
1908
|
+
const result = JSON.stringify({ specs });
|
|
1909
|
+
if (Buffer.byteLength(result) > 80 * 1024) throw new PreEffectToolRefusal("too large");
|
|
1910
|
+
if (!specs.every(spec => boundedSafe(`${spec.title}\n${spec.body}`))) throw new PreEffectToolRefusal("spec withheld by privacy policy");
|
|
1911
|
+
return result;
|
|
1912
|
+
}
|
|
1913
|
+
if (op === "hub_check_start") {
|
|
1914
|
+
need("check_run", "run isolated checks");
|
|
1915
|
+
if (stopping || recoveryActive()) throw new PreEffectToolRefusal("runner unavailable: recovery or shutdown");
|
|
1916
|
+
const authorized = () => {
|
|
1917
|
+
const task = board.get(a.id);
|
|
1918
|
+
if (!task || task.owner !== by || task.state !== "in_progress" || tasks.isPii(task) || turns.get(by)?.private || contextPii(by)) throw new PreEffectToolRefusal("check unavailable");
|
|
1919
|
+
return task;
|
|
1920
|
+
};
|
|
1921
|
+
const task = authorized(), updated = task.updated, revision = task.history.length, generation = connectionGeneration(by);
|
|
1922
|
+
if (!generation) throw new PreEffectToolRefusal("check requires an attached owner");
|
|
1923
|
+
if (a.check !== "test" && a.check !== "typecheck"
|
|
1924
|
+
|| 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 === "..")))
|
|
1925
|
+
|| a.check === "typecheck" && a.files !== undefined) throw new PreEffectToolRefusal("invalid check request");
|
|
1926
|
+
await refreshRunner();
|
|
1927
|
+
const current = authorized();
|
|
1928
|
+
if (current.updated !== updated || current.history.length !== revision || connectionGeneration(by) !== generation || !may(by, "check_run") || stopping || recoveryActive()) throw new PreEffectToolRefusal("check unavailable");
|
|
1929
|
+
if (!runnerState.available) throw new PreEffectToolRefusal(`runner unavailable: ${runnerState.reason ?? "unknown"}`);
|
|
1930
|
+
const ref = current.refs.commit ?? current.refs.branch;
|
|
1931
|
+
if (!ref) throw new PreEffectToolRefusal("check requires task branch or commit");
|
|
1932
|
+
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 }) });
|
|
1933
|
+
checkPending.add(handle.run);
|
|
1934
|
+
void handle.done.finally(() => checkPending.delete(handle.run)).catch(() => {});
|
|
1935
|
+
return JSON.stringify({ run: handle.run });
|
|
1936
|
+
}
|
|
1937
|
+
if (op === "hub_check_result" || op === "hub_check_cancel") {
|
|
1938
|
+
need("check_run", "read or cancel isolated checks");
|
|
1939
|
+
let receipt = await checkRunner.result(a.run);
|
|
1940
|
+
const task = receipt ? board.get(receipt.task) : undefined;
|
|
1941
|
+
if (!receipt || by !== USER && (receipt.owner !== by || !task || tasks.isPii(task))) throw new PreEffectToolRefusal("check unavailable");
|
|
1942
|
+
if (receipt.status === "stale" && by !== USER) throw new PreEffectToolRefusal("check unavailable: stale binding");
|
|
1943
|
+
if (!currentCheck(receipt)) {
|
|
1944
|
+
await checkRunner.markStale(receipt.run); receipt = { ...receipt, status: "stale" };
|
|
1945
|
+
// markStale persists and can abort a run: a refusal after it is never proof of absent effects.
|
|
1946
|
+
if (by !== USER) throw new Error("check unavailable: stale binding");
|
|
1947
|
+
}
|
|
1948
|
+
if (op === "hub_check_cancel") {
|
|
1949
|
+
await checkRunner.cancel(receipt.run);
|
|
1950
|
+
const actual = await checkRunner.result(receipt.run);
|
|
1951
|
+
if (!actual) throw new Error("check receipt unavailable after cancellation");
|
|
1952
|
+
return JSON.stringify({ run: actual.run, status: actual.status, cancelled: actual.cancelled });
|
|
1953
|
+
}
|
|
1954
|
+
if (by !== USER && !currentCheck(receipt)) throw new PreEffectToolRefusal("check unavailable: stale binding");
|
|
1955
|
+
if (by === receipt.owner) await checkRunner.consumeOutput(receipt.run);
|
|
1956
|
+
if (by !== USER && (!currentCheck(receipt) || turns.get(by)?.private || contextPii(by))) throw new Error("check unavailable: stale binding");
|
|
1957
|
+
// Policy can change during output cleanup, so form the final projection again.
|
|
1958
|
+
return JSON.stringify(publicCheck(receipt));
|
|
1959
|
+
}
|
|
1681
1960
|
switch (op) {
|
|
1961
|
+
case "hub_delivery_retry": {
|
|
1962
|
+
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");
|
|
1963
|
+
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");
|
|
1964
|
+
if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
|
|
1965
|
+
bus.retryDelivery(by, a.delivery_id);
|
|
1966
|
+
return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
|
|
1967
|
+
}
|
|
1682
1968
|
case "hub_backlog_list":
|
|
1683
1969
|
return JSON.stringify(tasks.backlogList(by));
|
|
1684
1970
|
case "hub_release_list":
|
|
@@ -1710,6 +1996,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1710
1996
|
if (silent && factsOn()) facts.sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
|
|
1711
1997
|
return [line(t), overlap, tasks.planGuardText(t)].filter(Boolean).join("\n");
|
|
1712
1998
|
}
|
|
1999
|
+
case "hub_task_cancel": {
|
|
2000
|
+
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
|
|
2001
|
+
|| !Number.isSafeInteger(a.id) || a.id < 1 || typeof a.reason !== "string" || !a.reason.trim() || a.reason.length > 2000
|
|
2002
|
+
|| a.superseded_by !== undefined && (typeof a.superseded_by !== "string" || !a.superseded_by.trim() || a.superseded_by.length > 2000)
|
|
2003
|
+
|| a.revision !== undefined && (!Number.isSafeInteger(a.revision) || a.revision < 0)) throw new PreEffectToolRefusal("invalid cancellation request");
|
|
2004
|
+
if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("cancellation unavailable during a PII turn");
|
|
2005
|
+
return JSON.stringify(tasks.publicView(await tasks.cancel(a.id, by, { reason: a.reason, supersededBy: a.superseded_by, expectedFlowRevision: a.revision })));
|
|
2006
|
+
}
|
|
1713
2007
|
case "hub_task_decline":
|
|
1714
2008
|
return line(await tasks.decline(by, a.id, a.reason));
|
|
1715
2009
|
case "hub_task_done": {
|
|
@@ -1814,8 +2108,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1814
2108
|
}
|
|
1815
2109
|
case "task_escalate":
|
|
1816
2110
|
return line(await tasks.escalate(USER, a.id, undefined, undefined, true));
|
|
1817
|
-
case "route_explain":
|
|
1818
|
-
|
|
2111
|
+
case "route_explain": {
|
|
2112
|
+
const trace = tasks.explain(a.id !== undefined ? Number(a.id) : { title: String(a.title ?? ""), class: a.class as TaskClass });
|
|
2113
|
+
return [...trace, ...bus.knownPeers().flatMap(peer => {
|
|
2114
|
+
if (trace.some(line => line.startsWith(` hold: ${peer}'s queue is held:`))) return [];
|
|
2115
|
+
const hold = queueHold(peer); return hold ? [`${peer} queue: ${hold}`] : [];
|
|
2116
|
+
})].join("\n");
|
|
2117
|
+
}
|
|
1819
2118
|
case "turn_revert": {
|
|
1820
2119
|
// `ahub undo --context` (issue #33): the conversation half of an undo; the CLI restores the files itself.
|
|
1821
2120
|
const turn = turnLog?.get(String(a.turn));
|
|
@@ -1840,7 +2139,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1840
2139
|
}
|
|
1841
2140
|
function recoveryTaskPreface(peer: PeerId): void {
|
|
1842
2141
|
if (recoveryPhase !== "restored") return;
|
|
1843
|
-
const open = board.list().filter((task) => (task.owner === peer || task.reviewer === peer) && !
|
|
2142
|
+
const open = board.list().filter((task) => (task.owner === peer || task.reviewer === peer) && !isClosedTaskState(task.state));
|
|
1844
2143
|
if (!open.length) return;
|
|
1845
2144
|
const lines = open.map((task) => {
|
|
1846
2145
|
const view = tasks.publicView(task) as { title?: unknown; detail?: unknown; state?: unknown; owner?: unknown; reviewer?: unknown };
|
|
@@ -1854,6 +2153,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1854
2153
|
const permissionChanges = new Map<string, Promise<void>>();
|
|
1855
2154
|
const permissionModes = new Map<string, PermissionMode>(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, mode === "never-ask" ? "ask" : mode]));
|
|
1856
2155
|
for (const peer of pendingPermissionDefaults.keys()) permissionModes.set(peer, "ask");
|
|
2156
|
+
const changedPermissionDefaults = new Set<string>();
|
|
2157
|
+
const applyPendingPermissionDefault = (peer: string): void => {
|
|
2158
|
+
if (changedPermissionDefaults.has(peer) && (!bus.peers.has(peer) || bus.peers.get(peer)!.state === "offline")) {
|
|
2159
|
+
changedPermissionDefaults.delete(peer);
|
|
2160
|
+
const mode = config.permission_modes[peer] ?? "ask";
|
|
2161
|
+
const source = config.permission_default_sources?.[peer]?.source ?? "configuration default";
|
|
2162
|
+
permissionModes.set(peer, mode === "never-ask" ? "ask" : mode);
|
|
2163
|
+
permissionModeSources.set(peer, source);
|
|
2164
|
+
pendingPermissionDefaults.delete(peer);
|
|
2165
|
+
if (mode === "never-ask") pendingPermissionDefaults.set(peer, source);
|
|
2166
|
+
if (config.permission_default_sources?.[peer]) defaultSources[peer] = config.permission_default_sources[peer]!;
|
|
2167
|
+
else delete defaultSources[peer];
|
|
2168
|
+
}
|
|
2169
|
+
};
|
|
1857
2170
|
const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
|
|
1858
2171
|
const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
|
|
1859
2172
|
const unattendedPeers = new Set<string>();
|
|
@@ -1875,6 +2188,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1875
2188
|
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
2189
|
const recoveryReady = () => {
|
|
1877
2190
|
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;
|
|
2191
|
+
if (checkPending.size !== 0) return false;
|
|
1878
2192
|
if (!recoveryPeerSnapshot) return true;
|
|
1879
2193
|
const current = recoveryPeers();
|
|
1880
2194
|
return recoveryPeerSnapshot.every((saved) => {
|
|
@@ -2029,7 +2343,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2029
2343
|
}
|
|
2030
2344
|
return now;
|
|
2031
2345
|
};
|
|
2032
|
-
const queueHoldStatus = (peer: PeerId, heldBy?: string) => heldBy ? { heldBy, holdNote: queueHold(peer) } : {};
|
|
2346
|
+
const queueHoldStatus = (peer: PeerId, heldBy?: string) => heldBy ? { heldBy, holdNote: queueHold(peer), heldRows: heldRows(peer) } : {};
|
|
2033
2347
|
const logPermissionStart = (peer: string) => {
|
|
2034
2348
|
const requested = defaultSources[peer];
|
|
2035
2349
|
const selected = permissionMode(peer), pending = pendingPermissionDefaults.has(peer);
|
|
@@ -2062,15 +2376,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2062
2376
|
};
|
|
2063
2377
|
const status = (includeAttentionItems = false, includeAttentionInput = false) => {
|
|
2064
2378
|
const taskNeeds = board.statusTasks();
|
|
2379
|
+
const taskStalls = tasks.stalled();
|
|
2380
|
+
const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
|
|
2065
2381
|
const peerIds = statusPeerIds(taskNeeds);
|
|
2066
2382
|
const view = {
|
|
2067
2383
|
...(crashReport.length ? { crash: crashReport } : {}),
|
|
2068
2384
|
projectId,
|
|
2069
2385
|
instanceId,
|
|
2070
2386
|
version: VERSION,
|
|
2387
|
+
pendingRestart,
|
|
2071
2388
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2072
2389
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
2073
2390
|
protocol: PROTOCOL,
|
|
2391
|
+
features: DAEMON_FEATURES,
|
|
2074
2392
|
stopping,
|
|
2075
2393
|
pid: process.pid,
|
|
2076
2394
|
cwd: opts.cwd,
|
|
@@ -2082,11 +2400,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2082
2400
|
),
|
|
2083
2401
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2084
2402
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
2085
|
-
roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id) }])),
|
|
2403
|
+
roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
|
|
2404
|
+
planner: { peers: plannerPeers(), source: plannerSource },
|
|
2086
2405
|
...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
|
|
2087
2406
|
...(sidecar ? { switchyard: sidecar.status } : {}),
|
|
2088
2407
|
...(modelRelay ? { models: modelRelay.status() } : {}),
|
|
2408
|
+
isolatedChecks: { pending: checkPending.size, orphans: orphanChecks, unknownReceipts: checkRunner.unknownReceipts ?? [], maintenanceUnavailable: checkRunner.maintenanceUnavailable === true },
|
|
2409
|
+
taskCapabilities: peerCapabilities(),
|
|
2089
2410
|
tasks: board.counts(),
|
|
2411
|
+
taskStalls, pendingTaskOffers,
|
|
2090
2412
|
...(bus.storageError ? { deliveryError: bus.storageError } : {}),
|
|
2091
2413
|
...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
|
|
2092
2414
|
};
|
|
@@ -2103,6 +2425,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2103
2425
|
storageBlocked: !!bus.storageError,
|
|
2104
2426
|
recovery: recoveryOperationId ? { id: recoveryOperationId, running: true } : undefined,
|
|
2105
2427
|
tasks: taskNeeds,
|
|
2428
|
+
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
2429
|
budget: Object.fromEntries(Object.entries(budget.status()).map(([peer, row]) => [peer, row.paused ? { paused: { since: row.paused.since, resetsAt: row.paused.resetsAt } } : {}])),
|
|
2107
2430
|
};
|
|
2108
2431
|
const attention = needs(attentionInput, Date.now());
|
|
@@ -2122,37 +2445,58 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2122
2445
|
const taskChanged = board.onChange;
|
|
2123
2446
|
board.onChange = (task, entry) => {
|
|
2124
2447
|
try { taskChanged?.(task, entry); }
|
|
2125
|
-
finally {
|
|
2448
|
+
finally {
|
|
2449
|
+
try { settleSupersededDeliveries(); }
|
|
2450
|
+
finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
|
|
2451
|
+
}
|
|
2126
2452
|
};
|
|
2127
2453
|
|
|
2128
2454
|
// A queue change is not a bus event: without this, status.json reports the queue as it was before the last
|
|
2129
2455
|
// 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
|
-
}))
|
|
2456
|
+
function heldRows(peer: PeerId) {
|
|
2457
|
+
return bus.queueList(peer).filter(row => row.state === "needs_review").map(row => ({
|
|
2458
|
+
id: row.id, senders: [...new Set(row.originals.map(env => sanitize(env.from)))],
|
|
2459
|
+
kinds: [...new Set(row.originals.map(env => env.kind))], createdAt: row.createdAt,
|
|
2460
|
+
context: deliveryTaskContext(row.originals, board, peer),
|
|
2461
|
+
}));
|
|
2136
2462
|
}
|
|
2137
2463
|
function queueHold(peer: PeerId): string | undefined {
|
|
2138
|
-
const
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2464
|
+
const rows = heldRows(peer);
|
|
2465
|
+
if (!rows.length) return undefined;
|
|
2466
|
+
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("; ");
|
|
2467
|
+
}
|
|
2468
|
+
let settlingSuperseded = false;
|
|
2469
|
+
function settleSupersededDeliveries() {
|
|
2470
|
+
if (settlingSuperseded || stopping || recoveryActive() || bus.isRecoveryHeld) return;
|
|
2471
|
+
settlingSuperseded = true;
|
|
2472
|
+
try {
|
|
2473
|
+
for (const row of bus.queueList()) {
|
|
2474
|
+
if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
|
|
2475
|
+
const reason = supersededDeliveryReason(row.originals, board, row.peer);
|
|
2476
|
+
if (!reason) continue;
|
|
2477
|
+
try { bus.resolveDelivery(row.id, row.revision, "completed", reason, "hub"); }
|
|
2478
|
+
catch { /* Busy peers, storage failures and changed revisions remain held for the next queue/board change. */ }
|
|
2479
|
+
}
|
|
2480
|
+
} finally { settlingSuperseded = false; }
|
|
2143
2481
|
}
|
|
2144
2482
|
const noticedHolds = new Set<string>();
|
|
2483
|
+
let reportingQueues = false;
|
|
2145
2484
|
bus.onQueues = () => {
|
|
2146
|
-
if (stopping) return;
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2485
|
+
if (stopping || settlingSuperseded || reportingQueues) return;
|
|
2486
|
+
reportingQueues = true;
|
|
2487
|
+
try {
|
|
2488
|
+
settleSupersededDeliveries();
|
|
2489
|
+
for (const peer of bus.knownPeers()) {
|
|
2490
|
+
const rows = heldRows(peer);
|
|
2491
|
+
if (!rows.some(row => !noticedHolds.has(row.id))) continue;
|
|
2492
|
+
for (const row of rows) { noticedHolds.add(row.id); supervision.needsReview(peer, row.id); }
|
|
2493
|
+
notify(`${peer} queue ${queueHold(peer)}`);
|
|
2494
|
+
}
|
|
2495
|
+
writeStatus();
|
|
2496
|
+
} finally { reportingQueues = false; }
|
|
2155
2497
|
};
|
|
2498
|
+
// Opening the journal/restoring the snapshot preceded callbacks; classify those holds too.
|
|
2499
|
+
settleSupersededDeliveries();
|
|
2156
2500
|
|
|
2157
2501
|
// When each peer went offline; a peer never seen attached counts from hub start (issue #6).
|
|
2158
2502
|
const offlineSince = new Map<PeerId, number>();
|
|
@@ -2266,17 +2610,57 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2266
2610
|
|
|
2267
2611
|
// Session identities of the attached peers, kept current for crash recovery (issue #37); a clean stop removes them.
|
|
2268
2612
|
let sessionsWritten = "";
|
|
2613
|
+
// One bounded failure generation: unchanged events neither contend for the writer lock nor repeat its notice.
|
|
2614
|
+
let piSettlementFailure: { key: string; error: string; retryAfter: number } | undefined;
|
|
2615
|
+
function settlePiContinuity(): void {
|
|
2616
|
+
if (stopping || recoveryActive() || piContinuity.state !== "ready") return;
|
|
2617
|
+
const peer = bus.peers.get("pi");
|
|
2618
|
+
if (!(peer instanceof PiPeer) || peer.state === "offline" || !peer.sessionGeneration || !peer.recoveryReady) return;
|
|
2619
|
+
const saved = piContinuity.bookmark, actual = peer.recoveryMetadata();
|
|
2620
|
+
if (typeof actual.sessionId !== "string" || !actual.sessionId) return;
|
|
2621
|
+
const key = JSON.stringify([piContinuity.digest, peer.sessionGeneration, actual]);
|
|
2622
|
+
if (piSettlementFailure?.key === key && Date.now() < piSettlementFailure.retryAfter) return;
|
|
2623
|
+
const matches = actual.sessionId === saved.pi.sessionId && (saved.pi.sessionFile === undefined || actual.sessionFile === saved.pi.sessionFile);
|
|
2624
|
+
try {
|
|
2625
|
+
const expected = { projectId, projectRoot: opts.cwd, operationId: saved.operationId, digest: piContinuity.digest };
|
|
2626
|
+
const settled = matches ? consumeRecoveryContinuity(opts.stateDir, expected) : retireRecoveryContinuity(opts.stateDir, expected, "authenticated_session_changed").retired;
|
|
2627
|
+
piSettlementFailure = undefined;
|
|
2628
|
+
if (settled) {
|
|
2629
|
+
piContinuity = { state: "absent" };
|
|
2630
|
+
if (!matches) log("Pi archived continuity retired after recording a different authenticated session");
|
|
2631
|
+
}
|
|
2632
|
+
else {
|
|
2633
|
+
// Re-read after the failed exact CAS; never settle a newer bookmark in this call.
|
|
2634
|
+
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
2635
|
+
if (piContinuity.state !== "absent") log("Pi continuity bookmark changed before attachment settlement; retained for inspection");
|
|
2636
|
+
}
|
|
2637
|
+
} catch (error) {
|
|
2638
|
+
const signature = error instanceof Error ? `${error.name}:${error.message}` : String(error);
|
|
2639
|
+
const report = piSettlementFailure?.key !== key || piSettlementFailure.error !== signature;
|
|
2640
|
+
piSettlementFailure = { key, error: signature, retryAfter: Date.now() + 30_000 };
|
|
2641
|
+
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
2642
|
+
if (report) log("Pi continuity attachment settlement unavailable; bookmark retained");
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2269
2645
|
const recordSessions = () => {
|
|
2270
2646
|
if (stopping) return;
|
|
2647
|
+
const pi = bus.peers.get("pi");
|
|
2648
|
+
if (piContinuity.state === "ready" && (!(pi instanceof PiPeer) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
|
|
2649
|
+
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
2650
|
+
}
|
|
2271
2651
|
const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
|
|
2272
2652
|
let meta: Record<string, unknown> = {};
|
|
2273
2653
|
try { meta = (p as { recoveryMetadata?: () => Record<string, unknown> }).recoveryMetadata?.() ?? {}; } catch { /* not ready yet: the id alone */ }
|
|
2274
2654
|
return { peer: p.id, meta };
|
|
2275
2655
|
});
|
|
2276
2656
|
const text = JSON.stringify(peers);
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2657
|
+
try {
|
|
2658
|
+
if (text !== sessionsWritten) {
|
|
2659
|
+
writeSessions(opts.stateDir, { instanceId, at: Date.now(), peers });
|
|
2660
|
+
sessionsWritten = text;
|
|
2661
|
+
}
|
|
2662
|
+
settlePiContinuity(); // the identical snapshot may already be durable; never retire after a failed write
|
|
2663
|
+
} catch (error) { log(`session record not written: ${(error as Error).message}`); }
|
|
2280
2664
|
};
|
|
2281
2665
|
const recoverAfterCrash = async (prev: SessionsFile) => {
|
|
2282
2666
|
const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
|
|
@@ -2284,6 +2668,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2284
2668
|
report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
2285
2669
|
const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
|
|
2286
2670
|
for (const step of crashPlan(prev.peers)) {
|
|
2671
|
+
if (step.peer === "pi" && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session"); continue; }
|
|
2287
2672
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
2288
2673
|
// fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
|
|
2289
2674
|
if (step.peer === "pi" && piAutoStart()) {
|
|
@@ -2299,7 +2684,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2299
2684
|
report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
|
|
2300
2685
|
continue;
|
|
2301
2686
|
}
|
|
2302
|
-
if (!step.resume || !autoResume) {
|
|
2687
|
+
if (!step.resume || !autoResume()) {
|
|
2303
2688
|
report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`);
|
|
2304
2689
|
continue;
|
|
2305
2690
|
}
|
|
@@ -2324,7 +2709,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2324
2709
|
// #248: the add-ons it works with, once per attach (names and versions only).
|
|
2325
2710
|
// A result is written unless a later attachment began meanwhile: a peer that detached before its probe settled
|
|
2326
2711
|
// still did its work under that attachment.
|
|
2327
|
-
if (e.state === "offline") attachedNow.delete(e.peer);
|
|
2712
|
+
if (e.state === "offline") { attachedNow.delete(e.peer); applyPendingPermissionDefault(e.peer); }
|
|
2328
2713
|
else if (!attachedNow.has(e.peer)) {
|
|
2329
2714
|
attachedNow.add(e.peer);
|
|
2330
2715
|
const peer = e.peer, attachment = ++attachments;
|
|
@@ -2345,20 +2730,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2345
2730
|
const pii = holdsPii(e.peer);
|
|
2346
2731
|
for (const id of turnTasks.keys()) if (!turnLog?.get(id)) turnTasks.delete(id);
|
|
2347
2732
|
turnTasks.set(id, board.list().filter((t) => t.owner === e.peer && ["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state)).map((t) => t.id));
|
|
2348
|
-
const
|
|
2349
|
-
|
|
2733
|
+
const capture = config.snapshots.enabled && !!turnLog;
|
|
2734
|
+
const start = capture && !pii ? snap(`the start of ${id}`) : undefined; // before the peer is handed anything: the tap runs inside setState
|
|
2735
|
+
try { if (capture) turnLog?.begin(id, e.peer, start?.tree); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
|
|
2350
2736
|
const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
|
|
2351
|
-
turns.set(e.peer, { id, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
2737
|
+
turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
2352
2738
|
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
2353
2739
|
} else if (!busy && open) {
|
|
2354
2740
|
turns.delete(e.peer);
|
|
2355
2741
|
let afterTurn: (() => void) | undefined;
|
|
2356
2742
|
let files: number | undefined;
|
|
2357
2743
|
let snapshotMs = open.snapshotMs;
|
|
2358
|
-
if (turnLog && (open.private || holdsPii(e.peer))) {
|
|
2744
|
+
if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
|
|
2359
2745
|
try { turnLog.end(open.id, undefined, [], Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
|
|
2360
|
-
} else if (turnLog) {
|
|
2361
|
-
const end = snap(`the end of ${open.id}
|
|
2746
|
+
} else if (open.capture && turnLog) {
|
|
2747
|
+
const end = snap(`the end of ${open.id}`, true);
|
|
2362
2748
|
const changed = open.tree && end.tree ? changedPaths(repo!, open.tree, end.tree) : [];
|
|
2363
2749
|
if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
|
|
2364
2750
|
snapshotMs = (snapshotMs ?? 0) + end.ms;
|
|
@@ -2523,7 +2909,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2523
2909
|
/** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
|
|
2524
2910
|
const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
|
|
2525
2911
|
(result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
|
|
2526
|
-
(error) => log(`Pi auto-start failed: ${(error as Error).message}`));
|
|
2912
|
+
(error) => log(`Pi auto-start failed: ${(error as Error).message}${piContinuity.state === "ready" ? `. ${continuityCleanup()}` : ""}`));
|
|
2527
2913
|
async function stopPiForReplacement(pi: PiPeer): Promise<void> {
|
|
2528
2914
|
replacingPi.add(pi);
|
|
2529
2915
|
try { await pi.stop(); } finally { replacingPi.delete(pi); }
|
|
@@ -2544,6 +2930,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2544
2930
|
const running = startPeerOnce(peer, args).then(async result => {
|
|
2545
2931
|
if (!result.ok && peer === "codex") unattendedPeers.delete(peer);
|
|
2546
2932
|
if (!result.ok || result.already) return result;
|
|
2933
|
+
if (peer === "pi") recordSessions();
|
|
2547
2934
|
return permissionChange(peer, async () => {
|
|
2548
2935
|
if (peer === "codex") { if (args.unattended) unattendedPeers.add(peer); else unattendedPeers.delete(peer); }
|
|
2549
2936
|
logPermissionStart(peer); writeStatus(); return result;
|
|
@@ -2572,6 +2959,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2572
2959
|
const mode = startMode(peer), owner = bus.peers.get(peer), can = mode === "tui" ? opener.available() : undefined;
|
|
2573
2960
|
return { peer, mode, attached: !!owner && owner.state !== "offline", command: peer === "kimi" ? "ahub kimi" : `ahub ${startWords(peer, mode).join(" ")}`, ...(can ? (can.ok ? { via: can.via } : { why: can.why }) : {}) };
|
|
2574
2961
|
});
|
|
2962
|
+
// Printed for a person's explicit reconciliation only; never executed by auto-start.
|
|
2963
|
+
const continuityCleanup = () => `After inspection, archive the active bookmark with ${["mv", "-n", "--", join(opts.stateDir, "recovery-continuity.json"), join(opts.stateDir, `recovery-continuity.retired.manual.${randomUUID()}.json`)].map(shellQuote).join(" ")} before choosing another session`;
|
|
2575
2964
|
async function hubStart(peer: unknown, by: string): Promise<Record<string, unknown>> {
|
|
2576
2965
|
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
2577
2966
|
if (rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
|
|
@@ -2579,7 +2968,32 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2579
2968
|
if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
|
|
2580
2969
|
if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
|
|
2581
2970
|
if (recoveryActive()) return { ok: false, error: "recovery is holding mutations" };
|
|
2971
|
+
const continuity = peer === "pi" && by === "pi.auto_start"
|
|
2972
|
+
? piContinuity.state === "ready" ? (piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd })) : piContinuity
|
|
2973
|
+
: undefined;
|
|
2974
|
+
if (continuity?.state === "invalid") return { ok: false, error: `archived Pi continuity evidence is invalid or unreadable; no fresh session was started. ${continuityCleanup()}` };
|
|
2975
|
+
const resume = continuity?.state === "ready" ? continuity.bookmark.pi : undefined;
|
|
2582
2976
|
const mode = startMode(peer);
|
|
2977
|
+
if (resume?.sessionFile && continuity?.state === "ready") {
|
|
2978
|
+
try { validatePiSession(opts.stateDir, opts.cwd, resume.sessionFile, resume.sessionId); }
|
|
2979
|
+
catch (error) {
|
|
2980
|
+
const retry = `ahub pi --mode ${mode} --session-file ${shellQuote(resume.sessionFile)}`;
|
|
2981
|
+
if (error instanceof PiSessionUnresumable || error instanceof PiSessionBookmarkMismatch) {
|
|
2982
|
+
try {
|
|
2983
|
+
const retired = retireRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd, operationId: continuity.bookmark.operationId, digest: continuity.digest }, "resume_unavailable");
|
|
2984
|
+
if (retired.retired) {
|
|
2985
|
+
piContinuity = { state: "absent" };
|
|
2986
|
+
const diagnosis = error instanceof PiSessionBookmarkMismatch ? "Pi continuity bookmark does not match the preserved session" : "recorded Pi session is unresumable";
|
|
2987
|
+
return { ok: false, error: `${diagnosis}; continuity evidence was archived; no fresh session was started. Inspect the preserved session, then select a valid managed file with ahub pi --mode ${mode} --session-file <managed-file>` };
|
|
2988
|
+
}
|
|
2989
|
+
} catch { return { ok: false, error: `Pi continuity retirement could not be confirmed; bookmark retained; no fresh session was started. After inspecting storage, retry ${retry}. ${continuityCleanup()}` }; }
|
|
2990
|
+
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
2991
|
+
return { ok: false, error: `Pi continuity changed before retirement; inspect recovery-continuity.json; no fresh session was started. Retry the intended session with ${retry}. ${continuityCleanup()}` };
|
|
2992
|
+
}
|
|
2993
|
+
return { ok: false, error: `Pi continuity session storage could not be verified; bookmark retained; no fresh session was started. After inspecting storage, retry ${retry}. ${continuityCleanup()}` };
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
if (mode === "tui" && (peer === "pi" || peer === "local")) { await ensureSpec(true); await refreshRunner(); }
|
|
2583
2997
|
const owner = bus.peers.get(peer);
|
|
2584
2998
|
if (mode === "headless") {
|
|
2585
2999
|
// A Pi a person runs in a terminal is theirs: a start made for them never takes it over as a headless one.
|
|
@@ -2587,23 +3001,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2587
3001
|
// like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
|
|
2588
3002
|
// issued to tell the two apart.
|
|
2589
3003
|
if (owner instanceof PiPeer && owner.state !== "offline" && owner.mode === "tui") return { ok: false, error: "Pi runs in a terminal; end it there before starting a headless one", command: "ahub pi --headless" };
|
|
2590
|
-
return startPeer(peer, peer === "pi" || peer === "kimi" ? { mode: "headless" } : {});
|
|
3004
|
+
return startPeer(peer, peer === "pi" || peer === "kimi" ? { mode: "headless", ...(resume ?? {}) } : {});
|
|
2591
3005
|
}
|
|
2592
3006
|
if (owner && owner.state !== "offline") return { ok: true, already: true, mode };
|
|
2593
|
-
const
|
|
3007
|
+
const words = [...startWords(peer, mode)];
|
|
3008
|
+
if (resume) words.push(...(resume.sessionFile ? ["--session-file", resume.sessionFile] : ["--session-id", resume.sessionId]), ...(resume.backend ? ["--backend", resume.backend] : []), ...(resume.model ? ["--model", resume.model] : []));
|
|
3009
|
+
const command = `ahub ${resume ? words.map(shellQuote).join(" ") : words.join(" ")}`;
|
|
2594
3010
|
const since = Date.now() - (opening.get(peer) ?? 0);
|
|
2595
3011
|
if (since < PEER_START_RETRY_MS) return { ok: false, error: `a terminal for ${peer} was opened ${Math.round(since / 1000)}s ago; wait for its TUI, or run ${command} yourself`, command };
|
|
2596
3012
|
// Reserved before the provider is awaited, so starts that arrive together open one terminal, not one each.
|
|
2597
3013
|
opening.set(peer, Date.now());
|
|
3014
|
+
if (resume && (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer))) { opening.delete(peer); return { ok: false, error: "Pi lifecycle changed before its continuity terminal opened; bookmark retained" }; }
|
|
2598
3015
|
// Every word is the hub's own: its runtime, its entry point, this project and a peer from the closed list.
|
|
2599
|
-
const opened = await opener.open(`${peer} (agent-hub)`, [process.execPath, CLI_ENTRY, "--project", opts.cwd, ...
|
|
3016
|
+
const opened = await opener.open(`${peer} (agent-hub)`, [process.execPath, CLI_ENTRY, "--project", opts.cwd, ...words]).catch((error: Error) => ({ ok: false as const, why: "the terminal provider failed", detail: error.message }));
|
|
2600
3017
|
if (!opened.ok) {
|
|
2601
3018
|
opening.delete(peer); // nothing was opened: the next request may try again at once
|
|
2602
3019
|
if (opened.detail) log(`terminal provider for ${peer}: ${opened.detail}`);
|
|
2603
3020
|
const headless = peer === "claude" ? "" : `, or set its start mode to headless (ahub settings set peers.${peer}.start_mode headless)`;
|
|
2604
3021
|
return { ok: false, error: `${peer} starts in its TUI and the hub could not open a terminal for it (${opened.why}). Run ${command} in a terminal${headless}`, command };
|
|
2605
3022
|
}
|
|
2606
|
-
try { notify(`${peer}: ${by} opened a terminal running ${command} (${opened.via}); it attaches when its TUI is ready`); } catch { /* the terminal is open either way */ }
|
|
3023
|
+
try { notify(resume ? `pi: pi.auto_start opened a terminal for its recorded archived session (${opened.via}); attachment is not yet verified` : `${peer}: ${by} opened a terminal running ${command} (${opened.via}); it attaches when its TUI is ready`); } catch { /* the terminal is open either way */ }
|
|
2607
3024
|
return { ok: true, mode, opened: opened.via, command };
|
|
2608
3025
|
}
|
|
2609
3026
|
|
|
@@ -2662,6 +3079,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2662
3079
|
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
3080
|
let unmute: (() => void) | undefined;
|
|
2664
3081
|
try {
|
|
3082
|
+
applyPendingPermissionDefault(peer);
|
|
3083
|
+
if (peer === "pi" || peer === "local") { await ensureSpec(true); await refreshRunner(); }
|
|
2665
3084
|
return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
|
|
2666
3085
|
} finally {
|
|
2667
3086
|
unmute?.();
|
|
@@ -2669,8 +3088,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2669
3088
|
}
|
|
2670
3089
|
|
|
2671
3090
|
async function startPeerBody(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 }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
|
|
3091
|
+
const adapter = definitionOf(peer);
|
|
2672
3092
|
if (peer === "jcode" && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "Jcode native TUI driving is unverified; use headless" };
|
|
2673
|
-
if (
|
|
3093
|
+
if (adapter?.transport === "acp" && adapter.start.create) {
|
|
3094
|
+
const refusal = adapter.start.preflight?.({ instance: peer, form: args.mode });
|
|
3095
|
+
if (refusal) return { ok: false, error: refusal };
|
|
3096
|
+
}
|
|
2674
3097
|
if (peer === "kimi") {
|
|
2675
3098
|
if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Kimi mode" };
|
|
2676
3099
|
const owner = bus.peers.get(peer);
|
|
@@ -2757,32 +3180,36 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2757
3180
|
catch { await jcode.stop().catch(() => {}); throw new Error("Jcode startup not verified; check native version, provider profile, MCP store and owned daemon (details withheld)"); }
|
|
2758
3181
|
return { ok: true, policy: "unmanaged" };
|
|
2759
3182
|
}
|
|
2760
|
-
if (
|
|
2761
|
-
|
|
2762
|
-
const launch =
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
3183
|
+
if (adapter?.transport === "acp" && adapter.start.create) {
|
|
3184
|
+
// ponytail: A1 migrates one ACP factory; A2 generalizes its native config and activity ports before adding another.
|
|
3185
|
+
const launch = adapter.start.launch({ instance: peer, form: "headless", projectRoot: opts.cwd, stateDir: opts.stateDir, entrypoint: CLI_ENTRY,
|
|
3186
|
+
userArgs: [], unattended: false, env: {}, nativeCommand: config.qwen_cmd, model: args.model });
|
|
3187
|
+
const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
|
|
3188
|
+
const permission = permissionMode(peer);
|
|
3189
|
+
const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
|
|
3190
|
+
onActivity: item => activityRings[peer as keyof typeof activityRings].append(item),
|
|
3191
|
+
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3192
|
+
cmd: [launch.cmd, ...launch.args], permissionMode: permission,
|
|
2767
3193
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
2768
3194
|
cwd: opts.cwd, watchdogMs: config.watchdog_ms, onPermission,
|
|
2769
3195
|
autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
|
|
2770
3196
|
log: () => log("Qwen runtime diagnostic: details withheld"),
|
|
2771
3197
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
2772
3198
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
2773
|
-
const current = contextSession(
|
|
2774
|
-
contexts.report(
|
|
3199
|
+
const current = contextSession(peer); if (!current) return;
|
|
3200
|
+
contexts.report(peer, observation.contextUsed !== undefined && observation.contextCapacity !== undefined
|
|
2775
3201
|
? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
|
|
2776
3202
|
: { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null }, current);
|
|
2777
3203
|
},
|
|
2778
|
-
onTurnFailure: () => { supervisionTurns.delete(
|
|
2779
|
-
mcpServers
|
|
2780
|
-
preamble: sessionRoleContract(
|
|
2781
|
-
});
|
|
2782
|
-
|
|
2783
|
-
|
|
3204
|
+
onTurnFailure: () => { supervisionTurns.delete(peer); },
|
|
3205
|
+
mcpServers,
|
|
3206
|
+
preamble: sessionRoleContract(peer),
|
|
3207
|
+
} } });
|
|
3208
|
+
if (!(owner instanceof AcpPeer)) throw new Error("ACP factory did not return its native transport");
|
|
3209
|
+
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3210
|
+
try { await owner.start(); await reconcileNativeStart(peer, owner); }
|
|
2784
3211
|
catch (error) {
|
|
2785
|
-
await
|
|
3212
|
+
await owner.stop().catch(() => log("Qwen stop after failed startup was not confirmed"));
|
|
2786
3213
|
if (error instanceof QwenIdentityError) throw error;
|
|
2787
3214
|
throw new Error("Qwen could not reach its provider or complete its startup handshake; verify the provider connection and Qwen Code 0.25.0 (details withheld)");
|
|
2788
3215
|
}
|
|
@@ -2828,7 +3255,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2828
3255
|
current);
|
|
2829
3256
|
},
|
|
2830
3257
|
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 })) }],
|
|
3258
|
+
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi", true)).map(([name, value]) => ({ name, value })) }],
|
|
2832
3259
|
preamble: sessionRoleContract("kimi"),
|
|
2833
3260
|
});
|
|
2834
3261
|
recoveryTaskPreface("kimi");
|
|
@@ -2916,11 +3343,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2916
3343
|
if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
|
|
2917
3344
|
const problem = mlxLaunchProblem(config, { backend, model: args.model });
|
|
2918
3345
|
if (problem) return { ok: false, error: problem };
|
|
2919
|
-
modelRelay ??= await startModelRelay({ omni, admitRequest: admitPiRequest, dgxMaxInputTokens:
|
|
3346
|
+
modelRelay ??= await startModelRelay({ omni, admitRequest: admitPiRequest, dgxMaxInputTokens: activeRouting().pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast", enableHubAuto: true,
|
|
2920
3347
|
routeSessionKey: () => { const session = bus.peers.get("pi")?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
|
|
2921
|
-
staySwitch: () =>
|
|
3348
|
+
staySwitch: () => activeRouting(),
|
|
2922
3349
|
onRoute: record => event({ type: "route", peer: "pi", ...record }),
|
|
2923
|
-
efficientWaitMs:
|
|
3350
|
+
efficientWaitMs: activeRouting().pi.efficient_wait_ms,
|
|
2924
3351
|
underBudget: () => { const current = bus.peers.get("pi"); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, "pi"); },
|
|
2925
3352
|
onCooldown: record => {
|
|
2926
3353
|
log(`model ${record.alias}: ${record.event === "start" ? `cooling down for ${Math.round(record.ms! / 1000)} s after ${record.failures} transport or startup failures` : "cooldown ended"}`);
|
|
@@ -2960,7 +3387,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2960
3387
|
return typeof refused === "string" ? `not sent: ${refused}` : "sent";
|
|
2961
3388
|
},
|
|
2962
3389
|
};
|
|
2963
|
-
const routing =
|
|
3390
|
+
const routing = activeRouting();
|
|
2964
3391
|
const pi = new PiPeer("pi", {
|
|
2965
3392
|
cwd: opts.cwd, stateDir: opts.stateDir, cmd: config.pi.cmd, mode, backend,
|
|
2966
3393
|
model: args.model,
|
|
@@ -3008,7 +3435,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3008
3435
|
if (backend !== "auto") return backend === "mlx" ? "mlx/fast" : "dgx/coding";
|
|
3009
3436
|
const taskId = envs.find(env => env.refs?.task)?.refs?.task;
|
|
3010
3437
|
const task = taskId ? board.get(Number(taskId)) : undefined;
|
|
3011
|
-
const policyBackend = task ?
|
|
3438
|
+
const policyBackend = task ? activeRouting().classes[task.class]?.pi_backend : undefined;
|
|
3012
3439
|
if (policyBackend === "mlx") {
|
|
3013
3440
|
if (config.mlx.enabled !== false) return "mlx/fast";
|
|
3014
3441
|
const problem = disabledMlxPolicyProblem(config, opts.cwd);
|
|
@@ -3076,7 +3503,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3076
3503
|
return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
|
|
3077
3504
|
}
|
|
3078
3505
|
if (peer === "local") {
|
|
3079
|
-
const routing =
|
|
3506
|
+
const routing = activeRouting();
|
|
3080
3507
|
// `--model` pins a model on OmniRoute and skips L2; `--route` picks another Switchyard route.
|
|
3081
3508
|
const route = args.model ? undefined : (args.route ?? routing.local.route);
|
|
3082
3509
|
if (route && !(route.startsWith("hub/") ? routing.hub_routes?.[route] : routing.routes[route])) return { ok: false, ...(existing && existing.state !== "offline" ? { already: true } : {}), error: `routing.toml has no route "${route}"` };
|
|
@@ -3123,8 +3550,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3123
3550
|
admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, "local", unit),
|
|
3124
3551
|
...(route ? { route } : {}),
|
|
3125
3552
|
...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
|
|
3126
|
-
hubRoutes: () =>
|
|
3127
|
-
staySwitch: () =>
|
|
3553
|
+
hubRoutes: () => activeRouting().hub_routes ?? {},
|
|
3554
|
+
staySwitch: () => activeRouting(),
|
|
3128
3555
|
onRoute: record => event({ type: "route", peer: "local", ...record }),
|
|
3129
3556
|
onAdvisor: record => event({ type: "advisor", peer: "local", ...record }),
|
|
3130
3557
|
onRouteOutcome: record => event({ type: "route_outcome", peer: "local", ...record }),
|
|
@@ -3167,7 +3594,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3167
3594
|
}
|
|
3168
3595
|
|
|
3169
3596
|
async function validateLocalChoice(route: string | undefined, fixedModel: string): Promise<void> {
|
|
3170
|
-
const routing =
|
|
3597
|
+
const routing = activeRouting();
|
|
3171
3598
|
if (route && !(route.startsWith("hub/") ? routing.hub_routes?.[route] : routing.routes[route])) throw new Error(`routing.toml has no route "${route}"`);
|
|
3172
3599
|
const models = await omni.models();
|
|
3173
3600
|
const required = new Set([fixedModel]);
|
|
@@ -3351,7 +3778,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3351
3778
|
instanceId,
|
|
3352
3779
|
attention: attentionStatus.attentionInput,
|
|
3353
3780
|
pet,
|
|
3354
|
-
status: { peers: peerView, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
3781
|
+
status: { peers: peerView, pendingRestart, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
3355
3782
|
boardCapabilities: { backlog: true },
|
|
3356
3783
|
planningCapabilities: { enabled: true, wipPull: true, split: true },
|
|
3357
3784
|
releases: tasks.releaseList(USER),
|
|
@@ -3364,7 +3791,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3364
3791
|
tasks: boardTasks.map((task) => {
|
|
3365
3792
|
const view = tasks.publicView(task);
|
|
3366
3793
|
// 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 } : {}) };
|
|
3794
|
+
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
3795
|
}),
|
|
3369
3796
|
links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
|
|
3370
3797
|
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))) }])),
|
|
@@ -3386,6 +3813,237 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3386
3813
|
};
|
|
3387
3814
|
}
|
|
3388
3815
|
|
|
3816
|
+
/** Set a validated leaf in place: objects captured by policy consumers retain their identity. */
|
|
3817
|
+
function putConfig(target: object, key: string, value: unknown): void {
|
|
3818
|
+
const parts = key.split(".");
|
|
3819
|
+
if (parts.some(part => ["__proto__", "constructor", "prototype"].includes(part))) throw new Error("unsafe configuration path");
|
|
3820
|
+
let node = target as Record<string, unknown>;
|
|
3821
|
+
for (const part of parts.slice(0, -1)) {
|
|
3822
|
+
if (!Object.hasOwn(node, part) || !node[part] || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {};
|
|
3823
|
+
node = node[part] as Record<string, unknown>;
|
|
3824
|
+
}
|
|
3825
|
+
if (value === undefined) delete node[parts.at(-1)!];
|
|
3826
|
+
else node[parts.at(-1)!] = structuredClone(value);
|
|
3827
|
+
}
|
|
3828
|
+
function changedRawPaths(before: unknown, after: unknown, prefix = ""): string[] {
|
|
3829
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
3830
|
+
if (object(before) || object(after)) {
|
|
3831
|
+
const old = object(before) ? before : {}, next = object(after) ? after : {};
|
|
3832
|
+
return [...new Set([...Object.keys(old), ...Object.keys(next)])].flatMap(key => changedRawPaths(old[key], next[key], prefix ? `${prefix}.${key}` : key));
|
|
3833
|
+
}
|
|
3834
|
+
return JSON.stringify(before) === JSON.stringify(after) ? [] : [prefix];
|
|
3835
|
+
}
|
|
3836
|
+
function declaredPaths(value: unknown, prefix = ""): string[] {
|
|
3837
|
+
// Switchyard dictionaries have opaque keys (including dots); publish each dictionary as one value.
|
|
3838
|
+
if (["routing.targets", "routing.routes", "routing.hub_routes"].includes(prefix)) return [prefix];
|
|
3839
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
|
|
3840
|
+
return prefix ? [prefix] : [];
|
|
3841
|
+
}
|
|
3842
|
+
const isMachineKey = (key: string) => ALL_MACHINE_LOCAL.some(path => key === path || key.startsWith(`${path}.`));
|
|
3843
|
+
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
3844
|
+
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
3845
|
+
const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
|
|
3846
|
+
const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled));
|
|
3847
|
+
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
3848
|
+
}
|
|
3849
|
+
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
3850
|
+
const fileConfig = loadConfig(opts.cwd, sources.layers);
|
|
3851
|
+
validateEffectiveConfig(fileConfig);
|
|
3852
|
+
const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
|
|
3853
|
+
const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
|
|
3854
|
+
const previews: { key: string; before: string; after: string; effect?: string }[] = [];
|
|
3855
|
+
const paths = new Set<string>();
|
|
3856
|
+
for (const layer of [...sources.trustLayers, ...acceptedSources.trustLayers]) {
|
|
3857
|
+
const prefix = layer.name.endsWith(".toml") ? "routing" : "";
|
|
3858
|
+
for (const path of declaredPaths(layer.raw, prefix)) paths.add(path);
|
|
3859
|
+
}
|
|
3860
|
+
if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig)) paths.add(change.key);
|
|
3861
|
+
for (const key of paths) {
|
|
3862
|
+
if (scope && key !== scope) continue;
|
|
3863
|
+
const route = key.startsWith("routing."), path = (route ? key.slice(8) : key).split(".");
|
|
3864
|
+
const layers = sources.trustLayers.filter(layer => layer.name.endsWith(route ? ".toml" : ".json"));
|
|
3865
|
+
const priorLayers = acceptedSources.trustLayers.filter(layer => layer.name.endsWith(route ? ".toml" : ".json"));
|
|
3866
|
+
const current = [...layers].reverse().find(layer => valueAt(layer.raw, path) !== undefined);
|
|
3867
|
+
const prior = [...priorLayers].reverse().find(layer => valueAt(layer.raw, path) !== undefined);
|
|
3868
|
+
const changedLayers = [...new Set([...layers.map(layer => layer.name), ...priorLayers.map(layer => layer.name)])].filter(name => JSON.stringify(valueAt(layers.find(layer => layer.name === name)?.raw, path)) !== JSON.stringify(valueAt(priorLayers.find(layer => layer.name === name)?.raw, path)));
|
|
3869
|
+
const rawChanged = changedLayers.length > 0;
|
|
3870
|
+
if (opts.config && !rawChanged) continue; // embedding overrides are not file defaults
|
|
3871
|
+
const before = valueAt(route ? desiredRouting : desiredConfig, path);
|
|
3872
|
+
const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
|
|
3873
|
+
const rawAfter = current ? valueAt(current.raw, path) : undefined;
|
|
3874
|
+
if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
|
|
3875
|
+
const machine = !route && isMachineKey(key);
|
|
3876
|
+
const origin = current ?? prior;
|
|
3877
|
+
let tracked = origin ? configTracked(opts.cwd, origin.name) : false;
|
|
3878
|
+
if (!machine && tracked === undefined && !configRepositoryPresent(opts.cwd)) tracked = false;
|
|
3879
|
+
const guard = SETTINGS.find(def => def.store === (route ? "routing" : "config") && def.path.join(".") === path.join("."));
|
|
3880
|
+
const acceptedFallback = !machine && tracked !== undefined && !!current && JSON.stringify(after) === JSON.stringify(valueAt(route ? startupRouting : embeddedConfig, path)) && JSON.stringify(after) === JSON.stringify(rawAfter) && JSON.stringify(rawAfter) === JSON.stringify(valueAt(startupSources.find(layer => layer.name === current.name)?.raw, path));
|
|
3881
|
+
if (key.startsWith("roster.") && scope !== key && JSON.stringify(before) !== JSON.stringify(after)) {
|
|
3882
|
+
rejected.push({ key, reason: "use the confirmed ahub roster add/remove setting; a full reload cannot bypass peer lifecycle" });
|
|
3883
|
+
continue;
|
|
3884
|
+
}
|
|
3885
|
+
let settledHolderChange = false, effect: string | undefined;
|
|
3886
|
+
if (key === "planner" || key === "roles" || key.startsWith("roles.")) {
|
|
3887
|
+
const prospective = structuredClone(candidate);
|
|
3888
|
+
putConfig(prospective, path.join("."), after);
|
|
3889
|
+
const oldHolders = roleHolders(candidate), newHolders = roleHolders(prospective);
|
|
3890
|
+
settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
|
|
3891
|
+
if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
|
|
3892
|
+
}
|
|
3893
|
+
const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
|
|
3894
|
+
const decision = authorizeConfigLeaf(input);
|
|
3895
|
+
if (!decision.allowed) {
|
|
3896
|
+
const next = `${decision.reason}; review and run ahub settings reload, or revert this key${machine && tracked !== false ? "; move machine-local declarations to an untracked local file first" : ""}`;
|
|
3897
|
+
rejected.push({ key, reason: next });
|
|
3898
|
+
if (!scope && authority === "terminal" && authorizeConfigLeaf({ ...input, confirmed: true }).allowed) {
|
|
3899
|
+
const shown = { key, before: before === undefined ? "(unset)" : JSON.stringify(before)!, after: after === undefined ? "(unset)" : JSON.stringify(after)!, ...(effect ? { effect } : {}) };
|
|
3900
|
+
if (key.length > 160 || Buffer.byteLength(shown.before) > 16_384 || Buffer.byteLength(shown.after) > 16_384 || effect && Buffer.byteLength(effect) > 2048) rejected.at(-1)!.reason += "; complete preview exceeds the 16 KiB value/2 KiB effect bound; reduce this edit before confirmation";
|
|
3901
|
+
else { guarded.push(key); previews.push(shown); }
|
|
3902
|
+
}
|
|
3903
|
+
continue;
|
|
3904
|
+
}
|
|
3905
|
+
putConfig(route ? routing : candidate, path.join("."), after);
|
|
3906
|
+
if (route) {
|
|
3907
|
+
routing.sources ??= {};
|
|
3908
|
+
const origin = sources.routing.sources?.[path.join(".")];
|
|
3909
|
+
if (origin) routing.sources[path.join(".")] = origin; else delete routing.sources[path.join(".")];
|
|
3910
|
+
}
|
|
3911
|
+
if (!route && path[0] === "permission_modes" && path[1]) {
|
|
3912
|
+
candidate.permission_default_sources ??= {};
|
|
3913
|
+
const source = fileConfig.permission_default_sources?.[path[1]];
|
|
3914
|
+
if (source) candidate.permission_default_sources[path[1]] = source;
|
|
3915
|
+
else delete candidate.permission_default_sources[path[1]];
|
|
3916
|
+
}
|
|
3917
|
+
accepted.push(key);
|
|
3918
|
+
}
|
|
3919
|
+
// Describe the guarded transaction against all safe changes already admitted by this plan.
|
|
3920
|
+
const rolePreviews = previews.filter(item => item.key === "planner" || item.key === "roles" || item.key.startsWith("roles."));
|
|
3921
|
+
if (rolePreviews.length) {
|
|
3922
|
+
const prospective = structuredClone(candidate);
|
|
3923
|
+
for (const item of rolePreviews) putConfig(prospective, item.key, item.after === "(unset)" ? undefined : JSON.parse(item.after));
|
|
3924
|
+
const before = roleHolders(candidate), after = roleHolders(prospective);
|
|
3925
|
+
const effect = `planner ${JSON.stringify(before.planners)} -> ${JSON.stringify(after.planners)}; conductor ${JSON.stringify(before.conductors)} -> ${JSON.stringify(after.conductors)}`;
|
|
3926
|
+
for (const item of rolePreviews) item.effect = effect;
|
|
3927
|
+
}
|
|
3928
|
+
guarded.sort(); previews.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
3929
|
+
if (previews.some(item => item.effect && Buffer.byteLength(item.effect) > 2048) || guarded.length > 200 || Buffer.byteLength(JSON.stringify({ token: "x".repeat(36), keys: guarded, changes: previews })) > 131_072) {
|
|
3930
|
+
for (const item of rejected) if (guarded.includes(item.key)) item.reason += "; combined confirmation preview exceeds its 128 KiB bound; split this edit";
|
|
3931
|
+
guarded.length = 0; previews.length = 0;
|
|
3932
|
+
}
|
|
3933
|
+
const changes = configChanges({ ...config, roles: policyRoles }, candidate);
|
|
3934
|
+
const running = structuredClone({ ...config, roles: policyRoles });
|
|
3935
|
+
for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
|
|
3936
|
+
validateEffectiveConfig(running);
|
|
3937
|
+
conductorPeer(running.roles);
|
|
3938
|
+
for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
|
|
3939
|
+
const problem = running.mlx.enabled === false ? mlxPinProblem(routing, sources.texts["routing.toml"] !== null) : undefined;
|
|
3940
|
+
if (problem) throw new Error(problem);
|
|
3941
|
+
return { sources, fileConfig, candidate, running, routing, changes, accepted, rejected, guarded, previews };
|
|
3942
|
+
}
|
|
3943
|
+
/** Acknowledging one field never hides another pending edit in the same file. */
|
|
3944
|
+
function acknowledgeConfiguration(sources: ConfigSources, paths: readonly string[]): void {
|
|
3945
|
+
const layers = acceptedSources.trustLayers.map(layer => ({ ...layer, raw: structuredClone(layer.raw) }));
|
|
3946
|
+
for (const name of CONFIG_SOURCE_FILES) {
|
|
3947
|
+
let layer = layers.find(layer => layer.name === name);
|
|
3948
|
+
const relevant = paths.filter(key => key.startsWith("routing.") === name.endsWith(".toml"));
|
|
3949
|
+
if (!relevant.length) continue;
|
|
3950
|
+
if (!layer) { layer = { name, raw: {} }; layers.push(layer); }
|
|
3951
|
+
const next = sources.trustLayers.find(layer => layer.name === name)?.raw;
|
|
3952
|
+
for (const key of relevant) {
|
|
3953
|
+
const path = key.startsWith("routing.") ? key.slice(8) : key;
|
|
3954
|
+
const raw = valueAt(next, path.split("."));
|
|
3955
|
+
const winning = [...sources.trustLayers].reverse().find(source => source.name.endsWith(name.endsWith(".toml") ? ".toml" : ".json") && valueAt(source.raw, path.split(".")) !== undefined);
|
|
3956
|
+
if (raw !== undefined && winning?.name !== name) continue; // a shadowed edit was never authorized by its no-op effective value
|
|
3957
|
+
putConfig(layer.raw, path, raw);
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3960
|
+
acceptedSources = { ...sources, trustLayers: layers, layers: layers.filter((layer): layer is typeof layer & { name: "config.json" | "config.local.json" } => layer.name.endsWith(".json")), routing: desiredRouting };
|
|
3961
|
+
}
|
|
3962
|
+
function configWriteCheck(scratch: string, authority: SettingAuthority, scope?: string): HubConfig {
|
|
3963
|
+
const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
|
|
3964
|
+
texts["config.json"] = next["config.json"];
|
|
3965
|
+
texts["config.local.json"] = next["config.local.json"];
|
|
3966
|
+
const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
|
|
3967
|
+
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
3968
|
+
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
3969
|
+
return prepared.fileConfig;
|
|
3970
|
+
}
|
|
3971
|
+
function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
|
|
3972
|
+
const texts = configSourceTexts(opts.cwd);
|
|
3973
|
+
texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
|
|
3974
|
+
const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
|
|
3975
|
+
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
3976
|
+
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
3977
|
+
}
|
|
3978
|
+
function runConfigWatch(): void {
|
|
3979
|
+
if (stopping) return;
|
|
3980
|
+
if (rosterChanging) { configWatchPending = true; return; }
|
|
3981
|
+
void reloadConfig("ordinary");
|
|
3982
|
+
}
|
|
3983
|
+
/** The only publication path; named writes are scoped, full guarded reloads need person confirmation. */
|
|
3984
|
+
async function reloadConfig(authority: SettingAuthority, scope?: string, confirm?: unknown): Promise<Record<string, unknown>> {
|
|
3985
|
+
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding configuration reload" };
|
|
3986
|
+
let fingerprint = "unavailable", published = false;
|
|
3987
|
+
try {
|
|
3988
|
+
const texts = configSourceTexts(opts.cwd);
|
|
3989
|
+
fingerprint = configFingerprint(texts);
|
|
3990
|
+
if (confirm !== undefined && (typeof confirm !== "string" || authority !== "terminal" || scope || !reloadConfirmation || reloadConfirmation.token !== confirm || reloadConfirmation.fingerprint !== fingerprint || reloadConfirmation.revision !== configRevision)) return { ok: false, error: "configuration confirmation is stale or changed; run ahub settings reload again" };
|
|
3991
|
+
const sources = parseConfigSources(texts);
|
|
3992
|
+
const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
|
|
3993
|
+
if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
|
|
3994
|
+
if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
|
|
3995
|
+
const { changes, candidate } = prepared;
|
|
3996
|
+
const routingChanges = changedRawPaths({ ...runningRouting, sources: undefined }, { ...prepared.routing, sources: undefined }, "routing");
|
|
3997
|
+
if (prepared.running.snapshots.enabled && repo && !turnLog) turnLog = new Turns(join(opts.stateDir, "hub.db"));
|
|
3998
|
+
const priorDesired = JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers]);
|
|
3999
|
+
const rolesChanged = changes.some(change => change.key === "roles" || change.key.startsWith("roles.") || change.key === "planner");
|
|
4000
|
+
const beforeConductor = conductorPeer(config.roles);
|
|
4001
|
+
published = true;
|
|
4002
|
+
for (const change of changes) {
|
|
4003
|
+
if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
|
|
4004
|
+
putConfig(config, change.key, change.after);
|
|
4005
|
+
}
|
|
4006
|
+
if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
|
|
4007
|
+
for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
|
|
4008
|
+
for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
|
|
4009
|
+
}
|
|
4010
|
+
config.permission_default_sources = candidate.permission_default_sources;
|
|
4011
|
+
for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
|
|
4012
|
+
if (changes.some(change => change.key === "task_sweep" || change.key.startsWith("task_sweep."))) rescheduleSweep();
|
|
4013
|
+
if (changes.some(change => ["batch_max", "batch_ms", "queue_cap"].includes(change.key))) bus.configureScheduling({ batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap });
|
|
4014
|
+
if (changes.some(change => change.key === "watchdog_ms")) for (const peer of bus.peers.values()) if (peer instanceof BasePeer) peer.configureWatchdog(config.watchdog_ms);
|
|
4015
|
+
Object.assign(limits, config.limits);
|
|
4016
|
+
turnFreeNow = undefined;
|
|
4017
|
+
runningRouting = { ...prepared.routing, pi: runningRouting.pi };
|
|
4018
|
+
desiredConfig = structuredClone(candidate);
|
|
4019
|
+
desiredRouting = structuredClone(prepared.routing);
|
|
4020
|
+
acknowledgeConfiguration(sources, prepared.accepted);
|
|
4021
|
+
if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
|
|
4022
|
+
pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
|
|
4023
|
+
pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
|
|
4024
|
+
storedConfigCache = undefined;
|
|
4025
|
+
if (beforeConductor && (beforeConductor !== conductorPeer(config.roles) || config.conductor.feed === "off")) {
|
|
4026
|
+
bus.revokeSupervision(beforeConductor); supervision.resetPending(beforeConductor); budgetFeedSeen.clear();
|
|
4027
|
+
}
|
|
4028
|
+
if (confirm !== undefined) reloadConfirmation = undefined;
|
|
4029
|
+
if (prepared.guarded.length) reloadConfirmation = { token: randomUUID(), fingerprint, revision: configRevision, keys: prepared.guarded };
|
|
4030
|
+
const refusal = prepared.rejected.map(item => `${item.key}: ${item.reason}`).join("; ");
|
|
4031
|
+
const failure = refusal ? `${fingerprint}:${refusal}` : undefined;
|
|
4032
|
+
if (failure && lastReloadFailure !== failure) { try { notify(`configuration keys retained: ${sanitize(refusal).slice(0, 1200)}`); } catch { log("configuration keys retained; notice unavailable"); } }
|
|
4033
|
+
lastReloadFailure = failure;
|
|
4034
|
+
writeStatus();
|
|
4035
|
+
const applied = [...changes.filter(change => change.applies !== "restart").map(change => change.key), ...routingChanges.filter(key => configApply(key).applies !== "restart")];
|
|
4036
|
+
return { ok: true, applied, pendingRestart, rejected: prepared.rejected,
|
|
4037
|
+
...(prepared.guarded.length ? { confirmation: { token: reloadConfirmation!.token, keys: reloadConfirmation!.keys, changes: prepared.previews } } : {}),
|
|
4038
|
+
text: [applied.length ? `configuration reloaded: ${applied.join(", ")}` : pendingRestart.length ? `pending restart: ${pendingRestart.map(item => item.key).join(", ")}` : "configuration unchanged", ...(prepared.rejected.length ? [`retained keys: ${prepared.rejected.map(item => item.key).join(", ")}; review ahub settings reload or revert the file`] : [])].join("\n") };
|
|
4039
|
+
} catch (error) {
|
|
4040
|
+
const message = sanitize(error instanceof Error ? error.message : "configuration reload could not be confirmed").slice(0, 600);
|
|
4041
|
+
const failure = `${fingerprint}:${message}`;
|
|
4042
|
+
if (lastReloadFailure !== failure) { lastReloadFailure = failure; try { notify(`configuration reload ${published ? "unconfirmed" : "refused"}: ${message}; run ahub settings reload after reviewing or reverting the file`); } catch { log("configuration reload notice unavailable"); } }
|
|
4043
|
+
return { ok: false, error: message, ...(published ? { applied: true } : {}), pendingRestart };
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
|
|
3389
4047
|
// #269: settings. One registry (src/hub/settings.ts) serves the dashboard and `ahub settings`; nothing outside it is written.
|
|
3390
4048
|
let storedConfigCache: { stamp: string; config: HubConfig | undefined } | undefined;
|
|
3391
4049
|
/** What the config files hold now, as the loader reads them. Re-read only when a file changed: the dashboard polls. */
|
|
@@ -3398,16 +4056,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3398
4056
|
}
|
|
3399
4057
|
return storedConfigCache.config;
|
|
3400
4058
|
};
|
|
3401
|
-
/** Stored start modes and the scoped instruction policy follow a settings write or undo. */
|
|
3402
|
-
const applyStartModes = () => { const stored = storedConfig(); if (stored) { config.peers = stored.peers; config.protect_instruction_files = stored.protect_instruction_files; } };
|
|
3403
|
-
/** Apply only the named peer; unrelated hand edits retain their prior publication scope. */
|
|
3404
|
-
const applyRoleSelection = (peer: string) => {
|
|
3405
|
-
const stored = storedConfig(); if (!stored) return;
|
|
3406
|
-
config.catalog_roles = { ...config.catalog_roles };
|
|
3407
|
-
const ids = catalogAssignments(stored.catalog_roles)[peer];
|
|
3408
|
-
if (ids === undefined) delete config.catalog_roles[peer]; else config.catalog_roles[peer] = ids;
|
|
3409
|
-
roleAssignments[peer] = ids ?? resolvedProfile.catalog_roles[peer] ?? [];
|
|
3410
|
-
};
|
|
3411
4059
|
const settingScalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
3412
4060
|
const routingValue = (routing: ReturnType<typeof currentRouting>, def: SettingDef): SettingValue =>
|
|
3413
4061
|
settingScalar(def.path[0] === "stay_switch" ? routing.stay_switch : (routing.classes[def.path[1] as TaskClass] as Record<string, unknown> | undefined)?.[def.path[2]!]);
|
|
@@ -3417,14 +4065,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3417
4065
|
try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
|
|
3418
4066
|
catch { return {}; }
|
|
3419
4067
|
}
|
|
3420
|
-
function settingsView(): { rows: SettingRow[]; undo?: string } {
|
|
4068
|
+
function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[] } {
|
|
3421
4069
|
const stored = storedConfig();
|
|
3422
4070
|
// What the loader would say about the files as they are: an overlay that does not parse, or one that no longer
|
|
3423
4071
|
// merges (it names a route routing.toml dropped), leaves the last good policy in force, and the rows say why.
|
|
3424
4072
|
let routingProblem: string | undefined;
|
|
3425
4073
|
try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
|
|
3426
4074
|
const rows = settingRows({
|
|
3427
|
-
cwd: opts.cwd, routing:
|
|
4075
|
+
cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
3428
4076
|
running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
|
|
3429
4077
|
next: stored ? (path) => path[0] === "roster" ? rosterAllows(stored.roster, path[1]!, stored.pi.enabled) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
|
|
3430
4078
|
roles: roleState,
|
|
@@ -3435,16 +4083,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3435
4083
|
},
|
|
3436
4084
|
});
|
|
3437
4085
|
const undo = pendingUndo(opts.stateDir);
|
|
3438
|
-
|
|
4086
|
+
const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
|
|
4087
|
+
return { pendingRestart, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
3439
4088
|
}
|
|
3440
4089
|
/** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
|
|
3441
|
-
const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false) => {
|
|
3442
|
-
event({ type: "settings", key, from, to, source, ...(undo ? { undo: true } : {}) });
|
|
4090
|
+
const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
|
|
4091
|
+
event({ type: "settings", key, from, to, source, ...(undo ? { undo: true } : {}), ...(reason ? { reason } : {}) });
|
|
3443
4092
|
try { notify(`setting ${key}: ${settingText(from)} -> ${settingText(to)} (${undo ? "undo, " : ""}${source})`); } catch { /* the write stands */ }
|
|
3444
4093
|
};
|
|
3445
4094
|
const settingSource = (authority: SettingAuthority) => authority === "terminal" ? "terminal" as const : "dashboard" as const;
|
|
3446
4095
|
function rosterView() {
|
|
3447
|
-
const open = board.list().filter(task => task.state
|
|
4096
|
+
const open = board.list().filter(task => !isClosedTaskState(task.state));
|
|
3448
4097
|
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
4098
|
}
|
|
3450
4099
|
function rosterCandidate(def: SettingDef, value: SettingValue, undo = false): PeerRoster {
|
|
@@ -3475,7 +4124,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3475
4124
|
? `Stop ${peer}'s existing work in its retained terminal; removal detaches the hub connection and does not stop that terminal's work.`
|
|
3476
4125
|
: undefined;
|
|
3477
4126
|
}
|
|
3478
|
-
async function changeRosterSetting(def: SettingDef, value: SettingValue, source: "dashboard" | "terminal", confirm: unknown, now: boolean, undo = false): Promise<Record<string, unknown>> {
|
|
4127
|
+
async function changeRosterSetting(def: SettingDef, value: SettingValue, source: "dashboard" | "terminal", confirm: unknown, now: boolean, undo = false, authority: SettingAuthority = "ordinary", reason?: string, undoIdentity?: UndoIdentity): Promise<Record<string, unknown>> {
|
|
3479
4128
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
3480
4129
|
const peer = def.path[1]!;
|
|
3481
4130
|
let saved = false;
|
|
@@ -3490,12 +4139,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3490
4139
|
}
|
|
3491
4140
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding roster changes" };
|
|
3492
4141
|
rosterChanging = peer;
|
|
3493
|
-
if (undo) undoSetting(opts.cwd, opts.stateDir, (scratch) =>
|
|
3494
|
-
else writeConfigSetting(opts.cwd, opts.stateDir, def, value,
|
|
4142
|
+
if (undo) undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, `roster.${peer}`), (_routing, overlay) => routingWriteCheck(authority, overlay), undoIdentity);
|
|
4143
|
+
else writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, `roster.${peer}`));
|
|
3495
4144
|
// Apply exactly the authorized key. Other file edits stay pending until the next hub start.
|
|
3496
4145
|
config.roster = next;
|
|
4146
|
+
settlePlanner(); // #455: an automatic planner follows the roster
|
|
3497
4147
|
saved = true;
|
|
3498
|
-
recordSetting(def.key, before, after, source, undo);
|
|
4148
|
+
recordSetting(def.key, before, after, source, undo, reason);
|
|
3499
4149
|
bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
|
|
3500
4150
|
if (!after) {
|
|
3501
4151
|
for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
|
|
@@ -3517,30 +4167,40 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3517
4167
|
const owner = bus.peers.get(peer);
|
|
3518
4168
|
if (owner instanceof PiPeer && owner.mode === "tui") await owner.resumeFromRoster();
|
|
3519
4169
|
}
|
|
4170
|
+
const reloaded = await reloadConfig(authority, `roster.${peer}`);
|
|
4171
|
+
if (reloaded.ok !== true) return { ...reloaded, saved: true };
|
|
3520
4172
|
writeStatus();
|
|
3521
|
-
const stayed = !after ? board.list().filter(task => task.state
|
|
4173
|
+
const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
|
|
3522
4174
|
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
4175
|
} catch (error) {
|
|
3524
4176
|
if (saved) writeStatus();
|
|
3525
4177
|
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
3526
|
-
} finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); } }
|
|
4178
|
+
} finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); if (configWatchPending && !stopping) { configWatchPending = false; clearTimeout(reloadDebounce); reloadDebounce = setTimeout(runConfigWatch, 100); reloadDebounce.unref?.(); } } }
|
|
3527
4179
|
}
|
|
3528
4180
|
/** A checked definition and value, or the refusal. Every settings write and preview starts here. */
|
|
3529
4181
|
function settingInput(key: unknown, raw: unknown): { def: SettingDef; value: SettingValue } | { error: string } {
|
|
3530
4182
|
const def = settingDef(key);
|
|
3531
4183
|
if (typeof def === "string") return { error: def };
|
|
3532
|
-
const routing =
|
|
4184
|
+
const routing = activeRouting();
|
|
3533
4185
|
try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : [...Object.keys(routing.routes), ...Object.keys(routing.hub_routes ?? {})]) }; }
|
|
3534
4186
|
catch (error) { return { error: (error as Error).message }; }
|
|
3535
4187
|
}
|
|
3536
|
-
|
|
4188
|
+
const settingReason = (reason: unknown): string | undefined => {
|
|
4189
|
+
if (reason === undefined) return undefined;
|
|
4190
|
+
if (typeof reason !== "string" || !reason.trim() || Buffer.byteLength(reason) > 400) throw new Error("setting reason must contain 1..400 UTF-8 bytes");
|
|
4191
|
+
return sanitize(reason.trim());
|
|
4192
|
+
};
|
|
4193
|
+
async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown): Promise<Record<string, unknown>> {
|
|
4194
|
+
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
|
|
4195
|
+
let reason: string | undefined;
|
|
4196
|
+
try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
3537
4197
|
const input = settingInput(key, raw);
|
|
3538
4198
|
if ("error" in input) return { ok: false, error: input.error };
|
|
3539
4199
|
const { def, value } = input;
|
|
3540
4200
|
const refusal = settingRefusal(def, value, authority);
|
|
3541
4201
|
if (refusal) return { ok: false, error: refusal };
|
|
3542
4202
|
const source = settingSource(authority);
|
|
3543
|
-
if (def.group === "Roster") return changeRosterSetting(def, value, source, confirm, now === true);
|
|
4203
|
+
if (def.group === "Roster") return changeRosterSetting(def, value, source, confirm, now === true, false, authority, reason);
|
|
3544
4204
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
3545
4205
|
const peer = def.group === "Permissions" ? def.path.at(-1)! : undefined;
|
|
3546
4206
|
// The console asks for y before never-ask; here the person types the peer it is for.
|
|
@@ -3551,53 +4211,63 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3551
4211
|
const from = permissionMode(peer!);
|
|
3552
4212
|
const result = await permissionChange(peer!, () => changePermission(peer!, value, value === "never-ask"));
|
|
3553
4213
|
if (result.ok !== true) return result;
|
|
3554
|
-
recordSetting(def.key, from, permissionMode(peer!), source);
|
|
4214
|
+
recordSetting(def.key, from, permissionMode(peer!), source, false, reason);
|
|
3555
4215
|
return { ok: true, text: `${def.key}: ${result.permissionMode}`, ...(typeof result.note === "string" ? { note: result.note } : {}) };
|
|
3556
4216
|
}
|
|
4217
|
+
let saved = false;
|
|
3557
4218
|
try {
|
|
3558
4219
|
if (def.store === "routing") {
|
|
3559
|
-
const from = routingValue(
|
|
3560
|
-
const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value,
|
|
4220
|
+
const from = routingValue(activeRouting(), def);
|
|
4221
|
+
const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value, (_routing, overlay) => routingWriteCheck(authority, overlay, def.key));
|
|
3561
4222
|
if (!next) return { ok: true, text: `${def.key}: ${OVERLAY_FILE} already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
|
|
3562
|
-
|
|
3563
|
-
|
|
4223
|
+
saved = true;
|
|
4224
|
+
const result = await reloadConfig(authority, def.key);
|
|
4225
|
+
if (result.ok !== true) return { ...result, saved: true };
|
|
4226
|
+
recordSetting(def.key, from, routingValue(activeRouting(), def), source, false, reason);
|
|
4227
|
+
return { ok: true, text: `${def.key}: ${settingText(routingValue(activeRouting(), def))} (in force now)` };
|
|
3564
4228
|
}
|
|
3565
|
-
// Removing the machine-local value uncovers the shared file's: never-ask there needs the same confirmation.
|
|
3566
4229
|
if (peer && value === null && storedLocally(opts.cwd, def) && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" && confirm !== peer) return { ok: false, error: `removing the machine-local value leaves never-ask from config.json for ${peer}: it needs the peer id as the confirmation; nothing was changed` };
|
|
3567
4230
|
const from = settingScalar(valueAt(storedConfig(), def.path));
|
|
3568
|
-
if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value,
|
|
3569
|
-
|
|
4231
|
+
if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, def.path.join(".")))) return { ok: true, text: `${def.key}: config.local.json already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
|
|
4232
|
+
saved = true;
|
|
4233
|
+
const result = await reloadConfig(authority, def.path.join("."));
|
|
4234
|
+
if (result.ok !== true) return { ...result, saved: true };
|
|
3570
4235
|
const to = settingScalar(valueAt(storedConfig(), def.path));
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
return { ok: true, text: dropped ? `${def.key}: written, but the hub does not read it (${dropped})` : `${def.key}: ${settingText(to)} (${def.applies === "live" ? "in force now" : def.applies === "peer start" ? "applies at the peer's next start" : "applies at the next hub start"})` };
|
|
4236
|
+
recordSetting(def.key, from, to, source, false, reason);
|
|
4237
|
+
if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
|
|
4238
|
+
const timing = def.applies === "live" ? "in force now" : def.applies === "restart" ? "pending restart"
|
|
4239
|
+
: ["permission_modes", "catalog_roles", "peers"].includes(def.path[0]!) ? "applies at the peer's next start" : def.key === "coordination" ? "applies to new tasks" : "applies at next use";
|
|
4240
|
+
return { ok: true, text: `${def.key}: ${settingText(to)} (${timing})` };
|
|
3577
4241
|
} catch (error) {
|
|
3578
|
-
return { ok: false, error: (error as Error).message };
|
|
4242
|
+
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
3579
4243
|
}
|
|
3580
4244
|
}
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
4245
|
+
|
|
4246
|
+
async function settingsUndo(authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown, expectedKey?: unknown, expectedDigest?: unknown): Promise<Record<string, unknown>> {
|
|
4247
|
+
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
|
|
4248
|
+
let reason: string | undefined;
|
|
4249
|
+
try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
3585
4250
|
// The previous version can hold a wider value than the one in force, so undo is never an ordinary session's.
|
|
3586
4251
|
if (authority === "ordinary") return { ok: false, error: "undo can put back a value that widens what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings undo in a terminal" };
|
|
3587
|
-
const
|
|
3588
|
-
if (
|
|
3589
|
-
|
|
4252
|
+
const identity = pendingUndoIdentity(opts.stateDir);
|
|
4253
|
+
if (expectedKey !== undefined || expectedDigest !== undefined) {
|
|
4254
|
+
if (typeof expectedKey !== "string" || !expectedKey || typeof expectedDigest !== "string" || !/^[a-f0-9]{64}$/.test(expectedDigest)) return { ok: false, error: "invalid undo preview identity; preview again; nothing was undone" };
|
|
4255
|
+
if (!identity || identity.key !== expectedKey || identity.undoDigest !== expectedDigest) return { ok: false, error: "undo record changed since preview; preview again; nothing was undone" };
|
|
4256
|
+
}
|
|
4257
|
+
const def = settingDef(identity?.key);
|
|
4258
|
+
if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
|
|
4259
|
+
if (def.group === "Roster") return changeRosterSetting(def, null, settingSource(authority), confirm, now === true, true, authority, reason, identity);
|
|
3590
4260
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
3591
|
-
const read = (): SettingValue => def.store === "routing" ? routingValue(
|
|
4261
|
+
const read = (): SettingValue => def.store === "routing" ? routingValue(activeRouting(), def) : settingScalar(valueAt(storedConfig(), def.path));
|
|
3592
4262
|
// never-ask is set with its confirmation, never put back by an undo that carries none.
|
|
3593
4263
|
if (def.group === "Permissions" && restoredValue(opts.cwd, opts.stateDir, def) === "never-ask") return { ok: false, error: `undo would put never-ask back for ${def.path.at(-1)} without its confirmation: set ${def.key} again (never-ask, or inherit where the shared file holds it) with the confirmation instead; nothing was undone` };
|
|
3594
4264
|
try {
|
|
3595
4265
|
const from = read();
|
|
3596
|
-
undoSetting(opts.cwd, opts.stateDir,
|
|
3597
|
-
|
|
3598
|
-
if (
|
|
3599
|
-
recordSetting(def.key, from, read(), settingSource(authority), true);
|
|
3600
|
-
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) });
|
|
4266
|
+
undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, def.path.join(".")), (_routing, overlay) => routingWriteCheck(authority, overlay, def.key), identity);
|
|
4267
|
+
const result = await reloadConfig(authority, def.store === "routing" ? def.key : def.path.join("."));
|
|
4268
|
+
if (result.ok !== true) return { ...result, saved: true };
|
|
4269
|
+
recordSetting(def.key, from, read(), settingSource(authority), true, reason);
|
|
4270
|
+
if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
|
|
3601
4271
|
return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
|
|
3602
4272
|
} catch (error) {
|
|
3603
4273
|
return { ok: false, error: (error as Error).message };
|
|
@@ -3611,21 +4281,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3611
4281
|
if (def.group === "Roster") {
|
|
3612
4282
|
try {
|
|
3613
4283
|
const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled);
|
|
3614
|
-
return { ok: true, lines: [`${peer}: ${onRoster(peer) ? "on" : "off"} -> ${on ? "on" : "off"} roster`, ...(on ? ["Admission, starts and new work become available; any existing pause or held delivery remains."] : [`${bus.queued(peer)} queued messages are retained until the peer returns.`, "Owned runtimes stop; native terminal sessions detach. Open tasks are offered to another eligible peer.", ...(retainedTerminalNotice(peer) ? [retainedTerminalNotice(peer)!] : []), ...tasks.rosterPreview(peer).map(move => sanitize(`task ${move.title}: ${move.to ? `${move.role} offered to ${move.to} if removed now` : `${move.role} stays with ${peer}; no eligible replacement`}`)), ...(rosterRemovalRefusal(peer, false) ? [rosterRemovalRefusal(peer, false)!] : [])])], ...(on ? {} : { confirm: peer }) };
|
|
4284
|
+
return { ok: true, lines: [`${peer}: ${onRoster(peer) ? "on" : "off"} -> ${on ? "on" : "off"} roster`, ...(on ? ["Admission, starts and new work become available; any existing pause or held delivery remains."] : [`${bus.queued(peer)} queued messages are retained until the peer returns.`, "Owned runtimes stop; native terminal sessions detach. Open tasks are offered to another eligible peer.", `For a busy peer, use ahub roster remove ${peer} --now explicitly; this stops its owned runtime or detaches its native session during the active turn.`, ...(retainedTerminalNotice(peer) ? [retainedTerminalNotice(peer)!] : []), ...tasks.rosterPreview(peer).map(move => sanitize(`task ${move.title}: ${move.to ? `${move.role} offered to ${move.to} if removed now` : `${move.role} stays with ${peer}; no eligible replacement`}`)), ...(rosterRemovalRefusal(peer, false) ? [rosterRemovalRefusal(peer, false)!] : [])])], ...(on ? {} : { confirm: peer }) };
|
|
3615
4285
|
} catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
|
|
3616
4286
|
}
|
|
3617
4287
|
if (def.key === "scoped_grants.protect_instructions") return { ok: true, lines: ["Applies now to scoped writes and edits of AGENTS.md, CLAUDE.md and QWEN.md, including symlinks and folded spellings.", "true asks a person; false keeps these documents grantable (the default). Explicit approval and never-ask keep their meaning."] };
|
|
3618
4288
|
if (def.group === "Permissions") {
|
|
3619
4289
|
const peer = def.path.at(-1)!;
|
|
3620
|
-
return { ok: true, lines: [permissionBoundary(peer), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value)}`] : ["inherit: the shared file's default, or ask"])] };
|
|
4290
|
+
return { ok: true, lines: [permissionBoundary(peer), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
|
|
3621
4291
|
}
|
|
3622
|
-
if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "
|
|
4292
|
+
if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "next use" ? "applies at next use" : def.applies === "live" ? "in force as soon as it is saved" : "pending restart"}`] };
|
|
3623
4293
|
try {
|
|
3624
4294
|
const after = parseRouting(routingText(opts.cwd), routingCandidate(readOverlay(opts.cwd), def, value));
|
|
3625
|
-
const open = board.list().filter((task) => task.state
|
|
4295
|
+
const open = board.list().filter((task) => !isClosedTaskState(task.state));
|
|
3626
4296
|
// A PII task's routing is fixed by its constraint and its text is private: it is counted, not shown.
|
|
3627
4297
|
const shown = open.filter((task) => !tasks.isPii(task)).slice(0, 20);
|
|
3628
|
-
return { ok: true, lines: [`${def.key}: ${settingText(routingValue(
|
|
4298
|
+
return { ok: true, lines: [`${def.key}: ${settingText(routingValue(activeRouting(), def))} -> ${settingText(routingValue(after, def))}`, ...(open.length ? [] : ["no open task: nothing is routed differently now"])],
|
|
3629
4299
|
tasks: shown.map((task) => ({ id: task.id, before: tasks.publicExplain(task.id), after: tasks.publicExplain(task.id, after) })),
|
|
3630
4300
|
...(open.length > shown.length ? { hidden: open.length - shown.length } : {}) };
|
|
3631
4301
|
} catch (error) {
|
|
@@ -3633,6 +4303,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3633
4303
|
}
|
|
3634
4304
|
}
|
|
3635
4305
|
|
|
4306
|
+
function settingsUndoPreview(): Record<string, unknown> {
|
|
4307
|
+
const identity = pendingUndoIdentity(opts.stateDir), def = settingDef(identity?.key);
|
|
4308
|
+
if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
|
|
4309
|
+
const value = restoredValue(opts.cwd, opts.stateDir, def), preview = settingsPreview(def.key, value ?? null);
|
|
4310
|
+
if (pendingUndoIdentity(opts.stateDir)?.undoDigest !== identity.undoDigest) return { ok: false, error: "undo record changed during preview; preview again" };
|
|
4311
|
+
return { ...preview, ...identity, lines: [`Undo ${def.key}: put back ${settingText(value)}`, ...(Array.isArray(preview.lines) ? preview.lines : []), "The saved previous machine-local file is revalidated before writing."] };
|
|
4312
|
+
}
|
|
4313
|
+
|
|
3636
4314
|
async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
|
|
3637
4315
|
const bad = { ok: false, error: "invalid or unavailable dashboard action" };
|
|
3638
4316
|
// #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
|
|
@@ -3711,11 +4389,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3711
4389
|
return { ok: false, error: `${a.peer} did not start; inspect ahub status and hub.log` };
|
|
3712
4390
|
}
|
|
3713
4391
|
case "setting":
|
|
3714
|
-
return settingsSet(a.key, a.value, authority, a.confirm, a.now);
|
|
4392
|
+
return settingsSet(a.key, a.value, authority, a.confirm, a.now, a.reason);
|
|
3715
4393
|
case "setting_undo":
|
|
3716
|
-
return settingsUndo(authority, a.confirm, a.now);
|
|
4394
|
+
return settingsUndo(authority, a.confirm, a.now, a.reason, a.key, a.undoDigest);
|
|
3717
4395
|
case "setting_preview":
|
|
3718
|
-
return settingsPreview(a.key, a.value);
|
|
4396
|
+
return a.undo === true ? settingsUndoPreview() : settingsPreview(a.key, a.value);
|
|
4397
|
+
case "setting_reload":
|
|
4398
|
+
return reloadConfig(authority);
|
|
3719
4399
|
default:
|
|
3720
4400
|
return bad;
|
|
3721
4401
|
}
|
|
@@ -3735,6 +4415,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3735
4415
|
// #272: the rest of what recoveryReady() waits for, so the upgrade screen can name the wait. Display only.
|
|
3736
4416
|
...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
|
|
3737
4417
|
...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
|
|
4418
|
+
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
3738
4419
|
...((piReceipts?.inFlight ?? 0) !== 0 ? ["Pi tool calls in flight"] : []),
|
|
3739
4420
|
...(releasing ? ["ready tasks being released"] : []),
|
|
3740
4421
|
...([...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 +4431,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3750
4431
|
async function recoveryOp(msg: any): Promise<Record<string, unknown>> {
|
|
3751
4432
|
if (typeof msg.op !== "string" || !["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) return recoveryError("unknown recovery operation");
|
|
3752
4433
|
if (typeof msg.expectedInstanceId !== "string" || msg.expectedInstanceId !== instanceId) return recoveryError("expected daemon instance does not match");
|
|
4434
|
+
const incompatibleCancellationTarget = () => {
|
|
4435
|
+
const ids = board.list().filter(task => task.state === "cancelled").map(task => task.id);
|
|
4436
|
+
return ids.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_cancel"))
|
|
4437
|
+
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
4438
|
+
};
|
|
4439
|
+
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
4440
|
+
if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
4441
|
+
|
|
3753
4442
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
3754
4443
|
const pi = bus.peers.get("pi");
|
|
3755
4444
|
if (pi instanceof PiPeer && pi.state === "idle") {
|
|
3756
4445
|
try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
4446
|
+
if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
3757
4447
|
}
|
|
3758
4448
|
}
|
|
3759
4449
|
if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
|
|
@@ -3769,6 +4459,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3769
4459
|
recoveryPeerSnapshot = undefined;
|
|
3770
4460
|
}
|
|
3771
4461
|
if (recoveryOperationId && recoveryOperationId !== op) return recoveryError("another recovery operation is active");
|
|
4462
|
+
{ const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
3772
4463
|
recoveryOperationId = op;
|
|
3773
4464
|
if (!recoveryPhase) recoveryPhase = "preparing";
|
|
3774
4465
|
armRecoveryLease();
|
|
@@ -3776,6 +4467,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3776
4467
|
budget.setRecoveryHold(true);
|
|
3777
4468
|
bus.setRecoveryHold(true);
|
|
3778
4469
|
await bus.fenceRecovery();
|
|
4470
|
+
{ const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
3779
4471
|
const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
|
|
3780
4472
|
if (!blocked.length && permissions.size === 0 && recoveryReady()) {
|
|
3781
4473
|
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
@@ -3823,6 +4515,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3823
4515
|
peers,
|
|
3824
4516
|
integrity: integrity(),
|
|
3825
4517
|
};
|
|
4518
|
+
{ const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
3826
4519
|
try { writeRestartSnapshot(opts.stateDir, snapshot); } catch { return recoveryError("could not persist restart state"); }
|
|
3827
4520
|
recoveryCommitted = true;
|
|
3828
4521
|
clearTimeout(recoveryLeaseTimer);
|
|
@@ -4009,7 +4702,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4009
4702
|
if (important) ws.notifyQueued({ queued: bus.queued(c.peer!), important });
|
|
4010
4703
|
});
|
|
4011
4704
|
}
|
|
4012
|
-
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL });
|
|
4705
|
+
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
|
|
4013
4706
|
}
|
|
4014
4707
|
if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
|
|
4015
4708
|
if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
|
|
@@ -4048,11 +4741,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4048
4741
|
return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
|
|
4049
4742
|
} catch { return void reply({ ok: false, error: "resolution refused: refresh the delivery revision and inspect active or already resolved work" }); }
|
|
4050
4743
|
}
|
|
4744
|
+
case "delivery_holds": {
|
|
4745
|
+
if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
|
|
4746
|
+
try {
|
|
4747
|
+
const held = bus.heldDeliveryHints(c.peer);
|
|
4748
|
+
return void reply(held ? { ok: true, held } : { ok: false, error: "held metadata unavailable" });
|
|
4749
|
+
} catch { return void reply({ ok: false, error: "held metadata unavailable" }); }
|
|
4750
|
+
}
|
|
4051
4751
|
case "delivery_receipt": {
|
|
4052
4752
|
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" });
|
|
4753
|
+
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" });
|
|
4754
|
+
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
4755
|
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
|
|
4756
|
+
bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state !== "accepted" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
|
|
4056
4757
|
return void reply({ ok: true });
|
|
4057
4758
|
}
|
|
4058
4759
|
case "inbox": {
|
|
@@ -4285,7 +4986,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4285
4986
|
}
|
|
4286
4987
|
case "watch": {
|
|
4287
4988
|
if (c.role !== "console") return void reply({ ok: false, error: "watch is a person-only console command" });
|
|
4288
|
-
if (msg.peer !== "
|
|
4989
|
+
if (typeof msg.peer !== "string" || !definitionOf(msg.peer)?.capabilities?.watch) return void reply({ ok: false, error: "watch supports headless kimi and qwen only" });
|
|
4289
4990
|
const peer = msg.peer as "kimi" | "qwen";
|
|
4290
4991
|
// Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
|
|
4291
4992
|
const kimiOwner = bus.peers.get("kimi");
|
|
@@ -4358,16 +5059,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4358
5059
|
case "settings_get":
|
|
4359
5060
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
4360
5061
|
return void reply({ ok: true, ...settingsView(), roster: rosterView() });
|
|
5062
|
+
case "settings_reload":
|
|
5063
|
+
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
|
|
5064
|
+
return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
|
|
4361
5065
|
case "settings_preview":
|
|
4362
5066
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
4363
|
-
return void reply(settingsPreview(msg.key, msg.value));
|
|
5067
|
+
return void reply(msg.undo === true ? settingsUndoPreview() : settingsPreview(msg.key, msg.value));
|
|
4364
5068
|
case "settings_set":
|
|
4365
5069
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
4366
|
-
void settingsSet(msg.key, msg.value, "terminal", msg.confirm, msg.now).then(reply, () => reply({ ok: false, error: "the setting could not be changed; inspect hub.log" }));
|
|
5070
|
+
void settingsSet(msg.key, msg.value, "terminal", msg.confirm, msg.now, msg.reason).then(reply, () => reply({ ok: false, error: "the setting could not be changed; inspect hub.log" }));
|
|
4367
5071
|
return;
|
|
4368
5072
|
case "settings_undo":
|
|
4369
5073
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
4370
|
-
return void settingsUndo("terminal", msg.confirm, msg.now).then(reply, () => reply({ ok: false, error: "the setting could not be undone; inspect hub.log" }));
|
|
5074
|
+
return void settingsUndo("terminal", msg.confirm, msg.now, msg.reason, msg.key, msg.undoDigest).then(reply, () => reply({ ok: false, error: "the setting could not be undone; inspect hub.log" }));
|
|
4371
5075
|
case "kimi_launch_end": {
|
|
4372
5076
|
if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
|
|
4373
5077
|
if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
|
|
@@ -4424,7 +5128,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4424
5128
|
board,
|
|
4425
5129
|
isPii: (t) => tasks.isPii(t),
|
|
4426
5130
|
onCampus,
|
|
4427
|
-
isPiiText: (q) =>
|
|
5131
|
+
isPiiText: (q) => activeRouting().constraints.pii === "local_only" && detectSignals({ title: q, detail: "", refs: {} }, activeRouting(), opts.cwd).includes("pii"),
|
|
4428
5132
|
...(config.memory.enabled ? { memory } : {}),
|
|
4429
5133
|
// #258: entries that match the question, then the graph neighbourhood of what they are about.
|
|
4430
5134
|
...(memoryStore ? { native: (keys: string[]) => {
|
|
@@ -4501,6 +5205,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4501
5205
|
}
|
|
4502
5206
|
async function stopOnce(): Promise<void> {
|
|
4503
5207
|
stopping = true;
|
|
5208
|
+
runnerRefreshAbort.abort();
|
|
4504
5209
|
// A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
|
|
4505
5210
|
try { removeSessions(opts.stateDir, instanceId); } catch { /* the state dir is gone */ }
|
|
4506
5211
|
checksClosed = true;
|
|
@@ -4508,12 +5213,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4508
5213
|
log("hub stopping");
|
|
4509
5214
|
writeStatus();
|
|
4510
5215
|
dashboard?.stop();
|
|
5216
|
+
stopConfigWatch();
|
|
5217
|
+
clearInterval(sweepTimer);
|
|
4511
5218
|
for (const i of intervals) clearInterval(i);
|
|
4512
5219
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
4513
5220
|
for (const p of permissions.values()) p.done(undefined);
|
|
4514
5221
|
try {
|
|
4515
5222
|
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
4516
5223
|
// Drain those starts before taking the final owned-process snapshot.
|
|
5224
|
+
// Fence/abort the runner now, but slow container teardown must not consume the peer-stop deadline.
|
|
5225
|
+
const runnerStopped = Promise.allSettled([checkRunner.stopAll(), refreshingRunner ?? Promise.resolve()]);
|
|
4517
5226
|
await Promise.allSettled([...starting.values()]);
|
|
4518
5227
|
// A fingerprint still being detected belongs in the research records written below; its peer must still be
|
|
4519
5228
|
// attached when it settles, so this comes before the peers stop (each detection is bounded by its probe deadline).
|
|
@@ -4522,6 +5231,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4522
5231
|
// A peer whose stop rejects must not strand the daemon (issue #56): its failure is
|
|
4523
5232
|
// logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
|
|
4524
5233
|
for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
|
|
5234
|
+
await runnerStopped;
|
|
4525
5235
|
await modelRelay?.close();
|
|
4526
5236
|
await egress?.close();
|
|
4527
5237
|
await piReceipts?.close();
|
|
@@ -4566,9 +5276,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4566
5276
|
writeFileSync(tokenFile, token, { mode: 0o600 });
|
|
4567
5277
|
chmodSync(tokenFile, 0o600);
|
|
4568
5278
|
budget.restore(); // pauses recorded by an earlier hub run stay in force; unfinished handoffs wait for peers to attach
|
|
4569
|
-
|
|
5279
|
+
bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
|
|
4570
5280
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
4571
5281
|
ready = true;
|
|
5282
|
+
for (const name of CONFIG_SOURCE_FILES) {
|
|
5283
|
+
const path = join(opts.cwd, ".agenthub", name);
|
|
5284
|
+
const listener = (current: import("node:fs").Stats, previous: import("node:fs").Stats) => {
|
|
5285
|
+
if (stopping || current.mtimeMs === previous.mtimeMs && current.size === previous.size && current.ino === previous.ino && current.nlink === previous.nlink) return;
|
|
5286
|
+
clearTimeout(reloadDebounce);
|
|
5287
|
+
reloadDebounce = setTimeout(runConfigWatch, 100);
|
|
5288
|
+
reloadDebounce.unref?.();
|
|
5289
|
+
};
|
|
5290
|
+
watchFile(path, { persistent: false, interval: 100 }, listener);
|
|
5291
|
+
configWatchers.push({ path, listener });
|
|
5292
|
+
}
|
|
5293
|
+
startupCleanup.push(stopConfigWatch);
|
|
4572
5294
|
// #258: approvals whose settle entry a crash cut off are settled now (decisions, contracts and fails reach long-term).
|
|
4573
5295
|
try { const settled = tasks.reconcileMemory(); if (settled) log(`hub memory: settled ${settled} approved task${settled === 1 ? "" : "s"} a stop had left unsettled`); }
|
|
4574
5296
|
catch (e) { log(`hub memory reconciliation failed: ${(e as Error).message}`); }
|
|
@@ -4594,7 +5316,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4594
5316
|
});
|
|
4595
5317
|
}
|
|
4596
5318
|
// After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
|
|
4597
|
-
const piRecovered = !!crashed?.peers.some((p) => p.peer === "pi");
|
|
5319
|
+
const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => p.peer === "pi");
|
|
4598
5320
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
4599
5321
|
return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
4600
5322
|
} finally {
|