@staix/agent-hub 0.25.0 → 0.27.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 +17 -0
- package/README.md +12 -6
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/daemon.md +7 -5
- package/docs/agent-notes/tasks.md +7 -6
- package/docs/agent-notes/tests.md +1 -0
- package/docs/dogfood.md +7 -1
- package/docs/operations.md +160 -17
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +18 -4
- package/docs/sdlc.md +3 -3
- package/docs/security.md +52 -9
- package/docs/specs/2026-09-19-agent-hub-design.md +1 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +21 -7
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verification/2026-11-01-0.27.0.md +49 -0
- package/docs/verified.json +28 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +190 -101
- package/src/adapters/channel-peer.ts +4 -2
- package/src/adapters/definitions/builtin-metadata.ts +6 -6
- package/src/adapters/definitions/qwen.ts +2 -0
- package/src/adapters/registry-types.ts +2 -0
- package/src/adapters/registry.ts +1 -0
- package/src/cli/conductor-recovery.ts +3 -2
- package/src/cli/help.ts +9 -8
- package/src/cli/identity.ts +5 -5
- package/src/cli/main.ts +68 -24
- 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/start-agents-run.ts +125 -0
- package/src/cli/study-output.ts +56 -0
- package/src/cli/study.ts +26 -3
- package/src/cli/task-output.ts +5 -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/action-policy.ts +5 -3
- package/src/hub/autonomy-defaults.ts +32 -7
- package/src/hub/board.ts +129 -13
- package/src/hub/budget.ts +3 -4
- 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 +30 -10
- package/src/hub/daemon.ts +103 -26
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/events.ts +2 -1
- package/src/hub/execution-budget.ts +3 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/hub-tools.ts +7 -5
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +49 -33
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/project-runtime-state.ts +164 -0
- package/src/hub/project-runtime.ts +62 -0
- package/src/hub/protocol-window.ts +19 -0
- package/src/hub/recovery-store.ts +102 -12
- package/src/hub/registry.ts +5 -0
- package/src/hub/research-experiments.ts +33 -4
- package/src/hub/research-runner.ts +172 -0
- package/src/hub/restart.ts +9 -0
- package/src/hub/runtime-capture-prune.ts +415 -0
- package/src/hub/runtime-prune-health.ts +112 -0
- package/src/hub/schedules.ts +3 -4
- package/src/hub/sdlc-merge.ts +7 -0
- package/src/hub/sdlc-queue.ts +13 -1
- package/src/hub/sdlc-release-authorization.ts +11 -5
- package/src/hub/session-identity.ts +1 -1
- 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 +133 -34
- package/src/hub/usage-rollup.ts +3 -4
- package/src/pi/tool-receipts.ts +3 -4
- package/templates/presets/confirmation-matrix.json +5 -5
- package/templates/release-guard.block.md +2 -1
package/src/hub/board.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { parseAttemptReceipt, parseResearchRunners, verifyAttemptReceipt, type AttemptReceiptV1 } from "./research-runner.ts";
|
|
1
2
|
import { canonicalJson } from "./packs.ts";
|
|
2
|
-
import { parseComparisonPlan, comparisonPlanHash, evaluatorIdentityHash, parseExperimentProtocol, protocolHash, parseExperimentAttempt, parseExperimentEvent, parseExperimentHostEvidence, experimentStatus, projectExperimentWithObservations,
|
|
3
|
+
import { parseComparisonPlan, comparisonPlanHash, evaluatorIdentityHash, parseExperimentProtocol, protocolHash, parseExperimentAttempt, parseExperimentEvent, parseExperimentHostEvidence, experimentStatus, projectExperimentWithObservations, declaredExperimentEvents,
|
|
3
4
|
type ComparisonPlanV1, type ExperimentProtocolRecord, type ExperimentAttemptRecord, type ExperimentEventRecord, type ExperimentWriteFence, type DeclaredExperimentEventInput, type ExperimentHostEvidence, type ExperimentStatus } from "./research-experiments.ts";
|
|
4
5
|
import type { InvocationRow } from "./invocations.ts";
|
|
5
6
|
import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
|
|
@@ -8,11 +9,11 @@ import { planFingerprint } from "./plan-conformance.ts";
|
|
|
8
9
|
import type { PlannerReason } from "./routing.ts";
|
|
9
10
|
import type { ReviewerPoolReason } from "./reviewer-pool.ts";
|
|
10
11
|
import type { GitHubSource } from "./github-import.ts";
|
|
11
|
-
import { Database } from "
|
|
12
|
+
import type { Database } from "./db-schema.ts";
|
|
13
|
+
import { openHubDatabase, advanceHubDbSchema } from "./db-schema.ts";
|
|
12
14
|
import { planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
13
15
|
import { HUB, USER, type PeerId } from "./envelope.ts";
|
|
14
16
|
import type { SweepRecord } from "./task-sweep.ts";
|
|
15
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
16
17
|
import type { SdlcReceipt } from "./sdlc.ts";
|
|
17
18
|
import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
|
|
18
19
|
import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
|
|
@@ -107,6 +108,12 @@ const EXPERIMENT_PLAN_SELECT = "SELECT rowid AS row_id,hash,project,campaign_id,
|
|
|
107
108
|
interface ExperimentProtocolRow { hash: string; project: string; campaign_id: string; campaign_revision: number; task_id: number; body: string | null; by_peer: string; at: number; private: number }
|
|
108
109
|
interface ExperimentAttemptRow { row_id?: number; attempt_id: string; project: string; campaign_id: string; campaign_revision: number; protocol_hash: string; task_id: number; idempotency_key: string; body: string | null; body_sha256: string; by_peer: string; at: number; private: number }
|
|
109
110
|
interface ExperimentEventRow { attempt_id: string; project: string; seq: number; idempotency_key: string; kind: string; body: string | null; body_sha256: string; by_peer: string; at: number; private: number }
|
|
111
|
+
export interface RunnerReceiptRecord {
|
|
112
|
+
receipt: AttemptReceiptV1; signature: string; publicKey: string; keyHash: string; bodyHash: string;
|
|
113
|
+
seq: number; by: PeerId; at: number; private: boolean;
|
|
114
|
+
}
|
|
115
|
+
interface RunnerReceiptRow { receipt_id: string; project: string; attempt_id: string; event_seq: number; body: string | null; body_sha256: string; by_peer: string; at: number; private: number }
|
|
116
|
+
const RUNNER_RECEIPT_SELECT = "SELECT receipt_id,project,attempt_id,event_seq,CASE WHEN length(CAST(body AS BLOB))<=131072 THEN body END AS body,body_sha256,by_peer,at,private FROM research_runner_receipts";
|
|
110
117
|
interface ExperimentObservationRow { attempt_id: string; event_seq: number; body: string | null; body_sha256: string }
|
|
111
118
|
const EXPERIMENT_PROTOCOL_SELECT = "SELECT hash,project,campaign_id,campaign_revision,task_id,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,by_peer,at,private FROM research_protocols";
|
|
112
119
|
const EXPERIMENT_ATTEMPT_SELECT = "SELECT rowid AS row_id,attempt_id,project,campaign_id,campaign_revision,protocol_hash,task_id,idempotency_key,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,body_sha256,by_peer,at,private FROM research_attempts";
|
|
@@ -212,6 +219,8 @@ export interface HistoryEntry {
|
|
|
212
219
|
evidenceOnly?: true;
|
|
213
220
|
/** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
|
|
214
221
|
reviewFindings?: ReviewFindingSet;
|
|
222
|
+
/** Immutable artifact head of this verdict, including when lifecycle gates are off. */
|
|
223
|
+
reviewedHead?: string;
|
|
215
224
|
/**
|
|
216
225
|
* #509a: the files a `done`, a lifecycle record or an approval bound, with the content hashes the hub computed. An approval carries the hashes
|
|
217
226
|
* it was given at review time, so a later edit reads as "deliverable changed since review". Full paths live here; every view goes through
|
|
@@ -403,8 +412,7 @@ export class Board {
|
|
|
403
412
|
}
|
|
404
413
|
|
|
405
414
|
constructor(path: string) {
|
|
406
|
-
this.db =
|
|
407
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
415
|
+
this.db = openHubDatabase(path, { create: true });
|
|
408
416
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
409
417
|
this.db.run(`CREATE TABLE IF NOT EXISTS tasks (
|
|
410
418
|
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', class TEXT NOT NULL,
|
|
@@ -505,6 +513,10 @@ export class Board {
|
|
|
505
513
|
this.db.run(`CREATE TABLE IF NOT EXISTS research_attempt_observations (
|
|
506
514
|
attempt_id TEXT NOT NULL, event_seq INTEGER NOT NULL, body TEXT NOT NULL, body_sha256 TEXT NOT NULL,
|
|
507
515
|
PRIMARY KEY(attempt_id,event_seq))`);
|
|
516
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_runner_receipts (
|
|
517
|
+
receipt_id TEXT PRIMARY KEY, project TEXT NOT NULL, attempt_id TEXT NOT NULL, event_seq INTEGER NOT NULL,
|
|
518
|
+
body TEXT NOT NULL, body_sha256 TEXT NOT NULL, by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL,
|
|
519
|
+
UNIQUE(attempt_id,event_seq))`);
|
|
508
520
|
this.db.run(`CREATE TABLE IF NOT EXISTS research_plans (
|
|
509
521
|
hash TEXT PRIMARY KEY, project TEXT NOT NULL, campaign_id TEXT NOT NULL, campaign_revision INTEGER NOT NULL,
|
|
510
522
|
task_id INTEGER NOT NULL, body TEXT NOT NULL, by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL)`);
|
|
@@ -867,7 +879,7 @@ export class Board {
|
|
|
867
879
|
}
|
|
868
880
|
|
|
869
881
|
hasResearchState(): boolean {
|
|
870
|
-
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_campaigns) OR EXISTS(SELECT 1 FROM research_units) OR EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) AS present").get() as { present: number }).present === 1;
|
|
882
|
+
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_campaigns) OR EXISTS(SELECT 1 FROM research_units) OR EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) OR EXISTS(SELECT 1 FROM research_runner_receipts) AS present").get() as { present: number }).present === 1;
|
|
871
883
|
}
|
|
872
884
|
|
|
873
885
|
/** A consistent, streaming digest includes metadata and body hashes, not merely row counts. */
|
|
@@ -1104,6 +1116,15 @@ export class Board {
|
|
|
1104
1116
|
if (typeof row.body !== "string" || !researchInteger(row.seq) || !researchInteger(row.at, 0) || !researchText(row.by_peer) || ![0, 1].includes(row.private) || !researchText(row.idempotency_key, 64)) throw experimentFailure();
|
|
1105
1117
|
const attempt = this.experimentAttempt(row.attempt_id), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
|
|
1106
1118
|
if (!attempt || !protocol || row.project !== attempt.project) throw experimentFailure();
|
|
1119
|
+
if (row.kind === "attestation") {
|
|
1120
|
+
const raw = JSON.parse(row.body), body = raw?.body, receipt = typeof body?.receiptId === "string" && this.runnerReceipt(body.receiptId);
|
|
1121
|
+
if (!receipt || raw.kind !== "attestation" || Object.keys(raw).length !== 2 || Object.keys(body).length !== 1 || receipt.receipt.attemptId !== row.attempt_id || receipt.seq !== row.seq || receipt.at !== row.at
|
|
1122
|
+
|| receipt.by !== row.by_peer || row.body_sha256 !== experimentHash({ kind: "attestation", body }) || row.idempotency_key !== `rr-${receipt.bodyHash.slice(0, 61)}`
|
|
1123
|
+
|| this.db.query("SELECT 1 FROM research_attempt_observations WHERE attempt_id=? AND event_seq=?").get(row.attempt_id, row.seq)) throw experimentFailure();
|
|
1124
|
+
return { attemptId: row.attempt_id, idempotencyKey: row.idempotency_key,
|
|
1125
|
+
event: { kind: "attestation", body: { receiptId: receipt.receipt.receiptId }, seq: row.seq, at: row.at, provenance: "runner-attested" },
|
|
1126
|
+
bodyHash: row.body_sha256, by: row.by_peer, private: row.private === 1 || attempt.private || receipt.private, observations: [] };
|
|
1127
|
+
}
|
|
1107
1128
|
const input = parseExperimentEvent(JSON.parse(row.body), protocol.protocol);
|
|
1108
1129
|
if (input.kind !== row.kind || experimentHash(input) !== row.body_sha256) throw experimentFailure();
|
|
1109
1130
|
const event = { ...input, seq: row.seq, at: row.at, provenance: "declared" as const };
|
|
@@ -1147,6 +1168,87 @@ export class Board {
|
|
|
1147
1168
|
});
|
|
1148
1169
|
}
|
|
1149
1170
|
|
|
1171
|
+
private runnerReceiptFromRow(row: RunnerReceiptRow): RunnerReceiptRecord {
|
|
1172
|
+
try {
|
|
1173
|
+
if (typeof row.body !== "string" || !researchInteger(row.event_seq) || !researchInteger(row.at, 0) || !researchText(row.by_peer) || ![0, 1].includes(row.private)) throw experimentFailure();
|
|
1174
|
+
const raw = JSON.parse(row.body);
|
|
1175
|
+
if (!raw || Object.keys(raw).sort().join(",") !== "publicKey,receipt,signature") throw experimentFailure();
|
|
1176
|
+
const receipt = parseAttemptReceipt(raw.receipt);
|
|
1177
|
+
const runner = parseResearchRunners({ [receipt.runner]: { publicKey: raw.publicKey, projects: [receipt.projectId], revoked: false } })[receipt.runner]!;
|
|
1178
|
+
if (typeof raw.signature !== "string" || raw.signature.length !== 88 || Buffer.from(raw.signature, "base64").length !== 64
|
|
1179
|
+
|| Buffer.from(raw.signature, "base64").toString("base64") !== raw.signature) throw experimentFailure();
|
|
1180
|
+
const body = { receipt, signature: raw.signature as string, publicKey: runner.publicKey };
|
|
1181
|
+
const attempt = this.experimentAttempt(receipt.attemptId), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
|
|
1182
|
+
if (!attempt || !protocol || row.receipt_id !== receipt.receiptId || row.attempt_id !== receipt.attemptId || row.project !== receipt.projectId || row.project !== attempt.project
|
|
1183
|
+
|| receipt.protocolHash !== attempt.attempt.protocolHash || experimentHash(body) !== row.body_sha256
|
|
1184
|
+
|| receipt.codeCommit !== undefined && "commit" in protocol.protocol.code && receipt.codeCommit !== protocol.protocol.code.commit) throw experimentFailure();
|
|
1185
|
+
const event = this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND seq=?`).get(row.attempt_id, row.event_seq) as ExperimentEventRow | null;
|
|
1186
|
+
const expectedEvent = { kind: "attestation", body: { receiptId: receipt.receiptId } };
|
|
1187
|
+
if (!event || event.kind !== "attestation" || typeof event.body !== "string" || event.body !== canonicalJson(expectedEvent)
|
|
1188
|
+
|| event.body_sha256 !== experimentHash(expectedEvent) || event.project !== row.project || event.by_peer !== row.by_peer || event.at !== row.at
|
|
1189
|
+
|| event.idempotency_key !== `rr-${row.body_sha256.slice(0, 61)}` || ![0, 1].includes(event.private)
|
|
1190
|
+
|| this.db.query("SELECT 1 FROM research_attempt_observations WHERE attempt_id=? AND event_seq=?").get(row.attempt_id, row.event_seq)) throw experimentFailure();
|
|
1191
|
+
return { ...body, keyHash: createHash("sha256").update(Buffer.from(runner.publicKey, "base64")).digest("hex"), bodyHash: row.body_sha256,
|
|
1192
|
+
seq: row.event_seq, by: row.by_peer, at: row.at, private: row.private === 1 || event.private === 1 || attempt.private };
|
|
1193
|
+
} catch { throw experimentFailure(); }
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
runnerReceipt(id: string): RunnerReceiptRecord | undefined {
|
|
1197
|
+
const row = this.db.query(`${RUNNER_RECEIPT_SELECT} WHERE receipt_id=?`).get(id) as RunnerReceiptRow | null;
|
|
1198
|
+
return row ? this.runnerReceiptFromRow(row) : undefined;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
runnerReceipts(attemptId: string): RunnerReceiptRecord[] {
|
|
1202
|
+
if (!this.experimentAttempt(attemptId)) throw experimentFailure();
|
|
1203
|
+
// ponytail: at most 64 signed receipts per attempt; paginate only with a reviewed evidence-page contract.
|
|
1204
|
+
const rows = this.db.query(`${RUNNER_RECEIPT_SELECT} WHERE attempt_id=? ORDER BY event_seq LIMIT 65`).all(attemptId) as RunnerReceiptRow[];
|
|
1205
|
+
if (rows.length > 64) throw experimentFailure();
|
|
1206
|
+
return rows.map(row => this.runnerReceiptFromRow(row));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
appendRunnerReceipt(record: Pick<RunnerReceiptRecord, "receipt" | "signature" | "publicKey" | "by" | "at" | "private">, expectedLastSeq: number, fence: ExperimentWriteFence): RunnerReceiptRecord {
|
|
1210
|
+
const receipt = parseAttemptReceipt(record.receipt), attempt = this.experimentAttempt(receipt.attemptId), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
|
|
1211
|
+
if (!attempt || !protocol || record.by !== fence.owner && record.by !== USER || !researchInteger(record.at, 0) || typeof record.private !== "boolean" || !researchInteger(expectedLastSeq, 0)) throw experimentFailure();
|
|
1212
|
+
const body = { receipt, signature: record.signature, publicKey: record.publicKey }, bodyHash = experimentHash(body);
|
|
1213
|
+
return this.atomic(() => {
|
|
1214
|
+
const { campaign } = this.experimentFence(fence);
|
|
1215
|
+
if (attempt.project !== fence.project || attempt.attempt.taskId !== fence.taskId || protocol.protocol.campaignId !== campaign.id || protocol.protocol.campaignRevision !== campaign.revision) throw experimentFailure();
|
|
1216
|
+
const prior = this.runnerReceipt(receipt.receiptId);
|
|
1217
|
+
if (prior) {
|
|
1218
|
+
if (prior.bodyHash !== bodyHash || canonicalJson({ receipt: prior.receipt, signature: prior.signature, publicKey: prior.publicKey }) !== canonicalJson(body)) throw new Error("receiptId already records different signed bytes");
|
|
1219
|
+
return prior;
|
|
1220
|
+
}
|
|
1221
|
+
verifyAttemptReceipt(receipt, record.signature, { runners: { [receipt.runner]: { publicKey: record.publicKey, projects: [fence.project], revoked: false } }, projectId: fence.project,
|
|
1222
|
+
attempt: { id: attempt.attemptId, projectId: attempt.project, protocolHash: attempt.attempt.protocolHash, ...("commit" in protocol.protocol.code ? { codeCommit: protocol.protocol.code.commit } : {}) } });
|
|
1223
|
+
if (this.runnerReceipts(attempt.attemptId).length >= 64) throw new Error("an attempt supports at most 64 runner receipts");
|
|
1224
|
+
if (this.experimentLastSeq(attempt.attemptId) !== expectedLastSeq || !researchInteger(expectedLastSeq + 1)) throw new Error("experiment event sequence changed");
|
|
1225
|
+
const seq = expectedLastSeq + 1, event = { kind: "attestation", body: { receiptId: receipt.receiptId } };
|
|
1226
|
+
// The first attestation is incompatible with older event readers, so both writes and the format floor commit together.
|
|
1227
|
+
advanceHubDbSchema(this.db, 2, "0.26.0");
|
|
1228
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_attempt_events(attempt_id,project,seq,idempotency_key,kind,body,body_sha256,by_peer,at,private) VALUES(?,?,?,?,?,?,?,?,?,?)")
|
|
1229
|
+
.run(attempt.attemptId, fence.project, seq, `rr-${bodyHash.slice(0, 61)}`, "attestation", canonicalJson(event), experimentHash(event), record.by, record.at, record.private || attempt.private ? 1 : 0));
|
|
1230
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_runner_receipts(receipt_id,project,attempt_id,event_seq,body,body_sha256,by_peer,at,private) VALUES(?,?,?,?,?,?,?,?,?)")
|
|
1231
|
+
.run(receipt.receiptId, fence.project, attempt.attemptId, seq, canonicalJson(body), bodyHash, record.by, record.at, record.private || attempt.private ? 1 : 0));
|
|
1232
|
+
if (record.private || attempt.private) this.taskWrite(() => this.db.query("UPDATE research_attempts SET private=1 WHERE attempt_id=? AND private=0").run(attempt.attemptId));
|
|
1233
|
+
return this.runnerReceipt(receipt.receiptId)!;
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
hasRunnerReceipts(): boolean {
|
|
1238
|
+
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_runner_receipts) OR EXISTS(SELECT 1 FROM research_attempt_events WHERE kind='attestation') AS present").get() as { present: number }).present === 1;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
runnerReceiptIntegrity(): { count: number; digest: string } {
|
|
1242
|
+
return this.db.transaction(() => {
|
|
1243
|
+
if (this.db.query("SELECT 1 FROM research_attempt_events AS e LEFT JOIN research_runner_receipts AS r ON r.attempt_id=e.attempt_id AND r.event_seq=e.seq WHERE e.kind='attestation' AND r.receipt_id IS NULL LIMIT 1").get()) throw experimentFailure();
|
|
1244
|
+
const digest = createHash("sha256").update("agent-hub.runner-receipts-integrity/v1\n"); let count = 0;
|
|
1245
|
+
for (const row of this.db.query(`${RUNNER_RECEIPT_SELECT} ORDER BY receipt_id`).iterate() as Iterable<RunnerReceiptRow>) {
|
|
1246
|
+
digest.update(canonicalJson(this.runnerReceiptFromRow(row))).update("\n"); count++;
|
|
1247
|
+
}
|
|
1248
|
+
return { count, digest: digest.digest("hex") };
|
|
1249
|
+
}).deferred();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1150
1252
|
experimentLastSeq(attemptId: string): number {
|
|
1151
1253
|
if (!this.experimentAttempt(attemptId)) throw experimentFailure();
|
|
1152
1254
|
const last = this.db.query("SELECT MAX(seq) AS seq,COUNT(*) AS count FROM research_attempt_events WHERE attempt_id=?").get(attemptId) as { seq: number | null; count: number };
|
|
@@ -1177,7 +1279,7 @@ export class Board {
|
|
|
1177
1279
|
if (endRows.length > 1) throw experimentFailure();
|
|
1178
1280
|
const end = endRows[0], start = !end && this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND kind='start' ORDER BY seq LIMIT 1`).get(attemptId) as ExperimentEventRow | null;
|
|
1179
1281
|
const events = end ? [this.experimentEventFromRow(end).event] : start ? [this.experimentEventFromRow(start).event] : [];
|
|
1180
|
-
return experimentStatus(events, this.get(attempt.attempt.taskId)?.state);
|
|
1282
|
+
return experimentStatus(declaredExperimentEvents(events), this.get(attempt.attempt.taskId)?.state);
|
|
1181
1283
|
}
|
|
1182
1284
|
|
|
1183
1285
|
// ponytail: exact counts validate all campaign attempts on each show (linear in attempt count). Upgrade to a transactional status projection maintained on event and task-state changes.
|
|
@@ -1194,7 +1296,7 @@ export class Board {
|
|
|
1194
1296
|
}
|
|
1195
1297
|
|
|
1196
1298
|
hasResearchExperimentState(): boolean {
|
|
1197
|
-
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) AS present").get() as { present: number }).present === 1;
|
|
1299
|
+
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) OR EXISTS(SELECT 1 FROM research_runner_receipts) AS present").get() as { present: number }).present === 1;
|
|
1198
1300
|
}
|
|
1199
1301
|
|
|
1200
1302
|
researchExperimentsIntegrity(): { protocols: number; attempts: number; events: number; observations: number; digest: string } {
|
|
@@ -1352,19 +1454,26 @@ export class Board {
|
|
|
1352
1454
|
|
|
1353
1455
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
1354
1456
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
1355
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
1457
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
1356
1458
|
const task = this.get(id);
|
|
1357
1459
|
if (!task) throw new Error(`no task #${id}`);
|
|
1358
1460
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
1359
1461
|
const untouchedReturn = event === "backlog returned untouched" && patch.state === "backlog" && patch.owner === null && patch.reviewer === null
|
|
1360
1462
|
&& ["proposed", "in_progress"].includes(task.state) && expectedFlowRevision !== undefined && !!note?.trim() && !taskHasWorkEvidence(task);
|
|
1361
1463
|
// A new delivered head with a configured check is owner work again until that check finishes.
|
|
1362
|
-
const reviewCheck = event === "done (checking)" && task.state
|
|
1363
|
-
&& expectedFlowRevision !== undefined && (by === task.owner || by === "user") && patch.owner === undefined && patch.reviewer === undefined
|
|
1464
|
+
const reviewCheck = task.sdlc?.merge?.state !== "merged" && event === "done (checking)" && ["in_review", "approved"].includes(task.state) && patch.state === "in_progress"
|
|
1465
|
+
&& expectedFlowRevision !== undefined && (by === task.owner || by === "user") && patch.owner === undefined && (patch.reviewer === undefined || task.state === "approved" && !task.reviewer && patch.reviewer === "user")
|
|
1364
1466
|
&& typeof patch.refs?.commit === "string" && /^[a-f0-9]{40}$/.test(patch.refs.commit)
|
|
1365
1467
|
&& patch.refs.commit !== (task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead);
|
|
1468
|
+
const approvalReopen = task.state === "approved" && task.sdlc?.merge?.state !== "merged" && expectedFlowRevision !== undefined
|
|
1469
|
+
&& patch.owner === undefined && (
|
|
1470
|
+
event === "done" && patch.state === "in_review" && (by === task.owner || by === "user")
|
|
1471
|
+
&& typeof patch.refs?.commit === "string" && /^[a-f0-9]{40}$/.test(patch.refs.commit)
|
|
1472
|
+
&& patch.refs.commit !== (task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead)
|
|
1473
|
+
|| event === "changes_requested" && patch.state === "changes_requested" && (by === task.reviewer || by === "user"));
|
|
1474
|
+
if (extra.reviewedHead !== undefined && (!/^[a-f0-9]{40}$/.test(extra.reviewedHead) || !["approved", "changes_requested"].includes(event))) throw new Error("invalid verdict head");
|
|
1366
1475
|
if (task.draft && patch.state && patch.state !== "backlog" && patch.state !== "cancelled") throw new Error(`task #${id} is an intake draft; the person approves it first`);
|
|
1367
|
-
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn && !reviewCheck) {
|
|
1476
|
+
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn && !reviewCheck && !approvalReopen) {
|
|
1368
1477
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
1369
1478
|
}
|
|
1370
1479
|
const planner = Object.hasOwn(patch, "planner") ? plannerValue(patch.planner) : task.planner ?? null;
|
|
@@ -1417,7 +1526,14 @@ export class Board {
|
|
|
1417
1526
|
// Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
|
|
1418
1527
|
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
1419
1528
|
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
1420
|
-
|
|
1529
|
+
// Older verdict rows kept their bound head only on the receipt. Preserve it atomically before reopening drops that receipt.
|
|
1530
|
+
const priorApproval = task.history.findLastIndex(entry => entry.event === "approved");
|
|
1531
|
+
const priorHead = task.sdlc?.reviewedHead;
|
|
1532
|
+
const backfill = (approvalReopen || reviewCheck && task.state === "approved") && priorApproval >= 0
|
|
1533
|
+
&& priorHead !== undefined && /^[a-f0-9]{40}$/.test(priorHead);
|
|
1534
|
+
const previousHistory = backfill ? task.history.map((entry, index) => index === priorApproval && !entry.reviewedHead
|
|
1535
|
+
? { ...entry, reviewedHead: entry.reviewFindings?.head ?? priorHead } : entry) : task.history;
|
|
1536
|
+
const history = [...previousHistory, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
|
|
1421
1537
|
this.taskWrite(() => {
|
|
1422
1538
|
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
|
|
1423
1539
|
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);
|
package/src/hub/budget.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { definitionOf, type AdapterDefinition } from "../adapters/registry.ts";
|
|
2
|
-
import { Database } from "
|
|
2
|
+
import type { Database } from "./db-schema.ts";
|
|
3
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
3
4
|
import type { PeerId } from "./envelope.ts";
|
|
4
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
5
5
|
import { windowKind, type WindowKind } from "./window-kind.ts";
|
|
6
6
|
export { windowKind, type WindowKind };
|
|
7
7
|
|
|
@@ -136,8 +136,7 @@ export class Budget {
|
|
|
136
136
|
private readonly hooks: BudgetHooks,
|
|
137
137
|
private readonly now: () => number = Date.now,
|
|
138
138
|
) {
|
|
139
|
-
this.db =
|
|
140
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
139
|
+
this.db = openHubDatabase(dbPath, { create: true });
|
|
141
140
|
this.db.run(`CREATE TABLE IF NOT EXISTS budget_pauses (peer TEXT PRIMARY KEY, since INTEGER NOT NULL, resets_at INTEGER NOT NULL,
|
|
142
141
|
reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
|
|
143
142
|
try { this.normalizeStoredPauses(); } catch (error) { this.db.close(); throw error; }
|
package/src/hub/child-process.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import { withoutOrcaEnv } from "./orca-env.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Recovery authority belongs to the short-lived ahub wrapper/launcher. Native
|
|
@@ -19,9 +20,9 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
|
|
|
19
20
|
* typed or started there has the console's identity, whatever started the hub.
|
|
20
21
|
*/
|
|
21
22
|
export function personEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED"
|
|
23
|
+
// The Orca terminal the hub itself was started in is not this new terminal: a launch there must not record it as its own, so every ORCA_ variable goes (#613).
|
|
24
|
+
const env = withoutOrcaEnv(childEnv(source));
|
|
25
|
+
for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED"]) delete env[name];
|
|
25
26
|
return env;
|
|
26
27
|
}
|
|
27
28
|
|
package/src/hub/conductor.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { definitionOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
2
|
-
import { Database } from "
|
|
2
|
+
import type { Database } from "./db-schema.ts";
|
|
3
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
3
4
|
import type { Task } from "./board.ts";
|
|
4
5
|
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
5
6
|
import { HUB, sanitize, USER } from "./envelope.ts";
|
|
@@ -11,7 +12,6 @@ import type { SupervisionFeed } from "./supervision.ts";
|
|
|
11
12
|
import { DOING_STUB, type PeerDoing } from "./peer-doing.ts";
|
|
12
13
|
import type { Budget } from "./budget.ts";
|
|
13
14
|
import { windowKind, type WindowKind } from "./window-kind.ts";
|
|
14
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
15
15
|
import { supportsHeadlessInstances } from "./instances.ts";
|
|
16
16
|
|
|
17
17
|
/** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
|
|
@@ -72,8 +72,7 @@ export interface ConductorHold { peer: string; actor: string; since: number }
|
|
|
72
72
|
export class ConductorHolds {
|
|
73
73
|
private readonly db: Database;
|
|
74
74
|
constructor(dbPath: string, private readonly now: () => number = Date.now) {
|
|
75
|
-
this.db =
|
|
76
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
75
|
+
this.db = openHubDatabase(dbPath, { create: true });
|
|
77
76
|
this.db.run("CREATE TABLE IF NOT EXISTS conductor_holds (peer TEXT PRIMARY KEY, actor TEXT NOT NULL, since INTEGER NOT NULL)");
|
|
78
77
|
this.db.run("CREATE TABLE IF NOT EXISTS supervision_rounds (peer TEXT PRIMARY KEY, signature TEXT NOT NULL)");
|
|
79
78
|
}
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { operationalNumber, OPERATIONAL_NUMBERS } from "./operational-settings.ts";
|
|
2
|
+
import { parseResearchRunners } from "./research-runner.ts";
|
|
2
3
|
import { QUEUE_BACKPRESSURE_LIMITS } from "./queue-backpressure.ts";
|
|
3
4
|
import { operationalSettingKey } from "./operational-authority.ts";
|
|
4
5
|
import { remoteRunnerConfig } from "./check-runner.ts";
|
|
@@ -14,6 +15,7 @@ import { createProjectAdapterCatalog, type ProjectAdapterCatalog, ADAPTERS, DEFA
|
|
|
14
15
|
import { CLASSES } from "./board.ts";
|
|
15
16
|
import { permissionDefaults } from "./permission-mode.ts";
|
|
16
17
|
import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
|
|
18
|
+
import { checkStartAgents } from "./start-agents.ts";
|
|
17
19
|
import { catalogAssignments } from "./roles.ts";
|
|
18
20
|
import { resolveProfile } from "./profiles.ts";
|
|
19
21
|
import { foldersConfig, schedulesConfig } from "./schedules.ts";
|
|
@@ -62,6 +64,12 @@ export interface ConfigAuthorizationDecision { key: string; allowed: boolean; gu
|
|
|
62
64
|
export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuthorizationDecision {
|
|
63
65
|
const { key, before, after } = input;
|
|
64
66
|
const result = (allowed: boolean, guarded: boolean, reason: string): ConfigAuthorizationDecision => ({ key, allowed, guarded, reason });
|
|
67
|
+
const runners = key === "research.runners" || key.startsWith("research.runners.") || key === "research"
|
|
68
|
+
&& [before, after].some(value => !!value && typeof value === "object" && Object.hasOwn(value, "runners"));
|
|
69
|
+
if (runners) {
|
|
70
|
+
if (input.source !== "config" || input.sourceFile !== "config.local.json" || input.tracked !== false) return result(false, true, "research.runners requires untracked config.local.json");
|
|
71
|
+
if (input.authority === "conductor") return result(false, true, "runner registration is person only");
|
|
72
|
+
}
|
|
65
73
|
if (key === "action_policy" || key.startsWith("action_policy.")) {
|
|
66
74
|
if (input.sourceFile !== "config.local.json") return result(false, true, "action policy requires .agenthub/config.local.json");
|
|
67
75
|
if (input.tracked !== false) return result(false, true, "action policy requires an untracked machine-local source");
|
|
@@ -175,6 +183,8 @@ export const validatePetConfig: Check = (v, k) => {
|
|
|
175
183
|
};
|
|
176
184
|
shapes.set(validatePetConfig, { fields: petFields });
|
|
177
185
|
const nativeValidators = { bool, text, strings, argv, number, words, block };
|
|
186
|
+
const researchRunners: Check = value => { parseResearchRunners(value); };
|
|
187
|
+
shapes.set(researchRunners, { item: block({ publicKey: text, projects: strings, revoked: bool }) });
|
|
178
188
|
const schema: Record<string, Check> = {
|
|
179
189
|
_autonomy: v => { autonomyOwnership(v); },
|
|
180
190
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
|
|
@@ -214,9 +224,10 @@ const schema: Record<string, Check> = {
|
|
|
214
224
|
else if (key === "remote") remoteRunnerConfig(value);
|
|
215
225
|
else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
|
|
216
226
|
} },
|
|
217
|
-
snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
|
|
227
|
+
snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool, runners: researchRunners }), bench: block({ enabled: bool }),
|
|
218
228
|
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
|
|
219
229
|
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
230
|
+
start: (v, k) => { block({ agents: strings })(v, k); if (object(v, k).agents !== undefined) checkStartAgents(object(v, k).agents, `${k}.agents`); },
|
|
220
231
|
limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
|
|
221
232
|
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool, reconnect_wait_s: operationalNumberCheck("recovery.reconnect_wait_s"), unmanaged_reconnect_wait_s: operationalNumberCheck("recovery.unmanaged_reconnect_wait_s"), control_timeout_s: operationalNumberCheck("recovery.control_timeout_s"), shutdown_wait_s: operationalNumberCheck("recovery.shutdown_wait_s"), terminal_idle_wait_s: operationalNumberCheck("recovery.terminal_idle_wait_s") }),
|
|
222
233
|
capabilities: map(strings), invocation: block({ share: map(map(() => {})), timeout_s: number(10, 86_400, true) }), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
@@ -269,7 +280,7 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
|
|
|
269
280
|
if (top !== "routing" && top !== "permission" && key.includes(".")) {
|
|
270
281
|
const special: Record<string, readonly string[]> = {
|
|
271
282
|
task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
|
|
272
|
-
checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"],
|
|
283
|
+
checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"], start: ["agents"],
|
|
273
284
|
};
|
|
274
285
|
let check = schema[top];
|
|
275
286
|
for (const [index, part] of key.split(".").slice(1).entries()) {
|
|
@@ -160,6 +160,7 @@ export function createConfigEditor(host: ConfigEditHost) {
|
|
|
160
160
|
function leafKeys(plan: Extract<ConfigEditPlan, { ok: true }>): string[] {
|
|
161
161
|
const out = new Set<string>();
|
|
162
162
|
const walk = (prefix: string, before: unknown, after: unknown): void => {
|
|
163
|
+
if (prefix === "research.runners") { out.add(prefix); return; }
|
|
163
164
|
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
164
165
|
if (object(before) || object(after)) {
|
|
165
166
|
const old = object(before) ? before : {}, next = object(after) ? after : {};
|
package/src/hub/config-tier.ts
CHANGED
|
@@ -41,6 +41,9 @@ export function configTier(file: ConfigEditFile, path: readonly string[], value?
|
|
|
41
41
|
const key = path.join(".");
|
|
42
42
|
if (file === "sdlc.toml") return path.length === 1 && (SDLC_KEYS as readonly string[]).includes(path[0]!) ? { tier: 2 } : refuse(`sdlc.${key} is not editable here: only mode and required_checks are; edit .agenthub/sdlc.toml in your editor`);
|
|
43
43
|
if (registryKey(key)) return { refused: `${key} is already editable with ahub settings set ${key}; nothing was written` };
|
|
44
|
+
if (path[0] === "research" && path[1] === "runners") {
|
|
45
|
+
return path.length === 2 ? { tier: 2 } : refuse("research.runners: edit the whole registration block");
|
|
46
|
+
}
|
|
44
47
|
if (path[0] === "instances") {
|
|
45
48
|
if (path.length === 1) return refuse("instances: name one instance (instances.<id>)");
|
|
46
49
|
if (!PEER.test(path[1]!)) return refuse(`instances.${path[1]}: an instance id is lower-case letters, digits and hyphens`);
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -36,7 +36,9 @@ export const MACHINE_LOCAL = [
|
|
|
36
36
|
"invocation", // #445 C1: what another agent may be offered from this machine's agents (a digest-pinned allowlist); a committed config may not share a person's agents' commands with someone else's peers
|
|
37
37
|
] as const;
|
|
38
38
|
/** Legacy exported field list stays compatible; new policies use the complete list below. */
|
|
39
|
-
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "rooms", "tasks.queue_backpressure", "context.max_assignment_used", "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
|
|
39
|
+
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "research.runners", "rooms", "tasks.queue_backpressure", "context.max_assignment_used", "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "start.agents", "adapters", "adapter_modules", "text_projection", "instances"] as const;
|
|
40
|
+
/** An empty list of these is a value (`start.agents` empty starts no agent), not "the default", so the empty-value drop below skips them. */
|
|
41
|
+
const EMPTY_IS_VALUE: readonly string[] = ["start.agents"];
|
|
40
42
|
|
|
41
43
|
/** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
|
|
42
44
|
export function configRepositoryPresent(cwd: string): boolean {
|
|
@@ -102,11 +104,17 @@ const drop = (file: Record<string, unknown>, path: string) => {
|
|
|
102
104
|
* once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
|
|
103
105
|
*/
|
|
104
106
|
export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
|
|
107
|
+
const research = file.research;
|
|
108
|
+
if (research && typeof research === "object" && Object.hasOwn(research, "runners")) {
|
|
109
|
+
if (name !== "config.local.json") throw new Error("research.runners requires untracked config.local.json");
|
|
110
|
+
const why = configRefusal(cwd, name);
|
|
111
|
+
if (why) throw new Error(`research.runners requires untracked config.local.json: ${why}`);
|
|
112
|
+
}
|
|
105
113
|
if (name !== "config.local.json") delete file.rooms;
|
|
106
114
|
const localOnly = name !== "config.local.json" && file.action_policy !== undefined;
|
|
107
115
|
if (localOnly) delete file.action_policy;
|
|
108
116
|
const originNotice = localOnly ? "action_policy ignored: only .agenthub/config.local.json may set it" : undefined;
|
|
109
|
-
for (const p of ALL_MACHINE_LOCAL) if (valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
|
|
117
|
+
for (const p of ALL_MACHINE_LOCAL) if (!EMPTY_IS_VALUE.includes(p) && valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
|
|
110
118
|
const set = ALL_MACHINE_LOCAL.filter((p) => {
|
|
111
119
|
const v = valueAt(file, p);
|
|
112
120
|
return v !== undefined && JSON.stringify(v) !== JSON.stringify(valueAt(defaults, p));
|
|
@@ -2,16 +2,27 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { projectContext } from "./project.ts";
|
|
4
4
|
import { externalAdapterMetadata, externalInstanceCatalog, type ExternalInstanceCatalog, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
|
|
5
|
+
import { negotiateProtocol, protocolWindow, type ProtocolWindow } from "./protocol-window.ts";
|
|
5
6
|
|
|
6
7
|
export function stateDirFor(cwd: string): string {
|
|
7
8
|
return projectContext(cwd).stateDir;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
11
|
-
export const PROTOCOL =
|
|
12
|
-
|
|
12
|
+
export const PROTOCOL = 17;
|
|
13
|
+
/** The descriptor's legacy protocol field remains this floor for previous published clients. */
|
|
14
|
+
export const PROTOCOL_MIN = 16;
|
|
15
|
+
export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard"] as const;
|
|
13
16
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
14
|
-
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
17
|
+
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, 16, PROTOCOL] as const;
|
|
18
|
+
|
|
19
|
+
/** Modern descriptors advertise their maximum separately; old descriptors represent singleton windows. */
|
|
20
|
+
export function controlProtocolWindow(descriptor: { protocol?: unknown; protocolMin?: unknown; protocolMax?: unknown }): ProtocolWindow | undefined {
|
|
21
|
+
const legacy = protocolWindow(descriptor.protocol);
|
|
22
|
+
const window = protocolWindow(descriptor.protocolMax ?? descriptor.protocol, descriptor.protocolMin ?? descriptor.protocol);
|
|
23
|
+
if (!legacy || !window || legacy.v < window.vMin || legacy.v > window.v) return undefined;
|
|
24
|
+
return window;
|
|
25
|
+
}
|
|
15
26
|
|
|
16
27
|
/** Only a launch retries transient transport failures. Refusals and identity mismatches
|
|
17
28
|
* remain terminal; every attempt rereads the descriptor and applies the instance pin. */
|
|
@@ -62,6 +73,8 @@ export interface ControlDescriptor {
|
|
|
62
73
|
instanceId?: string;
|
|
63
74
|
cwd?: string;
|
|
64
75
|
protocol?: number;
|
|
76
|
+
protocolMin?: number;
|
|
77
|
+
protocolMax?: number;
|
|
65
78
|
pid?: number;
|
|
66
79
|
features?: readonly string[];
|
|
67
80
|
/** The daemon's accepted native start modes, used by launchers when the file value is retained. */
|
|
@@ -81,7 +94,7 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
|
|
|
81
94
|
? Object.fromEntries(Object.entries(status.startModes).filter((entry): entry is [string, "tui" | "headless"] => entry[1] === "tui" || entry[1] === "headless"))
|
|
82
95
|
: undefined;
|
|
83
96
|
return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
|
|
84
|
-
instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid, features: Array.isArray(status.features) ? status.features.filter((feature: unknown): feature is string => typeof feature === "string") : [],
|
|
97
|
+
instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, protocolMin: status.protocolMin, protocolMax: status.protocolMax, pid: status.pid, features: Array.isArray(status.features) ? status.features.filter((feature: unknown): feature is string => typeof feature === "string") : [],
|
|
85
98
|
...(startModes ? { startModes } : {}),
|
|
86
99
|
...(status.externalAdapters !== undefined ? { externalAdapters: externalAdapterMetadata(status.externalAdapters) } : {}),
|
|
87
100
|
...(typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}) };
|
|
@@ -105,6 +118,8 @@ export class ControlClient {
|
|
|
105
118
|
features: readonly string[] = [];
|
|
106
119
|
/** The id the hub minted for a distinct session (agent-hub#1036); absent for every ordinary connection. */
|
|
107
120
|
sessionId?: string;
|
|
121
|
+
/** Selected wire version, checked against the welcome when that legacy-optional field is present. */
|
|
122
|
+
protocol?: number;
|
|
108
123
|
private welcomeInstanceId?: string;
|
|
109
124
|
/** Identity from the authenticated welcome, never the unauthenticated descriptor alone. */
|
|
110
125
|
get instanceId(): string | undefined { return this.welcomeInstanceId; }
|
|
@@ -128,17 +143,20 @@ export class ControlClient {
|
|
|
128
143
|
}
|
|
129
144
|
|
|
130
145
|
/** `protocol` is used only by the upgrade coordinator for an authenticated, explicitly supported source contract. */
|
|
131
|
-
static connect(stateDir: string, hello: Hello, timeoutMs = 3000, protocol
|
|
132
|
-
if (!RECOVERY_SOURCE_PROTOCOLS.includes(protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
|
|
146
|
+
static connect(stateDir: string, hello: Hello, timeoutMs = 3000, protocol?: number): Promise<ControlClient> {
|
|
147
|
+
if (protocol !== undefined && !RECOVERY_SOURCE_PROTOCOLS.includes(protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
|
|
133
148
|
const control = readControl(stateDir);
|
|
134
149
|
if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
|
|
135
150
|
const pin = instancePins.get(resolve(stateDir));
|
|
136
151
|
if (pin && (control.instanceId !== pin.instanceId || hello.instanceId !== undefined && hello.instanceId !== pin.instanceId)) {
|
|
137
152
|
return Promise.reject(new Error("delegated audit consumer changed; refusing a connection to its replacement"));
|
|
138
153
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
154
|
+
const local = protocolWindow(protocol ?? PROTOCOL, protocol ?? PROTOCOL_MIN)!;
|
|
155
|
+
const versioned = control.protocol !== undefined || control.protocolMin !== undefined || control.protocolMax !== undefined;
|
|
156
|
+
const remote = versioned ? controlProtocolWindow(control) : undefined;
|
|
157
|
+
const selected = versioned ? remote && negotiateProtocol(local, remote) : protocol ?? PROTOCOL_MIN;
|
|
158
|
+
const mismatch = () => Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocolMax ?? control.protocol}, CLI speaks ${protocol ?? PROTOCOL}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 });
|
|
159
|
+
if (selected === undefined) return Promise.reject(mismatch());
|
|
142
160
|
if ((hello.projectId && hello.projectId !== control.projectId) ||
|
|
143
161
|
(hello.instanceId && hello.instanceId !== control.instanceId) ||
|
|
144
162
|
(hello.projectRoot && hello.projectRoot !== control.cwd)) {
|
|
@@ -169,13 +187,15 @@ export class ControlClient {
|
|
|
169
187
|
client.pending.delete(msg.rid);
|
|
170
188
|
done(msg);
|
|
171
189
|
};
|
|
172
|
-
ws.onopen = () => void client.request({ t: "hello",
|
|
190
|
+
ws.onopen = () => void client.request({ t: "hello", token: control.token, ...hello, ...expected, v: selected, vMin: remote?.vMin === undefined ? selected : Math.max(local.vMin, remote.vMin) }, timeoutMs).then((reply) => {
|
|
173
191
|
if (reply.t !== "welcome" || reply.ok === false) return refuse(new Error(reply.error ?? "hub refused handshake"));
|
|
174
192
|
if ((expected.projectId && reply.projectId !== expected.projectId) ||
|
|
175
193
|
(expected.instanceId && reply.instanceId !== expected.instanceId) ||
|
|
176
194
|
(expected.projectRoot && reply.cwd !== expected.projectRoot)) {
|
|
177
195
|
return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
|
|
178
196
|
}
|
|
197
|
+
if (reply.protocol !== undefined && reply.protocol !== selected) return refuse(mismatch());
|
|
198
|
+
client.protocol = selected;
|
|
179
199
|
client.features = Object.freeze(Array.isArray(reply.features) ? reply.features.filter((feature: unknown): feature is string => typeof feature === "string") : []);
|
|
180
200
|
client.welcomeInstanceId = typeof reply.instanceId === "string" && reply.instanceId ? reply.instanceId : undefined;
|
|
181
201
|
client.sessionId = typeof reply.session === "string" && reply.session.length <= 64 ? reply.session : undefined;
|