@staix/agent-hub 0.24.0 → 0.26.0
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 +23 -0
- package/README.md +35 -8
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +18 -3
- package/docs/agent-notes/tasks.md +12 -4
- package/docs/agent-notes/tests.md +3 -1
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +264 -14
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +23 -6
- package/docs/security.md +198 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verified.json +45 -17
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5294 -4797
- package/src/adapters/channel-peer.ts +4 -2
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/definitions/builtin-metadata.ts +6 -6
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/definitions/qwen.ts +2 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry-types.ts +2 -0
- package/src/adapters/registry.ts +2 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/conductor-recovery.ts +3 -2
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +11 -8
- package/src/cli/identity.ts +7 -1
- package/src/cli/main.ts +138 -30
- package/src/cli/output.ts +8 -5
- package/src/cli/recovery-package.ts +94 -4
- package/src/cli/reset.ts +14 -4
- package/src/cli/runtime-version-warning.ts +28 -0
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/start-agents-run.ts +125 -0
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +6 -0
- package/src/cli/upgrade-interactive.ts +4 -4
- package/src/cli/upgrade-runtime.ts +105 -57
- package/src/cli/upgrade.ts +90 -28
- package/src/hub/autonomy-defaults.ts +32 -7
- package/src/hub/board.ts +993 -8
- package/src/hub/budget.ts +3 -4
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/child-process.ts +4 -3
- package/src/hub/conductor.ts +3 -4
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-edit-host.ts +1 -0
- package/src/hub/config-tier.ts +3 -0
- package/src/hub/config-trust.ts +10 -2
- package/src/hub/control-client.ts +39 -10
- package/src/hub/daemon.ts +326 -25
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +15 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +110 -87
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project-runtime-state.ts +139 -0
- package/src/hub/project-runtime.ts +62 -0
- package/src/hub/project.ts +14 -5
- package/src/hub/protocol-window.ts +19 -0
- package/src/hub/provenance.ts +30 -0
- package/src/hub/recovery-store.ts +102 -12
- package/src/hub/registry.ts +5 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +474 -0
- package/src/hub/research-runner.ts +172 -0
- package/src/hub/restart.ts +35 -0
- package/src/hub/runtime-capture-prune.ts +415 -0
- package/src/hub/runtime-prune-health.ts +112 -0
- package/src/hub/schedules.ts +213 -13
- package/src/hub/sdlc-merge.ts +7 -0
- package/src/hub/sdlc-queue.ts +13 -1
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/settings.ts +4 -0
- package/src/hub/snapshots.ts +3 -4
- package/src/hub/start-agents.ts +44 -0
- package/src/hub/task-approval.ts +23 -0
- package/src/hub/tasks.ts +1005 -34
- package/src/hub/ui.ts +15 -6
- package/src/hub/usage-rollup.ts +3 -4
- package/src/memory/capture.ts +2 -2
- package/src/pi/tool-receipts.ts +3 -4
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
package/src/cli/upgrade.ts
CHANGED
|
@@ -9,8 +9,8 @@ import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/r
|
|
|
9
9
|
import { Database } from "bun:sqlite";
|
|
10
10
|
import { createHash, randomUUID } from "node:crypto";
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
|
|
12
|
+
import { isAbsolute, join } from "node:path";
|
|
13
|
+
import { Registry, REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
|
|
14
14
|
import { hubHome } from "../hub/project.ts";
|
|
15
15
|
import { readRecoveryWaits } from "./recovery-waits.ts";
|
|
16
16
|
import { acquireRecoveryLock, claimRunner, coordinatorCurrent, operationIdPattern, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
|
|
@@ -27,6 +27,7 @@ const projectDefinition = (peer: string, root?: string) => {
|
|
|
27
27
|
|
|
28
28
|
export interface RecoveryPeer {
|
|
29
29
|
id: string;
|
|
30
|
+
controlProtocol?: number;
|
|
30
31
|
/** Read-only source observation; never consumed as restoration arguments. */
|
|
31
32
|
observation?: { kind: string; attached: boolean; sessionDigest?: string };
|
|
32
33
|
/** Captured stable module/instance identity; never a portable current-owner proof. */
|
|
@@ -42,6 +43,8 @@ export interface Inspection {
|
|
|
42
43
|
instanceId?: string;
|
|
43
44
|
version?: string;
|
|
44
45
|
protocol?: number;
|
|
46
|
+
protocolMin?: number;
|
|
47
|
+
protocolMax?: number;
|
|
45
48
|
peers: RecoveryPeer[];
|
|
46
49
|
/** `waiting` (#272): what the running hub says keeps it from being quiet (busy peers, pending approvals); display only. */
|
|
47
50
|
recovery?: { operationId?: string; phase?: string; ready?: boolean; waiting?: string[] };
|
|
@@ -65,6 +68,8 @@ export interface PlannedProject {
|
|
|
65
68
|
}
|
|
66
69
|
export interface UpgradePlan {
|
|
67
70
|
schema: 1;
|
|
71
|
+
/** Present for new CLI operations; absent only on retained legacy machine-wide plans. */
|
|
72
|
+
scope?: { projectRoot: string };
|
|
68
73
|
kind: "restart" | "upgrade";
|
|
69
74
|
version: string;
|
|
70
75
|
integrity?: string;
|
|
@@ -122,7 +127,7 @@ export interface RecoveryOperation {
|
|
|
122
127
|
* #227: 2 marks a stop-and-archive recorded on an operation whose coordinator predates #215. Every older runner and
|
|
123
128
|
* abort refuses a schema other than 1 before it takes the lock or claims the runner, so none can advance it.
|
|
124
129
|
*/
|
|
125
|
-
schema: 1 | 2;
|
|
130
|
+
schema: 1 | 2 | 3;
|
|
126
131
|
id: string;
|
|
127
132
|
plan: UpgradePlan;
|
|
128
133
|
createdAt: number;
|
|
@@ -417,20 +422,47 @@ export function planFingerprint(plan: Omit<UpgradePlan, "fingerprint">): string
|
|
|
417
422
|
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
|
418
423
|
}
|
|
419
424
|
|
|
425
|
+
/** A scoped receipt cannot expand into a second project, even when read by a later coordinator. */
|
|
426
|
+
export function recoveryScope(plan: Pick<UpgradePlan, "scope" | "projects">): string | undefined {
|
|
427
|
+
if (plan.scope === undefined) return undefined;
|
|
428
|
+
const scope = plan.scope;
|
|
429
|
+
if (!scope || typeof scope !== "object" || Array.isArray(scope) || Object.keys(scope).length !== 1 || !Object.hasOwn(scope, "projectRoot")
|
|
430
|
+
|| typeof scope.projectRoot !== "string" || !isAbsolute(scope.projectRoot) || !Array.isArray(plan.projects)
|
|
431
|
+
|| plan.projects.length !== 1 || plan.projects[0]?.project.root !== scope.projectRoot)
|
|
432
|
+
throw new Error("reviewed project recovery scope is invalid");
|
|
433
|
+
return scope.projectRoot;
|
|
434
|
+
}
|
|
435
|
+
function sealScopedPlan(op: RecoveryOperation): void {
|
|
436
|
+
if (op.schema !== 3) return;
|
|
437
|
+
const seen = new WeakSet<object>();
|
|
438
|
+
const freeze = (value: unknown): void => {
|
|
439
|
+
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
440
|
+
seen.add(value);
|
|
441
|
+
for (const item of Object.values(value)) freeze(item);
|
|
442
|
+
Object.freeze(value);
|
|
443
|
+
};
|
|
444
|
+
freeze(op.plan);
|
|
445
|
+
Object.defineProperty(op, "plan", { value: op.plan, enumerable: true, writable: false, configurable: false });
|
|
446
|
+
}
|
|
447
|
+
|
|
420
448
|
export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome(), delegation?: RecoveryDelegation, operationId: string = randomUUID()): RecoveryOperation {
|
|
421
449
|
if (!operationIdPattern.test(operationId)) throw new Error("invalid recovery operation ID");
|
|
422
|
-
if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) throw new Error("upgrade plan has blockers; no runtimes were changed");
|
|
423
450
|
if (!plan.projects.length) throw new Error("no running projects to recover");
|
|
451
|
+
if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) throw new Error("upgrade plan has blockers; no runtimes were changed");
|
|
424
452
|
const { fingerprint, ...body } = plan;
|
|
425
453
|
if (planFingerprint(body) !== fingerprint) throw new Error("upgrade plan fingerprint is invalid");
|
|
454
|
+
const scope = recoveryScope(plan);
|
|
426
455
|
const id = operationId;
|
|
427
456
|
const op: RecoveryOperation = {
|
|
428
|
-
schema: 1, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage",
|
|
457
|
+
schema: scope === undefined ? 1 : 3, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage",
|
|
429
458
|
projects: plan.projects.map((p) => ({ id: p.project.id, phase: "pending", terminals: {} })),
|
|
430
459
|
};
|
|
431
460
|
if (delegation) appendRecoveryDelegation(op, delegation, op.createdAt);
|
|
432
|
-
|
|
433
|
-
|
|
461
|
+
sealScopedPlan(op);
|
|
462
|
+
// Publish the nonterminal receipt and its hold atomically with capture pruning's inventory.
|
|
463
|
+
const registry = new Registry(join(home, "registry.db"));
|
|
464
|
+
try { registry.runtimeCaptureLocked(() => { writeOperation(id, op, home); acquireRecoveryLock(id, home, scope); }); }
|
|
465
|
+
finally { registry.close(); }
|
|
434
466
|
return op;
|
|
435
467
|
}
|
|
436
468
|
|
|
@@ -488,7 +520,9 @@ function validateReceipt(id: string, op: RecoveryOperation, historical = false):
|
|
|
488
520
|
// Cleanup validates owned historical facts; this mode never authorizes restore, fresh or reattach.
|
|
489
521
|
const options = historical ? { authority: "historical" as const } : {};
|
|
490
522
|
const decode = (value: unknown) => decodeRestoreReceipt(value, options);
|
|
491
|
-
|
|
523
|
+
const scope = recoveryScope(op.plan);
|
|
524
|
+
if (!(scope === undefined ? op.schema === 1 || (op.schema === 2 && op.disposition) : op.schema === 3) || op.id !== id)
|
|
525
|
+
throw new Error("unsupported operation receipt");
|
|
492
526
|
validateRecoveryDelegations(op);
|
|
493
527
|
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");
|
|
494
528
|
const { fingerprint, ...reviewed } = op.plan;
|
|
@@ -584,13 +618,22 @@ async function authorizeRecoveryDriver(driver: RecoveryDriver, op: RecoveryOpera
|
|
|
584
618
|
await driver.authorize(op, phase);
|
|
585
619
|
}
|
|
586
620
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
621
|
+
function scopedRecoveryCurrent(operation: () => RecoveryOperation, scope: string | undefined, id: string, home: string): (() => void) | undefined {
|
|
622
|
+
if (scope === undefined) return undefined;
|
|
623
|
+
return () => {
|
|
624
|
+
if (recoveryScope(operation().plan) !== scope || recoveryLock(home, scope) !== id) throw new Error("project recovery scope or hold changed");
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Closed effect ports only. Scoped operations also fence their project hold; inspection stays read-only. */
|
|
629
|
+
export function guardedRecoveryDriver(driver: RecoveryDriver, operation: () => RecoveryOperation, current?: () => void): RecoveryDriver {
|
|
630
|
+
if (driver.conductorPeer === undefined && !current) return driver;
|
|
590
631
|
const guard = <A extends unknown[], R>(action: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
|
|
632
|
+
current?.();
|
|
591
633
|
await authorizeRecoveryDriver(driver, operation(), "effect");
|
|
634
|
+
current?.();
|
|
592
635
|
try { return await action(...args); }
|
|
593
|
-
finally { await authorizeRecoveryDriver(driver, operation(), "effect"); }
|
|
636
|
+
finally { await authorizeRecoveryDriver(driver, operation(), "effect"); current?.(); }
|
|
594
637
|
};
|
|
595
638
|
return {
|
|
596
639
|
...driver,
|
|
@@ -619,14 +662,19 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
619
662
|
// mutable phase. A concurrent runner may have completed the operation meanwhile.
|
|
620
663
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
621
664
|
validateReceipt(id, op);
|
|
665
|
+
const scope = recoveryScope(op.plan);
|
|
666
|
+
sealScopedPlan(op);
|
|
622
667
|
if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "run");
|
|
623
|
-
|
|
668
|
+
const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
|
|
669
|
+
acquireRecoveryLock(id, home, scope);
|
|
624
670
|
const releaseRunner = claimRunner(id, home);
|
|
625
671
|
try {
|
|
626
672
|
op = readOperation<RecoveryOperation>(id, home);
|
|
673
|
+
if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner");
|
|
674
|
+
sealScopedPlan(op);
|
|
627
675
|
validateReceipt(id, op);
|
|
628
676
|
if (op.phase === "completed" || op.phase === "cancelled") {
|
|
629
|
-
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
677
|
+
if (recoveryLock(home, scope) === id) releaseRecoveryLock(id, home, scope);
|
|
630
678
|
releaseRunner();
|
|
631
679
|
return op;
|
|
632
680
|
}
|
|
@@ -636,7 +684,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
636
684
|
writeOperation(id, op, home);
|
|
637
685
|
}
|
|
638
686
|
} catch (error) { releaseRunner(); throw error; }
|
|
639
|
-
driver = guardedRecoveryDriver(driver, () => op);
|
|
687
|
+
driver = guardedRecoveryDriver(driver, () => op, scopeCurrent);
|
|
640
688
|
let targetFeatures: readonly string[] = [];
|
|
641
689
|
const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
|
|
642
690
|
const step = (value: string) => { op.step = value; save(); };
|
|
@@ -843,8 +891,8 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
843
891
|
progress.phase = "peers-restored"; save();
|
|
844
892
|
}
|
|
845
893
|
}
|
|
846
|
-
//
|
|
847
|
-
if (op.plan.kind === "upgrade" && !op.pluginInstalled) {
|
|
894
|
+
// Legacy receipts keep their shared installation steps; project upgrades leave global tools alone.
|
|
895
|
+
if (scope === undefined && op.plan.kind === "upgrade" && !op.pluginInstalled) {
|
|
848
896
|
step("install-plugin"); await driver.installPlugin(op); op.pluginInstalled = true; save();
|
|
849
897
|
}
|
|
850
898
|
for (let i = 0; i < op.projects.length; i++) {
|
|
@@ -866,10 +914,10 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
866
914
|
progress.phase = "verified"; save();
|
|
867
915
|
}
|
|
868
916
|
if (driver.refreshManager) { step("refresh-manager"); await driver.refreshManager(op); }
|
|
869
|
-
if (op.plan.kind === "upgrade" && !op.globalInstalled) {
|
|
917
|
+
if (scope === undefined && op.plan.kind === "upgrade" && !op.globalInstalled) {
|
|
870
918
|
step("install-global"); await driver.installGlobal(op); op.globalInstalled = true; save();
|
|
871
919
|
}
|
|
872
|
-
releaseRecoveryLock(id, home); op.phase = "completed"; step("completed");
|
|
920
|
+
releaseRecoveryLock(id, home, scope); op.phase = "completed"; step("completed");
|
|
873
921
|
} catch (error) {
|
|
874
922
|
// #215: every runner error ends with the choices status shows, read from every open source, never only this one.
|
|
875
923
|
const presentationError = error instanceof RecoveryPresentationError ? error
|
|
@@ -936,12 +984,18 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
936
984
|
return { progress, planned, marker, native };
|
|
937
985
|
};
|
|
938
986
|
selection(); // Finished or unrelated operations must not acquire a new lock merely to refuse.
|
|
939
|
-
|
|
987
|
+
const scope = recoveryScope(op.plan);
|
|
988
|
+
sealScopedPlan(op);
|
|
989
|
+
const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
|
|
990
|
+
acquireRecoveryLock(id, home, scope);
|
|
940
991
|
const releaseRunner = claimRunner(id, home);
|
|
992
|
+
driver = guardedRecoveryDriver(driver, () => op, scopeCurrent);
|
|
941
993
|
let attempted = false;
|
|
942
994
|
const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
|
|
943
995
|
try {
|
|
944
996
|
op = readOperation<RecoveryOperation>(id, home);
|
|
997
|
+
if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner");
|
|
998
|
+
sealScopedPlan(op);
|
|
945
999
|
validateReceipt(id, op);
|
|
946
1000
|
const { progress, planned, marker, native } = selection();
|
|
947
1001
|
const expected = structuredClone(marker.binding);
|
|
@@ -985,16 +1039,21 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
985
1039
|
export async function abortRecovery(id: string, driver: RecoveryDriver, home = hubHome()): Promise<void> {
|
|
986
1040
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
987
1041
|
validateReceipt(id, op, true);
|
|
1042
|
+
const scope = recoveryScope(op.plan);
|
|
1043
|
+
sealScopedPlan(op);
|
|
988
1044
|
if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "abort");
|
|
989
|
-
|
|
1045
|
+
const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
|
|
1046
|
+
acquireRecoveryLock(id, home, scope);
|
|
990
1047
|
const releaseRunner = claimRunner(id, home);
|
|
991
1048
|
try {
|
|
992
1049
|
// Re-read after the exclusive runner claim so cancellation cannot act on a
|
|
993
1050
|
// stale pending/prepared receipt after another runner advanced it.
|
|
994
1051
|
op = readOperation<RecoveryOperation>(id, home);
|
|
1052
|
+
if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner");
|
|
1053
|
+
sealScopedPlan(op);
|
|
995
1054
|
validateReceipt(id, op, true);
|
|
996
1055
|
if (op.phase === "completed" || op.phase === "cancelled") {
|
|
997
|
-
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
1056
|
+
if (recoveryLock(home, scope) === id) releaseRecoveryLock(id, home, scope);
|
|
998
1057
|
return;
|
|
999
1058
|
}
|
|
1000
1059
|
if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "abort");
|
|
@@ -1002,7 +1061,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
1002
1061
|
appendRecoveryDelegation(op, { peer: (driver.conductorPeer ?? driver.liveTestPeer)!, command: "recovery abort" }, driver.now());
|
|
1003
1062
|
writeOperation(id, op, home);
|
|
1004
1063
|
}
|
|
1005
|
-
driver = guardedRecoveryDriver(driver, () => op);
|
|
1064
|
+
driver = guardedRecoveryDriver(driver, () => op, scopeCurrent);
|
|
1006
1065
|
if (abandoned(op)) writeOperation(id, op, home);
|
|
1007
1066
|
const { fingerprint, ...body } = op.plan;
|
|
1008
1067
|
if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
|
|
@@ -1017,7 +1076,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
1017
1076
|
}
|
|
1018
1077
|
delete op.error; delete op.failure; delete op.blockedReattachment; delete op.presentationCause;
|
|
1019
1078
|
op.phase = "cancelled"; op.step = "cancelled"; op.updatedAt = driver.now();
|
|
1020
|
-
writeOperation(id, op, home); releaseRecoveryLock(id, home);
|
|
1079
|
+
writeOperation(id, op, home); releaseRecoveryLock(id, home, scope);
|
|
1021
1080
|
} catch (error) {
|
|
1022
1081
|
if (isDatabaseBusy(error)) throw new RecoveryDatabaseBusy();
|
|
1023
1082
|
throw error;
|
|
@@ -1037,15 +1096,18 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
1037
1096
|
if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
|
|
1038
1097
|
// A finished operation owns no lock: never take one only to refuse.
|
|
1039
1098
|
if (op.phase === "completed" || op.phase === "cancelled") throw new Error(`operation is already ${op.phase}`);
|
|
1040
|
-
|
|
1099
|
+
const scope = recoveryScope(op.plan);
|
|
1100
|
+
sealScopedPlan(op);
|
|
1101
|
+
const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
|
|
1102
|
+
acquireRecoveryLock(id, home, scope);
|
|
1041
1103
|
const releaseRunner = claimRunner(id, home);
|
|
1042
1104
|
try {
|
|
1043
|
-
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
|
|
1105
|
+
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner"); sealScopedPlan(op); }
|
|
1044
1106
|
catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
|
|
1045
1107
|
if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
|
|
1046
1108
|
if (op.phase === "completed" || op.phase === "cancelled") {
|
|
1047
1109
|
// Another runner finished it while this one waited: leave no lock behind, as resume and abort do.
|
|
1048
|
-
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
1110
|
+
if (recoveryLock(home, scope) === id) releaseRecoveryLock(id, home, scope);
|
|
1049
1111
|
throw new Error(`operation is already ${op.phase}`);
|
|
1050
1112
|
}
|
|
1051
1113
|
if (driver.liveTestPeer) {
|
|
@@ -1155,7 +1217,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
1155
1217
|
}
|
|
1156
1218
|
delete op.error; delete op.failure;
|
|
1157
1219
|
op.phase = "cancelled"; op.step = "disposed: stop-and-archive (abandoned, not completed)"; op.updatedAt = driver.now();
|
|
1158
|
-
writeOperation(id, op, home); releaseRecoveryLock(id, home);
|
|
1220
|
+
writeOperation(id, op, home); releaseRecoveryLock(id, home, scope);
|
|
1159
1221
|
return op;
|
|
1160
1222
|
} finally { releaseRunner(); }
|
|
1161
1223
|
}
|
|
@@ -3,8 +3,10 @@ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openS
|
|
|
3
3
|
import { isAbsolute, join, relative } from "node:path";
|
|
4
4
|
import { canonicalPath } from "./project.ts";
|
|
5
5
|
import { insideRoot } from "./path-policy.ts";
|
|
6
|
+
import { checkStartAgents } from "./start-agents.ts";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
/** `startAgents` is the machine default of `start.agents` (#608), present only when `ahub setup` chose one. */
|
|
9
|
+
export interface AutonomyDefaultsRead { enabled: boolean; fingerprint: string; notice?: string; startAgents?: string[] }
|
|
8
10
|
const MAX_BYTES = 4096;
|
|
9
11
|
const same = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
10
12
|
const refusal = () => new Error("machine autonomy defaults are unsafe or changed; nothing was written");
|
|
@@ -30,26 +32,49 @@ function capture(home: string, roots: readonly string[]): { stat?: Stats; text?:
|
|
|
30
32
|
return { stat: after, text, fingerprint: createHash("sha256").update(`${after.dev}:${after.ino}:${after.ctimeMs}:`).update(text).digest("hex") };
|
|
31
33
|
} finally { closeSync(fd); }
|
|
32
34
|
}
|
|
35
|
+
interface DefaultsDocument { autonomy?: boolean; startAgents?: string[] }
|
|
36
|
+
/** The file's content, or the refusal. `{schema:1}` alone and any key but schema, autonomy and start are refused. */
|
|
37
|
+
function parseDefaults(text: string): DefaultsDocument {
|
|
38
|
+
const raw: unknown = JSON.parse(text);
|
|
39
|
+
const document = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : undefined;
|
|
40
|
+
if (!document || Object.keys(document).some(key => !["schema", "autonomy", "start"].includes(key)) || document.schema !== 1) throw refusal();
|
|
41
|
+
if (document.autonomy !== undefined && typeof document.autonomy !== "boolean") throw refusal();
|
|
42
|
+
let startAgents: string[] | undefined;
|
|
43
|
+
if (document.start !== undefined) {
|
|
44
|
+
const start = document.start;
|
|
45
|
+
if (!start || typeof start !== "object" || Array.isArray(start) || Object.keys(start).some(key => key !== "agents")) throw refusal();
|
|
46
|
+
try { startAgents = checkStartAgents((start as Record<string, unknown>).agents); } catch { throw refusal(); }
|
|
47
|
+
}
|
|
48
|
+
if (document.autonomy === undefined && startAgents === undefined) throw refusal();
|
|
49
|
+
return { ...(document.autonomy === undefined ? {} : { autonomy: document.autonomy }), ...(startAgents ? { startAgents } : {}) };
|
|
50
|
+
}
|
|
33
51
|
/** Missing or untrusted machine files never change the shipped ask default. */
|
|
34
52
|
export function readAutonomyDefaults(home: string, roots: readonly string[] = []): AutonomyDefaultsRead {
|
|
35
53
|
try {
|
|
36
54
|
const held = capture(home, roots);
|
|
37
55
|
if (held.text === undefined) return { enabled: false, fingerprint: held.fingerprint };
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
if (!document || Object.keys(document).some(key => !["schema", "autonomy"].includes(key)) || document.schema !== 1 || typeof document.autonomy !== "boolean") throw refusal();
|
|
41
|
-
return { enabled: document.autonomy, fingerprint: held.fingerprint };
|
|
56
|
+
const document = parseDefaults(held.text);
|
|
57
|
+
return { enabled: document.autonomy === true, fingerprint: held.fingerprint, ...(document.startAgents ? { startAgents: document.startAgents } : {}) };
|
|
42
58
|
} catch { return { enabled: false, fingerprint: "untrusted", notice: "Machine autonomy defaults ignored: defaults.json must be an owner-only regular file with a valid schema, outside project roots." }; }
|
|
43
59
|
}
|
|
44
|
-
/** Person CLI only. Exact prior evidence is rechecked after confirmation and before replacement. */
|
|
60
|
+
/** Person CLI only. Exact prior evidence is rechecked after confirmation and before replacement. The other half of the file (the start set) is kept. */
|
|
45
61
|
export function writeAutonomyDefaults(home: string, enabled: boolean, expected: string, roots: readonly string[] = []): void {
|
|
62
|
+
writeDefaults(home, expected, roots, kept => ({ schema: 1, autonomy: enabled, ...(kept.startAgents ? { start: { agents: kept.startAgents } } : {}) }));
|
|
63
|
+
}
|
|
64
|
+
/** Person CLI only (`ahub setup`): the machine default of `start.agents`. The autonomy half of the file is kept, and absent stays absent. */
|
|
65
|
+
export function writeMachineStartAgents(home: string, agents: readonly string[], expected: string, roots: readonly string[] = []): void {
|
|
66
|
+
const checked = checkStartAgents([...agents]);
|
|
67
|
+
writeDefaults(home, expected, roots, kept => ({ schema: 1, ...(kept.autonomy === undefined ? {} : { autonomy: kept.autonomy }), start: { agents: checked } }));
|
|
68
|
+
}
|
|
69
|
+
function writeDefaults(home: string, expected: string, roots: readonly string[], build: (kept: DefaultsDocument) => Record<string, unknown>): void {
|
|
46
70
|
const before = capture(home, roots);
|
|
47
71
|
if (before.fingerprint !== expected) throw refusal();
|
|
72
|
+
const kept = before.text === undefined ? {} : parseDefaults(before.text);
|
|
48
73
|
const parent = location(home, roots, true)!;
|
|
49
74
|
const file = join(home, "defaults.json"), temporary = join(home, `.defaults.${randomUUID()}.tmp`);
|
|
50
75
|
try {
|
|
51
76
|
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
52
|
-
try { writeFileSync(fd, `${JSON.stringify(
|
|
77
|
+
try { writeFileSync(fd, `${JSON.stringify(build(kept), null, 2)}\n`); fsyncSync(fd); }
|
|
53
78
|
finally { closeSync(fd); }
|
|
54
79
|
const current = lstatSync(home);
|
|
55
80
|
if (current.dev !== parent.dev || current.ino !== parent.ino || capture(home, roots).fingerprint !== expected) throw refusal();
|