@staix/agent-hub 0.12.33 → 0.12.35
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 +22 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +9 -3
- package/docs/agent-notes/budget.md +7 -3
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +7 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +6 -2
- package/docs/agent-notes/tests.md +2 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +80 -16
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +59 -34
- package/docs/security.md +7 -4
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +407 -10
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verified.json +35 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +23567 -16606
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/codex-appserver.ts +82 -12
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +34 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +27 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +57 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +15 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +53 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +21 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +41 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +100 -10
- package/src/adapters/registry.ts +127 -11
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +8 -7
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +41 -102
- package/src/cli/main.ts +126 -290
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +26 -126
- package/src/cli/recovery-guidance.ts +43 -22
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +81 -22
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-restore-state.ts +17 -7
- package/src/cli/upgrade-runtime.ts +126 -147
- package/src/cli/upgrade.ts +44 -27
- package/src/hub/budget.ts +59 -31
- package/src/hub/bus.ts +1 -1
- package/src/hub/conductor.ts +19 -9
- package/src/hub/config-apply.ts +22 -15
- package/src/hub/config-reload.ts +18 -3
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +3 -27
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +15 -39
- package/src/hub/daemon.ts +506 -341
- package/src/hub/execution-budget.ts +3 -2
- package/src/hub/hub-tools.ts +19 -8
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +100 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +14 -17
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/roster.ts +9 -9
- package/src/hub/routing.ts +94 -19
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +84 -38
- package/src/hub/settings.ts +39 -10
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +13 -8
- package/src/hub/status-roster.ts +8 -0
- package/src/hub/tasks.ts +60 -27
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/hub/workflow.ts +30 -52
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +5 -3
- package/src/ui/index.html +4 -3
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/AGENTS.block.md +2 -1
- package/templates/routing.toml +2 -2
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
2
|
+
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
1
3
|
import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
|
|
2
4
|
import { checkedSessionPath } from "./recovery-session-file.ts";
|
|
3
5
|
import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
|
|
@@ -13,6 +15,13 @@ import { resolveAcceptedBinding, piArchiveExit, recordedPeerGuidance, type Recov
|
|
|
13
15
|
import type { TerminalBinding } from "./terminal-recovery.ts";
|
|
14
16
|
import { RecoveryPresentationError, type RecoveryPresentationCause, type RecoveryWaitTiming } from "./upgrade-presentation-data.ts";
|
|
15
17
|
|
|
18
|
+
const projectDefinition = (peer: string, root?: string) => {
|
|
19
|
+
const builtin = definitionOf(peer);
|
|
20
|
+
if (builtin || !root) return builtin;
|
|
21
|
+
try { return definitionOf(peer, readInstanceConfig(root).instances); } catch { return undefined; }
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
|
|
16
25
|
export interface RecoveryPeer {
|
|
17
26
|
id: string;
|
|
18
27
|
state: string;
|
|
@@ -287,7 +296,8 @@ export function nextChoices(receipt: RecoveryOperation, runner?: number | "unkno
|
|
|
287
296
|
const stuck = resumeBlocked(op, live);
|
|
288
297
|
const selected = op.phase === "blocked" ? op.blockedReattachment : undefined;
|
|
289
298
|
const repair = selected && op.projects.find(project => project.id === selected.projectId)?.reattachments?.[selected.peer];
|
|
290
|
-
const reattach = selected && repair && (
|
|
299
|
+
const reattach = selected && repair && projectDefinition(selected.peer, op.plan.projects.find(project => project.project.id === selected.projectId)?.project.root)?.session.launchRecord === true &&
|
|
300
|
+
(repair.state === "ready" || repairWaiting(op, selected.projectId, selected.peer));
|
|
291
301
|
return [
|
|
292
302
|
...(stuck ? [] : [{ kind: "resume" as const, text: `${recoveryCommand(op, "resume")}${op.error ? " (after the next action in error)" : ""}${old ? ` (runs the coordinator that started this operation, which cannot re-prepare an expired hold: if it reports "source is no longer prepared", use ${abortable ? "abort or " : ""}stop-and-archive)` : ""}` }]),
|
|
293
303
|
...(abortable ? [{ kind: "abort" as const, text: recoveryCommand(op, "abort") }] : []),
|
|
@@ -322,12 +332,12 @@ export async function liveProjects(op: RecoveryOperation, inspect: (project: Pro
|
|
|
322
332
|
return Object.fromEntries(await Promise.all(open.map(async (p) => [p.project.id, await inspect(p.project).catch(() => undefined)] as const)));
|
|
323
333
|
}
|
|
324
334
|
|
|
325
|
-
function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: string): boolean {
|
|
335
|
+
function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: string, options: { authority?: "current" | "historical" } = {}): boolean {
|
|
326
336
|
const evidence = op.missingRecordedSession;
|
|
327
337
|
if (!evidence || evidence.projectId !== progress.id || evidence.peer !== peer || (!progress.repairedSessions?.[evidence.peer] && !Object.hasOwn(progress.reattachments ?? {}, peer))) return false;
|
|
328
338
|
const planned = op.plan.projects.find(project => project.project.id === progress.id);
|
|
329
339
|
if (!planned) return false;
|
|
330
|
-
try { return resolveAcceptedBinding(planned, progress, evidence.peer)?.sessionId === evidence.sessionId; } catch { return false; }
|
|
340
|
+
try { return resolveAcceptedBinding(planned, progress, evidence.peer, options)?.sessionId === evidence.sessionId; } catch { return false; }
|
|
331
341
|
}
|
|
332
342
|
/**
|
|
333
343
|
* Peers of a project whose restoration failed and for which a fresh session is a real choice: not Pi (a restored hub
|
|
@@ -336,8 +346,8 @@ function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: s
|
|
|
336
346
|
*/
|
|
337
347
|
function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
|
|
338
348
|
const planned = op.plan.projects.find((p) => p.project.id === progress.id);
|
|
339
|
-
return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
|
|
340
|
-
.filter((peer) => peer
|
|
349
|
+
return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && (() => { try { return canChooseFresh(value); } catch { return false; } })()).map(([key]) => key.slice("restored:".length))
|
|
350
|
+
.filter((peer) => projectDefinition(peer, planned?.project.root)?.session.personFresh === true && ((!Object.hasOwn(progress.reattachments ?? {}, peer) && !Object.hasOwn(progress.repairedSessions ?? {}, peer)) || missingRepair(op, progress, peer)) && !progress.fresh?.[peer] && (!planned?.freshStart?.includes(peer) || missingRepair(op, progress, peer)) && !plannedAttached(op, progress, peer, live));
|
|
341
351
|
}
|
|
342
352
|
|
|
343
353
|
/** Read-only scope classification; it confers no delegation authority. */
|
|
@@ -363,7 +373,7 @@ function plannedAttached(op: RecoveryOperation, progress: ProjectProgress, peer:
|
|
|
363
373
|
? resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)
|
|
364
374
|
: (planned?.terminals as { peer: string; sessionId: string }[] | undefined)?.find((t) => t.peer === peer);
|
|
365
375
|
const current = state?.state === "running" ? state.peers.find((p) => p.id === peer && p.state !== "offline") : undefined;
|
|
366
|
-
return !!binding && (peer
|
|
376
|
+
return !!binding && current?.[projectDefinition(peer, planned?.project.root)?.session.idField ?? "sessionId"] === binding.sessionId;
|
|
367
377
|
}
|
|
368
378
|
|
|
369
379
|
/** Registry reads for planning must not create a registry or run migrations. */
|
|
@@ -413,7 +423,10 @@ function abandoned(op: RecoveryOperation): boolean {
|
|
|
413
423
|
return true;
|
|
414
424
|
}
|
|
415
425
|
|
|
416
|
-
function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
426
|
+
function validateReceipt(id: string, op: RecoveryOperation, historical = false): void {
|
|
427
|
+
// Cleanup validates owned historical facts; this mode never authorizes restore, fresh or reattach.
|
|
428
|
+
const options = historical ? { authority: "historical" as const } : {};
|
|
429
|
+
const decode = (value: unknown) => decodeRestoreReceipt(value, options);
|
|
417
430
|
if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
|
|
418
431
|
if (op.targetFeatures !== undefined && (!Array.isArray(op.targetFeatures) || !op.targetFeatures.every(feature => typeof feature === "string" && feature.length > 0) || !op.targetRoot || !op.targetDigest)) throw new Error("invalid target features");
|
|
419
432
|
const { fingerprint, ...reviewed } = op.plan;
|
|
@@ -424,13 +437,13 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
|
424
437
|
if (progress.repairedSessions !== undefined) {
|
|
425
438
|
if (!object(progress.repairedSessions)) throw new Error("invalid repaired session provenance");
|
|
426
439
|
for (const [peer, protectedSession] of Object.entries(progress.repairedSessions)) {
|
|
427
|
-
const restored =
|
|
440
|
+
const restored = decode(progress.terminals[`restored:${peer}`]);
|
|
428
441
|
const bindings = [progress.reattachments?.[peer as RecoveryNativePeer]?.binding,
|
|
429
442
|
restored?.state === "done" || restored?.state === "waiting" ? restored.binding : undefined,
|
|
430
443
|
progress.terminals[`retired:${peer}`]];
|
|
431
444
|
if (protectedSession !== true || !bindings.some(binding => {
|
|
432
445
|
if (binding === undefined) return false;
|
|
433
|
-
const receipt =
|
|
446
|
+
const receipt = decode({ state: "done", binding });
|
|
434
447
|
return receipt?.state === "done" && receipt.binding.peer === peer && receipt.binding.projectRoot === op.plan.projects[index]?.project.root;
|
|
435
448
|
})) throw new Error("repaired session provenance does not match its retained peer and project");
|
|
436
449
|
}
|
|
@@ -439,14 +452,14 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
|
439
452
|
if (choice.repaired === undefined) continue;
|
|
440
453
|
const planned = op.plan.projects[index]!;
|
|
441
454
|
if (choice.repaired !== true || !progress.repairedSessions?.[peer as RecoveryNativePeer] ||
|
|
442
|
-
resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)?.sessionId !== choice.lost) throw new Error("invalid one-use repaired-session choice");
|
|
455
|
+
resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer, options)?.sessionId !== choice.lost) throw new Error("invalid one-use repaired-session choice");
|
|
443
456
|
}
|
|
444
457
|
if (progress.reattachments === undefined) continue;
|
|
445
458
|
if (!object(progress.reattachments)) throw new Error("invalid reattachment markers");
|
|
446
459
|
for (const [peer, marker] of Object.entries(progress.reattachments)) {
|
|
447
|
-
if (!
|
|
460
|
+
if ((!historical && projectDefinition(peer, op.plan.projects[index]?.project.root)?.session.launchRecord !== true) || !object(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
|
|
448
461
|
!["ready", "attempted"].includes(String(marker.state)) || marker.cause !== "detached after restore" || typeof marker.at !== "number" || !Number.isFinite(marker.at) || marker.at < 0) throw new Error("invalid reattachment marker");
|
|
449
|
-
const receipt =
|
|
462
|
+
const receipt = decode({ state: "done", binding: marker.binding });
|
|
450
463
|
if (receipt?.state !== "done" || receipt.binding.peer !== peer || receipt.binding.projectRoot !== op.plan.projects[index]?.project.root) throw new Error("reattachment binding does not match its peer and project");
|
|
451
464
|
}
|
|
452
465
|
}
|
|
@@ -455,7 +468,7 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
|
455
468
|
if (!object(evidence) || Object.keys(evidence).some(key => !["projectId", "peer", "sessionId", "kind", "checkedPath"].includes(key)) ||
|
|
456
469
|
typeof evidence.projectId !== "string" || typeof evidence.peer !== "string" || typeof evidence.sessionId !== "string" ||
|
|
457
470
|
evidence.checkedPath !== undefined && (typeof evidence.checkedPath !== "string" || !evidence.checkedPath) ||
|
|
458
|
-
!["rollout", "transcript"].includes(evidence.kind) || !op.projects.some(progress => missingRepair(op, progress, evidence.peer))) throw new Error("invalid recorded-session missing evidence");
|
|
471
|
+
!["rollout", "transcript"].includes(evidence.kind) || !op.projects.some(progress => missingRepair(op, progress, evidence.peer, options))) throw new Error("invalid recorded-session missing evidence");
|
|
459
472
|
}
|
|
460
473
|
if (op.freshOverride !== undefined) {
|
|
461
474
|
const override = op.freshOverride, evidence = override.evidence;
|
|
@@ -466,14 +479,14 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
|
|
|
466
479
|
}
|
|
467
480
|
if (op.blockedReattachment !== undefined) {
|
|
468
481
|
const selected = op.blockedReattachment;
|
|
469
|
-
if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" ||
|
|
482
|
+
if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" || typeof selected.peer !== "string" || (!historical && projectDefinition(selected.peer, op.plan.projects.find(project => project.project.id === selected.projectId)?.project.root)?.session.launchRecord !== true) ||
|
|
470
483
|
!op.projects.some(project => project.id === selected.projectId && Object.hasOwn(project.reattachments ?? {}, selected.peer))) throw new Error("invalid blocked reattachment selector");
|
|
471
484
|
}
|
|
472
485
|
}
|
|
473
486
|
|
|
474
487
|
export interface RecoveryDriver {
|
|
475
488
|
stage(op: RecoveryOperation): Promise<{ root: string; digest: string; targetFeatures?: readonly string[] }>;
|
|
476
|
-
inspect(project: Project, targetFeatures?: readonly string[]): Promise<Inspection>;
|
|
489
|
+
inspect(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection>;
|
|
477
490
|
prepare(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
|
|
478
491
|
abort(project: Project, operation: string, instance: string): Promise<void>;
|
|
479
492
|
commit(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
|
|
@@ -765,7 +778,11 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
765
778
|
|
|
766
779
|
/** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
|
|
767
780
|
export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
|
|
768
|
-
const effect = (key: string, value: unknown) =>
|
|
781
|
+
const effect = (key: string, value: unknown) => {
|
|
782
|
+
if (!key.startsWith("restored:")) return value === "pending" || value === "failed" ? value : "done";
|
|
783
|
+
const receipt = decodeRestoreReceipt(value, { authority: "historical" });
|
|
784
|
+
return !receipt ? "unstarted" : receipt.state === "waiting" ? `waiting:${receipt.origin}` : receipt.state;
|
|
785
|
+
};
|
|
769
786
|
const choices = nextChoices(op, runnerPid, live), next = choices.map(choice => choice.text);
|
|
770
787
|
return { id: op.id, phase: op.phase, step: op.step, version: op.plan.version, updatedAt: op.updatedAt,
|
|
771
788
|
runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
|
|
@@ -794,7 +811,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
794
811
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
795
812
|
validateReceipt(id, op);
|
|
796
813
|
const selection = () => {
|
|
797
|
-
if (
|
|
814
|
+
if (projectDefinition(peer, op.plan.projects.find(project => project.project.id === op.blockedReattachment?.projectId)?.project.root)?.session.launchRecord !== true || op.phase !== "blocked" || op.disposition || op.final || op.blockedReattachment?.peer !== peer) throw new Error("reattach is available only for the peer named by a blocked reattachment receipt");
|
|
798
815
|
const progress = op.projects.find(project => project.id === op.blockedReattachment!.projectId);
|
|
799
816
|
const planned = op.plan.projects.find(project => project.project.id === progress?.id);
|
|
800
817
|
const native = peer as RecoveryNativePeer;
|
|
@@ -819,7 +836,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
819
836
|
};
|
|
820
837
|
await target();
|
|
821
838
|
attempted = true;
|
|
822
|
-
await driver.restore(planned, progress, op, native === "claude" ? "claude" : "native", save, native);
|
|
839
|
+
await driver.restore(planned, progress, op, projectDefinition(native, planned.project.root)?.transport === "claude-channel" ? "claude" : "native", save, native);
|
|
823
840
|
// A done receipt is written by the runtime only after authenticated attachment and exact settlement.
|
|
824
841
|
// Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
|
|
825
842
|
const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);
|
|
@@ -852,14 +869,14 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
852
869
|
/** Escape a blocked preflight without abandoning a stopped runtime or an uncertain terminal mutation. */
|
|
853
870
|
export async function abortRecovery(id: string, driver: RecoveryDriver, home = hubHome()): Promise<void> {
|
|
854
871
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
855
|
-
validateReceipt(id, op);
|
|
872
|
+
validateReceipt(id, op, true);
|
|
856
873
|
acquireRecoveryLock(id, home);
|
|
857
874
|
const releaseRunner = claimRunner(id, home);
|
|
858
875
|
try {
|
|
859
876
|
// Re-read after the exclusive runner claim so cancellation cannot act on a
|
|
860
877
|
// stale pending/prepared receipt after another runner advanced it.
|
|
861
878
|
op = readOperation<RecoveryOperation>(id, home);
|
|
862
|
-
validateReceipt(id, op);
|
|
879
|
+
validateReceipt(id, op, true);
|
|
863
880
|
if (op.phase === "completed" || op.phase === "cancelled") {
|
|
864
881
|
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
865
882
|
return;
|
|
@@ -867,7 +884,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
867
884
|
if (abandoned(op)) writeOperation(id, op, home);
|
|
868
885
|
const { fingerprint, ...body } = op.plan;
|
|
869
886
|
if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
|
|
870
|
-
const live = await liveProjects(op, driver.inspect);
|
|
887
|
+
const live = await liveProjects(op, project => driver.inspect(project, undefined, "cleanup"));
|
|
871
888
|
const refusal = abortRefusal(op, live);
|
|
872
889
|
if (refusal) throw new Error(`abort refused: ${refusal}; ${nextActionsText(op, live)}`);
|
|
873
890
|
// Only this operation's own uncommitted holds are aborted; a lapsed hold, a replacement daemon or another
|
|
@@ -892,7 +909,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
892
909
|
*/
|
|
893
910
|
export async function disposeRecovery(id: string, choice: { fresh: string } | { stop: true }, reason: string, driver: RecoveryDriver, home = hubHome()): Promise<RecoveryOperation> {
|
|
894
911
|
let op: RecoveryOperation;
|
|
895
|
-
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op); }
|
|
912
|
+
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
|
|
896
913
|
catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
|
|
897
914
|
if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
|
|
898
915
|
// A finished operation owns no lock: never take one only to refuse.
|
|
@@ -900,7 +917,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
900
917
|
acquireRecoveryLock(id, home);
|
|
901
918
|
const releaseRunner = claimRunner(id, home);
|
|
902
919
|
try {
|
|
903
|
-
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op); }
|
|
920
|
+
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
|
|
904
921
|
catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
|
|
905
922
|
if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
|
|
906
923
|
if (op.phase === "completed" || op.phase === "cancelled") {
|
|
@@ -911,11 +928,11 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
911
928
|
if (abandoned(op)) writeOperation(id, op, home);
|
|
912
929
|
const at = driver.now();
|
|
913
930
|
// Read once, as status and the runner do: every choice and refusal below is decided on it.
|
|
914
|
-
const live = await liveProjects(op, driver.inspect);
|
|
931
|
+
const live = await liveProjects(op, project => driver.inspect(project, undefined, "stop" in choice ? "cleanup" : undefined));
|
|
915
932
|
const next = nextActionsText(op, live);
|
|
916
933
|
if ("fresh" in choice) {
|
|
917
934
|
// `ahub pi` sends no fresh flag, and a restored hub refills a Pi start from its recorded resume (daemon startPeer).
|
|
918
|
-
if (choice.fresh ===
|
|
935
|
+
if (op.plan.projects.some(planned => projectDefinition(choice.fresh, planned.project.root)?.session.personFresh === false)) throw new Error(`pi: --fresh-session is not supported: a restored hub resumes Pi's recorded session, so a fresh one cannot be guaranteed; ${op.plan.projects.filter(planned => projectDefinition(choice.fresh, planned.project.root)?.session.personFresh === false).map(planned => piArchiveExit(planned, recoveryCommand(op, "dispose", STOP), choice.fresh)).join("; ")}; ${next}`);
|
|
919
936
|
if (op.disposition) throw new Error(`a stop-and-archive of this operation is partway; ${next}`);
|
|
920
937
|
if (!targetReadsWaivers(op)) throw new Error(`--fresh-session is not available: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session; ${next}`);
|
|
921
938
|
// The new session is launched by resume, so it helps only where resume can get past what is live.
|
|
@@ -996,7 +1013,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
996
1013
|
else if (act === "stopped" || act === "target stopped") {
|
|
997
1014
|
const outcome = await driver.stopAndArchive(planned.project, op, act === "target stopped" ? live!.instanceId : undefined);
|
|
998
1015
|
if (outcome && outcome.continuity) (disposition.continuity ??= {})[planned.project.id] = outcome.continuity;
|
|
999
|
-
if ((await driver.inspect(planned.project)).state !== "stopped") throw new Error(`${planned.project.id}: shutdown of instance ${live!.instanceId ?? "unknown"} is not verified`);
|
|
1016
|
+
if ((await driver.inspect(planned.project, undefined, "cleanup")).state !== "stopped") throw new Error(`${planned.project.id}: shutdown of instance ${live!.instanceId ?? "unknown"} is not verified`);
|
|
1000
1017
|
}
|
|
1001
1018
|
disposition.projects[planned.project.id] ??= act;
|
|
1002
1019
|
op.updatedAt = driver.now(); writeOperation(id, op, home);
|
|
@@ -1004,7 +1021,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
1004
1021
|
} catch (error) {
|
|
1005
1022
|
if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
|
|
1006
1023
|
op.phase = "blocked"; op.updatedAt = driver.now();
|
|
1007
|
-
op.error = `stop-and-archive stopped partway and keeps the lock: ${error instanceof Error ? error.message : "disposition failed"} (a committed source stops by itself); ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
|
|
1024
|
+
op.error = `stop-and-archive stopped partway and keeps the lock: ${error instanceof Error ? error.message : "disposition failed"} (a committed source stops by itself); ${nextActionsText(op, await liveProjects(op, project => driver.inspect(project, undefined, "cleanup")))}`;
|
|
1008
1025
|
writeOperation(id, op, home);
|
|
1009
1026
|
throw error;
|
|
1010
1027
|
}
|
package/src/hub/budget.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import type { PeerId } from "./envelope.ts";
|
|
3
4
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
@@ -45,6 +46,8 @@ export interface BudgetConfig {
|
|
|
45
46
|
export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
|
|
46
47
|
|
|
47
48
|
export interface BudgetHooks {
|
|
49
|
+
/** Stable/idempotent for this Budget lifetime. All lifecycle hooks receive this canonical account id. */
|
|
50
|
+
account?(peer: PeerId): PeerId;
|
|
48
51
|
pause(peer: PeerId): void;
|
|
49
52
|
/** Must leave a manual `ahub pause` in place. */
|
|
50
53
|
resume(peer: PeerId): void;
|
|
@@ -70,8 +73,6 @@ const WAIT_NOTE = "; keeps its work:";
|
|
|
70
73
|
const WAITED_NOTE = "; waited, then the reset moved to";
|
|
71
74
|
const waitedNote = (min: number, max: number) => `${WAITED_NOTE} ${min} min, beyond wait_max_min ${max}`;
|
|
72
75
|
const FALLBACK_WINDOW_MINS = 300;
|
|
73
|
-
/** Peers the coordinator never pauses: the local worker has no quota. */
|
|
74
|
-
const EXEMPT = new Set<PeerId>(["local", "user", "hub"]);
|
|
75
76
|
|
|
76
77
|
/**
|
|
77
78
|
* Budget relay. Readings come from the adapters (Codex rate limits, Claude's status line, Kimi tokens) or from
|
|
@@ -102,6 +103,43 @@ export class Budget {
|
|
|
102
103
|
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
103
104
|
this.db.run(`CREATE TABLE IF NOT EXISTS budget_pauses (peer TEXT PRIMARY KEY, since INTEGER NOT NULL, resets_at INTEGER NOT NULL,
|
|
104
105
|
reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
|
|
106
|
+
try { this.normalizeStoredPauses(); } catch (error) { this.db.close(); throw error; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private account(peer: PeerId): PeerId {
|
|
110
|
+
if (!this.hooks.account) return peer;
|
|
111
|
+
const account = this.hooks.account(peer);
|
|
112
|
+
if (typeof account !== "string" || !account.trim() || this.hooks.account(account) !== account) throw new Error("budget account resolver must return a canonical idempotent id");
|
|
113
|
+
return account;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A newly declared alias must not erase an older custom peer's persisted pause. */
|
|
117
|
+
private normalizeStoredPauses(): void {
|
|
118
|
+
if (!this.hooks.account) return; // identity/default startup leaves existing storage byte-for-byte alone
|
|
119
|
+
this.db.transaction(() => {
|
|
120
|
+
const rows = (this.db.query("SELECT peer FROM budget_pauses ORDER BY peer").all() as { peer: string }[]).map(row => this.readRecord(row.peer)!);
|
|
121
|
+
const groups = new Map<PeerId, PauseRecord[]>();
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
const account = this.account(row.peer), group = groups.get(account) ?? [];
|
|
124
|
+
group.push(row); groups.set(account, group);
|
|
125
|
+
}
|
|
126
|
+
for (const [peer, group] of groups) {
|
|
127
|
+
if (group.length === 1 && group[0]!.peer === peer) continue;
|
|
128
|
+
// Conservative collision reduction: retain the longest pause, earliest start and every distinct move.
|
|
129
|
+
// Any unfinished handoff stays unfinished so the daemon's account fanout retries remaining open work.
|
|
130
|
+
const summaries = group.filter(row => row.summary !== null).map(row => `[${row.peer}]\n${row.summary}`);
|
|
131
|
+
const moved = [...new Map(group.flatMap(row => row.moved).map(move => [JSON.stringify(move), move])).values()];
|
|
132
|
+
const first = group[0]!;
|
|
133
|
+
const record = group.length === 1 ? { ...first, peer } : {
|
|
134
|
+
peer, since: Math.min(...group.map(row => row.since)), resetsAt: Math.max(...group.map(row => row.resetsAt)),
|
|
135
|
+
reason: [...new Set(group.map(row => row.reason))].join(" | "), summary: summaries.length ? summaries.join("\n\n") : null,
|
|
136
|
+
handedOff: group.every(row => row.handedOff), moved,
|
|
137
|
+
};
|
|
138
|
+
for (const row of group) this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(row.peer);
|
|
139
|
+
this.db.query("INSERT INTO budget_pauses (peer, since, resets_at, reason, summary, handed_off, moved) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
140
|
+
.run(record.peer, record.since, record.resetsAt, record.reason, record.summary, record.handedOff ? 1 : 0, JSON.stringify(record.moved));
|
|
141
|
+
}
|
|
142
|
+
}).immediate();
|
|
105
143
|
}
|
|
106
144
|
|
|
107
145
|
/** Freeze pause/resume and handoff transitions while a controlled restart is fenced. Readings continue to update. */
|
|
@@ -122,6 +160,10 @@ export class Budget {
|
|
|
122
160
|
}
|
|
123
161
|
|
|
124
162
|
record(peer: PeerId): PauseRecord | undefined {
|
|
163
|
+
return this.readRecord(this.account(peer));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private readRecord(peer: PeerId): PauseRecord | undefined {
|
|
125
167
|
const r = this.db.query("SELECT * FROM budget_pauses WHERE peer = ?").get(peer) as Record<string, any> | null;
|
|
126
168
|
return r ? { peer: r.peer, since: r.since, resetsAt: r.resets_at, reason: r.reason, summary: r.summary, handedOff: !!r.handed_off, moved: JSON.parse(r.moved) } : undefined;
|
|
127
169
|
}
|
|
@@ -156,12 +198,17 @@ export class Budget {
|
|
|
156
198
|
/** `hard`: the peer cannot run another turn (its provider refused one), so there is nobody to ask for a checkpoint. */
|
|
157
199
|
/** `at`: when the numbers were measured, for sources that arrive through a file (a leftover file is not a fresh reading). */
|
|
158
200
|
report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
|
|
159
|
-
|
|
201
|
+
// Execution-budget instrumentation is independent of native quota accounting.
|
|
202
|
+
// Explicit quota exemption or absent budget metadata exempts registered kinds; external ids keep accounting.
|
|
203
|
+
const reporter = peer;
|
|
204
|
+
peer = this.account(peer);
|
|
205
|
+
const adapter = definitionOf(reporter) ?? definitionOf(peer);
|
|
206
|
+
if (reporter === "user" || reporter === "hub" || peer === "user" || peer === "hub" || adapter && (!adapter.budget || adapter.budget.quotaExempt === true) || this.closed) return;
|
|
160
207
|
// An override ignores soft readings, not a provider that has actually refused another turn.
|
|
161
208
|
if (hard && this.ignoreUntil.delete(peer)) {
|
|
162
209
|
try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
|
|
163
210
|
}
|
|
164
|
-
try { this.hooks.reading?.(
|
|
211
|
+
try { this.hooks.reading?.(reporter, windows, hard, at); } catch { /* telemetry never costs a reading */ }
|
|
165
212
|
const mine = this.readings.get(peer) ?? new Map<string, Reading>();
|
|
166
213
|
this.readings.set(peer, mine);
|
|
167
214
|
for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
|
|
@@ -171,12 +218,14 @@ export class Budget {
|
|
|
171
218
|
|
|
172
219
|
/** Readings that still say something: not stale, and not about a window that has reset since they were taken. */
|
|
173
220
|
private fresh(peer: PeerId): Reading[] {
|
|
221
|
+
peer = this.account(peer);
|
|
174
222
|
const staleMs = this.cfg.stale_min * 60_000;
|
|
175
223
|
return [...(this.readings.get(peer)?.values() ?? [])].filter((r) => this.now() - r.at <= staleMs && !(r.resetsAt && r.resetsAt <= this.now()));
|
|
176
224
|
}
|
|
177
225
|
|
|
178
226
|
/** The owner's override: lift the pause now and leave the peer alone until the window that caused it has reset. */
|
|
179
227
|
override(peer: PeerId): boolean {
|
|
228
|
+
peer = this.account(peer);
|
|
180
229
|
const open = this.record(peer);
|
|
181
230
|
if (!open) return false;
|
|
182
231
|
this.ignoreUntil.set(peer, open.resetsAt);
|
|
@@ -188,6 +237,7 @@ export class Budget {
|
|
|
188
237
|
}
|
|
189
238
|
|
|
190
239
|
private async evaluate(peer: PeerId, hard: boolean): Promise<void> {
|
|
240
|
+
peer = this.account(peer);
|
|
191
241
|
if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
|
|
192
242
|
const fresh = this.fresh(peer);
|
|
193
243
|
const over = fresh.filter((r) => r.used >= this.cfg.gate);
|
|
@@ -250,6 +300,7 @@ export class Budget {
|
|
|
250
300
|
}
|
|
251
301
|
|
|
252
302
|
private async finishHandoff(peer: PeerId, summary: string | undefined): Promise<void> {
|
|
303
|
+
peer = this.account(peer);
|
|
253
304
|
if (this.closed || this.recoveryHeld) { this.deferred = true; return; }
|
|
254
305
|
if (this.handingOff.has(peer) || !this.hooks.canHandOff(peer)) return; // retried by the next tick
|
|
255
306
|
this.handingOff.add(peer);
|
|
@@ -262,6 +313,7 @@ export class Budget {
|
|
|
262
313
|
}
|
|
263
314
|
|
|
264
315
|
private async handOff(peer: PeerId, summary: string | undefined): Promise<void> {
|
|
316
|
+
peer = this.account(peer);
|
|
265
317
|
const context = summary ?? (await this.hooks.platformContext(peer).catch(() => undefined));
|
|
266
318
|
// Wait or hand off (issue #36): a handoff costs the next peer the whole context, so a short wait can be cheaper.
|
|
267
319
|
const resetsAt = this.record(peer)?.resetsAt;
|
|
@@ -296,6 +348,7 @@ export class Budget {
|
|
|
296
348
|
|
|
297
349
|
/** `hub_checkpoint` arrived from a peer. Resolved by the daemon's requestCheckpoint wait; kept on the record for a restart. */
|
|
298
350
|
checkpointed(peer: PeerId, summary: string): void {
|
|
351
|
+
peer = this.account(peer);
|
|
299
352
|
if (this.closed) return; // a hub_checkpoint that lands during shutdown
|
|
300
353
|
this.db.query("UPDATE budget_pauses SET summary = ? WHERE peer = ?").run(summary, peer);
|
|
301
354
|
}
|
|
@@ -312,6 +365,7 @@ export class Budget {
|
|
|
312
365
|
}
|
|
313
366
|
|
|
314
367
|
private resume(peer: PeerId, why: string): void {
|
|
368
|
+
peer = this.account(peer);
|
|
315
369
|
const record = this.record(peer);
|
|
316
370
|
if (!record) return;
|
|
317
371
|
this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(peer);
|
|
@@ -356,30 +410,4 @@ export class Budget {
|
|
|
356
410
|
}
|
|
357
411
|
}
|
|
358
412
|
|
|
359
|
-
|
|
360
|
-
export function codexWindows(rateLimits: any): UsageWindow[] {
|
|
361
|
-
const out: UsageWindow[] = [];
|
|
362
|
-
// The slot decides (primary = short window, secondary = long one) and the duration overrides it when present:
|
|
363
|
-
// readings are keyed by window id, so two windows must never both come out as "5h".
|
|
364
|
-
for (const [w, slot] of [[rateLimits?.primary, "5h"], [rateLimits?.secondary, "week"]] as const) {
|
|
365
|
-
if (!w || typeof w.usedPercent !== "number") continue;
|
|
366
|
-
const mins = typeof w.windowDurationMins === "number" ? w.windowDurationMins : undefined;
|
|
367
|
-
const id = mins === undefined ? slot : mins > 600 ? "week" : "5h";
|
|
368
|
-
if (out.some((o) => o.id === id)) continue; // both slots claim the same window: keep the first, never overwrite
|
|
369
|
-
out.push({ id, used: w.usedPercent / 100, ...(w.resetsAt ? { resetsAt: w.resetsAt * 1000 } : {}), ...(mins ? { windowMins: mins } : {}), source: "codex rateLimits" });
|
|
370
|
-
}
|
|
371
|
-
if (rateLimits?.rateLimitReachedType && !out.some((w) => w.used >= 1)) out.push({ id: "5h", used: 1, source: `codex ${rateLimits.rateLimitReachedType}` });
|
|
372
|
-
return out;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/** Claude Code status line `rate_limits` -> windows. */
|
|
376
|
-
export function claudeWindows(rateLimits: any): UsageWindow[] {
|
|
377
|
-
const out: UsageWindow[] = [];
|
|
378
|
-
for (const [key, id, mins] of [["five_hour", "5h", 300], ["seven_day", "week", 10_080]] as const) {
|
|
379
|
-
const w = rateLimits?.[key];
|
|
380
|
-
if (!w || typeof w.used_percentage !== "number") continue;
|
|
381
|
-
const resets = Number(w.resets_at);
|
|
382
|
-
out.push({ id, used: w.used_percentage / 100, ...(resets ? { resetsAt: resets < 1e12 ? resets * 1000 : resets } : {}), windowMins: mins, source: "claude status line" });
|
|
383
|
-
}
|
|
384
|
-
return out;
|
|
385
|
-
}
|
|
413
|
+
export { codexWindows, claudeWindows } from "../adapters/definitions/native-quota.ts";
|
package/src/hub/bus.ts
CHANGED
|
@@ -974,7 +974,7 @@ export class Bus {
|
|
|
974
974
|
}
|
|
975
975
|
|
|
976
976
|
/**
|
|
977
|
-
* What one push delivery would take (the preface, then `take`'s batch),
|
|
977
|
+
* What one push delivery would take (the preface, then `take`'s batch), explicitly pulled by its attached session and recorded as
|
|
978
978
|
* completed: the tool result that returns it is the readback, so nothing waits for settlement and nothing is ever
|
|
979
979
|
* `accepted` (issue #205). It is checkpointed like a push, so queued rows of the same envelopes (an operator retry)
|
|
980
980
|
* are grouped into it, and registered for `reply_to` like a push. Undefined while held. A failed journal write puts
|
package/src/hub/conductor.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import type { Task } from "./board.ts";
|
|
3
4
|
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
4
5
|
import { HUB, USER } from "./envelope.ts";
|
|
5
6
|
import { OWNERSHIP_EVENTS, type TaskAssignmentRole } from "./tasks.ts";
|
|
6
7
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
7
|
-
import
|
|
8
|
+
import type { StartMode } from "./start-mode.ts";
|
|
8
9
|
import type { HubEvent } from "./events.ts";
|
|
9
10
|
import type { SupervisionFeed } from "./supervision.ts";
|
|
10
11
|
import type { Budget } from "./budget.ts";
|
|
@@ -103,18 +104,23 @@ export class ConductorHolds {
|
|
|
103
104
|
close(): void { this.db.close(); }
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
export type ConductorStart = { peer:
|
|
107
|
+
export type ConductorStart = { peer: string; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
|
|
107
108
|
/**
|
|
108
109
|
* What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
|
|
109
110
|
* only repeat it. Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text.
|
|
110
111
|
*/
|
|
111
|
-
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer:
|
|
112
|
+
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: string) => StartMode, instances?: KindMap): ConductorStart {
|
|
112
113
|
const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
|
|
113
114
|
if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
const adapter = typeof peer === "string" ? definitionOf(peer, instances) : undefined;
|
|
116
|
+
if (typeof peer !== "string" || !adapter?.start.conductorStartable) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
|
|
117
|
+
const selected = peer;
|
|
118
|
+
const extra = adapter.kind !== selected;
|
|
119
|
+
if (extra && (!adapter.start.create || !adapter.start.forms.includes("headless") || !["env", "acp-session"].includes(adapter.identity.mcp))) throw refuse(`${peer}: this native transport does not support extra headless instances`);
|
|
120
|
+
const own = startMode(selected);
|
|
121
|
+
if (extra && own !== "headless") throw refuse(`${peer}: extra instances require headless mode in I2`);
|
|
116
122
|
if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
|
|
117
|
-
return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer, mode: "headless" };
|
|
123
|
+
return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer: selected, mode: "headless" };
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
@@ -160,9 +166,11 @@ export interface ConductorHooks {
|
|
|
160
166
|
escalate(actor: string, id: number): Promise<unknown>;
|
|
161
167
|
preview(peer: "claude" | "codex" | "pi"): string;
|
|
162
168
|
/** The mode a start of this peer takes: a person's setting. */
|
|
163
|
-
startMode(peer:
|
|
169
|
+
startMode(peer: string): StartMode;
|
|
164
170
|
/** Starts the peer in that mode: headless at once, or its fixed command in a terminal; throws with the command to run by hand when no terminal can be opened. */
|
|
165
|
-
start(peer:
|
|
171
|
+
start(peer: string): Promise<unknown>;
|
|
172
|
+
/** Explicit project declarations only; an undeclared suffix never acquires a native kind. */
|
|
173
|
+
instances?(): KindMap;
|
|
166
174
|
known(peer: string): boolean;
|
|
167
175
|
pause(peer: string): void;
|
|
168
176
|
validateRelease?(peer: string): Promise<void>;
|
|
@@ -227,7 +235,9 @@ export class Conductor {
|
|
|
227
235
|
return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
|
|
228
236
|
}
|
|
229
237
|
if (tool === "hub_peer_start") {
|
|
230
|
-
const
|
|
238
|
+
const instances = this.hooks.instances?.();
|
|
239
|
+
const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode, instances);
|
|
240
|
+
if (definitionOf(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
|
|
231
241
|
const result = await this.hooks.start(start.peer);
|
|
232
242
|
const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
|
|
233
243
|
emit({ peer: start.peer }); return { ...start, ...opened };
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { HubConfig } from "./daemon.ts";
|
|
2
2
|
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
|
|
3
|
-
import { SUPPORTED_PEERS, PLANNER_PEERS } from "
|
|
3
|
+
import { ADAPTERS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
4
4
|
import { CLASSES } from "./board.ts";
|
|
5
5
|
import { permissionDefaults } from "./permission-mode.ts";
|
|
6
6
|
import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
|
|
@@ -8,6 +8,8 @@ import { catalogAssignments } from "./roles.ts";
|
|
|
8
8
|
import { resolveProfile } from "./profiles.ts";
|
|
9
9
|
import { taskSweepConfig } from "./task-sweep.ts";
|
|
10
10
|
import { projectionConfig } from "./text-projection.ts";
|
|
11
|
+
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
12
|
+
import { peerRosterConfig } from "./roster.ts";
|
|
11
13
|
|
|
12
14
|
export type ApplyClass = "live" | "next use" | "restart";
|
|
13
15
|
export interface ApplyRule { applies: ApplyClass; reason?: string }
|
|
@@ -103,9 +105,12 @@ const block = (schema: Record<string, Check>): Check => {
|
|
|
103
105
|
};
|
|
104
106
|
const peerMap = (check: Check) => map(check, SUPPORTED_PEERS);
|
|
105
107
|
const timerSeconds = number(1, 2_147_483_647 / 1000), timerMinutes = number(1, 2_147_483_647 / 60_000);
|
|
108
|
+
const nativeValidators = { bool, text, strings, argv, number, words, block };
|
|
106
109
|
const schema: Record<string, Check> = {
|
|
110
|
+
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
|
|
107
111
|
watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
|
|
108
112
|
permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
|
|
113
|
+
instances: v => { validateInstances(v); },
|
|
109
114
|
batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
|
|
110
115
|
memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
|
|
111
116
|
short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
|
|
@@ -119,11 +124,9 @@ const schema: Record<string, Check> = {
|
|
|
119
124
|
context: block({ gate: number(0, 1), stale_min: timerMinutes }),
|
|
120
125
|
inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
|
|
121
126
|
omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
|
|
122
|
-
pi: block({ enabled: bool, auto_start: bool, cmd: argv, backend: words(["auto", "dgx", "mlx"]), dgx_coding: text, dgx_fast: text, max_steps: number(1, 1_000_000, true) }),
|
|
123
127
|
mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
|
|
124
128
|
contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
|
|
125
|
-
|
|
126
|
-
approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)) }),
|
|
129
|
+
approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true) }),
|
|
127
130
|
task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
|
|
128
131
|
checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
|
|
129
132
|
if (value === undefined) continue;
|
|
@@ -132,36 +135,40 @@ const schema: Record<string, Check> = {
|
|
|
132
135
|
else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
|
|
133
136
|
} },
|
|
134
137
|
snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
|
|
135
|
-
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }),
|
|
138
|
+
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
|
|
136
139
|
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
137
140
|
limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
|
|
138
141
|
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
|
|
139
142
|
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
140
143
|
};
|
|
141
|
-
export function validateRawConfig(raw: unknown): asserts raw is Record<string, unknown> {
|
|
144
|
+
export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
|
|
145
|
+
const value = object(raw, "config");
|
|
146
|
+
const instances = validateInstances(value.instances ?? declarations);
|
|
147
|
+
if (!Object.keys(instances).length) return block(schema)(raw, "config");
|
|
148
|
+
block({ ...schema, permission_modes: v => { permissionDefaults(v, instances); },
|
|
149
|
+
roster: v => { peerRosterConfig(v, instances); }, peers: v => { peerStartConfig(v, instances); },
|
|
150
|
+
})(raw, "config");
|
|
151
|
+
}
|
|
142
152
|
export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
|
|
143
153
|
const raw = { ...object(value, "config") };
|
|
144
154
|
delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
|
|
145
155
|
validateRawConfig(raw);
|
|
146
156
|
const profile = resolveProfile(raw.profile, raw.profile_overrides);
|
|
147
157
|
for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
|
|
148
|
-
|
|
158
|
+
for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
|
|
149
159
|
}
|
|
150
160
|
|
|
151
161
|
const restart: Record<string, string> = {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
"routing.pi": "Pi relay context and model publication are bound to the running relay",
|
|
162
|
+
instances: "Native instance identity, worktree, ports and account bindings are fixed at daemon startup",
|
|
163
|
+
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
|
|
155
164
|
memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
|
|
156
165
|
omniroute: "Credentials and gateway clients are bound to relay ownership", mlx: "Model runtime ownership and device resources belong to this daemon",
|
|
157
166
|
checks: "Bounded runner and completion-check contracts are constructed together",
|
|
158
167
|
pii: "Existing native sessions retain their startup privacy boundary", data: "Existing native sessions retain their startup read-only policy",
|
|
159
|
-
"local.deny": "Existing native sessions retain their sandbox deny policy", "local.read_allow": "Existing native sessions retain their sandbox read grants",
|
|
160
|
-
"local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance",
|
|
161
168
|
profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
|
|
162
169
|
text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
|
|
163
170
|
};
|
|
164
|
-
const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "approvals.notify"]);
|
|
171
|
+
const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify"]);
|
|
165
172
|
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
166
173
|
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
167
174
|
export function configApply(key: string): ApplyRule {
|
|
@@ -178,11 +185,11 @@ export function configApply(key: string): ApplyRule {
|
|
|
178
185
|
if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
|
|
179
186
|
else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
|
|
180
187
|
else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
|
|
181
|
-
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers"].includes(top)) break;
|
|
188
|
+
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances"].includes(top)) break;
|
|
182
189
|
else return fail(key, "unknown configuration key");
|
|
183
190
|
}
|
|
184
191
|
}
|
|
185
|
-
if (top === "routing" && !
|
|
192
|
+
if (top === "routing" && !ADAPTERS.some(adapter => Object.keys(adapter.configuration?.restartReasons ?? {}).some(prefix => prefix.startsWith("routing.") && key.startsWith(prefix))) && key !== "routing") {
|
|
186
193
|
return { applies: ["classes", "stay_switch", "signals", "constraints", "hub_routes", "max_switch_prefill_tokens"].includes(key.split(".")[1]!) ? "live" : "next use" };
|
|
187
194
|
}
|
|
188
195
|
if (nextMemory.has(key)) return { applies: "next use" };
|