@staix/agent-hub 0.12.31 → 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 +13 -1
- package/README.md +13 -7
- package/docs/agent-notes/adapters.md +7 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +10 -7
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/agent-notes/tests.md +1 -0
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +114 -22
- package/docs/quickstart.md +10 -6
- package/docs/security.md +12 -8
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +191 -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 +1296 -26
- package/src/adapters/acp.ts +5 -6
- 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/pi.ts +6 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/console-state.ts +74 -5
- package/src/cli/console.ts +35 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +13 -3
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +200 -39
- package/src/cli/output.ts +2 -0
- 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 -75
- 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 +6 -2
- package/src/cli/upgrade-runtime.ts +345 -194
- package/src/cli/upgrade.ts +276 -25
- package/src/hub/bus.ts +11 -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/crash.ts +8 -6
- package/src/hub/daemon.ts +559 -170
- package/src/hub/events.ts +2 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +10 -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/sdlc.ts +5 -1
- package/src/hub/settings.ts +50 -36
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/supervision.ts +3 -2
- package/src/hub/tasks.ts +28 -8
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +23 -7
package/src/hub/daemon.ts
CHANGED
|
@@ -19,22 +19,26 @@ import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from
|
|
|
19
19
|
import { ActivityRing } from "./activity.ts";
|
|
20
20
|
import { JcodePeer } from "../adapters/jcode.ts";
|
|
21
21
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
22
|
-
import { loadSdlc } from "./sdlc.ts";
|
|
22
|
+
import { loadSdlc, sdlcGh } from "./sdlc.ts";
|
|
23
23
|
import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
|
|
24
24
|
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer } from "./peer-catalog.ts";
|
|
25
25
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
26
|
-
import { buildKimiLaunch
|
|
26
|
+
import { buildKimiLaunch } from "../cli/launch.ts";
|
|
27
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
27
28
|
import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
|
|
28
29
|
import type { ToolObservation } from "../models/route/signals.ts";
|
|
29
30
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
30
|
-
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";
|
|
31
32
|
import { join, relative, resolve } from "node:path";
|
|
32
33
|
import { homedir } from "node:os";
|
|
33
34
|
import { spawn } from "node:child_process";
|
|
34
35
|
import { childEnv } from "./child-process.ts";
|
|
35
36
|
import { runCheck } from "./checks.ts";
|
|
36
37
|
import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
|
|
37
|
-
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";
|
|
38
42
|
import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
|
|
39
43
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
40
44
|
import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
@@ -47,7 +51,7 @@ import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
|
|
|
47
51
|
import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
|
|
48
52
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
49
53
|
import { processSignature } from "../pi/process-signature.ts";
|
|
50
|
-
import { recordPiReplacement, validatePiSession } from "../pi/session.ts";
|
|
54
|
+
import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
|
|
51
55
|
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
52
56
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
53
57
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
@@ -73,8 +77,8 @@ import { launcherPreview } from "../cli/preview.ts";
|
|
|
73
77
|
import { AssignmentUndeliveredError, Tasks } from "./tasks.ts";
|
|
74
78
|
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
|
|
75
79
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
76
|
-
import { currentRouting, detectSignals, OVERLAY_FILE, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
77
|
-
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";
|
|
78
82
|
import { Bus } from "./bus.ts";
|
|
79
83
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
80
84
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
@@ -95,6 +99,8 @@ import type { JournalDelivery } from "./delivery-journal.ts";
|
|
|
95
99
|
import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
|
|
96
100
|
import { changedPaths, hasTree, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
|
|
97
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";
|
|
98
104
|
|
|
99
105
|
import { isStartablePeer, peerStartConfig, PEER_START_RETRY_MS, STARTABLE_PEERS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
|
|
100
106
|
import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
|
|
@@ -235,16 +241,18 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
235
241
|
|
|
236
242
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
237
243
|
const CONFIG_FILES = ["config.json", "config.local.json"] as const;
|
|
244
|
+
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
238
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"];
|
|
239
246
|
|
|
240
247
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
241
|
-
export function loadConductorPolicy(cwd: string): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
|
|
248
|
+
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
|
|
242
249
|
const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
|
|
243
250
|
const conductor = { ...DEFAULT_CONFIG.conductor };
|
|
244
251
|
let planner: string | undefined;
|
|
245
252
|
for (const name of CONFIG_FILES) {
|
|
246
253
|
let file: Record<string, unknown>;
|
|
247
|
-
|
|
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")); }
|
|
248
256
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
249
257
|
if (file.roles !== undefined) { conductorPeer(file.roles); Object.assign(roles, file.roles); }
|
|
250
258
|
if (file.planner !== undefined) {
|
|
@@ -279,13 +287,14 @@ function syncMinutes(v: unknown): number {
|
|
|
279
287
|
return v;
|
|
280
288
|
}
|
|
281
289
|
|
|
282
|
-
export function loadConfig(cwd: string): HubConfig {
|
|
290
|
+
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubConfig {
|
|
283
291
|
const ignored: string[] = [];
|
|
284
292
|
const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
285
293
|
const files = CONFIG_FILES.flatMap((name) => {
|
|
286
294
|
let file: Record<string, any>; // parsed JSON, checked field by field below
|
|
295
|
+
if (layers && !layers.some(layer => layer.name === name)) return [];
|
|
287
296
|
try {
|
|
288
|
-
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"));
|
|
289
298
|
} catch (error) {
|
|
290
299
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
291
300
|
throw error;
|
|
@@ -340,7 +349,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
340
349
|
throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
|
|
341
350
|
}
|
|
342
351
|
const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
|
|
343
|
-
const conductorPolicy = loadConductorPolicy(cwd);
|
|
352
|
+
const conductorPolicy = loadConductorPolicy(cwd, layers);
|
|
344
353
|
if (!mlx.enabled && file.pi?.backend === "mlx") throw new Error("Pi backend mlx conflicts with mlx.enabled=false");
|
|
345
354
|
if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
|
|
346
355
|
if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
|
|
@@ -563,7 +572,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
563
572
|
try {
|
|
564
573
|
const loadedConfig = opts.config ?? loadConfig(opts.cwd);
|
|
565
574
|
const lifecycleConfig = loadSdlc(opts.cwd);
|
|
566
|
-
|
|
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 } };
|
|
567
578
|
let rosterChanging: string | undefined;
|
|
568
579
|
let kimiSessionMode: StartMode | undefined;
|
|
569
580
|
let kimiAdmissionId: string | undefined;
|
|
@@ -579,6 +590,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
579
590
|
const logFile = join(opts.stateDir, "hub.log");
|
|
580
591
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
581
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;
|
|
582
609
|
// #455: the planner in force. `policyRoles` is what the config files say; settlePlanner derives config.roles from it in
|
|
583
610
|
// place (Tasks holds the same object): an explicit `planner` key is exclusive, and with none and no rostered planner
|
|
584
611
|
// the first rostered PLANNER_PEERS entry plans. Never written to disk: a person pins it with the `planner` setting.
|
|
@@ -586,23 +613,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
586
613
|
let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
|
|
587
614
|
let plannerLogged = "";
|
|
588
615
|
const settlePlanner = () => {
|
|
589
|
-
const roles =
|
|
590
|
-
const pinned = (PLANNER_PEERS as readonly string[]).includes(config.planner ?? "") ? config.planner! : undefined;
|
|
591
|
-
let source: typeof plannerSource;
|
|
592
|
-
if (pinned) {
|
|
593
|
-
for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
|
|
594
|
-
roles[pinned] = ["planner", ...(roles[pinned] ?? [])];
|
|
595
|
-
source = "setting";
|
|
596
|
-
} else if (Object.entries(roles).some(([peer, list]) => list.includes("planner") && onRoster(peer))) source = "roles";
|
|
597
|
-
else {
|
|
598
|
-
const auto = PLANNER_PEERS.find(onRoster);
|
|
599
|
-
if (auto) { // the off-roster holders give the role up while the automatic planner holds it
|
|
600
|
-
for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
|
|
601
|
-
roles[auto] = [...(roles[auto] ?? []), "planner"];
|
|
602
|
-
}
|
|
603
|
-
source = auto ? "automatic" : "none";
|
|
604
|
-
}
|
|
605
|
-
conductorPeer(roles);
|
|
616
|
+
const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
|
|
606
617
|
for (const key of Object.keys(config.roles)) delete config.roles[key];
|
|
607
618
|
Object.assign(config.roles, roles);
|
|
608
619
|
plannerSource = source;
|
|
@@ -631,16 +642,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
631
642
|
try { configured = configuredRoles(peer); } catch { note = "catalog selection is incompatible or unavailable"; }
|
|
632
643
|
const prepared = preparedSessionRoles.get(peer);
|
|
633
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"; }
|
|
634
648
|
const value = active ? prepared ?? null : configured;
|
|
635
|
-
return { value, values: compatibleRoles(peer), ...(
|
|
636
|
-
}
|
|
637
|
-
function validateRoleSetting(scratch: string, peer: string) {
|
|
638
|
-
const candidate = loadConfig(scratch);
|
|
639
|
-
selectedRoles(peer, config.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(candidate.catalog_roles) }, catalog);
|
|
640
|
-
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." } : {}) };
|
|
641
650
|
}
|
|
651
|
+
const prepareRoleSelection = (peer: string) => { roleAssignments[peer] = catalogAssignments(config.catalog_roles)[peer] ?? resolvedProfile.catalog_roles[peer] ?? []; };
|
|
642
652
|
function sessionRoleContract(peer: string): string {
|
|
643
653
|
try {
|
|
654
|
+
prepareRoleSelection(peer);
|
|
644
655
|
const selected = selectedRoles(peer, config.roles, roleAssignments, catalog);
|
|
645
656
|
preparedSessionRoles.set(peer, selected.map(role => role.id));
|
|
646
657
|
preparedSessionRoleIdentities.set(peer, selected.map(roleIdentity));
|
|
@@ -746,8 +757,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
746
757
|
|
|
747
758
|
// A session record left by a run that never shut down means it crashed (issue #37). A controlled restart has its own.
|
|
748
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";
|
|
749
763
|
if (config.mlx.enabled === false) {
|
|
750
|
-
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)];
|
|
751
765
|
for (const launch of launches) {
|
|
752
766
|
const problem = mlxLaunchProblem(config, (launch ?? {}) as { backend?: unknown; model?: unknown });
|
|
753
767
|
if (problem) throw new Error(`recorded Pi recovery: ${problem}`);
|
|
@@ -756,7 +770,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
756
770
|
// A controlled restart's source may have been cut short before it removed its record: this run is not a crash, and
|
|
757
771
|
// a record left now would make the next ordinary start look like one.
|
|
758
772
|
if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
|
|
759
|
-
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
|
|
760
774
|
const piAutoStart = () => onRoster("pi") && config.pi.auto_start;
|
|
761
775
|
const startedAt = Date.now();
|
|
762
776
|
/** What crash recovery did or asks the user to do, for `ahub status`. */
|
|
@@ -790,8 +804,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
790
804
|
// silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
|
|
791
805
|
// together. Cached until the board changes: it is asked per recipient of every message.
|
|
792
806
|
let turnFreeNow: boolean | undefined;
|
|
793
|
-
const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
|
|
794
|
-
const turnFree = (): boolean => ![...turns.values()].some(turn => turn.private) && (turnFreeNow ??=
|
|
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)));
|
|
795
809
|
/**
|
|
796
810
|
* Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
|
|
797
811
|
* has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
|
|
@@ -836,10 +850,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
836
850
|
const hubStartedAt = Date.now();
|
|
837
851
|
// The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
|
|
838
852
|
let relevantNotice: (peer: PeerId, env: Envelope) => boolean = () => true;
|
|
839
|
-
const staleOff = config.experiments?.stale_notices === "deliver";
|
|
840
|
-
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)");
|
|
841
855
|
const requestedPeerStops = new Set<string>();
|
|
842
|
-
const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff || relevantNotice(peer, env), silence,
|
|
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,
|
|
843
857
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
|
|
844
858
|
startupCleanup.push(() => bus.closeJournal());
|
|
845
859
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
@@ -895,7 +909,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
895
909
|
memoryNotice(`hub native memory disabled: ${reason}`);
|
|
896
910
|
};
|
|
897
911
|
if (config.memory.native) {
|
|
898
|
-
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) => {
|
|
899
913
|
const m = /#(\d+)@(\d+)$/.exec(key), t = m ? board.get(Number(m[1])) : undefined;
|
|
900
914
|
return t && String(t.created) === m![2] ? [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])] : [];
|
|
901
915
|
}) }, memoryUnavailable); memoryStore = opened.store; memoryBinding = opened.binding; startupCleanup.push(() => opened.store.close()); }
|
|
@@ -920,7 +934,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
920
934
|
* Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
|
|
921
935
|
* policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
|
|
922
936
|
*/
|
|
923
|
-
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 ?? []);
|
|
924
938
|
let candidates: CandidateStore | undefined;
|
|
925
939
|
if (memoryStore) {
|
|
926
940
|
try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
|
|
@@ -1031,7 +1045,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1031
1045
|
const onCampus = async (): Promise<boolean> => (await omni.onCampus()) && !(sidecar?.upstream && omni.isAccessHost(sidecar.upstream));
|
|
1032
1046
|
let sidecar: Sidecar | undefined; // L2, started by the first hub-owned model call, stopped with the hub
|
|
1033
1047
|
let sidecarRouting = "";
|
|
1034
|
-
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 });
|
|
1035
1049
|
|
|
1036
1050
|
let dashboard: ReturnType<typeof startDashboard> | undefined;
|
|
1037
1051
|
const uiEvents: { seq: number; event: BusEvent }[] = [];
|
|
@@ -1053,7 +1067,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1053
1067
|
conductor: () => conductorPeer(config.roles) ?? undefined,
|
|
1054
1068
|
scope: () => config.conductor?.feed ?? "own",
|
|
1055
1069
|
tasks: () => board.list(), publicTitle: (task) => tasks.publicTitle(task), isPrivate: (task) => tasks.isPii(task),
|
|
1056
|
-
approvalAgeMs: (config.conductor?.approval_wait_s ?? 30) * 1000,
|
|
1070
|
+
approvalAgeMs: () => (config.conductor?.approval_wait_s ?? 30) * 1000,
|
|
1057
1071
|
readRound: (peer) => conductorHolds.readRound(peer), writeRound: (peer, signature) => conductorHolds.writeRound(peer, signature),
|
|
1058
1072
|
emit: (notice) => {
|
|
1059
1073
|
if (stopping || recoveryActive()) return false;
|
|
@@ -1064,7 +1078,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1064
1078
|
const refreshConductorPolicy = () => {
|
|
1065
1079
|
if (opts.config) return; // explicit embedding/test configuration remains its authority
|
|
1066
1080
|
const before = conductorPeer(config.roles);
|
|
1067
|
-
const policy =
|
|
1081
|
+
const policy = { roles: policyRoles, planner: config.planner, conductor: config.conductor };
|
|
1068
1082
|
policyRoles = structuredClone(policy.roles);
|
|
1069
1083
|
config.planner = policy.planner;
|
|
1070
1084
|
config.conductor = policy.conductor;
|
|
@@ -1186,17 +1200,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1186
1200
|
catalog,
|
|
1187
1201
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1188
1202
|
projectedReview: task => reviewProjection(task),
|
|
1189
|
-
sdlc: { config: () => lifecycleConfig },
|
|
1203
|
+
sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
|
|
1190
1204
|
board,
|
|
1191
1205
|
bus,
|
|
1192
1206
|
executionBudget,
|
|
1193
|
-
routing: () =>
|
|
1207
|
+
routing: () => activeRouting(), // only the complete validated reload can publish routing changes
|
|
1194
1208
|
cwd: opts.cwd,
|
|
1195
1209
|
project: chain.at(-1)!,
|
|
1196
1210
|
...(config.memory.enabled ? { memory } : {}),
|
|
1197
1211
|
candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
|
|
1198
1212
|
memoryCheck: checkMemory,
|
|
1199
|
-
...(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,
|
|
1200
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,
|
|
1201
1215
|
(peer, task, counts) => event({ type: "memory_brief", peer, task, ...counts }),
|
|
1202
1216
|
items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
|
|
@@ -1231,7 +1245,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1231
1245
|
},
|
|
1232
1246
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
1233
1247
|
// The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
|
|
1234
|
-
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 }),
|
|
1235
1249
|
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
1236
1250
|
quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
|
|
1237
1251
|
review: config.review,
|
|
@@ -1243,6 +1257,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1243
1257
|
stallNotice: stall => { supervision.taskStalled(stall); },
|
|
1244
1258
|
roles: config.roles,
|
|
1245
1259
|
turnFree,
|
|
1260
|
+
coordination: () => config.coordination === "turn-free",
|
|
1246
1261
|
capable: (peer) => capable.has(peer),
|
|
1247
1262
|
idle: (peer) => idle(peer),
|
|
1248
1263
|
treeHash: (paths, windows) => facts.tree(paths, windows),
|
|
@@ -1605,9 +1620,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1605
1620
|
event({ type: "supervision_turn", peer, turn: receipt.id, ...(receipt.tokens === undefined ? {} : { tokens: receipt.tokens }), ms: Math.max(0, Date.now() - receipt.at) });
|
|
1606
1621
|
};
|
|
1607
1622
|
// Per-turn snapshots (issue #33): a git tree at both turn boundaries, recorded in hub.db for `ahub turns` and `ahub undo`.
|
|
1608
|
-
const repo =
|
|
1609
|
-
|
|
1610
|
-
|
|
1623
|
+
const repo = factsRepo;
|
|
1624
|
+
let turnLog = config.snapshots.enabled && repo ? new Turns(join(opts.stateDir, "hub.db")) : undefined;
|
|
1625
|
+
startupCleanup.push(() => turnLog?.close());
|
|
1611
1626
|
/** Whether a peer has a PII task open in any state: assigned and not yet accepted counts. */
|
|
1612
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));
|
|
1613
1628
|
const memoryObservations = memoryTelemetry ? new MemoryObservations(memoryTelemetry, (peer, envs) => {
|
|
@@ -1631,7 +1646,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1631
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
|
|
1632
1647
|
* in snapshots.ts.
|
|
1633
1648
|
*/
|
|
1634
|
-
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 };
|
|
1635
1651
|
const t0 = performance.now();
|
|
1636
1652
|
let tree: string | undefined;
|
|
1637
1653
|
try {
|
|
@@ -1647,7 +1663,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1647
1663
|
}
|
|
1648
1664
|
function reviewProjection(task: Task): string | undefined {
|
|
1649
1665
|
if (!projections.enabled || tasks.isPii(task)) return undefined;
|
|
1650
|
-
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)";
|
|
1651
1667
|
if (privateSnapshotWindow()) return "(projected review unavailable: private snapshot window)";
|
|
1652
1668
|
const created = taskProposalAt(task);
|
|
1653
1669
|
const from = created === undefined ? undefined : turnLog.taskStart(task.id, created);
|
|
@@ -1749,10 +1765,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1749
1765
|
}
|
|
1750
1766
|
}, 5_000),
|
|
1751
1767
|
];
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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));
|
|
1756
1783
|
for (const i of intervals) i.unref?.();
|
|
1757
1784
|
startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
|
|
1758
1785
|
|
|
@@ -2126,6 +2153,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2126
2153
|
const permissionChanges = new Map<string, Promise<void>>();
|
|
2127
2154
|
const permissionModes = new Map<string, PermissionMode>(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, mode === "never-ask" ? "ask" : mode]));
|
|
2128
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
|
+
};
|
|
2129
2170
|
const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
|
|
2130
2171
|
const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
|
|
2131
2172
|
const unattendedPeers = new Set<string>();
|
|
@@ -2343,6 +2384,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2343
2384
|
projectId,
|
|
2344
2385
|
instanceId,
|
|
2345
2386
|
version: VERSION,
|
|
2387
|
+
pendingRestart,
|
|
2346
2388
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2347
2389
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
2348
2390
|
protocol: PROTOCOL,
|
|
@@ -2568,17 +2610,57 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2568
2610
|
|
|
2569
2611
|
// Session identities of the attached peers, kept current for crash recovery (issue #37); a clean stop removes them.
|
|
2570
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
|
+
}
|
|
2571
2645
|
const recordSessions = () => {
|
|
2572
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
|
+
}
|
|
2573
2651
|
const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
|
|
2574
2652
|
let meta: Record<string, unknown> = {};
|
|
2575
2653
|
try { meta = (p as { recoveryMetadata?: () => Record<string, unknown> }).recoveryMetadata?.() ?? {}; } catch { /* not ready yet: the id alone */ }
|
|
2576
2654
|
return { peer: p.id, meta };
|
|
2577
2655
|
});
|
|
2578
2656
|
const text = JSON.stringify(peers);
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
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}`); }
|
|
2582
2664
|
};
|
|
2583
2665
|
const recoverAfterCrash = async (prev: SessionsFile) => {
|
|
2584
2666
|
const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
|
|
@@ -2586,6 +2668,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2586
2668
|
report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
2587
2669
|
const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
|
|
2588
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; }
|
|
2589
2672
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
2590
2673
|
// fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
|
|
2591
2674
|
if (step.peer === "pi" && piAutoStart()) {
|
|
@@ -2601,7 +2684,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2601
2684
|
report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
|
|
2602
2685
|
continue;
|
|
2603
2686
|
}
|
|
2604
|
-
if (!step.resume || !autoResume) {
|
|
2687
|
+
if (!step.resume || !autoResume()) {
|
|
2605
2688
|
report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`);
|
|
2606
2689
|
continue;
|
|
2607
2690
|
}
|
|
@@ -2626,7 +2709,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2626
2709
|
// #248: the add-ons it works with, once per attach (names and versions only).
|
|
2627
2710
|
// A result is written unless a later attachment began meanwhile: a peer that detached before its probe settled
|
|
2628
2711
|
// still did its work under that attachment.
|
|
2629
|
-
if (e.state === "offline") attachedNow.delete(e.peer);
|
|
2712
|
+
if (e.state === "offline") { attachedNow.delete(e.peer); applyPendingPermissionDefault(e.peer); }
|
|
2630
2713
|
else if (!attachedNow.has(e.peer)) {
|
|
2631
2714
|
attachedNow.add(e.peer);
|
|
2632
2715
|
const peer = e.peer, attachment = ++attachments;
|
|
@@ -2647,20 +2730,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2647
2730
|
const pii = holdsPii(e.peer);
|
|
2648
2731
|
for (const id of turnTasks.keys()) if (!turnLog?.get(id)) turnTasks.delete(id);
|
|
2649
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));
|
|
2650
|
-
const
|
|
2651
|
-
|
|
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}`); }
|
|
2652
2736
|
const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
|
|
2653
|
-
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 } : {}) });
|
|
2654
2738
|
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
2655
2739
|
} else if (!busy && open) {
|
|
2656
2740
|
turns.delete(e.peer);
|
|
2657
2741
|
let afterTurn: (() => void) | undefined;
|
|
2658
2742
|
let files: number | undefined;
|
|
2659
2743
|
let snapshotMs = open.snapshotMs;
|
|
2660
|
-
if (turnLog && (open.private || holdsPii(e.peer))) {
|
|
2744
|
+
if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
|
|
2661
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}`); }
|
|
2662
|
-
} else if (turnLog) {
|
|
2663
|
-
const end = snap(`the end of ${open.id}
|
|
2746
|
+
} else if (open.capture && turnLog) {
|
|
2747
|
+
const end = snap(`the end of ${open.id}`, true);
|
|
2664
2748
|
const changed = open.tree && end.tree ? changedPaths(repo!, open.tree, end.tree) : [];
|
|
2665
2749
|
if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
|
|
2666
2750
|
snapshotMs = (snapshotMs ?? 0) + end.ms;
|
|
@@ -2825,7 +2909,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2825
2909
|
/** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
|
|
2826
2910
|
const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
|
|
2827
2911
|
(result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
|
|
2828
|
-
(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()}` : ""}`));
|
|
2829
2913
|
async function stopPiForReplacement(pi: PiPeer): Promise<void> {
|
|
2830
2914
|
replacingPi.add(pi);
|
|
2831
2915
|
try { await pi.stop(); } finally { replacingPi.delete(pi); }
|
|
@@ -2846,6 +2930,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2846
2930
|
const running = startPeerOnce(peer, args).then(async result => {
|
|
2847
2931
|
if (!result.ok && peer === "codex") unattendedPeers.delete(peer);
|
|
2848
2932
|
if (!result.ok || result.already) return result;
|
|
2933
|
+
if (peer === "pi") recordSessions();
|
|
2849
2934
|
return permissionChange(peer, async () => {
|
|
2850
2935
|
if (peer === "codex") { if (args.unattended) unattendedPeers.add(peer); else unattendedPeers.delete(peer); }
|
|
2851
2936
|
logPermissionStart(peer); writeStatus(); return result;
|
|
@@ -2874,6 +2959,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2874
2959
|
const mode = startMode(peer), owner = bus.peers.get(peer), can = mode === "tui" ? opener.available() : undefined;
|
|
2875
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 }) : {}) };
|
|
2876
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`;
|
|
2877
2964
|
async function hubStart(peer: unknown, by: string): Promise<Record<string, unknown>> {
|
|
2878
2965
|
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
2879
2966
|
if (rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
|
|
@@ -2881,7 +2968,31 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2881
2968
|
if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
|
|
2882
2969
|
if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
|
|
2883
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;
|
|
2884
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
|
+
}
|
|
2885
2996
|
if (mode === "tui" && (peer === "pi" || peer === "local")) { await ensureSpec(true); await refreshRunner(); }
|
|
2886
2997
|
const owner = bus.peers.get(peer);
|
|
2887
2998
|
if (mode === "headless") {
|
|
@@ -2890,23 +3001,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2890
3001
|
// like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
|
|
2891
3002
|
// issued to tell the two apart.
|
|
2892
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" };
|
|
2893
|
-
return startPeer(peer, peer === "pi" || peer === "kimi" ? { mode: "headless" } : {});
|
|
3004
|
+
return startPeer(peer, peer === "pi" || peer === "kimi" ? { mode: "headless", ...(resume ?? {}) } : {});
|
|
2894
3005
|
}
|
|
2895
3006
|
if (owner && owner.state !== "offline") return { ok: true, already: true, mode };
|
|
2896
|
-
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(" ")}`;
|
|
2897
3010
|
const since = Date.now() - (opening.get(peer) ?? 0);
|
|
2898
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 };
|
|
2899
3012
|
// Reserved before the provider is awaited, so starts that arrive together open one terminal, not one each.
|
|
2900
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" }; }
|
|
2901
3015
|
// Every word is the hub's own: its runtime, its entry point, this project and a peer from the closed list.
|
|
2902
|
-
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 }));
|
|
2903
3017
|
if (!opened.ok) {
|
|
2904
3018
|
opening.delete(peer); // nothing was opened: the next request may try again at once
|
|
2905
3019
|
if (opened.detail) log(`terminal provider for ${peer}: ${opened.detail}`);
|
|
2906
3020
|
const headless = peer === "claude" ? "" : `, or set its start mode to headless (ahub settings set peers.${peer}.start_mode headless)`;
|
|
2907
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 };
|
|
2908
3022
|
}
|
|
2909
|
-
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 */ }
|
|
2910
3024
|
return { ok: true, mode, opened: opened.via, command };
|
|
2911
3025
|
}
|
|
2912
3026
|
|
|
@@ -2965,6 +3079,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2965
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>> {
|
|
2966
3080
|
let unmute: (() => void) | undefined;
|
|
2967
3081
|
try {
|
|
3082
|
+
applyPendingPermissionDefault(peer);
|
|
2968
3083
|
if (peer === "pi" || peer === "local") { await ensureSpec(true); await refreshRunner(); }
|
|
2969
3084
|
return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
|
|
2970
3085
|
} finally {
|
|
@@ -2973,8 +3088,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2973
3088
|
}
|
|
2974
3089
|
|
|
2975
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);
|
|
2976
3092
|
if (peer === "jcode" && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "Jcode native TUI driving is unverified; use headless" };
|
|
2977
|
-
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
|
+
}
|
|
2978
3097
|
if (peer === "kimi") {
|
|
2979
3098
|
if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Kimi mode" };
|
|
2980
3099
|
const owner = bus.peers.get(peer);
|
|
@@ -3061,32 +3180,36 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3061
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)"); }
|
|
3062
3181
|
return { ok: true, policy: "unmanaged" };
|
|
3063
3182
|
}
|
|
3064
|
-
if (
|
|
3065
|
-
|
|
3066
|
-
const launch =
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
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,
|
|
3071
3193
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
3072
3194
|
cwd: opts.cwd, watchdogMs: config.watchdog_ms, onPermission,
|
|
3073
3195
|
autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3074
3196
|
log: () => log("Qwen runtime diagnostic: details withheld"),
|
|
3075
3197
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
3076
3198
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
3077
|
-
const current = contextSession(
|
|
3078
|
-
contexts.report(
|
|
3199
|
+
const current = contextSession(peer); if (!current) return;
|
|
3200
|
+
contexts.report(peer, observation.contextUsed !== undefined && observation.contextCapacity !== undefined
|
|
3079
3201
|
? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
|
|
3080
3202
|
: { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null }, current);
|
|
3081
3203
|
},
|
|
3082
|
-
onTurnFailure: () => { supervisionTurns.delete(
|
|
3083
|
-
mcpServers
|
|
3084
|
-
preamble: sessionRoleContract(
|
|
3085
|
-
});
|
|
3086
|
-
|
|
3087
|
-
|
|
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); }
|
|
3088
3211
|
catch (error) {
|
|
3089
|
-
await
|
|
3212
|
+
await owner.stop().catch(() => log("Qwen stop after failed startup was not confirmed"));
|
|
3090
3213
|
if (error instanceof QwenIdentityError) throw error;
|
|
3091
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)");
|
|
3092
3215
|
}
|
|
@@ -3220,11 +3343,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3220
3343
|
if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
|
|
3221
3344
|
const problem = mlxLaunchProblem(config, { backend, model: args.model });
|
|
3222
3345
|
if (problem) return { ok: false, error: problem };
|
|
3223
|
-
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,
|
|
3224
3347
|
routeSessionKey: () => { const session = bus.peers.get("pi")?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
|
|
3225
|
-
staySwitch: () =>
|
|
3348
|
+
staySwitch: () => activeRouting(),
|
|
3226
3349
|
onRoute: record => event({ type: "route", peer: "pi", ...record }),
|
|
3227
|
-
efficientWaitMs:
|
|
3350
|
+
efficientWaitMs: activeRouting().pi.efficient_wait_ms,
|
|
3228
3351
|
underBudget: () => { const current = bus.peers.get("pi"); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, "pi"); },
|
|
3229
3352
|
onCooldown: record => {
|
|
3230
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"}`);
|
|
@@ -3264,7 +3387,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3264
3387
|
return typeof refused === "string" ? `not sent: ${refused}` : "sent";
|
|
3265
3388
|
},
|
|
3266
3389
|
};
|
|
3267
|
-
const routing =
|
|
3390
|
+
const routing = activeRouting();
|
|
3268
3391
|
const pi = new PiPeer("pi", {
|
|
3269
3392
|
cwd: opts.cwd, stateDir: opts.stateDir, cmd: config.pi.cmd, mode, backend,
|
|
3270
3393
|
model: args.model,
|
|
@@ -3312,7 +3435,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3312
3435
|
if (backend !== "auto") return backend === "mlx" ? "mlx/fast" : "dgx/coding";
|
|
3313
3436
|
const taskId = envs.find(env => env.refs?.task)?.refs?.task;
|
|
3314
3437
|
const task = taskId ? board.get(Number(taskId)) : undefined;
|
|
3315
|
-
const policyBackend = task ?
|
|
3438
|
+
const policyBackend = task ? activeRouting().classes[task.class]?.pi_backend : undefined;
|
|
3316
3439
|
if (policyBackend === "mlx") {
|
|
3317
3440
|
if (config.mlx.enabled !== false) return "mlx/fast";
|
|
3318
3441
|
const problem = disabledMlxPolicyProblem(config, opts.cwd);
|
|
@@ -3380,7 +3503,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3380
3503
|
return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
|
|
3381
3504
|
}
|
|
3382
3505
|
if (peer === "local") {
|
|
3383
|
-
const routing =
|
|
3506
|
+
const routing = activeRouting();
|
|
3384
3507
|
// `--model` pins a model on OmniRoute and skips L2; `--route` picks another Switchyard route.
|
|
3385
3508
|
const route = args.model ? undefined : (args.route ?? routing.local.route);
|
|
3386
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}"` };
|
|
@@ -3427,8 +3550,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3427
3550
|
admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, "local", unit),
|
|
3428
3551
|
...(route ? { route } : {}),
|
|
3429
3552
|
...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
|
|
3430
|
-
hubRoutes: () =>
|
|
3431
|
-
staySwitch: () =>
|
|
3553
|
+
hubRoutes: () => activeRouting().hub_routes ?? {},
|
|
3554
|
+
staySwitch: () => activeRouting(),
|
|
3432
3555
|
onRoute: record => event({ type: "route", peer: "local", ...record }),
|
|
3433
3556
|
onAdvisor: record => event({ type: "advisor", peer: "local", ...record }),
|
|
3434
3557
|
onRouteOutcome: record => event({ type: "route_outcome", peer: "local", ...record }),
|
|
@@ -3471,7 +3594,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3471
3594
|
}
|
|
3472
3595
|
|
|
3473
3596
|
async function validateLocalChoice(route: string | undefined, fixedModel: string): Promise<void> {
|
|
3474
|
-
const routing =
|
|
3597
|
+
const routing = activeRouting();
|
|
3475
3598
|
if (route && !(route.startsWith("hub/") ? routing.hub_routes?.[route] : routing.routes[route])) throw new Error(`routing.toml has no route "${route}"`);
|
|
3476
3599
|
const models = await omni.models();
|
|
3477
3600
|
const required = new Set([fixedModel]);
|
|
@@ -3655,7 +3778,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3655
3778
|
instanceId,
|
|
3656
3779
|
attention: attentionStatus.attentionInput,
|
|
3657
3780
|
pet,
|
|
3658
|
-
status: { peers: peerView, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
3781
|
+
status: { peers: peerView, pendingRestart, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
3659
3782
|
boardCapabilities: { backlog: true },
|
|
3660
3783
|
planningCapabilities: { enabled: true, wipPull: true, split: true },
|
|
3661
3784
|
releases: tasks.releaseList(USER),
|
|
@@ -3690,6 +3813,237 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3690
3813
|
};
|
|
3691
3814
|
}
|
|
3692
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
|
+
|
|
3693
4047
|
// #269: settings. One registry (src/hub/settings.ts) serves the dashboard and `ahub settings`; nothing outside it is written.
|
|
3694
4048
|
let storedConfigCache: { stamp: string; config: HubConfig | undefined } | undefined;
|
|
3695
4049
|
/** What the config files hold now, as the loader reads them. Re-read only when a file changed: the dashboard polls. */
|
|
@@ -3702,18 +4056,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3702
4056
|
}
|
|
3703
4057
|
return storedConfigCache.config;
|
|
3704
4058
|
};
|
|
3705
|
-
/** Stored start modes and the scoped instruction policy follow a settings write or undo. */
|
|
3706
|
-
/** #455: the stored planner key, applied at once; planning authority and backlog notices follow config.roles. */
|
|
3707
|
-
const applyPlanner = () => { const stored = storedConfig(); if (stored) { config.planner = stored.planner; settlePlanner(); writeStatus(); } };
|
|
3708
|
-
const applyStartModes = () => { const stored = storedConfig(); if (stored) { config.peers = stored.peers; config.protect_instruction_files = stored.protect_instruction_files; } };
|
|
3709
|
-
/** Apply only the named peer; unrelated hand edits retain their prior publication scope. */
|
|
3710
|
-
const applyRoleSelection = (peer: string) => {
|
|
3711
|
-
const stored = storedConfig(); if (!stored) return;
|
|
3712
|
-
config.catalog_roles = { ...config.catalog_roles };
|
|
3713
|
-
const ids = catalogAssignments(stored.catalog_roles)[peer];
|
|
3714
|
-
if (ids === undefined) delete config.catalog_roles[peer]; else config.catalog_roles[peer] = ids;
|
|
3715
|
-
roleAssignments[peer] = ids ?? resolvedProfile.catalog_roles[peer] ?? [];
|
|
3716
|
-
};
|
|
3717
4059
|
const settingScalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
3718
4060
|
const routingValue = (routing: ReturnType<typeof currentRouting>, def: SettingDef): SettingValue =>
|
|
3719
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]!]);
|
|
@@ -3723,14 +4065,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3723
4065
|
try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
|
|
3724
4066
|
catch { return {}; }
|
|
3725
4067
|
}
|
|
3726
|
-
function settingsView(): { rows: SettingRow[]; undo?: string } {
|
|
4068
|
+
function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[] } {
|
|
3727
4069
|
const stored = storedConfig();
|
|
3728
4070
|
// What the loader would say about the files as they are: an overlay that does not parse, or one that no longer
|
|
3729
4071
|
// merges (it names a route routing.toml dropped), leaves the last good policy in force, and the rows say why.
|
|
3730
4072
|
let routingProblem: string | undefined;
|
|
3731
4073
|
try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
|
|
3732
4074
|
const rows = settingRows({
|
|
3733
|
-
cwd: opts.cwd, routing:
|
|
4075
|
+
cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
3734
4076
|
running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
|
|
3735
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,
|
|
3736
4078
|
roles: roleState,
|
|
@@ -3742,11 +4084,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3742
4084
|
});
|
|
3743
4085
|
const undo = pendingUndo(opts.stateDir);
|
|
3744
4086
|
const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
|
|
3745
|
-
return { rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
4087
|
+
return { pendingRestart, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
3746
4088
|
}
|
|
3747
4089
|
/** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
|
|
3748
|
-
const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false) => {
|
|
3749
|
-
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 } : {}) });
|
|
3750
4092
|
try { notify(`setting ${key}: ${settingText(from)} -> ${settingText(to)} (${undo ? "undo, " : ""}${source})`); } catch { /* the write stands */ }
|
|
3751
4093
|
};
|
|
3752
4094
|
const settingSource = (authority: SettingAuthority) => authority === "terminal" ? "terminal" as const : "dashboard" as const;
|
|
@@ -3782,7 +4124,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3782
4124
|
? `Stop ${peer}'s existing work in its retained terminal; removal detaches the hub connection and does not stop that terminal's work.`
|
|
3783
4125
|
: undefined;
|
|
3784
4126
|
}
|
|
3785
|
-
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>> {
|
|
3786
4128
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
3787
4129
|
const peer = def.path[1]!;
|
|
3788
4130
|
let saved = false;
|
|
@@ -3797,13 +4139,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3797
4139
|
}
|
|
3798
4140
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding roster changes" };
|
|
3799
4141
|
rosterChanging = peer;
|
|
3800
|
-
if (undo) undoSetting(opts.cwd, opts.stateDir, (scratch) =>
|
|
3801
|
-
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}`));
|
|
3802
4144
|
// Apply exactly the authorized key. Other file edits stay pending until the next hub start.
|
|
3803
4145
|
config.roster = next;
|
|
3804
4146
|
settlePlanner(); // #455: an automatic planner follows the roster
|
|
3805
4147
|
saved = true;
|
|
3806
|
-
recordSetting(def.key, before, after, source, undo);
|
|
4148
|
+
recordSetting(def.key, before, after, source, undo, reason);
|
|
3807
4149
|
bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
|
|
3808
4150
|
if (!after) {
|
|
3809
4151
|
for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
|
|
@@ -3825,30 +4167,40 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3825
4167
|
const owner = bus.peers.get(peer);
|
|
3826
4168
|
if (owner instanceof PiPeer && owner.mode === "tui") await owner.resumeFromRoster();
|
|
3827
4169
|
}
|
|
4170
|
+
const reloaded = await reloadConfig(authority, `roster.${peer}`);
|
|
4171
|
+
if (reloaded.ok !== true) return { ...reloaded, saved: true };
|
|
3828
4172
|
writeStatus();
|
|
3829
4173
|
const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
|
|
3830
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 } : {}) };
|
|
3831
4175
|
} catch (error) {
|
|
3832
4176
|
if (saved) writeStatus();
|
|
3833
4177
|
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
3834
|
-
} 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?.(); } } }
|
|
3835
4179
|
}
|
|
3836
4180
|
/** A checked definition and value, or the refusal. Every settings write and preview starts here. */
|
|
3837
4181
|
function settingInput(key: unknown, raw: unknown): { def: SettingDef; value: SettingValue } | { error: string } {
|
|
3838
4182
|
const def = settingDef(key);
|
|
3839
4183
|
if (typeof def === "string") return { error: def };
|
|
3840
|
-
const routing =
|
|
4184
|
+
const routing = activeRouting();
|
|
3841
4185
|
try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : [...Object.keys(routing.routes), ...Object.keys(routing.hub_routes ?? {})]) }; }
|
|
3842
4186
|
catch (error) { return { error: (error as Error).message }; }
|
|
3843
4187
|
}
|
|
3844
|
-
|
|
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 }; }
|
|
3845
4197
|
const input = settingInput(key, raw);
|
|
3846
4198
|
if ("error" in input) return { ok: false, error: input.error };
|
|
3847
4199
|
const { def, value } = input;
|
|
3848
4200
|
const refusal = settingRefusal(def, value, authority);
|
|
3849
4201
|
if (refusal) return { ok: false, error: refusal };
|
|
3850
4202
|
const source = settingSource(authority);
|
|
3851
|
-
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);
|
|
3852
4204
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
3853
4205
|
const peer = def.group === "Permissions" ? def.path.at(-1)! : undefined;
|
|
3854
4206
|
// The console asks for y before never-ask; here the person types the peer it is for.
|
|
@@ -3859,53 +4211,63 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3859
4211
|
const from = permissionMode(peer!);
|
|
3860
4212
|
const result = await permissionChange(peer!, () => changePermission(peer!, value, value === "never-ask"));
|
|
3861
4213
|
if (result.ok !== true) return result;
|
|
3862
|
-
recordSetting(def.key, from, permissionMode(peer!), source);
|
|
4214
|
+
recordSetting(def.key, from, permissionMode(peer!), source, false, reason);
|
|
3863
4215
|
return { ok: true, text: `${def.key}: ${result.permissionMode}`, ...(typeof result.note === "string" ? { note: result.note } : {}) };
|
|
3864
4216
|
}
|
|
4217
|
+
let saved = false;
|
|
3865
4218
|
try {
|
|
3866
4219
|
if (def.store === "routing") {
|
|
3867
|
-
const from = routingValue(
|
|
3868
|
-
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));
|
|
3869
4222
|
if (!next) return { ok: true, text: `${def.key}: ${OVERLAY_FILE} already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
|
|
3870
|
-
|
|
3871
|
-
|
|
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)` };
|
|
3872
4228
|
}
|
|
3873
|
-
// Removing the machine-local value uncovers the shared file's: never-ask there needs the same confirmation.
|
|
3874
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` };
|
|
3875
4230
|
const from = settingScalar(valueAt(storedConfig(), def.path));
|
|
3876
|
-
if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value,
|
|
3877
|
-
|
|
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 };
|
|
3878
4235
|
const to = settingScalar(valueAt(storedConfig(), def.path));
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
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})` };
|
|
3885
4241
|
} catch (error) {
|
|
3886
|
-
return { ok: false, error: (error as Error).message };
|
|
4242
|
+
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
3887
4243
|
}
|
|
3888
4244
|
}
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
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 }; }
|
|
3893
4250
|
// The previous version can hold a wider value than the one in force, so undo is never an ordinary session's.
|
|
3894
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" };
|
|
3895
|
-
const
|
|
3896
|
-
if (
|
|
3897
|
-
|
|
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);
|
|
3898
4260
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
3899
|
-
const read = (): SettingValue => def.store === "routing" ? routingValue(
|
|
4261
|
+
const read = (): SettingValue => def.store === "routing" ? routingValue(activeRouting(), def) : settingScalar(valueAt(storedConfig(), def.path));
|
|
3900
4262
|
// never-ask is set with its confirmation, never put back by an undo that carries none.
|
|
3901
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` };
|
|
3902
4264
|
try {
|
|
3903
4265
|
const from = read();
|
|
3904
|
-
undoSetting(opts.cwd, opts.stateDir,
|
|
3905
|
-
|
|
3906
|
-
if (
|
|
3907
|
-
recordSetting(def.key, from, read(), settingSource(authority), true);
|
|
3908
|
-
if (def.type === "roles") event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) });
|
|
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) }); }
|
|
3909
4271
|
return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
|
|
3910
4272
|
} catch (error) {
|
|
3911
4273
|
return { ok: false, error: (error as Error).message };
|
|
@@ -3919,21 +4281,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3919
4281
|
if (def.group === "Roster") {
|
|
3920
4282
|
try {
|
|
3921
4283
|
const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled);
|
|
3922
|
-
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 }) };
|
|
3923
4285
|
} catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
|
|
3924
4286
|
}
|
|
3925
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."] };
|
|
3926
4288
|
if (def.group === "Permissions") {
|
|
3927
4289
|
const peer = def.path.at(-1)!;
|
|
3928
|
-
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 } : {}) };
|
|
3929
4291
|
}
|
|
3930
|
-
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"}`] };
|
|
3931
4293
|
try {
|
|
3932
4294
|
const after = parseRouting(routingText(opts.cwd), routingCandidate(readOverlay(opts.cwd), def, value));
|
|
3933
4295
|
const open = board.list().filter((task) => !isClosedTaskState(task.state));
|
|
3934
4296
|
// A PII task's routing is fixed by its constraint and its text is private: it is counted, not shown.
|
|
3935
4297
|
const shown = open.filter((task) => !tasks.isPii(task)).slice(0, 20);
|
|
3936
|
-
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"])],
|
|
3937
4299
|
tasks: shown.map((task) => ({ id: task.id, before: tasks.publicExplain(task.id), after: tasks.publicExplain(task.id, after) })),
|
|
3938
4300
|
...(open.length > shown.length ? { hidden: open.length - shown.length } : {}) };
|
|
3939
4301
|
} catch (error) {
|
|
@@ -3941,6 +4303,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3941
4303
|
}
|
|
3942
4304
|
}
|
|
3943
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
|
+
|
|
3944
4314
|
async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
|
|
3945
4315
|
const bad = { ok: false, error: "invalid or unavailable dashboard action" };
|
|
3946
4316
|
// #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
|
|
@@ -4019,11 +4389,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4019
4389
|
return { ok: false, error: `${a.peer} did not start; inspect ahub status and hub.log` };
|
|
4020
4390
|
}
|
|
4021
4391
|
case "setting":
|
|
4022
|
-
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);
|
|
4023
4393
|
case "setting_undo":
|
|
4024
|
-
return settingsUndo(authority, a.confirm, a.now);
|
|
4394
|
+
return settingsUndo(authority, a.confirm, a.now, a.reason, a.key, a.undoDigest);
|
|
4025
4395
|
case "setting_preview":
|
|
4026
|
-
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);
|
|
4027
4399
|
default:
|
|
4028
4400
|
return bad;
|
|
4029
4401
|
}
|
|
@@ -4614,7 +4986,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4614
4986
|
}
|
|
4615
4987
|
case "watch": {
|
|
4616
4988
|
if (c.role !== "console") return void reply({ ok: false, error: "watch is a person-only console command" });
|
|
4617
|
-
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" });
|
|
4618
4990
|
const peer = msg.peer as "kimi" | "qwen";
|
|
4619
4991
|
// Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
|
|
4620
4992
|
const kimiOwner = bus.peers.get("kimi");
|
|
@@ -4687,16 +5059,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4687
5059
|
case "settings_get":
|
|
4688
5060
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
4689
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" }));
|
|
4690
5065
|
case "settings_preview":
|
|
4691
5066
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
4692
|
-
return void reply(settingsPreview(msg.key, msg.value));
|
|
5067
|
+
return void reply(msg.undo === true ? settingsUndoPreview() : settingsPreview(msg.key, msg.value));
|
|
4693
5068
|
case "settings_set":
|
|
4694
5069
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
4695
|
-
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" }));
|
|
4696
5071
|
return;
|
|
4697
5072
|
case "settings_undo":
|
|
4698
5073
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
4699
|
-
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" }));
|
|
4700
5075
|
case "kimi_launch_end": {
|
|
4701
5076
|
if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
|
|
4702
5077
|
if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
|
|
@@ -4753,7 +5128,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4753
5128
|
board,
|
|
4754
5129
|
isPii: (t) => tasks.isPii(t),
|
|
4755
5130
|
onCampus,
|
|
4756
|
-
isPiiText: (q) =>
|
|
5131
|
+
isPiiText: (q) => activeRouting().constraints.pii === "local_only" && detectSignals({ title: q, detail: "", refs: {} }, activeRouting(), opts.cwd).includes("pii"),
|
|
4757
5132
|
...(config.memory.enabled ? { memory } : {}),
|
|
4758
5133
|
// #258: entries that match the question, then the graph neighbourhood of what they are about.
|
|
4759
5134
|
...(memoryStore ? { native: (keys: string[]) => {
|
|
@@ -4838,6 +5213,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4838
5213
|
log("hub stopping");
|
|
4839
5214
|
writeStatus();
|
|
4840
5215
|
dashboard?.stop();
|
|
5216
|
+
stopConfigWatch();
|
|
5217
|
+
clearInterval(sweepTimer);
|
|
4841
5218
|
for (const i of intervals) clearInterval(i);
|
|
4842
5219
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
4843
5220
|
for (const p of permissions.values()) p.done(undefined);
|
|
@@ -4902,6 +5279,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4902
5279
|
bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
|
|
4903
5280
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
4904
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);
|
|
4905
5294
|
// #258: approvals whose settle entry a crash cut off are settled now (decisions, contracts and fails reach long-term).
|
|
4906
5295
|
try { const settled = tasks.reconcileMemory(); if (settled) log(`hub memory: settled ${settled} approved task${settled === 1 ? "" : "s"} a stop had left unsettled`); }
|
|
4907
5296
|
catch (e) { log(`hub memory reconciliation failed: ${(e as Error).message}`); }
|
|
@@ -4927,7 +5316,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4927
5316
|
});
|
|
4928
5317
|
}
|
|
4929
5318
|
// After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
|
|
4930
|
-
const piRecovered = !!crashed?.peers.some((p) => p.peer === "pi");
|
|
5319
|
+
const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => p.peer === "pi");
|
|
4931
5320
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
4932
5321
|
return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
4933
5322
|
} finally {
|