@staix/agent-hub 0.12.31 → 0.12.33
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 +20 -1
- package/README.md +14 -7
- package/docs/agent-notes/adapters.md +7 -4
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +13 -8
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +4 -3
- 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 +141 -25
- package/docs/quickstart.md +10 -6
- package/docs/security.md +18 -9
- 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 +211 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verification/2026-10-18-0.12.33.md +42 -0
- package/docs/verified.json +24 -16
- 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/reset.ts +13 -1
- 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 +227 -0
- package/src/cli/upgrade-restore-state.ts +6 -2
- package/src/cli/upgrade-runtime.ts +360 -199
- package/src/cli/upgrade.ts +278 -25
- package/src/hub/bus.ts +144 -14
- package/src/hub/cohorts.ts +2 -2
- package/src/hub/config-apply.ts +242 -0
- package/src/hub/config-reload.ts +120 -0
- package/src/hub/config-trust.ts +11 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +577 -179
- package/src/hub/deferred-hub.ts +60 -0
- package/src/hub/delivery-journal.ts +6 -0
- 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/restart.ts +12 -1
- 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 +29 -9
- 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,27 @@ 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
|
+
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
25
26
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
26
|
-
import { buildKimiLaunch
|
|
27
|
+
import { buildKimiLaunch } from "../cli/launch.ts";
|
|
28
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
27
29
|
import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
|
|
28
30
|
import type { ToolObservation } from "../models/route/signals.ts";
|
|
29
31
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
30
|
-
import { appendFileSync, chmodSync, existsSync, fstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { appendFileSync, chmodSync, existsSync, fstatSync, mkdirSync, readFileSync, renameSync, rmSync, watchFile, unwatchFile, writeFileSync } from "node:fs";
|
|
31
33
|
import { join, relative, resolve } from "node:path";
|
|
32
34
|
import { homedir } from "node:os";
|
|
33
35
|
import { spawn } from "node:child_process";
|
|
34
36
|
import { childEnv } from "./child-process.ts";
|
|
35
37
|
import { runCheck } from "./checks.ts";
|
|
36
38
|
import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
|
|
37
|
-
import { stripUntrusted } from "./config-trust.ts";
|
|
39
|
+
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
|
|
40
|
+
import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig } from "./config-apply.ts";
|
|
41
|
+
import { resolveSettledRoles } from "./settled-roles.ts";
|
|
42
|
+
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
|
|
38
43
|
import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
|
|
39
44
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
40
45
|
import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
@@ -47,7 +52,7 @@ import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
|
|
|
47
52
|
import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
|
|
48
53
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
49
54
|
import { processSignature } from "../pi/process-signature.ts";
|
|
50
|
-
import { recordPiReplacement, validatePiSession } from "../pi/session.ts";
|
|
55
|
+
import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
|
|
51
56
|
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
52
57
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
53
58
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
@@ -73,8 +78,8 @@ import { launcherPreview } from "../cli/preview.ts";
|
|
|
73
78
|
import { AssignmentUndeliveredError, Tasks } from "./tasks.ts";
|
|
74
79
|
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
|
|
75
80
|
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";
|
|
81
|
+
import { currentRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
82
|
+
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
83
|
import { Bus } from "./bus.ts";
|
|
79
84
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
80
85
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
@@ -95,6 +100,8 @@ import type { JournalDelivery } from "./delivery-journal.ts";
|
|
|
95
100
|
import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
|
|
96
101
|
import { changedPaths, hasTree, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
|
|
97
102
|
import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
103
|
+
import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
|
|
104
|
+
import { shellQuote } from "../cli/terminal-recovery.ts";
|
|
98
105
|
|
|
99
106
|
import { isStartablePeer, peerStartConfig, PEER_START_RETRY_MS, STARTABLE_PEERS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
|
|
100
107
|
import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
|
|
@@ -235,16 +242,18 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
235
242
|
|
|
236
243
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
237
244
|
const CONFIG_FILES = ["config.json", "config.local.json"] as const;
|
|
245
|
+
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
238
246
|
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
247
|
|
|
240
248
|
/** 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 } {
|
|
249
|
+
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
|
|
242
250
|
const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
|
|
243
251
|
const conductor = { ...DEFAULT_CONFIG.conductor };
|
|
244
252
|
let planner: string | undefined;
|
|
245
253
|
for (const name of CONFIG_FILES) {
|
|
246
254
|
let file: Record<string, unknown>;
|
|
247
|
-
|
|
255
|
+
if (layers && !layers.some(layer => layer.name === name)) continue;
|
|
256
|
+
try { file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")); }
|
|
248
257
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
249
258
|
if (file.roles !== undefined) { conductorPeer(file.roles); Object.assign(roles, file.roles); }
|
|
250
259
|
if (file.planner !== undefined) {
|
|
@@ -279,13 +288,14 @@ function syncMinutes(v: unknown): number {
|
|
|
279
288
|
return v;
|
|
280
289
|
}
|
|
281
290
|
|
|
282
|
-
export function loadConfig(cwd: string): HubConfig {
|
|
291
|
+
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubConfig {
|
|
283
292
|
const ignored: string[] = [];
|
|
284
293
|
const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
285
294
|
const files = CONFIG_FILES.flatMap((name) => {
|
|
286
295
|
let file: Record<string, any>; // parsed JSON, checked field by field below
|
|
296
|
+
if (layers && !layers.some(layer => layer.name === name)) return [];
|
|
287
297
|
try {
|
|
288
|
-
file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
|
|
298
|
+
file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
|
|
289
299
|
} catch (error) {
|
|
290
300
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
291
301
|
throw error;
|
|
@@ -340,7 +350,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
340
350
|
throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
|
|
341
351
|
}
|
|
342
352
|
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);
|
|
353
|
+
const conductorPolicy = loadConductorPolicy(cwd, layers);
|
|
344
354
|
if (!mlx.enabled && file.pi?.backend === "mlx") throw new Error("Pi backend mlx conflicts with mlx.enabled=false");
|
|
345
355
|
if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
|
|
346
356
|
if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
|
|
@@ -563,7 +573,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
563
573
|
try {
|
|
564
574
|
const loadedConfig = opts.config ?? loadConfig(opts.cwd);
|
|
565
575
|
const lifecycleConfig = loadSdlc(opts.cwd);
|
|
566
|
-
|
|
576
|
+
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
577
|
+
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;
|
|
578
|
+
const config = { ...ownedConfig, roster: peerRosterConfig(loadedConfig.roster), permission_modes: permissionDefaults(loadedConfig.permission_modes), roles: { ...loadedConfig.roles }, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
|
|
567
579
|
let rosterChanging: string | undefined;
|
|
568
580
|
let kimiSessionMode: StartMode | undefined;
|
|
569
581
|
let kimiAdmissionId: string | undefined;
|
|
@@ -579,6 +591,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
579
591
|
const logFile = join(opts.stateDir, "hub.log");
|
|
580
592
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
581
593
|
const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
|
|
594
|
+
let runningRouting = currentRouting(opts.cwd, log);
|
|
595
|
+
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting);
|
|
596
|
+
const startupSources = structuredClone(acceptedSources.trustLayers);
|
|
597
|
+
const embeddedConfig = structuredClone(config);
|
|
598
|
+
let desiredConfig = structuredClone(config);
|
|
599
|
+
const startupRouting = structuredClone(runningRouting);
|
|
600
|
+
let desiredRouting = structuredClone(runningRouting);
|
|
601
|
+
let configRevision = 0;
|
|
602
|
+
let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
|
|
603
|
+
let pendingRestart: { key: string; reason: string }[] = [];
|
|
604
|
+
let lastReloadFailure: string | undefined;
|
|
605
|
+
let reloadDebounce: ReturnType<typeof setTimeout> | undefined;
|
|
606
|
+
let configWatchPending = false;
|
|
607
|
+
const configWatchers: { path: string; listener: (current: import("node:fs").Stats, previous: import("node:fs").Stats) => void }[] = [];
|
|
608
|
+
const stopConfigWatch = () => { configWatchPending = false; clearTimeout(reloadDebounce); for (const item of configWatchers.splice(0)) unwatchFile(item.path, item.listener); };
|
|
609
|
+
const activeRouting = () => runningRouting;
|
|
582
610
|
// #455: the planner in force. `policyRoles` is what the config files say; settlePlanner derives config.roles from it in
|
|
583
611
|
// place (Tasks holds the same object): an explicit `planner` key is exclusive, and with none and no rostered planner
|
|
584
612
|
// the first rostered PLANNER_PEERS entry plans. Never written to disk: a person pins it with the `planner` setting.
|
|
@@ -586,23 +614,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
586
614
|
let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
|
|
587
615
|
let plannerLogged = "";
|
|
588
616
|
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);
|
|
617
|
+
const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
|
|
606
618
|
for (const key of Object.keys(config.roles)) delete config.roles[key];
|
|
607
619
|
Object.assign(config.roles, roles);
|
|
608
620
|
plannerSource = source;
|
|
@@ -631,16 +643,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
631
643
|
try { configured = configuredRoles(peer); } catch { note = "catalog selection is incompatible or unavailable"; }
|
|
632
644
|
const prepared = preparedSessionRoles.get(peer);
|
|
633
645
|
const active = bus.peers.get(peer)?.state !== "offline" && bus.peers.has(peer);
|
|
646
|
+
let desired = configured;
|
|
647
|
+
try { desired = selectedRoles(peer, config.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(config.catalog_roles) }, catalog).map(role => role.id); }
|
|
648
|
+
catch { note = "catalog selection is incompatible or unavailable"; }
|
|
634
649
|
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;
|
|
650
|
+
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
651
|
}
|
|
652
|
+
const prepareRoleSelection = (peer: string) => { roleAssignments[peer] = catalogAssignments(config.catalog_roles)[peer] ?? resolvedProfile.catalog_roles[peer] ?? []; };
|
|
642
653
|
function sessionRoleContract(peer: string): string {
|
|
643
654
|
try {
|
|
655
|
+
prepareRoleSelection(peer);
|
|
644
656
|
const selected = selectedRoles(peer, config.roles, roleAssignments, catalog);
|
|
645
657
|
preparedSessionRoles.set(peer, selected.map(role => role.id));
|
|
646
658
|
preparedSessionRoleIdentities.set(peer, selected.map(roleIdentity));
|
|
@@ -746,8 +758,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
746
758
|
|
|
747
759
|
// A session record left by a run that never shut down means it crashed (issue #37). A controlled restart has its own.
|
|
748
760
|
const crashed = !recoveryOperation && !restartFilePresent ? readSessions(opts.stateDir) : undefined;
|
|
761
|
+
let piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
762
|
+
// A disposed operation's exact session takes precedence over a stale crash record for this whole startup.
|
|
763
|
+
const archivedPiContinuity = piContinuity.state !== "absent";
|
|
749
764
|
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)];
|
|
765
|
+
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
766
|
for (const launch of launches) {
|
|
752
767
|
const problem = mlxLaunchProblem(config, (launch ?? {}) as { backend?: unknown; model?: unknown });
|
|
753
768
|
if (problem) throw new Error(`recorded Pi recovery: ${problem}`);
|
|
@@ -756,7 +771,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
756
771
|
// A controlled restart's source may have been cut short before it removed its record: this run is not a crash, and
|
|
757
772
|
// a record left now would make the next ordinary start look like one.
|
|
758
773
|
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
|
|
774
|
+
const autoResume = () => config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
|
|
760
775
|
const piAutoStart = () => onRoster("pi") && config.pi.auto_start;
|
|
761
776
|
const startedAt = Date.now();
|
|
762
777
|
/** What crash recovery did or asks the user to do, for `ahub status`. */
|
|
@@ -790,8 +805,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
790
805
|
// silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
|
|
791
806
|
// together. Cached until the board changes: it is asked per recipient of every message.
|
|
792
807
|
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 ??=
|
|
808
|
+
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 }>();
|
|
809
|
+
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
810
|
/**
|
|
796
811
|
* Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
|
|
797
812
|
* has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
|
|
@@ -836,10 +851,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
836
851
|
const hubStartedAt = Date.now();
|
|
837
852
|
// The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
|
|
838
853
|
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)");
|
|
854
|
+
const staleOff = () => config.experiments?.stale_notices === "deliver";
|
|
855
|
+
if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
|
|
841
856
|
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,
|
|
857
|
+
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
858
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
|
|
844
859
|
startupCleanup.push(() => bus.closeJournal());
|
|
845
860
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
@@ -851,7 +866,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
851
866
|
let recoveryCommitted = false;
|
|
852
867
|
let recoveryPeerSnapshot: RestartPeerSnapshot[] | undefined;
|
|
853
868
|
let recoveryLeaseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
854
|
-
const recoveryActive = () => !!recoveryOperationId && recoveryPhase !== "released";
|
|
869
|
+
const recoveryActive = () => !!recoveryOperationId && (recoveryPhase !== "released" || bus.isRecoveryHeld);
|
|
855
870
|
if (restored) {
|
|
856
871
|
bus.restore(restored.bus, restored.operationId);
|
|
857
872
|
for (const hold of conductorHolds.list()) bus.pause(hold.peer);
|
|
@@ -860,22 +875,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
860
875
|
recoveryOperationId = restored.operationId;
|
|
861
876
|
recoveryPhase = "restored";
|
|
862
877
|
recoveryPeerSnapshot = restored.peers;
|
|
863
|
-
bus.setRecoveryHold(true);
|
|
878
|
+
bus.setRecoveryHold(true, true);
|
|
879
|
+
} else {
|
|
880
|
+
// A prior release may have committed its queue checkpoint before this daemon was stopped.
|
|
881
|
+
bus.setRecoveryHold(false);
|
|
864
882
|
}
|
|
865
883
|
const recoveryPeerAllowed = (id: PeerId) => !recoveryActive() || !recoveryPeerSnapshot || recoveryPeerSnapshot.some((peer) => peer.id === id);
|
|
866
|
-
const armRecoveryLease = () => {
|
|
884
|
+
const armRecoveryLease = (delayMs = 10 * 60_000): void => {
|
|
867
885
|
clearTimeout(recoveryLeaseTimer);
|
|
868
886
|
recoveryLeaseTimer = setTimeout(() => {
|
|
869
887
|
if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) return;
|
|
888
|
+
try { bus.setRecoveryHold(false); } catch (error) { log("recovery lease release deferred; delivery storage must be restored before abort"); armRecoveryLease(1000); return; }
|
|
870
889
|
log(`recovery lease expired for ${recoveryOperationId}; hold aborted`);
|
|
871
890
|
recoveryOperationId = undefined;
|
|
872
891
|
recoveryPhase = undefined;
|
|
873
892
|
recoveryPeerSnapshot = undefined;
|
|
874
|
-
bus.setRecoveryHold(false);
|
|
875
893
|
budget.setRecoveryHold(false);
|
|
876
894
|
removeRestartSnapshot(opts.stateDir);
|
|
877
895
|
writeStatus();
|
|
878
|
-
},
|
|
896
|
+
}, delayMs);
|
|
879
897
|
recoveryLeaseTimer.unref?.();
|
|
880
898
|
};
|
|
881
899
|
const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
|
|
@@ -895,7 +913,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
895
913
|
memoryNotice(`hub native memory disabled: ${reason}`);
|
|
896
914
|
};
|
|
897
915
|
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, () =>
|
|
916
|
+
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
917
|
const m = /#(\d+)@(\d+)$/.exec(key), t = m ? board.get(Number(m[1])) : undefined;
|
|
900
918
|
return t && String(t.created) === m![2] ? [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])] : [];
|
|
901
919
|
}) }, memoryUnavailable); memoryStore = opened.store; memoryBinding = opened.binding; startupCleanup.push(() => opened.store.close()); }
|
|
@@ -920,7 +938,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
920
938
|
* Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
|
|
921
939
|
* policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
|
|
922
940
|
*/
|
|
923
|
-
const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs,
|
|
941
|
+
const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs, activeRouting(), opts.cwd, config.pii?.paths ?? []);
|
|
924
942
|
let candidates: CandidateStore | undefined;
|
|
925
943
|
if (memoryStore) {
|
|
926
944
|
try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
|
|
@@ -1031,7 +1049,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1031
1049
|
const onCampus = async (): Promise<boolean> => (await omni.onCampus()) && !(sidecar?.upstream && omni.isAccessHost(sidecar.upstream));
|
|
1032
1050
|
let sidecar: Sidecar | undefined; // L2, started by the first hub-owned model call, stopped with the hub
|
|
1033
1051
|
let sidecarRouting = "";
|
|
1034
|
-
inference = new Inference(config.inference, { omni, sidecar: () => sidecar, route: "sy/fast", fixedModel: () =>
|
|
1052
|
+
inference = new Inference(config.inference, { omni, sidecar: () => sidecar, route: "sy/fast", fixedModel: () => activeRouting().local.fixed_model, log });
|
|
1035
1053
|
|
|
1036
1054
|
let dashboard: ReturnType<typeof startDashboard> | undefined;
|
|
1037
1055
|
const uiEvents: { seq: number; event: BusEvent }[] = [];
|
|
@@ -1053,7 +1071,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1053
1071
|
conductor: () => conductorPeer(config.roles) ?? undefined,
|
|
1054
1072
|
scope: () => config.conductor?.feed ?? "own",
|
|
1055
1073
|
tasks: () => board.list(), publicTitle: (task) => tasks.publicTitle(task), isPrivate: (task) => tasks.isPii(task),
|
|
1056
|
-
approvalAgeMs: (config.conductor?.approval_wait_s ?? 30) * 1000,
|
|
1074
|
+
approvalAgeMs: () => (config.conductor?.approval_wait_s ?? 30) * 1000,
|
|
1057
1075
|
readRound: (peer) => conductorHolds.readRound(peer), writeRound: (peer, signature) => conductorHolds.writeRound(peer, signature),
|
|
1058
1076
|
emit: (notice) => {
|
|
1059
1077
|
if (stopping || recoveryActive()) return false;
|
|
@@ -1064,7 +1082,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1064
1082
|
const refreshConductorPolicy = () => {
|
|
1065
1083
|
if (opts.config) return; // explicit embedding/test configuration remains its authority
|
|
1066
1084
|
const before = conductorPeer(config.roles);
|
|
1067
|
-
const policy =
|
|
1085
|
+
const policy = { roles: policyRoles, planner: config.planner, conductor: config.conductor };
|
|
1068
1086
|
policyRoles = structuredClone(policy.roles);
|
|
1069
1087
|
config.planner = policy.planner;
|
|
1070
1088
|
config.conductor = policy.conductor;
|
|
@@ -1186,17 +1204,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1186
1204
|
catalog,
|
|
1187
1205
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1188
1206
|
projectedReview: task => reviewProjection(task),
|
|
1189
|
-
sdlc: { config: () => lifecycleConfig },
|
|
1207
|
+
sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
|
|
1190
1208
|
board,
|
|
1191
1209
|
bus,
|
|
1192
1210
|
executionBudget,
|
|
1193
|
-
routing: () =>
|
|
1211
|
+
routing: () => activeRouting(), // only the complete validated reload can publish routing changes
|
|
1194
1212
|
cwd: opts.cwd,
|
|
1195
1213
|
project: chain.at(-1)!,
|
|
1196
1214
|
...(config.memory.enabled ? { memory } : {}),
|
|
1197
1215
|
candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
|
|
1198
1216
|
memoryCheck: checkMemory,
|
|
1199
|
-
...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, config.memory.brief_items,
|
|
1217
|
+
...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
|
|
1200
1218
|
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
1219
|
(peer, task, counts) => event({ type: "memory_brief", peer, task, ...counts }),
|
|
1202
1220
|
items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
|
|
@@ -1231,7 +1249,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1231
1249
|
},
|
|
1232
1250
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
1233
1251
|
// 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: () =>
|
|
1252
|
+
piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model }),
|
|
1235
1253
|
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
1236
1254
|
quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
|
|
1237
1255
|
review: config.review,
|
|
@@ -1243,6 +1261,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1243
1261
|
stallNotice: stall => { supervision.taskStalled(stall); },
|
|
1244
1262
|
roles: config.roles,
|
|
1245
1263
|
turnFree,
|
|
1264
|
+
coordination: () => config.coordination === "turn-free",
|
|
1246
1265
|
capable: (peer) => capable.has(peer),
|
|
1247
1266
|
idle: (peer) => idle(peer),
|
|
1248
1267
|
treeHash: (paths, windows) => facts.tree(paths, windows),
|
|
@@ -1605,9 +1624,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1605
1624
|
event({ type: "supervision_turn", peer, turn: receipt.id, ...(receipt.tokens === undefined ? {} : { tokens: receipt.tokens }), ms: Math.max(0, Date.now() - receipt.at) });
|
|
1606
1625
|
};
|
|
1607
1626
|
// 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
|
-
|
|
1627
|
+
const repo = factsRepo;
|
|
1628
|
+
let turnLog = config.snapshots.enabled && repo ? new Turns(join(opts.stateDir, "hub.db")) : undefined;
|
|
1629
|
+
startupCleanup.push(() => turnLog?.close());
|
|
1611
1630
|
/** Whether a peer has a PII task open in any state: assigned and not yet accepted counts. */
|
|
1612
1631
|
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
1632
|
const memoryObservations = memoryTelemetry ? new MemoryObservations(memoryTelemetry, (peer, envs) => {
|
|
@@ -1631,7 +1650,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1631
1650
|
* 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
1651
|
* in snapshots.ts.
|
|
1633
1652
|
*/
|
|
1634
|
-
const snap = (what: string): { tree?: string; ms: number } => {
|
|
1653
|
+
const snap = (what: string, capture = config.snapshots.enabled): { tree?: string; ms: number } => {
|
|
1654
|
+
if (!capture) return { ms: 0 };
|
|
1635
1655
|
const t0 = performance.now();
|
|
1636
1656
|
let tree: string | undefined;
|
|
1637
1657
|
try {
|
|
@@ -1647,7 +1667,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1647
1667
|
}
|
|
1648
1668
|
function reviewProjection(task: Task): string | undefined {
|
|
1649
1669
|
if (!projections.enabled || tasks.isPii(task)) return undefined;
|
|
1650
|
-
if (!repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
|
|
1670
|
+
if (!config.snapshots.enabled || !repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
|
|
1651
1671
|
if (privateSnapshotWindow()) return "(projected review unavailable: private snapshot window)";
|
|
1652
1672
|
const created = taskProposalAt(task);
|
|
1653
1673
|
const from = created === undefined ? undefined : turnLog.taskStart(task.id, created);
|
|
@@ -1749,10 +1769,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1749
1769
|
}
|
|
1750
1770
|
}, 5_000),
|
|
1751
1771
|
];
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1772
|
+
let sweepTimer: ReturnType<typeof setInterval> | undefined;
|
|
1773
|
+
const rescheduleSweep = () => {
|
|
1774
|
+
if (sweepTimer) clearInterval(sweepTimer);
|
|
1775
|
+
sweepTimer = undefined;
|
|
1776
|
+
const sweep = taskSweepConfig(config.task_sweep);
|
|
1777
|
+
tasks.configureSweep(sweep);
|
|
1778
|
+
if (sweep.enabled) {
|
|
1779
|
+
sweepTimer = setInterval(() => {
|
|
1780
|
+
void tasks.sweep(opts.taskSweepNow?.() ?? Date.now()).catch(() => notify("task idle sweep failed; inspect hub task history before manual action"));
|
|
1781
|
+
}, sweep.interval_s * 1000);
|
|
1782
|
+
sweepTimer.unref?.();
|
|
1783
|
+
}
|
|
1784
|
+
};
|
|
1785
|
+
rescheduleSweep();
|
|
1786
|
+
startupCleanup.push(() => clearInterval(sweepTimer));
|
|
1756
1787
|
for (const i of intervals) i.unref?.();
|
|
1757
1788
|
startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
|
|
1758
1789
|
|
|
@@ -2126,6 +2157,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2126
2157
|
const permissionChanges = new Map<string, Promise<void>>();
|
|
2127
2158
|
const permissionModes = new Map<string, PermissionMode>(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, mode === "never-ask" ? "ask" : mode]));
|
|
2128
2159
|
for (const peer of pendingPermissionDefaults.keys()) permissionModes.set(peer, "ask");
|
|
2160
|
+
const changedPermissionDefaults = new Set<string>();
|
|
2161
|
+
const applyPendingPermissionDefault = (peer: string): void => {
|
|
2162
|
+
if (changedPermissionDefaults.has(peer) && (!bus.peers.has(peer) || bus.peers.get(peer)!.state === "offline")) {
|
|
2163
|
+
changedPermissionDefaults.delete(peer);
|
|
2164
|
+
const mode = config.permission_modes[peer] ?? "ask";
|
|
2165
|
+
const source = config.permission_default_sources?.[peer]?.source ?? "configuration default";
|
|
2166
|
+
permissionModes.set(peer, mode === "never-ask" ? "ask" : mode);
|
|
2167
|
+
permissionModeSources.set(peer, source);
|
|
2168
|
+
pendingPermissionDefaults.delete(peer);
|
|
2169
|
+
if (mode === "never-ask") pendingPermissionDefaults.set(peer, source);
|
|
2170
|
+
if (config.permission_default_sources?.[peer]) defaultSources[peer] = config.permission_default_sources[peer]!;
|
|
2171
|
+
else delete defaultSources[peer];
|
|
2172
|
+
}
|
|
2173
|
+
};
|
|
2129
2174
|
const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
|
|
2130
2175
|
const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
|
|
2131
2176
|
const unattendedPeers = new Set<string>();
|
|
@@ -2343,6 +2388,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2343
2388
|
projectId,
|
|
2344
2389
|
instanceId,
|
|
2345
2390
|
version: VERSION,
|
|
2391
|
+
pendingRestart,
|
|
2346
2392
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2347
2393
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
2348
2394
|
protocol: PROTOCOL,
|
|
@@ -2568,17 +2614,57 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2568
2614
|
|
|
2569
2615
|
// Session identities of the attached peers, kept current for crash recovery (issue #37); a clean stop removes them.
|
|
2570
2616
|
let sessionsWritten = "";
|
|
2617
|
+
// One bounded failure generation: unchanged events neither contend for the writer lock nor repeat its notice.
|
|
2618
|
+
let piSettlementFailure: { key: string; error: string; retryAfter: number } | undefined;
|
|
2619
|
+
function settlePiContinuity(): void {
|
|
2620
|
+
if (stopping || recoveryActive() || piContinuity.state !== "ready") return;
|
|
2621
|
+
const peer = bus.peers.get("pi");
|
|
2622
|
+
if (!(peer instanceof PiPeer) || peer.state === "offline" || !peer.sessionGeneration || !peer.recoveryReady) return;
|
|
2623
|
+
const saved = piContinuity.bookmark, actual = peer.recoveryMetadata();
|
|
2624
|
+
if (typeof actual.sessionId !== "string" || !actual.sessionId) return;
|
|
2625
|
+
const key = JSON.stringify([piContinuity.digest, peer.sessionGeneration, actual]);
|
|
2626
|
+
if (piSettlementFailure?.key === key && Date.now() < piSettlementFailure.retryAfter) return;
|
|
2627
|
+
const matches = actual.sessionId === saved.pi.sessionId && (saved.pi.sessionFile === undefined || actual.sessionFile === saved.pi.sessionFile);
|
|
2628
|
+
try {
|
|
2629
|
+
const expected = { projectId, projectRoot: opts.cwd, operationId: saved.operationId, digest: piContinuity.digest };
|
|
2630
|
+
const settled = matches ? consumeRecoveryContinuity(opts.stateDir, expected) : retireRecoveryContinuity(opts.stateDir, expected, "authenticated_session_changed").retired;
|
|
2631
|
+
piSettlementFailure = undefined;
|
|
2632
|
+
if (settled) {
|
|
2633
|
+
piContinuity = { state: "absent" };
|
|
2634
|
+
if (!matches) log("Pi archived continuity retired after recording a different authenticated session");
|
|
2635
|
+
}
|
|
2636
|
+
else {
|
|
2637
|
+
// Re-read after the failed exact CAS; never settle a newer bookmark in this call.
|
|
2638
|
+
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
2639
|
+
if (piContinuity.state !== "absent") log("Pi continuity bookmark changed before attachment settlement; retained for inspection");
|
|
2640
|
+
}
|
|
2641
|
+
} catch (error) {
|
|
2642
|
+
const signature = error instanceof Error ? `${error.name}:${error.message}` : String(error);
|
|
2643
|
+
const report = piSettlementFailure?.key !== key || piSettlementFailure.error !== signature;
|
|
2644
|
+
piSettlementFailure = { key, error: signature, retryAfter: Date.now() + 30_000 };
|
|
2645
|
+
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
2646
|
+
if (report) log("Pi continuity attachment settlement unavailable; bookmark retained");
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2571
2649
|
const recordSessions = () => {
|
|
2572
2650
|
if (stopping) return;
|
|
2651
|
+
const pi = bus.peers.get("pi");
|
|
2652
|
+
if (piContinuity.state === "ready" && (!(pi instanceof PiPeer) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
|
|
2653
|
+
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
2654
|
+
}
|
|
2573
2655
|
const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
|
|
2574
2656
|
let meta: Record<string, unknown> = {};
|
|
2575
2657
|
try { meta = (p as { recoveryMetadata?: () => Record<string, unknown> }).recoveryMetadata?.() ?? {}; } catch { /* not ready yet: the id alone */ }
|
|
2576
2658
|
return { peer: p.id, meta };
|
|
2577
2659
|
});
|
|
2578
2660
|
const text = JSON.stringify(peers);
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2661
|
+
try {
|
|
2662
|
+
if (text !== sessionsWritten) {
|
|
2663
|
+
writeSessions(opts.stateDir, { instanceId, at: Date.now(), peers });
|
|
2664
|
+
sessionsWritten = text;
|
|
2665
|
+
}
|
|
2666
|
+
settlePiContinuity(); // the identical snapshot may already be durable; never retire after a failed write
|
|
2667
|
+
} catch (error) { log(`session record not written: ${(error as Error).message}`); }
|
|
2582
2668
|
};
|
|
2583
2669
|
const recoverAfterCrash = async (prev: SessionsFile) => {
|
|
2584
2670
|
const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
|
|
@@ -2586,6 +2672,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2586
2672
|
report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
2587
2673
|
const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
|
|
2588
2674
|
for (const step of crashPlan(prev.peers)) {
|
|
2675
|
+
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
2676
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
2590
2677
|
// fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
|
|
2591
2678
|
if (step.peer === "pi" && piAutoStart()) {
|
|
@@ -2601,7 +2688,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2601
2688
|
report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
|
|
2602
2689
|
continue;
|
|
2603
2690
|
}
|
|
2604
|
-
if (!step.resume || !autoResume) {
|
|
2691
|
+
if (!step.resume || !autoResume()) {
|
|
2605
2692
|
report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`);
|
|
2606
2693
|
continue;
|
|
2607
2694
|
}
|
|
@@ -2626,7 +2713,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2626
2713
|
// #248: the add-ons it works with, once per attach (names and versions only).
|
|
2627
2714
|
// A result is written unless a later attachment began meanwhile: a peer that detached before its probe settled
|
|
2628
2715
|
// still did its work under that attachment.
|
|
2629
|
-
if (e.state === "offline") attachedNow.delete(e.peer);
|
|
2716
|
+
if (e.state === "offline") { attachedNow.delete(e.peer); applyPendingPermissionDefault(e.peer); }
|
|
2630
2717
|
else if (!attachedNow.has(e.peer)) {
|
|
2631
2718
|
attachedNow.add(e.peer);
|
|
2632
2719
|
const peer = e.peer, attachment = ++attachments;
|
|
@@ -2647,20 +2734,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2647
2734
|
const pii = holdsPii(e.peer);
|
|
2648
2735
|
for (const id of turnTasks.keys()) if (!turnLog?.get(id)) turnTasks.delete(id);
|
|
2649
2736
|
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
|
-
|
|
2737
|
+
const capture = config.snapshots.enabled && !!turnLog;
|
|
2738
|
+
const start = capture && !pii ? snap(`the start of ${id}`) : undefined; // before the peer is handed anything: the tap runs inside setState
|
|
2739
|
+
try { if (capture) turnLog?.begin(id, e.peer, start?.tree); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
|
|
2652
2740
|
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 } : {}) });
|
|
2741
|
+
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
2742
|
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
2655
2743
|
} else if (!busy && open) {
|
|
2656
2744
|
turns.delete(e.peer);
|
|
2657
2745
|
let afterTurn: (() => void) | undefined;
|
|
2658
2746
|
let files: number | undefined;
|
|
2659
2747
|
let snapshotMs = open.snapshotMs;
|
|
2660
|
-
if (turnLog && (open.private || holdsPii(e.peer))) {
|
|
2748
|
+
if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
|
|
2661
2749
|
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}
|
|
2750
|
+
} else if (open.capture && turnLog) {
|
|
2751
|
+
const end = snap(`the end of ${open.id}`, true);
|
|
2664
2752
|
const changed = open.tree && end.tree ? changedPaths(repo!, open.tree, end.tree) : [];
|
|
2665
2753
|
if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
|
|
2666
2754
|
snapshotMs = (snapshotMs ?? 0) + end.ms;
|
|
@@ -2825,7 +2913,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2825
2913
|
/** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
|
|
2826
2914
|
const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
|
|
2827
2915
|
(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}`));
|
|
2916
|
+
(error) => log(`Pi auto-start failed: ${(error as Error).message}${piContinuity.state === "ready" ? `. ${continuityCleanup()}` : ""}`));
|
|
2829
2917
|
async function stopPiForReplacement(pi: PiPeer): Promise<void> {
|
|
2830
2918
|
replacingPi.add(pi);
|
|
2831
2919
|
try { await pi.stop(); } finally { replacingPi.delete(pi); }
|
|
@@ -2846,6 +2934,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2846
2934
|
const running = startPeerOnce(peer, args).then(async result => {
|
|
2847
2935
|
if (!result.ok && peer === "codex") unattendedPeers.delete(peer);
|
|
2848
2936
|
if (!result.ok || result.already) return result;
|
|
2937
|
+
if (peer === "pi") recordSessions();
|
|
2849
2938
|
return permissionChange(peer, async () => {
|
|
2850
2939
|
if (peer === "codex") { if (args.unattended) unattendedPeers.add(peer); else unattendedPeers.delete(peer); }
|
|
2851
2940
|
logPermissionStart(peer); writeStatus(); return result;
|
|
@@ -2874,6 +2963,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2874
2963
|
const mode = startMode(peer), owner = bus.peers.get(peer), can = mode === "tui" ? opener.available() : undefined;
|
|
2875
2964
|
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
2965
|
});
|
|
2966
|
+
// Printed for a person's explicit reconciliation only; never executed by auto-start.
|
|
2967
|
+
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
2968
|
async function hubStart(peer: unknown, by: string): Promise<Record<string, unknown>> {
|
|
2878
2969
|
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
2879
2970
|
if (rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
|
|
@@ -2881,7 +2972,31 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2881
2972
|
if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
|
|
2882
2973
|
if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
|
|
2883
2974
|
if (recoveryActive()) return { ok: false, error: "recovery is holding mutations" };
|
|
2975
|
+
const continuity = peer === "pi" && by === "pi.auto_start"
|
|
2976
|
+
? piContinuity.state === "ready" ? (piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd })) : piContinuity
|
|
2977
|
+
: undefined;
|
|
2978
|
+
if (continuity?.state === "invalid") return { ok: false, error: `archived Pi continuity evidence is invalid or unreadable; no fresh session was started. ${continuityCleanup()}` };
|
|
2979
|
+
const resume = continuity?.state === "ready" ? continuity.bookmark.pi : undefined;
|
|
2884
2980
|
const mode = startMode(peer);
|
|
2981
|
+
if (resume?.sessionFile && continuity?.state === "ready") {
|
|
2982
|
+
try { validatePiSession(opts.stateDir, opts.cwd, resume.sessionFile, resume.sessionId); }
|
|
2983
|
+
catch (error) {
|
|
2984
|
+
const retry = `ahub pi --mode ${mode} --session-file ${shellQuote(resume.sessionFile)}`;
|
|
2985
|
+
if (error instanceof PiSessionUnresumable || error instanceof PiSessionBookmarkMismatch) {
|
|
2986
|
+
try {
|
|
2987
|
+
const retired = retireRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd, operationId: continuity.bookmark.operationId, digest: continuity.digest }, "resume_unavailable");
|
|
2988
|
+
if (retired.retired) {
|
|
2989
|
+
piContinuity = { state: "absent" };
|
|
2990
|
+
const diagnosis = error instanceof PiSessionBookmarkMismatch ? "Pi continuity bookmark does not match the preserved session" : "recorded Pi session is unresumable";
|
|
2991
|
+
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>` };
|
|
2992
|
+
}
|
|
2993
|
+
} 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()}` }; }
|
|
2994
|
+
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
2995
|
+
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()}` };
|
|
2996
|
+
}
|
|
2997
|
+
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()}` };
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
2885
3000
|
if (mode === "tui" && (peer === "pi" || peer === "local")) { await ensureSpec(true); await refreshRunner(); }
|
|
2886
3001
|
const owner = bus.peers.get(peer);
|
|
2887
3002
|
if (mode === "headless") {
|
|
@@ -2890,23 +3005,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2890
3005
|
// like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
|
|
2891
3006
|
// issued to tell the two apart.
|
|
2892
3007
|
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" } : {});
|
|
3008
|
+
return startPeer(peer, peer === "pi" || peer === "kimi" ? { mode: "headless", ...(resume ?? {}) } : {});
|
|
2894
3009
|
}
|
|
2895
3010
|
if (owner && owner.state !== "offline") return { ok: true, already: true, mode };
|
|
2896
|
-
const
|
|
3011
|
+
const words = [...startWords(peer, mode)];
|
|
3012
|
+
if (resume) words.push(...(resume.sessionFile ? ["--session-file", resume.sessionFile] : ["--session-id", resume.sessionId]), ...(resume.backend ? ["--backend", resume.backend] : []), ...(resume.model ? ["--model", resume.model] : []));
|
|
3013
|
+
const command = `ahub ${resume ? words.map(shellQuote).join(" ") : words.join(" ")}`;
|
|
2897
3014
|
const since = Date.now() - (opening.get(peer) ?? 0);
|
|
2898
3015
|
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
3016
|
// Reserved before the provider is awaited, so starts that arrive together open one terminal, not one each.
|
|
2900
3017
|
opening.set(peer, Date.now());
|
|
3018
|
+
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
3019
|
// 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, ...
|
|
3020
|
+
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
3021
|
if (!opened.ok) {
|
|
2904
3022
|
opening.delete(peer); // nothing was opened: the next request may try again at once
|
|
2905
3023
|
if (opened.detail) log(`terminal provider for ${peer}: ${opened.detail}`);
|
|
2906
3024
|
const headless = peer === "claude" ? "" : `, or set its start mode to headless (ahub settings set peers.${peer}.start_mode headless)`;
|
|
2907
3025
|
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
3026
|
}
|
|
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 */ }
|
|
3027
|
+
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
3028
|
return { ok: true, mode, opened: opened.via, command };
|
|
2911
3029
|
}
|
|
2912
3030
|
|
|
@@ -2965,6 +3083,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2965
3083
|
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
3084
|
let unmute: (() => void) | undefined;
|
|
2967
3085
|
try {
|
|
3086
|
+
applyPendingPermissionDefault(peer);
|
|
2968
3087
|
if (peer === "pi" || peer === "local") { await ensureSpec(true); await refreshRunner(); }
|
|
2969
3088
|
return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
|
|
2970
3089
|
} finally {
|
|
@@ -2973,8 +3092,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2973
3092
|
}
|
|
2974
3093
|
|
|
2975
3094
|
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>> {
|
|
3095
|
+
const adapter = definitionOf(peer);
|
|
2976
3096
|
if (peer === "jcode" && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "Jcode native TUI driving is unverified; use headless" };
|
|
2977
|
-
if (
|
|
3097
|
+
if (adapter?.transport === "acp" && adapter.start.create) {
|
|
3098
|
+
const refusal = adapter.start.preflight?.({ instance: peer, form: args.mode });
|
|
3099
|
+
if (refusal) return { ok: false, error: refusal };
|
|
3100
|
+
}
|
|
2978
3101
|
if (peer === "kimi") {
|
|
2979
3102
|
if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Kimi mode" };
|
|
2980
3103
|
const owner = bus.peers.get(peer);
|
|
@@ -3061,32 +3184,36 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3061
3184
|
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
3185
|
return { ok: true, policy: "unmanaged" };
|
|
3063
3186
|
}
|
|
3064
|
-
if (
|
|
3065
|
-
|
|
3066
|
-
const launch =
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3187
|
+
if (adapter?.transport === "acp" && adapter.start.create) {
|
|
3188
|
+
// ponytail: A1 migrates one ACP factory; A2 generalizes its native config and activity ports before adding another.
|
|
3189
|
+
const launch = adapter.start.launch({ instance: peer, form: "headless", projectRoot: opts.cwd, stateDir: opts.stateDir, entrypoint: CLI_ENTRY,
|
|
3190
|
+
userArgs: [], unattended: false, env: {}, nativeCommand: config.qwen_cmd, model: args.model });
|
|
3191
|
+
const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
|
|
3192
|
+
const permission = permissionMode(peer);
|
|
3193
|
+
const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
|
|
3194
|
+
onActivity: item => activityRings[peer as keyof typeof activityRings].append(item),
|
|
3195
|
+
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3196
|
+
cmd: [launch.cmd, ...launch.args], permissionMode: permission,
|
|
3071
3197
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
3072
3198
|
cwd: opts.cwd, watchdogMs: config.watchdog_ms, onPermission,
|
|
3073
3199
|
autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3074
3200
|
log: () => log("Qwen runtime diagnostic: details withheld"),
|
|
3075
3201
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
3076
3202
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
3077
|
-
const current = contextSession(
|
|
3078
|
-
contexts.report(
|
|
3203
|
+
const current = contextSession(peer); if (!current) return;
|
|
3204
|
+
contexts.report(peer, observation.contextUsed !== undefined && observation.contextCapacity !== undefined
|
|
3079
3205
|
? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
|
|
3080
3206
|
: { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null }, current);
|
|
3081
3207
|
},
|
|
3082
|
-
onTurnFailure: () => { supervisionTurns.delete(
|
|
3083
|
-
mcpServers
|
|
3084
|
-
preamble: sessionRoleContract(
|
|
3085
|
-
});
|
|
3086
|
-
|
|
3087
|
-
|
|
3208
|
+
onTurnFailure: () => { supervisionTurns.delete(peer); },
|
|
3209
|
+
mcpServers,
|
|
3210
|
+
preamble: sessionRoleContract(peer),
|
|
3211
|
+
} } });
|
|
3212
|
+
if (!(owner instanceof AcpPeer)) throw new Error("ACP factory did not return its native transport");
|
|
3213
|
+
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3214
|
+
try { await owner.start(); await reconcileNativeStart(peer, owner); }
|
|
3088
3215
|
catch (error) {
|
|
3089
|
-
await
|
|
3216
|
+
await owner.stop().catch(() => log("Qwen stop after failed startup was not confirmed"));
|
|
3090
3217
|
if (error instanceof QwenIdentityError) throw error;
|
|
3091
3218
|
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
3219
|
}
|
|
@@ -3220,11 +3347,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3220
3347
|
if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
|
|
3221
3348
|
const problem = mlxLaunchProblem(config, { backend, model: args.model });
|
|
3222
3349
|
if (problem) return { ok: false, error: problem };
|
|
3223
|
-
modelRelay ??= await startModelRelay({ omni, admitRequest: admitPiRequest, dgxMaxInputTokens:
|
|
3350
|
+
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
3351
|
routeSessionKey: () => { const session = bus.peers.get("pi")?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
|
|
3225
|
-
staySwitch: () =>
|
|
3352
|
+
staySwitch: () => activeRouting(),
|
|
3226
3353
|
onRoute: record => event({ type: "route", peer: "pi", ...record }),
|
|
3227
|
-
efficientWaitMs:
|
|
3354
|
+
efficientWaitMs: activeRouting().pi.efficient_wait_ms,
|
|
3228
3355
|
underBudget: () => { const current = bus.peers.get("pi"); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, "pi"); },
|
|
3229
3356
|
onCooldown: record => {
|
|
3230
3357
|
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 +3391,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3264
3391
|
return typeof refused === "string" ? `not sent: ${refused}` : "sent";
|
|
3265
3392
|
},
|
|
3266
3393
|
};
|
|
3267
|
-
const routing =
|
|
3394
|
+
const routing = activeRouting();
|
|
3268
3395
|
const pi = new PiPeer("pi", {
|
|
3269
3396
|
cwd: opts.cwd, stateDir: opts.stateDir, cmd: config.pi.cmd, mode, backend,
|
|
3270
3397
|
model: args.model,
|
|
@@ -3312,7 +3439,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3312
3439
|
if (backend !== "auto") return backend === "mlx" ? "mlx/fast" : "dgx/coding";
|
|
3313
3440
|
const taskId = envs.find(env => env.refs?.task)?.refs?.task;
|
|
3314
3441
|
const task = taskId ? board.get(Number(taskId)) : undefined;
|
|
3315
|
-
const policyBackend = task ?
|
|
3442
|
+
const policyBackend = task ? activeRouting().classes[task.class]?.pi_backend : undefined;
|
|
3316
3443
|
if (policyBackend === "mlx") {
|
|
3317
3444
|
if (config.mlx.enabled !== false) return "mlx/fast";
|
|
3318
3445
|
const problem = disabledMlxPolicyProblem(config, opts.cwd);
|
|
@@ -3380,7 +3507,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3380
3507
|
return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
|
|
3381
3508
|
}
|
|
3382
3509
|
if (peer === "local") {
|
|
3383
|
-
const routing =
|
|
3510
|
+
const routing = activeRouting();
|
|
3384
3511
|
// `--model` pins a model on OmniRoute and skips L2; `--route` picks another Switchyard route.
|
|
3385
3512
|
const route = args.model ? undefined : (args.route ?? routing.local.route);
|
|
3386
3513
|
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 +3554,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3427
3554
|
admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, "local", unit),
|
|
3428
3555
|
...(route ? { route } : {}),
|
|
3429
3556
|
...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
|
|
3430
|
-
hubRoutes: () =>
|
|
3431
|
-
staySwitch: () =>
|
|
3557
|
+
hubRoutes: () => activeRouting().hub_routes ?? {},
|
|
3558
|
+
staySwitch: () => activeRouting(),
|
|
3432
3559
|
onRoute: record => event({ type: "route", peer: "local", ...record }),
|
|
3433
3560
|
onAdvisor: record => event({ type: "advisor", peer: "local", ...record }),
|
|
3434
3561
|
onRouteOutcome: record => event({ type: "route_outcome", peer: "local", ...record }),
|
|
@@ -3471,7 +3598,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3471
3598
|
}
|
|
3472
3599
|
|
|
3473
3600
|
async function validateLocalChoice(route: string | undefined, fixedModel: string): Promise<void> {
|
|
3474
|
-
const routing =
|
|
3601
|
+
const routing = activeRouting();
|
|
3475
3602
|
if (route && !(route.startsWith("hub/") ? routing.hub_routes?.[route] : routing.routes[route])) throw new Error(`routing.toml has no route "${route}"`);
|
|
3476
3603
|
const models = await omni.models();
|
|
3477
3604
|
const required = new Set([fixedModel]);
|
|
@@ -3655,7 +3782,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3655
3782
|
instanceId,
|
|
3656
3783
|
attention: attentionStatus.attentionInput,
|
|
3657
3784
|
pet,
|
|
3658
|
-
status: { peers: peerView, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
3785
|
+
status: { peers: peerView, pendingRestart, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
3659
3786
|
boardCapabilities: { backlog: true },
|
|
3660
3787
|
planningCapabilities: { enabled: true, wipPull: true, split: true },
|
|
3661
3788
|
releases: tasks.releaseList(USER),
|
|
@@ -3690,6 +3817,237 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3690
3817
|
};
|
|
3691
3818
|
}
|
|
3692
3819
|
|
|
3820
|
+
/** Set a validated leaf in place: objects captured by policy consumers retain their identity. */
|
|
3821
|
+
function putConfig(target: object, key: string, value: unknown): void {
|
|
3822
|
+
const parts = key.split(".");
|
|
3823
|
+
if (parts.some(part => ["__proto__", "constructor", "prototype"].includes(part))) throw new Error("unsafe configuration path");
|
|
3824
|
+
let node = target as Record<string, unknown>;
|
|
3825
|
+
for (const part of parts.slice(0, -1)) {
|
|
3826
|
+
if (!Object.hasOwn(node, part) || !node[part] || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {};
|
|
3827
|
+
node = node[part] as Record<string, unknown>;
|
|
3828
|
+
}
|
|
3829
|
+
if (value === undefined) delete node[parts.at(-1)!];
|
|
3830
|
+
else node[parts.at(-1)!] = structuredClone(value);
|
|
3831
|
+
}
|
|
3832
|
+
function changedRawPaths(before: unknown, after: unknown, prefix = ""): string[] {
|
|
3833
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
3834
|
+
if (object(before) || object(after)) {
|
|
3835
|
+
const old = object(before) ? before : {}, next = object(after) ? after : {};
|
|
3836
|
+
return [...new Set([...Object.keys(old), ...Object.keys(next)])].flatMap(key => changedRawPaths(old[key], next[key], prefix ? `${prefix}.${key}` : key));
|
|
3837
|
+
}
|
|
3838
|
+
return JSON.stringify(before) === JSON.stringify(after) ? [] : [prefix];
|
|
3839
|
+
}
|
|
3840
|
+
function declaredPaths(value: unknown, prefix = ""): string[] {
|
|
3841
|
+
// Switchyard dictionaries have opaque keys (including dots); publish each dictionary as one value.
|
|
3842
|
+
if (["routing.targets", "routing.routes", "routing.hub_routes"].includes(prefix)) return [prefix];
|
|
3843
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
|
|
3844
|
+
return prefix ? [prefix] : [];
|
|
3845
|
+
}
|
|
3846
|
+
const isMachineKey = (key: string) => ALL_MACHINE_LOCAL.some(path => key === path || key.startsWith(`${path}.`));
|
|
3847
|
+
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
3848
|
+
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
3849
|
+
const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
|
|
3850
|
+
const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled));
|
|
3851
|
+
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
3852
|
+
}
|
|
3853
|
+
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
3854
|
+
const fileConfig = loadConfig(opts.cwd, sources.layers);
|
|
3855
|
+
validateEffectiveConfig(fileConfig);
|
|
3856
|
+
const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
|
|
3857
|
+
const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
|
|
3858
|
+
const previews: { key: string; before: string; after: string; effect?: string }[] = [];
|
|
3859
|
+
const paths = new Set<string>();
|
|
3860
|
+
for (const layer of [...sources.trustLayers, ...acceptedSources.trustLayers]) {
|
|
3861
|
+
const prefix = layer.name.endsWith(".toml") ? "routing" : "";
|
|
3862
|
+
for (const path of declaredPaths(layer.raw, prefix)) paths.add(path);
|
|
3863
|
+
}
|
|
3864
|
+
if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig)) paths.add(change.key);
|
|
3865
|
+
for (const key of paths) {
|
|
3866
|
+
if (scope && key !== scope) continue;
|
|
3867
|
+
const route = key.startsWith("routing."), path = (route ? key.slice(8) : key).split(".");
|
|
3868
|
+
const layers = sources.trustLayers.filter(layer => layer.name.endsWith(route ? ".toml" : ".json"));
|
|
3869
|
+
const priorLayers = acceptedSources.trustLayers.filter(layer => layer.name.endsWith(route ? ".toml" : ".json"));
|
|
3870
|
+
const current = [...layers].reverse().find(layer => valueAt(layer.raw, path) !== undefined);
|
|
3871
|
+
const prior = [...priorLayers].reverse().find(layer => valueAt(layer.raw, path) !== undefined);
|
|
3872
|
+
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)));
|
|
3873
|
+
const rawChanged = changedLayers.length > 0;
|
|
3874
|
+
if (opts.config && !rawChanged) continue; // embedding overrides are not file defaults
|
|
3875
|
+
const before = valueAt(route ? desiredRouting : desiredConfig, path);
|
|
3876
|
+
const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
|
|
3877
|
+
const rawAfter = current ? valueAt(current.raw, path) : undefined;
|
|
3878
|
+
if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
|
|
3879
|
+
const machine = !route && isMachineKey(key);
|
|
3880
|
+
const origin = current ?? prior;
|
|
3881
|
+
let tracked = origin ? configTracked(opts.cwd, origin.name) : false;
|
|
3882
|
+
if (!machine && tracked === undefined && !configRepositoryPresent(opts.cwd)) tracked = false;
|
|
3883
|
+
const guard = SETTINGS.find(def => def.store === (route ? "routing" : "config") && def.path.join(".") === path.join("."));
|
|
3884
|
+
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));
|
|
3885
|
+
if (key.startsWith("roster.") && scope !== key && JSON.stringify(before) !== JSON.stringify(after)) {
|
|
3886
|
+
rejected.push({ key, reason: "use the confirmed ahub roster add/remove setting; a full reload cannot bypass peer lifecycle" });
|
|
3887
|
+
continue;
|
|
3888
|
+
}
|
|
3889
|
+
let settledHolderChange = false, effect: string | undefined;
|
|
3890
|
+
if (key === "planner" || key === "roles" || key.startsWith("roles.")) {
|
|
3891
|
+
const prospective = structuredClone(candidate);
|
|
3892
|
+
putConfig(prospective, path.join("."), after);
|
|
3893
|
+
const oldHolders = roleHolders(candidate), newHolders = roleHolders(prospective);
|
|
3894
|
+
settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
|
|
3895
|
+
if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
|
|
3896
|
+
}
|
|
3897
|
+
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) };
|
|
3898
|
+
const decision = authorizeConfigLeaf(input);
|
|
3899
|
+
if (!decision.allowed) {
|
|
3900
|
+
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" : ""}`;
|
|
3901
|
+
rejected.push({ key, reason: next });
|
|
3902
|
+
if (!scope && authority === "terminal" && authorizeConfigLeaf({ ...input, confirmed: true }).allowed) {
|
|
3903
|
+
const shown = { key, before: before === undefined ? "(unset)" : JSON.stringify(before)!, after: after === undefined ? "(unset)" : JSON.stringify(after)!, ...(effect ? { effect } : {}) };
|
|
3904
|
+
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";
|
|
3905
|
+
else { guarded.push(key); previews.push(shown); }
|
|
3906
|
+
}
|
|
3907
|
+
continue;
|
|
3908
|
+
}
|
|
3909
|
+
putConfig(route ? routing : candidate, path.join("."), after);
|
|
3910
|
+
if (route) {
|
|
3911
|
+
routing.sources ??= {};
|
|
3912
|
+
const origin = sources.routing.sources?.[path.join(".")];
|
|
3913
|
+
if (origin) routing.sources[path.join(".")] = origin; else delete routing.sources[path.join(".")];
|
|
3914
|
+
}
|
|
3915
|
+
if (!route && path[0] === "permission_modes" && path[1]) {
|
|
3916
|
+
candidate.permission_default_sources ??= {};
|
|
3917
|
+
const source = fileConfig.permission_default_sources?.[path[1]];
|
|
3918
|
+
if (source) candidate.permission_default_sources[path[1]] = source;
|
|
3919
|
+
else delete candidate.permission_default_sources[path[1]];
|
|
3920
|
+
}
|
|
3921
|
+
accepted.push(key);
|
|
3922
|
+
}
|
|
3923
|
+
// Describe the guarded transaction against all safe changes already admitted by this plan.
|
|
3924
|
+
const rolePreviews = previews.filter(item => item.key === "planner" || item.key === "roles" || item.key.startsWith("roles."));
|
|
3925
|
+
if (rolePreviews.length) {
|
|
3926
|
+
const prospective = structuredClone(candidate);
|
|
3927
|
+
for (const item of rolePreviews) putConfig(prospective, item.key, item.after === "(unset)" ? undefined : JSON.parse(item.after));
|
|
3928
|
+
const before = roleHolders(candidate), after = roleHolders(prospective);
|
|
3929
|
+
const effect = `planner ${JSON.stringify(before.planners)} -> ${JSON.stringify(after.planners)}; conductor ${JSON.stringify(before.conductors)} -> ${JSON.stringify(after.conductors)}`;
|
|
3930
|
+
for (const item of rolePreviews) item.effect = effect;
|
|
3931
|
+
}
|
|
3932
|
+
guarded.sort(); previews.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
3933
|
+
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) {
|
|
3934
|
+
for (const item of rejected) if (guarded.includes(item.key)) item.reason += "; combined confirmation preview exceeds its 128 KiB bound; split this edit";
|
|
3935
|
+
guarded.length = 0; previews.length = 0;
|
|
3936
|
+
}
|
|
3937
|
+
const changes = configChanges({ ...config, roles: policyRoles }, candidate);
|
|
3938
|
+
const running = structuredClone({ ...config, roles: policyRoles });
|
|
3939
|
+
for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
|
|
3940
|
+
validateEffectiveConfig(running);
|
|
3941
|
+
conductorPeer(running.roles);
|
|
3942
|
+
for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
|
|
3943
|
+
const problem = running.mlx.enabled === false ? mlxPinProblem(routing, sources.texts["routing.toml"] !== null) : undefined;
|
|
3944
|
+
if (problem) throw new Error(problem);
|
|
3945
|
+
return { sources, fileConfig, candidate, running, routing, changes, accepted, rejected, guarded, previews };
|
|
3946
|
+
}
|
|
3947
|
+
/** Acknowledging one field never hides another pending edit in the same file. */
|
|
3948
|
+
function acknowledgeConfiguration(sources: ConfigSources, paths: readonly string[]): void {
|
|
3949
|
+
const layers = acceptedSources.trustLayers.map(layer => ({ ...layer, raw: structuredClone(layer.raw) }));
|
|
3950
|
+
for (const name of CONFIG_SOURCE_FILES) {
|
|
3951
|
+
let layer = layers.find(layer => layer.name === name);
|
|
3952
|
+
const relevant = paths.filter(key => key.startsWith("routing.") === name.endsWith(".toml"));
|
|
3953
|
+
if (!relevant.length) continue;
|
|
3954
|
+
if (!layer) { layer = { name, raw: {} }; layers.push(layer); }
|
|
3955
|
+
const next = sources.trustLayers.find(layer => layer.name === name)?.raw;
|
|
3956
|
+
for (const key of relevant) {
|
|
3957
|
+
const path = key.startsWith("routing.") ? key.slice(8) : key;
|
|
3958
|
+
const raw = valueAt(next, path.split("."));
|
|
3959
|
+
const winning = [...sources.trustLayers].reverse().find(source => source.name.endsWith(name.endsWith(".toml") ? ".toml" : ".json") && valueAt(source.raw, path.split(".")) !== undefined);
|
|
3960
|
+
if (raw !== undefined && winning?.name !== name) continue; // a shadowed edit was never authorized by its no-op effective value
|
|
3961
|
+
putConfig(layer.raw, path, raw);
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
acceptedSources = { ...sources, trustLayers: layers, layers: layers.filter((layer): layer is typeof layer & { name: "config.json" | "config.local.json" } => layer.name.endsWith(".json")), routing: desiredRouting };
|
|
3965
|
+
}
|
|
3966
|
+
function configWriteCheck(scratch: string, authority: SettingAuthority, scope?: string): HubConfig {
|
|
3967
|
+
const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
|
|
3968
|
+
texts["config.json"] = next["config.json"];
|
|
3969
|
+
texts["config.local.json"] = next["config.local.json"];
|
|
3970
|
+
const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
|
|
3971
|
+
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
3972
|
+
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
3973
|
+
return prepared.fileConfig;
|
|
3974
|
+
}
|
|
3975
|
+
function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
|
|
3976
|
+
const texts = configSourceTexts(opts.cwd);
|
|
3977
|
+
texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
|
|
3978
|
+
const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
|
|
3979
|
+
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
3980
|
+
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
3981
|
+
}
|
|
3982
|
+
function runConfigWatch(): void {
|
|
3983
|
+
if (stopping) return;
|
|
3984
|
+
if (rosterChanging) { configWatchPending = true; return; }
|
|
3985
|
+
void reloadConfig("ordinary");
|
|
3986
|
+
}
|
|
3987
|
+
/** The only publication path; named writes are scoped, full guarded reloads need person confirmation. */
|
|
3988
|
+
async function reloadConfig(authority: SettingAuthority, scope?: string, confirm?: unknown): Promise<Record<string, unknown>> {
|
|
3989
|
+
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding configuration reload" };
|
|
3990
|
+
let fingerprint = "unavailable", published = false;
|
|
3991
|
+
try {
|
|
3992
|
+
const texts = configSourceTexts(opts.cwd);
|
|
3993
|
+
fingerprint = configFingerprint(texts);
|
|
3994
|
+
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" };
|
|
3995
|
+
const sources = parseConfigSources(texts);
|
|
3996
|
+
const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
|
|
3997
|
+
if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
|
|
3998
|
+
if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
|
|
3999
|
+
const { changes, candidate } = prepared;
|
|
4000
|
+
const routingChanges = changedRawPaths({ ...runningRouting, sources: undefined }, { ...prepared.routing, sources: undefined }, "routing");
|
|
4001
|
+
if (prepared.running.snapshots.enabled && repo && !turnLog) turnLog = new Turns(join(opts.stateDir, "hub.db"));
|
|
4002
|
+
const priorDesired = JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers]);
|
|
4003
|
+
const rolesChanged = changes.some(change => change.key === "roles" || change.key.startsWith("roles.") || change.key === "planner");
|
|
4004
|
+
const beforeConductor = conductorPeer(config.roles);
|
|
4005
|
+
published = true;
|
|
4006
|
+
for (const change of changes) {
|
|
4007
|
+
if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
|
|
4008
|
+
putConfig(config, change.key, change.after);
|
|
4009
|
+
}
|
|
4010
|
+
if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
|
|
4011
|
+
for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
|
|
4012
|
+
for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
|
|
4013
|
+
}
|
|
4014
|
+
config.permission_default_sources = candidate.permission_default_sources;
|
|
4015
|
+
for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
|
|
4016
|
+
if (changes.some(change => change.key === "task_sweep" || change.key.startsWith("task_sweep."))) rescheduleSweep();
|
|
4017
|
+
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 });
|
|
4018
|
+
if (changes.some(change => change.key === "watchdog_ms")) for (const peer of bus.peers.values()) if (peer instanceof BasePeer) peer.configureWatchdog(config.watchdog_ms);
|
|
4019
|
+
Object.assign(limits, config.limits);
|
|
4020
|
+
turnFreeNow = undefined;
|
|
4021
|
+
runningRouting = { ...prepared.routing, pi: runningRouting.pi };
|
|
4022
|
+
desiredConfig = structuredClone(candidate);
|
|
4023
|
+
desiredRouting = structuredClone(prepared.routing);
|
|
4024
|
+
acknowledgeConfiguration(sources, prepared.accepted);
|
|
4025
|
+
if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
|
|
4026
|
+
pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
|
|
4027
|
+
pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
|
|
4028
|
+
storedConfigCache = undefined;
|
|
4029
|
+
if (beforeConductor && (beforeConductor !== conductorPeer(config.roles) || config.conductor.feed === "off")) {
|
|
4030
|
+
bus.revokeSupervision(beforeConductor); supervision.resetPending(beforeConductor); budgetFeedSeen.clear();
|
|
4031
|
+
}
|
|
4032
|
+
if (confirm !== undefined) reloadConfirmation = undefined;
|
|
4033
|
+
if (prepared.guarded.length) reloadConfirmation = { token: randomUUID(), fingerprint, revision: configRevision, keys: prepared.guarded };
|
|
4034
|
+
const refusal = prepared.rejected.map(item => `${item.key}: ${item.reason}`).join("; ");
|
|
4035
|
+
const failure = refusal ? `${fingerprint}:${refusal}` : undefined;
|
|
4036
|
+
if (failure && lastReloadFailure !== failure) { try { notify(`configuration keys retained: ${sanitize(refusal).slice(0, 1200)}`); } catch { log("configuration keys retained; notice unavailable"); } }
|
|
4037
|
+
lastReloadFailure = failure;
|
|
4038
|
+
writeStatus();
|
|
4039
|
+
const applied = [...changes.filter(change => change.applies !== "restart").map(change => change.key), ...routingChanges.filter(key => configApply(key).applies !== "restart")];
|
|
4040
|
+
return { ok: true, applied, pendingRestart, rejected: prepared.rejected,
|
|
4041
|
+
...(prepared.guarded.length ? { confirmation: { token: reloadConfirmation!.token, keys: reloadConfirmation!.keys, changes: prepared.previews } } : {}),
|
|
4042
|
+
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") };
|
|
4043
|
+
} catch (error) {
|
|
4044
|
+
const message = sanitize(error instanceof Error ? error.message : "configuration reload could not be confirmed").slice(0, 600);
|
|
4045
|
+
const failure = `${fingerprint}:${message}`;
|
|
4046
|
+
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"); } }
|
|
4047
|
+
return { ok: false, error: message, ...(published ? { applied: true } : {}), pendingRestart };
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
|
|
3693
4051
|
// #269: settings. One registry (src/hub/settings.ts) serves the dashboard and `ahub settings`; nothing outside it is written.
|
|
3694
4052
|
let storedConfigCache: { stamp: string; config: HubConfig | undefined } | undefined;
|
|
3695
4053
|
/** What the config files hold now, as the loader reads them. Re-read only when a file changed: the dashboard polls. */
|
|
@@ -3702,18 +4060,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3702
4060
|
}
|
|
3703
4061
|
return storedConfigCache.config;
|
|
3704
4062
|
};
|
|
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
4063
|
const settingScalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
3718
4064
|
const routingValue = (routing: ReturnType<typeof currentRouting>, def: SettingDef): SettingValue =>
|
|
3719
4065
|
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 +4069,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3723
4069
|
try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
|
|
3724
4070
|
catch { return {}; }
|
|
3725
4071
|
}
|
|
3726
|
-
function settingsView(): { rows: SettingRow[]; undo?: string } {
|
|
4072
|
+
function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[] } {
|
|
3727
4073
|
const stored = storedConfig();
|
|
3728
4074
|
// What the loader would say about the files as they are: an overlay that does not parse, or one that no longer
|
|
3729
4075
|
// merges (it names a route routing.toml dropped), leaves the last good policy in force, and the rows say why.
|
|
3730
4076
|
let routingProblem: string | undefined;
|
|
3731
4077
|
try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
|
|
3732
4078
|
const rows = settingRows({
|
|
3733
|
-
cwd: opts.cwd, routing:
|
|
4079
|
+
cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
3734
4080
|
running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
|
|
3735
4081
|
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
4082
|
roles: roleState,
|
|
@@ -3742,11 +4088,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3742
4088
|
});
|
|
3743
4089
|
const undo = pendingUndo(opts.stateDir);
|
|
3744
4090
|
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 } : {}) };
|
|
4091
|
+
return { pendingRestart, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
3746
4092
|
}
|
|
3747
4093
|
/** 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 } : {}) });
|
|
4094
|
+
const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
|
|
4095
|
+
event({ type: "settings", key, from, to, source, ...(undo ? { undo: true } : {}), ...(reason ? { reason } : {}) });
|
|
3750
4096
|
try { notify(`setting ${key}: ${settingText(from)} -> ${settingText(to)} (${undo ? "undo, " : ""}${source})`); } catch { /* the write stands */ }
|
|
3751
4097
|
};
|
|
3752
4098
|
const settingSource = (authority: SettingAuthority) => authority === "terminal" ? "terminal" as const : "dashboard" as const;
|
|
@@ -3782,7 +4128,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3782
4128
|
? `Stop ${peer}'s existing work in its retained terminal; removal detaches the hub connection and does not stop that terminal's work.`
|
|
3783
4129
|
: undefined;
|
|
3784
4130
|
}
|
|
3785
|
-
async function changeRosterSetting(def: SettingDef, value: SettingValue, source: "dashboard" | "terminal", confirm: unknown, now: boolean, undo = false): Promise<Record<string, unknown>> {
|
|
4131
|
+
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
4132
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
3787
4133
|
const peer = def.path[1]!;
|
|
3788
4134
|
let saved = false;
|
|
@@ -3797,13 +4143,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3797
4143
|
}
|
|
3798
4144
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding roster changes" };
|
|
3799
4145
|
rosterChanging = peer;
|
|
3800
|
-
if (undo) undoSetting(opts.cwd, opts.stateDir, (scratch) =>
|
|
3801
|
-
else writeConfigSetting(opts.cwd, opts.stateDir, def, value,
|
|
4146
|
+
if (undo) undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, `roster.${peer}`), (_routing, overlay) => routingWriteCheck(authority, overlay), undoIdentity);
|
|
4147
|
+
else writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, `roster.${peer}`));
|
|
3802
4148
|
// Apply exactly the authorized key. Other file edits stay pending until the next hub start.
|
|
3803
4149
|
config.roster = next;
|
|
3804
4150
|
settlePlanner(); // #455: an automatic planner follows the roster
|
|
3805
4151
|
saved = true;
|
|
3806
|
-
recordSetting(def.key, before, after, source, undo);
|
|
4152
|
+
recordSetting(def.key, before, after, source, undo, reason);
|
|
3807
4153
|
bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
|
|
3808
4154
|
if (!after) {
|
|
3809
4155
|
for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
|
|
@@ -3825,30 +4171,40 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3825
4171
|
const owner = bus.peers.get(peer);
|
|
3826
4172
|
if (owner instanceof PiPeer && owner.mode === "tui") await owner.resumeFromRoster();
|
|
3827
4173
|
}
|
|
4174
|
+
const reloaded = await reloadConfig(authority, `roster.${peer}`);
|
|
4175
|
+
if (reloaded.ok !== true) return { ...reloaded, saved: true };
|
|
3828
4176
|
writeStatus();
|
|
3829
4177
|
const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
|
|
3830
4178
|
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
4179
|
} catch (error) {
|
|
3832
4180
|
if (saved) writeStatus();
|
|
3833
4181
|
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
3834
|
-
} finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); } }
|
|
4182
|
+
} finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); if (configWatchPending && !stopping) { configWatchPending = false; clearTimeout(reloadDebounce); reloadDebounce = setTimeout(runConfigWatch, 100); reloadDebounce.unref?.(); } } }
|
|
3835
4183
|
}
|
|
3836
4184
|
/** A checked definition and value, or the refusal. Every settings write and preview starts here. */
|
|
3837
4185
|
function settingInput(key: unknown, raw: unknown): { def: SettingDef; value: SettingValue } | { error: string } {
|
|
3838
4186
|
const def = settingDef(key);
|
|
3839
4187
|
if (typeof def === "string") return { error: def };
|
|
3840
|
-
const routing =
|
|
4188
|
+
const routing = activeRouting();
|
|
3841
4189
|
try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : [...Object.keys(routing.routes), ...Object.keys(routing.hub_routes ?? {})]) }; }
|
|
3842
4190
|
catch (error) { return { error: (error as Error).message }; }
|
|
3843
4191
|
}
|
|
3844
|
-
|
|
4192
|
+
const settingReason = (reason: unknown): string | undefined => {
|
|
4193
|
+
if (reason === undefined) return undefined;
|
|
4194
|
+
if (typeof reason !== "string" || !reason.trim() || Buffer.byteLength(reason) > 400) throw new Error("setting reason must contain 1..400 UTF-8 bytes");
|
|
4195
|
+
return sanitize(reason.trim());
|
|
4196
|
+
};
|
|
4197
|
+
async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown): Promise<Record<string, unknown>> {
|
|
4198
|
+
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
|
|
4199
|
+
let reason: string | undefined;
|
|
4200
|
+
try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
3845
4201
|
const input = settingInput(key, raw);
|
|
3846
4202
|
if ("error" in input) return { ok: false, error: input.error };
|
|
3847
4203
|
const { def, value } = input;
|
|
3848
4204
|
const refusal = settingRefusal(def, value, authority);
|
|
3849
4205
|
if (refusal) return { ok: false, error: refusal };
|
|
3850
4206
|
const source = settingSource(authority);
|
|
3851
|
-
if (def.group === "Roster") return changeRosterSetting(def, value, source, confirm, now === true);
|
|
4207
|
+
if (def.group === "Roster") return changeRosterSetting(def, value, source, confirm, now === true, false, authority, reason);
|
|
3852
4208
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
3853
4209
|
const peer = def.group === "Permissions" ? def.path.at(-1)! : undefined;
|
|
3854
4210
|
// The console asks for y before never-ask; here the person types the peer it is for.
|
|
@@ -3859,53 +4215,63 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3859
4215
|
const from = permissionMode(peer!);
|
|
3860
4216
|
const result = await permissionChange(peer!, () => changePermission(peer!, value, value === "never-ask"));
|
|
3861
4217
|
if (result.ok !== true) return result;
|
|
3862
|
-
recordSetting(def.key, from, permissionMode(peer!), source);
|
|
4218
|
+
recordSetting(def.key, from, permissionMode(peer!), source, false, reason);
|
|
3863
4219
|
return { ok: true, text: `${def.key}: ${result.permissionMode}`, ...(typeof result.note === "string" ? { note: result.note } : {}) };
|
|
3864
4220
|
}
|
|
4221
|
+
let saved = false;
|
|
3865
4222
|
try {
|
|
3866
4223
|
if (def.store === "routing") {
|
|
3867
|
-
const from = routingValue(
|
|
3868
|
-
const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value,
|
|
4224
|
+
const from = routingValue(activeRouting(), def);
|
|
4225
|
+
const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value, (_routing, overlay) => routingWriteCheck(authority, overlay, def.key));
|
|
3869
4226
|
if (!next) return { ok: true, text: `${def.key}: ${OVERLAY_FILE} already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
|
|
3870
|
-
|
|
3871
|
-
|
|
4227
|
+
saved = true;
|
|
4228
|
+
const result = await reloadConfig(authority, def.key);
|
|
4229
|
+
if (result.ok !== true) return { ...result, saved: true };
|
|
4230
|
+
recordSetting(def.key, from, routingValue(activeRouting(), def), source, false, reason);
|
|
4231
|
+
return { ok: true, text: `${def.key}: ${settingText(routingValue(activeRouting(), def))} (in force now)` };
|
|
3872
4232
|
}
|
|
3873
|
-
// Removing the machine-local value uncovers the shared file's: never-ask there needs the same confirmation.
|
|
3874
4233
|
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
4234
|
const from = settingScalar(valueAt(storedConfig(), def.path));
|
|
3876
|
-
if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value,
|
|
3877
|
-
|
|
4235
|
+
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` };
|
|
4236
|
+
saved = true;
|
|
4237
|
+
const result = await reloadConfig(authority, def.path.join("."));
|
|
4238
|
+
if (result.ok !== true) return { ...result, saved: true };
|
|
3878
4239
|
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"})` };
|
|
4240
|
+
recordSetting(def.key, from, to, source, false, reason);
|
|
4241
|
+
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) }); }
|
|
4242
|
+
const timing = def.applies === "live" ? "in force now" : def.applies === "restart" ? "pending restart"
|
|
4243
|
+
: ["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";
|
|
4244
|
+
return { ok: true, text: `${def.key}: ${settingText(to)} (${timing})` };
|
|
3885
4245
|
} catch (error) {
|
|
3886
|
-
return { ok: false, error: (error as Error).message };
|
|
4246
|
+
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
3887
4247
|
}
|
|
3888
4248
|
}
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
4249
|
+
|
|
4250
|
+
async function settingsUndo(authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown, expectedKey?: unknown, expectedDigest?: unknown): Promise<Record<string, unknown>> {
|
|
4251
|
+
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
|
|
4252
|
+
let reason: string | undefined;
|
|
4253
|
+
try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
3893
4254
|
// The previous version can hold a wider value than the one in force, so undo is never an ordinary session's.
|
|
3894
4255
|
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
|
-
|
|
4256
|
+
const identity = pendingUndoIdentity(opts.stateDir);
|
|
4257
|
+
if (expectedKey !== undefined || expectedDigest !== undefined) {
|
|
4258
|
+
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" };
|
|
4259
|
+
if (!identity || identity.key !== expectedKey || identity.undoDigest !== expectedDigest) return { ok: false, error: "undo record changed since preview; preview again; nothing was undone" };
|
|
4260
|
+
}
|
|
4261
|
+
const def = settingDef(identity?.key);
|
|
4262
|
+
if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
|
|
4263
|
+
if (def.group === "Roster") return changeRosterSetting(def, null, settingSource(authority), confirm, now === true, true, authority, reason, identity);
|
|
3898
4264
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
3899
|
-
const read = (): SettingValue => def.store === "routing" ? routingValue(
|
|
4265
|
+
const read = (): SettingValue => def.store === "routing" ? routingValue(activeRouting(), def) : settingScalar(valueAt(storedConfig(), def.path));
|
|
3900
4266
|
// never-ask is set with its confirmation, never put back by an undo that carries none.
|
|
3901
4267
|
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
4268
|
try {
|
|
3903
4269
|
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) });
|
|
4270
|
+
undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, def.path.join(".")), (_routing, overlay) => routingWriteCheck(authority, overlay, def.key), identity);
|
|
4271
|
+
const result = await reloadConfig(authority, def.store === "routing" ? def.key : def.path.join("."));
|
|
4272
|
+
if (result.ok !== true) return { ...result, saved: true };
|
|
4273
|
+
recordSetting(def.key, from, read(), settingSource(authority), true, reason);
|
|
4274
|
+
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
4275
|
return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
|
|
3910
4276
|
} catch (error) {
|
|
3911
4277
|
return { ok: false, error: (error as Error).message };
|
|
@@ -3919,21 +4285,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3919
4285
|
if (def.group === "Roster") {
|
|
3920
4286
|
try {
|
|
3921
4287
|
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 }) };
|
|
4288
|
+
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
4289
|
} catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
|
|
3924
4290
|
}
|
|
3925
4291
|
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
4292
|
if (def.group === "Permissions") {
|
|
3927
4293
|
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"])] };
|
|
4294
|
+
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
4295
|
}
|
|
3930
|
-
if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "
|
|
4296
|
+
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
4297
|
try {
|
|
3932
4298
|
const after = parseRouting(routingText(opts.cwd), routingCandidate(readOverlay(opts.cwd), def, value));
|
|
3933
4299
|
const open = board.list().filter((task) => !isClosedTaskState(task.state));
|
|
3934
4300
|
// A PII task's routing is fixed by its constraint and its text is private: it is counted, not shown.
|
|
3935
4301
|
const shown = open.filter((task) => !tasks.isPii(task)).slice(0, 20);
|
|
3936
|
-
return { ok: true, lines: [`${def.key}: ${settingText(routingValue(
|
|
4302
|
+
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
4303
|
tasks: shown.map((task) => ({ id: task.id, before: tasks.publicExplain(task.id), after: tasks.publicExplain(task.id, after) })),
|
|
3938
4304
|
...(open.length > shown.length ? { hidden: open.length - shown.length } : {}) };
|
|
3939
4305
|
} catch (error) {
|
|
@@ -3941,6 +4307,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3941
4307
|
}
|
|
3942
4308
|
}
|
|
3943
4309
|
|
|
4310
|
+
function settingsUndoPreview(): Record<string, unknown> {
|
|
4311
|
+
const identity = pendingUndoIdentity(opts.stateDir), def = settingDef(identity?.key);
|
|
4312
|
+
if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
|
|
4313
|
+
const value = restoredValue(opts.cwd, opts.stateDir, def), preview = settingsPreview(def.key, value ?? null);
|
|
4314
|
+
if (pendingUndoIdentity(opts.stateDir)?.undoDigest !== identity.undoDigest) return { ok: false, error: "undo record changed during preview; preview again" };
|
|
4315
|
+
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."] };
|
|
4316
|
+
}
|
|
4317
|
+
|
|
3944
4318
|
async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
|
|
3945
4319
|
const bad = { ok: false, error: "invalid or unavailable dashboard action" };
|
|
3946
4320
|
// #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
|
|
@@ -4019,11 +4393,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4019
4393
|
return { ok: false, error: `${a.peer} did not start; inspect ahub status and hub.log` };
|
|
4020
4394
|
}
|
|
4021
4395
|
case "setting":
|
|
4022
|
-
return settingsSet(a.key, a.value, authority, a.confirm, a.now);
|
|
4396
|
+
return settingsSet(a.key, a.value, authority, a.confirm, a.now, a.reason);
|
|
4023
4397
|
case "setting_undo":
|
|
4024
|
-
return settingsUndo(authority, a.confirm, a.now);
|
|
4398
|
+
return settingsUndo(authority, a.confirm, a.now, a.reason, a.key, a.undoDigest);
|
|
4025
4399
|
case "setting_preview":
|
|
4026
|
-
return settingsPreview(a.key, a.value);
|
|
4400
|
+
return a.undo === true ? settingsUndoPreview() : settingsPreview(a.key, a.value);
|
|
4401
|
+
case "setting_reload":
|
|
4402
|
+
return reloadConfig(authority);
|
|
4027
4403
|
default:
|
|
4028
4404
|
return bad;
|
|
4029
4405
|
}
|
|
@@ -4093,7 +4469,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4093
4469
|
armRecoveryLease();
|
|
4094
4470
|
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
4095
4471
|
budget.setRecoveryHold(true);
|
|
4096
|
-
bus.setRecoveryHold(true);
|
|
4472
|
+
bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
|
|
4097
4473
|
await bus.fenceRecovery();
|
|
4098
4474
|
{ const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
|
|
4099
4475
|
const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
|
|
@@ -4111,11 +4487,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4111
4487
|
if (msg.op === "abort") {
|
|
4112
4488
|
if (recoveryCommitted) return recoveryError("a committed recovery cannot be aborted");
|
|
4113
4489
|
if (recoveryPhase === "released") return { t: "recovery", ok: true, recovery: recoveryView() };
|
|
4490
|
+
bus.setRecoveryHold(false);
|
|
4114
4491
|
recoveryOperationId = undefined;
|
|
4115
4492
|
recoveryPhase = undefined;
|
|
4116
4493
|
recoveryPeerSnapshot = undefined;
|
|
4117
4494
|
clearTimeout(recoveryLeaseTimer);
|
|
4118
|
-
bus.setRecoveryHold(false);
|
|
4119
4495
|
budget.setRecoveryHold(false);
|
|
4120
4496
|
writeStatus();
|
|
4121
4497
|
return { t: "recovery", ok: true, aborted: true, recovery: recoveryView() };
|
|
@@ -4123,6 +4499,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4123
4499
|
if (msg.op === "commit") {
|
|
4124
4500
|
if ((recoveryPhase !== "prepared" && recoveryPhase !== "preparing") || !recoveryReady()) return recoveryError("recovery is not ready; inspect until peers are idle, approvals are complete and completion checks have finished");
|
|
4125
4501
|
recoveryPhase = "prepared";
|
|
4502
|
+
bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
|
|
4126
4503
|
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
4127
4504
|
const currentPeers = recoveryPeers();
|
|
4128
4505
|
const peers = (recoveryPeerSnapshot ?? Object.values(currentPeers)).map((saved) => ({
|
|
@@ -4153,7 +4530,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4153
4530
|
return { t: "recovery", ok: true, committed: true, recovery: { ...recoveryView(), phase: "prepared" } };
|
|
4154
4531
|
}
|
|
4155
4532
|
if (msg.op === "release") {
|
|
4156
|
-
if (recoveryPhase === "released")
|
|
4533
|
+
if (recoveryPhase === "released") {
|
|
4534
|
+
bus.setRecoveryHold(false); budget.setRecoveryHold(false);
|
|
4535
|
+
return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
|
|
4536
|
+
}
|
|
4157
4537
|
if (recoveryPhase !== "restored") return recoveryError("recovery is not restored");
|
|
4158
4538
|
await bus.fenceRecovery();
|
|
4159
4539
|
const current = recoveryPeers();
|
|
@@ -4425,7 +4805,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4425
4805
|
try { log(`recovery ${sanitize(String(msg.op))} database contention: ${databaseBusySite(error).join("\n")}`); } catch { /* the reply still settles its rid */ }
|
|
4426
4806
|
const busy = new RecoveryDatabaseBusy();
|
|
4427
4807
|
reply({ ...recoveryError(busy.message), code: busy.code, retryable: busy.retryable });
|
|
4428
|
-
} else reply(recoveryError(
|
|
4808
|
+
} else if (error instanceof RecoveryDeferredHubError) reply({ ...recoveryError(error.message), code: error.code });
|
|
4809
|
+
else reply(recoveryError((error as Error)?.message ?? "recovery operation failed"));
|
|
4429
4810
|
});
|
|
4430
4811
|
return;
|
|
4431
4812
|
case "send": {
|
|
@@ -4614,7 +4995,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4614
4995
|
}
|
|
4615
4996
|
case "watch": {
|
|
4616
4997
|
if (c.role !== "console") return void reply({ ok: false, error: "watch is a person-only console command" });
|
|
4617
|
-
if (msg.peer !== "
|
|
4998
|
+
if (typeof msg.peer !== "string" || !definitionOf(msg.peer)?.capabilities?.watch) return void reply({ ok: false, error: "watch supports headless kimi and qwen only" });
|
|
4618
4999
|
const peer = msg.peer as "kimi" | "qwen";
|
|
4619
5000
|
// Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
|
|
4620
5001
|
const kimiOwner = bus.peers.get("kimi");
|
|
@@ -4687,16 +5068,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4687
5068
|
case "settings_get":
|
|
4688
5069
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
4689
5070
|
return void reply({ ok: true, ...settingsView(), roster: rosterView() });
|
|
5071
|
+
case "settings_reload":
|
|
5072
|
+
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
|
|
5073
|
+
return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
|
|
4690
5074
|
case "settings_preview":
|
|
4691
5075
|
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));
|
|
5076
|
+
return void reply(msg.undo === true ? settingsUndoPreview() : settingsPreview(msg.key, msg.value));
|
|
4693
5077
|
case "settings_set":
|
|
4694
5078
|
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" }));
|
|
5079
|
+
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
5080
|
return;
|
|
4697
5081
|
case "settings_undo":
|
|
4698
5082
|
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" }));
|
|
5083
|
+
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
5084
|
case "kimi_launch_end": {
|
|
4701
5085
|
if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
|
|
4702
5086
|
if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
|
|
@@ -4753,7 +5137,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4753
5137
|
board,
|
|
4754
5138
|
isPii: (t) => tasks.isPii(t),
|
|
4755
5139
|
onCampus,
|
|
4756
|
-
isPiiText: (q) =>
|
|
5140
|
+
isPiiText: (q) => activeRouting().constraints.pii === "local_only" && detectSignals({ title: q, detail: "", refs: {} }, activeRouting(), opts.cwd).includes("pii"),
|
|
4757
5141
|
...(config.memory.enabled ? { memory } : {}),
|
|
4758
5142
|
// #258: entries that match the question, then the graph neighbourhood of what they are about.
|
|
4759
5143
|
...(memoryStore ? { native: (keys: string[]) => {
|
|
@@ -4838,6 +5222,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4838
5222
|
log("hub stopping");
|
|
4839
5223
|
writeStatus();
|
|
4840
5224
|
dashboard?.stop();
|
|
5225
|
+
stopConfigWatch();
|
|
5226
|
+
clearInterval(sweepTimer);
|
|
4841
5227
|
for (const i of intervals) clearInterval(i);
|
|
4842
5228
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
4843
5229
|
for (const p of permissions.values()) p.done(undefined);
|
|
@@ -4902,6 +5288,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4902
5288
|
bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
|
|
4903
5289
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
4904
5290
|
ready = true;
|
|
5291
|
+
for (const name of CONFIG_SOURCE_FILES) {
|
|
5292
|
+
const path = join(opts.cwd, ".agenthub", name);
|
|
5293
|
+
const listener = (current: import("node:fs").Stats, previous: import("node:fs").Stats) => {
|
|
5294
|
+
if (stopping || current.mtimeMs === previous.mtimeMs && current.size === previous.size && current.ino === previous.ino && current.nlink === previous.nlink) return;
|
|
5295
|
+
clearTimeout(reloadDebounce);
|
|
5296
|
+
reloadDebounce = setTimeout(runConfigWatch, 100);
|
|
5297
|
+
reloadDebounce.unref?.();
|
|
5298
|
+
};
|
|
5299
|
+
watchFile(path, { persistent: false, interval: 100 }, listener);
|
|
5300
|
+
configWatchers.push({ path, listener });
|
|
5301
|
+
}
|
|
5302
|
+
startupCleanup.push(stopConfigWatch);
|
|
4905
5303
|
// #258: approvals whose settle entry a crash cut off are settled now (decisions, contracts and fails reach long-term).
|
|
4906
5304
|
try { const settled = tasks.reconcileMemory(); if (settled) log(`hub memory: settled ${settled} approved task${settled === 1 ? "" : "s"} a stop had left unsettled`); }
|
|
4907
5305
|
catch (e) { log(`hub memory reconciliation failed: ${(e as Error).message}`); }
|
|
@@ -4927,7 +5325,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4927
5325
|
});
|
|
4928
5326
|
}
|
|
4929
5327
|
// 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");
|
|
5328
|
+
const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => p.peer === "pi");
|
|
4931
5329
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
4932
5330
|
return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
4933
5331
|
} finally {
|