@staix/agent-hub 0.25.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 +10 -0
- package/README.md +10 -5
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/daemon.md +6 -4
- package/docs/agent-notes/tasks.md +5 -4
- package/docs/agent-notes/tests.md +1 -0
- package/docs/dogfood.md +4 -0
- package/docs/operations.md +131 -14
- package/docs/quickstart.md +14 -4
- package/docs/security.md +51 -8
- package/docs/specs/2026-11-01-research-campaigns-design.md +9 -6
- package/docs/verification/2026-11-01-0.26.0.md +54 -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 +182 -95
- 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 +7 -7
- package/src/cli/main.ts +62 -23
- 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/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/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 +82 -17
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/execution-budget.ts +3 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +49 -33
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/project-runtime-state.ts +139 -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/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/src/hub/sdlc-queue.ts
CHANGED
|
@@ -4,7 +4,14 @@ import { evalGapBlockers, evalGateView, readEvalFor, type EvalGate } from "./eva
|
|
|
4
4
|
import { findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
5
5
|
import type { CriteriaView } from "./workflow.ts";
|
|
6
6
|
import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
|
|
7
|
-
export interface DeliveryTask {
|
|
7
|
+
export interface DeliveryTask {
|
|
8
|
+
/** Latest owner-reported artifact head; omitted by older library callers. */
|
|
9
|
+
latestHead?: string;
|
|
10
|
+
/** Host-derived verdict binding, never inferred from a mutable lifecycle receipt. */
|
|
11
|
+
approval?: { head?: string; latestHead?: string; mismatch: boolean; current: boolean };
|
|
12
|
+
plan?: { paths?: string[]; revision: number; fingerprint: string; accepted: { repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] }[] };
|
|
13
|
+
findings?: readonly ReviewFindingSet[]; id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView;
|
|
14
|
+
}
|
|
8
15
|
export interface MergeQueueRow { evalGate?: EvalGate; testIntegrity?: (TestIntegrity & { task?: number })[]; planConformance?: { task?: number; state: string; outside: number; untouched: number; accepted: boolean }[]; findings?: ReviewFindingTally; pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
|
|
9
16
|
export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
|
|
10
17
|
export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
|
|
@@ -94,6 +101,11 @@ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: De
|
|
|
94
101
|
const integrity = rawIntegrity ? publicTestIntegrity(rawIntegrity, readableMetadata) : undefined;
|
|
95
102
|
if (integrity) (row.testIntegrity ??= []).push({ task: holder!.id, ...integrity });
|
|
96
103
|
row.blockers.push(...sdlcStatus(config, holder?.receipt, read, holder?.phase).blockers);
|
|
104
|
+
const approved = holder?.receipt?.reviewedHead, binding = holder?.approval;
|
|
105
|
+
if (holder?.latestHead !== undefined && holder.latestHead !== approved) row.blockers.push("latest reported head differs from approved head");
|
|
106
|
+
if (binding && (!binding.current || binding.mismatch || !binding.head || binding.head !== approved
|
|
107
|
+
|| holder?.latestHead !== undefined && binding.head !== holder.latestHead
|
|
108
|
+
|| binding.latestHead !== undefined && binding.head !== binding.latestHead)) row.blockers.push("approval is not current at the latest reported head");
|
|
97
109
|
// #481 S5: the eval check itself is a required check inside sdlcStatus; its recorded pass rate and suite are read once per PR here.
|
|
98
110
|
if (!row.evalGate) {
|
|
99
111
|
const gate = evalGateView(config, read, await readEvalFor(config, read, repo, bounded));
|
package/src/hub/settings.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { CLASSES, type TaskClass } from "./board.ts";
|
|
|
12
12
|
import { configTracked, configRepositoryPresent } from "./config-trust.ts";
|
|
13
13
|
import { PERMISSION_MODES, permissionBoundary } from "./permission-mode.ts";
|
|
14
14
|
import { START_MODES } from "./start-mode.ts";
|
|
15
|
+
import { checkStartAgents } from "./start-agents.ts";
|
|
15
16
|
import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "./routing.ts";
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -74,6 +75,7 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
74
75
|
...ACTION_CLASSES.map((kind): SettingDef => ({ key: `action_policy.budgets.${kind}`, group: "Permissions", label: `${kind}: approved session budget`, type: "integer", min: 0, max: 1000, store: "config", applies: "live", risk: "raises", floor: 0, path: ["action_policy", "budgets", kind] })),
|
|
75
76
|
...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
|
|
76
77
|
...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at next peer start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "next use", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
|
|
78
|
+
{ key: "start.agents", group: "Start", label: "Agents ahub up starts (none for an empty list)", type: "peers", store: "config", applies: "next use", risk: "raises", path: ["start", "agents"] },
|
|
77
79
|
...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "next use", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
|
|
78
80
|
...ADAPTERS.flatMap(adapter => adapter.configuration?.settings ?? []),
|
|
79
81
|
{ key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
|
|
@@ -203,6 +205,7 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
|
|
|
203
205
|
}
|
|
204
206
|
return peers.length === 1 ? peers[0] as string : [...peers] as string[];
|
|
205
207
|
}
|
|
208
|
+
if (def.key === "start.agents") return checkStartAgents(raw, def.key);
|
|
206
209
|
if (!Array.isArray(raw) || raw.length > PEERS_MAX || raw.some((p) => typeof p !== "string" || !PEER.test(p)) || new Set(raw).size !== raw.length) throw new Error(`${def.key} takes up to ${PEERS_MAX} distinct peer ids`);
|
|
207
210
|
return [...(raw as string[])];
|
|
208
211
|
}
|
|
@@ -217,6 +220,7 @@ export function parseSettingText(def: SettingDef, text: string): unknown {
|
|
|
217
220
|
if (def.type === "number") return text.trim() && Number.isFinite(Number(text)) ? Number(text) : text;
|
|
218
221
|
if (def.type === "integer") return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text;
|
|
219
222
|
if (def.type === "boolean") return text === "true" ? true : text === "false" ? false : text;
|
|
223
|
+
if (def.key === "start.agents" && text.trim() === "none") return [];
|
|
220
224
|
if (def.type === "peers" || def.type === "roles") return text.split(",").map((p) => p.trim()).filter(Boolean);
|
|
221
225
|
return text;
|
|
222
226
|
}
|
package/src/hub/snapshots.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Database } from "
|
|
1
|
+
import type { Database } from "./db-schema.ts";
|
|
2
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
2
3
|
import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, type Stats } from "node:fs";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
@@ -7,7 +8,6 @@ import { childEnv } from "./child-process.ts";
|
|
|
7
8
|
import type { Touch } from "./conflicts.ts";
|
|
8
9
|
import { hubGitSync } from "./git.ts";
|
|
9
10
|
import { realPath } from "./project.ts";
|
|
10
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Per-turn workspace snapshots (issue #33): git tree objects written through a copy of the index, so the user's index
|
|
@@ -657,8 +657,7 @@ export function submodulePaths(repo: Repo): string[] {
|
|
|
657
657
|
export class Turns {
|
|
658
658
|
private readonly db: Database;
|
|
659
659
|
constructor(path: string, readonly?: boolean) {
|
|
660
|
-
this.db =
|
|
661
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
660
|
+
this.db = openHubDatabase(path, readonly ? { readonly: true } : { create: true });
|
|
662
661
|
if (readonly) return;
|
|
663
662
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
664
663
|
this.db.run(`CREATE TABLE IF NOT EXISTS turns (id TEXT PRIMARY KEY, peer TEXT NOT NULL, started INTEGER NOT NULL, ended INTEGER,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { SUPPORTED_PEERS, definitionOf } from "../adapters/registry.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #608 (agent-hub#1040): the agents `ahub up` asks the hub to start, `start.agents`. Pure and free of file-system
|
|
5
|
+
* imports: the config schema (`config-apply.ts`) reaches the Claude channel bundle. The list is read by the CLI at a
|
|
6
|
+
* person's `ahub up` and by the dashboard manager's start; the daemon never reads it, so a restart, a crash recovery,
|
|
7
|
+
* a reload or an upgrade relaunch starts nothing from it.
|
|
8
|
+
*
|
|
9
|
+
* Which kinds may be listed, which are in the default set and why a kind is refused all come from the kind's own
|
|
10
|
+
* `AdapterDefinition.start.upStart` (#451): nothing here names a peer.
|
|
11
|
+
*/
|
|
12
|
+
const upStart = (id: string) => definitionOf(id)?.start.upStart;
|
|
13
|
+
|
|
14
|
+
/** The kinds in the default set, in the registry's order. */
|
|
15
|
+
export const START_AGENTS_DEFAULT: readonly string[] = Object.freeze(SUPPORTED_PEERS.filter(peer => { const s = upStart(peer); return s?.allowed === true && s.default === true; }));
|
|
16
|
+
export const START_AGENTS_MAX = 8;
|
|
17
|
+
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
18
|
+
|
|
19
|
+
/** The kinds `ahub up` may start. A declared instance, a channel extra and an unknown id are never in it. */
|
|
20
|
+
export const START_AGENTS_ALLOWED: readonly string[] = Object.freeze(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.start.conductorStartable === true && upStart(peer)?.allowed === true));
|
|
21
|
+
|
|
22
|
+
/** Why `id` cannot be in the start set, or undefined. The reason never repeats the id unless it is a well-formed peer id. */
|
|
23
|
+
export function startAgentRefusal(id: unknown): string | undefined {
|
|
24
|
+
if (typeof id !== "string" || !PEER.test(id)) return "an entry is not a peer id (lowercase letters, digits and dashes)";
|
|
25
|
+
if (START_AGENTS_ALLOWED.includes(id)) return undefined;
|
|
26
|
+
const own = upStart(id);
|
|
27
|
+
if (own?.allowed === false) return `${id} cannot be started by ahub up: ${own.refusal}`;
|
|
28
|
+
if (SUPPORTED_PEERS.includes(id)) return `${id} cannot be started by ahub up: the hub does not start it; run ahub ${id} in a terminal`;
|
|
29
|
+
return `${id} cannot be started by ahub up: it is a declared instance, a channel extra or unknown, and only ${START_AGENTS_ALLOWED.join(", ")} are started by the hub`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The checked list (a copy), or a thrown reason. An empty list is allowed and starts nothing. */
|
|
33
|
+
export function checkStartAgents(raw: unknown, key = "start.agents"): string[] {
|
|
34
|
+
if (!Array.isArray(raw) || raw.length > START_AGENTS_MAX) throw new Error(`${key} takes a list of up to ${START_AGENTS_MAX} peer ids (an empty list starts none)`);
|
|
35
|
+
for (const id of raw) { const why = startAgentRefusal(id); if (why) throw new Error(`${key}: ${why}`); }
|
|
36
|
+
if (new Set(raw).size !== raw.length) throw new Error(`${key} takes distinct peer ids`);
|
|
37
|
+
return [...raw] as string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A person's text for the list: commas or spaces between ids, `none` for an empty list. */
|
|
41
|
+
export function parseStartAgents(text: string): string[] {
|
|
42
|
+
const words = text.split(/[\s,]+/).filter(Boolean);
|
|
43
|
+
return checkStartAgents(words.length === 1 && words[0] === "none" ? [] : words);
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Task } from "./board.ts";
|
|
2
|
+
|
|
3
|
+
export interface ApprovalBinding { head?: string; latestHead?: string; mismatch: boolean; current: boolean }
|
|
4
|
+
const fullHead = (value: unknown): string | undefined => typeof value === "string" && /^[a-f0-9]{40}$/.test(value) ? value : undefined;
|
|
5
|
+
|
|
6
|
+
/** The last approval remains historical evidence after a new delivery or a withdrawal. Unknown legacy heads stay unknown. */
|
|
7
|
+
export function approvalBinding(task: Task): ApprovalBinding | undefined {
|
|
8
|
+
const verdict = task.history.findLast(entry => entry.event === "approved");
|
|
9
|
+
if (!verdict) return undefined;
|
|
10
|
+
const head = fullHead(verdict.reviewedHead) ?? fullHead(verdict.reviewFindings?.head)
|
|
11
|
+
?? (task.state === "approved" ? fullHead(task.sdlc?.reviewedHead) : undefined);
|
|
12
|
+
const latestHead = fullHead(task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead);
|
|
13
|
+
const mismatch = !!head && !!latestHead && head !== latestHead;
|
|
14
|
+
return { ...(head ? { head } : {}), ...(latestHead ? { latestHead } : {}), mismatch,
|
|
15
|
+
current: task.state === "approved" && !!head && head === latestHead };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function approvalText(binding: ApprovalBinding | undefined): string {
|
|
19
|
+
if (!binding) return "";
|
|
20
|
+
return `Approval bound to head ${binding.head ?? "unknown"}; latest reported head ${binding.latestHead ?? "unknown"}. ${binding.mismatch
|
|
21
|
+
? "HEAD MISMATCH: earlier approval does not approve the latest reported head."
|
|
22
|
+
: binding.current ? "Approval is current at this head." : "Earlier approval is not current; review is required."}`;
|
|
23
|
+
}
|
package/src/hub/tasks.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseAttemptReceipt, parseResearchRunners, verifyAttemptReceipt } from "./research-runner.ts";
|
|
2
|
+
import { approvalBinding, approvalText } from "./task-approval.ts";
|
|
1
3
|
import type { InvocationRow } from "./invocations.ts";
|
|
2
4
|
import type { ContextAdmission } from "./context-window.ts";
|
|
3
5
|
import { queueBackpressure, queueBackpressurePolicy, type QueueBackpressureCode, type QueueBackpressurePolicy, type QueueBackpressureStatus, type QueuePressure } from "./queue-backpressure.ts";
|
|
@@ -5,7 +7,7 @@ import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LI
|
|
|
5
7
|
import { createHash, randomBytes } from "node:crypto";
|
|
6
8
|
import { types } from "node:util";
|
|
7
9
|
import { canonicalJson } from "./packs.ts";
|
|
8
|
-
import { parseExperimentProtocol, parseExperimentAttempt, parseExperimentEvent, protocolHash, checkRunObservation, deliverableObservation, projectExperimentWithObservations,
|
|
10
|
+
import { parseExperimentProtocol, parseExperimentAttempt, parseExperimentEvent, protocolHash, checkRunObservation, deliverableObservation, projectExperimentWithObservations, projectRunnerReceipt,
|
|
9
11
|
type ExperimentProtocolContract, type ExperimentProtocolRecord, type ExperimentAttemptRecord, type ExperimentWriteFence, type ExperimentHostEvidence, type ExperimentStatus } from "./research-experiments.ts";
|
|
10
12
|
import { parseComparisonPlan, comparisonPlanHash, comparisonProjection, evaluatorIdentityHash, protocolApprovalBindingMatches, type ComparisonPlanV1 } from "./research-experiments.ts";
|
|
11
13
|
import { parseResearchHandoff, studyProjection, campaignHash, parseResearchCampaign, type ResearchCampaignContract, type ResearchCampaignStatus } from "./research-campaign.ts";
|
|
@@ -190,6 +192,8 @@ export interface TasksDeps {
|
|
|
190
192
|
studyNow?: () => number;
|
|
191
193
|
/** Host-only reader for this project's check-runner receipts; record inputs never supply a receipt. */
|
|
192
194
|
experimentCheck?: (run: string) => Promise<unknown>;
|
|
195
|
+
/** Fresh trusted machine-local runner registrations. A source-trust failure refuses new receipts. */
|
|
196
|
+
researchRunners?: () => unknown;
|
|
193
197
|
/** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
|
|
194
198
|
review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
|
|
195
199
|
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
@@ -393,7 +397,7 @@ export const GROOM_FYI_WINDOW_MS = 10 * 60_000;
|
|
|
393
397
|
/** #694: a PR staged at done with the commit or branch it was read for. */
|
|
394
398
|
export interface ResearchCampaignReceipt { id: string; parentTaskId: number; revision: number; status: ResearchCampaignStatus; unitCount: number; units: ResearchUnitBinding[] }
|
|
395
399
|
interface StudyReferences { parent: Task; adopted: Map<string, Task>; reused: Map<string, Task>; private: boolean; key: string }
|
|
396
|
-
export interface ExperimentReceipt { kind: "protocol" | "attempt" | "event"; protocolHash: string; attemptId?: string; seq?: number }
|
|
400
|
+
export interface ExperimentReceipt { kind: "protocol" | "attempt" | "event" | "receipt"; protocolHash: string; attemptId?: string; seq?: number; receiptId?: string }
|
|
397
401
|
export interface ComparisonPlanReceipt { kind: "plan"; planHash: string }
|
|
398
402
|
export interface ProtocolApprovalReceipt { protocolHash: string; evaluatorHash: string; approvedBy: string; approvedAt: number }
|
|
399
403
|
export interface ExperimentAttemptView {
|
|
@@ -405,7 +409,7 @@ export interface ExperimentAttemptView {
|
|
|
405
409
|
export interface ExperimentView { attempts: ExperimentAttemptView[]; counts: { all: number; statuses: Record<ExperimentStatus, number> }; nextCursor?: number;
|
|
406
410
|
comparison?: { planHash: string; baselineAttemptId: string; variantAttemptId: string; approved: boolean; private: boolean; projection: ReturnType<typeof comparisonProjection> | "[pii]" } }
|
|
407
411
|
interface ExperimentContext { task: Task; campaign: ResearchCampaignRecord; private: boolean; key: string; fence: ExperimentWriteFence }
|
|
408
|
-
interface CompletionRefresh { revision: number; commit: string }
|
|
412
|
+
interface CompletionRefresh { revision: number; commit: string; state: "in_review" | "approved" }
|
|
409
413
|
interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
|
|
410
414
|
|
|
411
415
|
/** What one live check of a task's files says as a whole (#317): `unchanged` only when EVERY file measured unchanged. A change outranks a missing file, which outranks one the hub could not read. */
|
|
@@ -918,6 +922,7 @@ export class Tasks {
|
|
|
918
922
|
...(h.selfReview ? { selfReview: true } : {}),
|
|
919
923
|
...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
|
|
920
924
|
...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
|
|
925
|
+
...(h.reviewedHead ? { reviewedHead: h.reviewedHead } : {}),
|
|
921
926
|
...(h.reviewScope === "code" ? { reviewScope: "code" as const } : {}),
|
|
922
927
|
...(h.evidenceOnly ? { evidenceOnly: true as const } : {}),
|
|
923
928
|
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
@@ -933,7 +938,7 @@ export class Tasks {
|
|
|
933
938
|
const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
|
|
934
939
|
const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
|
|
935
940
|
const handedOff = this.d.board.handoffOrigin(task.id); // #604: where another project's handoff came from; two ids, never text
|
|
936
|
-
return { ...rest, ...(handedOff ? { handoffOrigin: { projectId: handedOff.projectId, peer: handedOff.peer, ...(handedOff.originTask === null ? {} : { task: handedOff.originTask }) } } : {}), ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
|
|
941
|
+
return { ...rest, ...(approvalBinding(task) ? { approval: approvalBinding(task) } : {}), ...(handedOff ? { handoffOrigin: { projectId: handedOff.projectId, peer: handedOff.peer, ...(handedOff.originTask === null ? {} : { task: handedOff.originTask }) } } : {}), ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
|
|
937
942
|
}
|
|
938
943
|
|
|
939
944
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -1891,13 +1896,43 @@ export class Tasks {
|
|
|
1891
1896
|
|
|
1892
1897
|
/** Record evidence only: this path never runs a check, a shell command or a runner. */
|
|
1893
1898
|
async experimentRecord(by: PeerId, input: { kind: "plan"; [key: string]: unknown }): Promise<ComparisonPlanReceipt>;
|
|
1894
|
-
async experimentRecord(by: PeerId, input: { kind: "protocol" | "attempt" | "event"; [key: string]: unknown }): Promise<ExperimentReceipt>;
|
|
1899
|
+
async experimentRecord(by: PeerId, input: { kind: "protocol" | "attempt" | "event" | "receipt"; [key: string]: unknown }): Promise<ExperimentReceipt>;
|
|
1895
1900
|
async experimentRecord(by: PeerId, input: unknown): Promise<ExperimentReceipt | ComparisonPlanReceipt>;
|
|
1896
1901
|
async experimentRecord(by: PeerId, input: unknown): Promise<ExperimentReceipt | ComparisonPlanReceipt> {
|
|
1897
1902
|
const requestCheck = this.requestCheck(by, () => this.studyHold());
|
|
1898
|
-
const envelope = this.studyFields(input, ["kind", "taskId", "protocol", "attempt", "attemptId", "idempotencyKey", "event", "plan"]);
|
|
1903
|
+
const envelope = this.studyFields(input, ["kind", "taskId", "protocol", "attempt", "attemptId", "idempotencyKey", "event", "plan", "receipt", "signature"]);
|
|
1899
1904
|
return this.studyWrite(async () => {
|
|
1900
1905
|
try {
|
|
1906
|
+
if (envelope.kind === "receipt") {
|
|
1907
|
+
const fields = this.studyFields(input, ["kind", "receipt", "signature"]), receipt = parseAttemptReceipt(fields.receipt);
|
|
1908
|
+
const attempt = this.experimentAttempt(receipt.attemptId), record = this.experimentProtocol(attempt.attempt.protocolHash);
|
|
1909
|
+
const relayOwner = by === USER ? this.studyParent(attempt.attempt.taskId).owner : by;
|
|
1910
|
+
if (!relayOwner) throw new PreEffectToolRefusal("receipt relay requires a current task owner");
|
|
1911
|
+
const before = this.experimentContext(relayOwner, attempt.attempt.taskId, record.protocol, record.recordedForTaskId);
|
|
1912
|
+
requestCheck();
|
|
1913
|
+
const proof = (stored: NonNullable<ReturnType<Board["runnerReceipt"]>>): ExperimentReceipt => ({ kind: "receipt", protocolHash: record.hash,
|
|
1914
|
+
attemptId: attempt.attemptId, seq: stored.seq, receiptId: receipt.receiptId });
|
|
1915
|
+
const known = this.d.board.runnerReceipt(receipt.receiptId);
|
|
1916
|
+
if (known) {
|
|
1917
|
+
if (canonicalJson(known.receipt) !== canonicalJson(receipt) || known.signature !== fields.signature)
|
|
1918
|
+
throw new PreEffectToolRefusal("receiptId already records different signed bytes");
|
|
1919
|
+
this.experimentFresh(before, relayOwner, record.protocol, record.recordedForTaskId); requestCheck();
|
|
1920
|
+
return proof(known); // immutable stored proof: do not re-verify against a revoked/replaced registration or write on replay
|
|
1921
|
+
}
|
|
1922
|
+
const registrations = parseResearchRunners(this.d.researchRunners?.() ?? {}), registrationKey = canonicalJson(registrations);
|
|
1923
|
+
const context = { projectId: this.d.project, attempt: { id: attempt.attemptId, projectId: attempt.project, protocolHash: attempt.attempt.protocolHash,
|
|
1924
|
+
...("commit" in record.protocol.code ? { codeCommit: record.protocol.code.commit } : {}) } };
|
|
1925
|
+
verifyAttemptReceipt(receipt, fields.signature, { ...context, runners: registrations });
|
|
1926
|
+
const lastSeq = this.d.board.experimentLastSeq(attempt.attemptId);
|
|
1927
|
+
await this.experimentScreen(canonicalJson(receipt), before.task); requestCheck();
|
|
1928
|
+
const current = this.experimentFresh(before, relayOwner, record.protocol, record.recordedForTaskId);
|
|
1929
|
+
const latest = parseResearchRunners(this.d.researchRunners?.() ?? {});
|
|
1930
|
+
if (canonicalJson(latest) !== registrationKey) throw new PreEffectToolRefusal("runner registration changed during receipt admission");
|
|
1931
|
+
verifyAttemptReceipt(receipt, fields.signature, { ...context, runners: latest }); requestCheck();
|
|
1932
|
+
const saved = this.d.board.appendRunnerReceipt({ receipt, signature: fields.signature as string, publicKey: latest[receipt.runner]!.publicKey,
|
|
1933
|
+
by, at: this.studyTime(), private: current.private || record.private || attempt.private }, lastSeq, current.fence);
|
|
1934
|
+
return proof(saved);
|
|
1935
|
+
}
|
|
1901
1936
|
if (envelope.kind === "plan") {
|
|
1902
1937
|
const fields = this.studyFields(input, ["kind", "taskId", "plan"]), taskId = this.studyNumber(fields.taskId, "taskId"), plan = parseComparisonPlan(fields.plan);
|
|
1903
1938
|
const before = this.experimentPlanContext(by, taskId, plan);
|
|
@@ -1970,6 +2005,17 @@ export class Tasks {
|
|
|
1970
2005
|
});
|
|
1971
2006
|
}
|
|
1972
2007
|
|
|
2008
|
+
private runnerKeyStatus(stored: NonNullable<ReturnType<Board["runnerReceipt"]>>): "active" | "revoked" | "replaced" | "unavailable" {
|
|
2009
|
+
try {
|
|
2010
|
+
const runners = parseResearchRunners(this.d.researchRunners?.() ?? {});
|
|
2011
|
+
if (!Object.hasOwn(runners, stored.receipt.runner)) return "unavailable";
|
|
2012
|
+
const current = runners[stored.receipt.runner]!;
|
|
2013
|
+
if (current.publicKey !== stored.publicKey) return "replaced";
|
|
2014
|
+
if (current.revoked) return "revoked";
|
|
2015
|
+
return current.projects.includes(stored.receipt.projectId) ? "active" : "unavailable";
|
|
2016
|
+
} catch { return "unavailable"; }
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1973
2019
|
private experimentPrivate(record: ExperimentProtocolRecord, attempt: ExperimentAttemptRecord): boolean {
|
|
1974
2020
|
try {
|
|
1975
2021
|
const campaign = this.d.board.researchCampaign(record.protocol.campaignId, record.protocol.campaignRevision), task = this.d.board.get(attempt.attempt.taskId);
|
|
@@ -2025,17 +2071,20 @@ export class Tasks {
|
|
|
2025
2071
|
const summary = this.d.board.experimentSummary(this.d.project, campaignId), views: ExperimentAttemptView[] = [];
|
|
2026
2072
|
for (const attempt of attempts) {
|
|
2027
2073
|
const record = this.experimentProtocol(attempt.attempt.protocolHash), page = this.d.board.experimentEvents(attempt.attemptId, eventAfter, limit);
|
|
2028
|
-
const
|
|
2074
|
+
const runnerReceipts = this.d.board.runnerReceipts(attempt.attemptId);
|
|
2075
|
+
const privateRecord = this.experimentPrivate(record, attempt) || page.rows.some(row => row.private)
|
|
2076
|
+
|| runnerReceipts.some(row => row.private || !this.nameable(canonicalJson(row.receipt)) || looksSecret(canonicalJson(row.receipt)));
|
|
2029
2077
|
const status = this.d.board.experimentAttemptStatus(attempt.attemptId);
|
|
2030
2078
|
const observations = page.rows.flatMap(row => row.observations), events = page.rows.map(row => row.event);
|
|
2031
|
-
const evidence = projectExperimentWithObservations(record.protocol, events, observations)
|
|
2079
|
+
const evidence = { ...projectExperimentWithObservations(record.protocol, events, observations),
|
|
2080
|
+
...(runnerReceipts.length ? { runnerAttested: runnerReceipts.map(row => ({ ...projectRunnerReceipt(record.protocol, row.receipt), key: { hash: row.keyHash, status: this.runnerKeyStatus(row) } })) } : {}) };
|
|
2032
2081
|
views.push({ id: attempt.attemptId, protocolHash: record.hash, taskId: attempt.attempt.taskId, phase: attempt.attempt.phase, status, private: privateRecord,
|
|
2033
2082
|
campaignRevision: record.protocol.campaignRevision, approval: this.experimentApproved(record) ? "approved" : "unapproved",
|
|
2034
2083
|
postHoc: this.d.board.experimentProtocolContext(record.hash).state,
|
|
2035
2084
|
...(attempt.attempt.retryOf ? { retryOf: attempt.attempt.retryOf } : {}), ...(attempt.attempt.reproducesAttemptId ? { reproducesAttemptId: attempt.attempt.reproducesAttemptId } : {}), independentlyReproduced: false,
|
|
2036
2085
|
protocol: { value: privateRecord ? "[pii]" : record.protocol, provenance: "declared" },
|
|
2037
2086
|
events: page.rows.map(row => privateRecord ? { seq: row.event.seq, kind: row.event.kind, at: row.event.at, provenance: row.event.provenance, body: "[pii]" } : row.event),
|
|
2038
|
-
evidence: privateRecord ? { declared: "[pii]", measured: evidence.measured.map(row => ({ eventSeq: row.eventSeq, source: row.source, provenance: "host-observed", values: "[pii]" })) } : evidence,
|
|
2087
|
+
evidence: privateRecord ? { declared: "[pii]", measured: evidence.measured.map(row => ({ eventSeq: row.eventSeq, source: row.source, provenance: "host-observed", values: "[pii]" })), ...(runnerReceipts.length ? { runnerAttested: runnerReceipts.map(() => ({ provenance: "runner-attested", values: "[pii]" })) } : {}) } : evidence,
|
|
2039
2088
|
eventsComplete: page.done, ...(!page.done ? { nextEventCursor: page.afterSeq } : {}) });
|
|
2040
2089
|
}
|
|
2041
2090
|
return { attempts: views, counts: { all: summary.total, statuses: summary.statuses }, ...(nextCursor === undefined ? {} : { nextCursor }) };
|
|
@@ -4862,7 +4911,7 @@ export class Tasks {
|
|
|
4862
4911
|
|
|
4863
4912
|
/** The board as the merge queue and the guarded merge read it (#786): ids, dependencies, privacy, receipts, findings and plans. */
|
|
4864
4913
|
private deliveryLinks(config: SdlcConfig): DeliveryTask[] {
|
|
4865
|
-
return this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
|
|
4914
|
+
return this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), latestHead: this.reviewHead(t), approval: approvalBinding(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
|
|
4866
4915
|
}
|
|
4867
4916
|
|
|
4868
4917
|
/**
|
|
@@ -4989,6 +5038,8 @@ export class Tasks {
|
|
|
4989
5038
|
private mergeRecordCurrent(id:number,by:PeerId,record:MergeRecord,artifactCurrent:()=>boolean):Task|undefined {
|
|
4990
5039
|
const now=this.d.board.get(id);
|
|
4991
5040
|
return artifactCurrent() && now && now.sdlc?.repo===record.repo && now.sdlc.pr===record.pr && now.sdlc.reviewedHead===record.head
|
|
5041
|
+
&& (!approvalBinding(now) || approvalBinding(now)!.current && approvalBinding(now)!.head === record.head)
|
|
5042
|
+
&& (!this.reviewHead(now) || this.reviewHead(now) === record.head)
|
|
4992
5043
|
&& now.state==="approved" && !this.isPii(now) && !this.d.privateTurn?.(by) && this.mergeAuthority(by)
|
|
4993
5044
|
&& !this.d.bus.isRecoveryHeld && !this.d.bus.storageError ? now : undefined;
|
|
4994
5045
|
}
|
|
@@ -5629,7 +5680,7 @@ export class Tasks {
|
|
|
5629
5680
|
isChecking = (id: number) => this.checking.has(id);
|
|
5630
5681
|
|
|
5631
5682
|
/** Invalidate stale artifact evidence without changing spec acceptance or open findings. */
|
|
5632
|
-
private completionDraft(task: Task, refresh?: CompletionRefresh): Task {
|
|
5683
|
+
private completionDraft(task: Task, refresh?: Pick<CompletionRefresh, "commit">): Task {
|
|
5633
5684
|
if (!refresh || !task.sdlc) return task;
|
|
5634
5685
|
const sdlc = { ...task.sdlc };
|
|
5635
5686
|
if (sdlc.head !== refresh.commit) { delete sdlc.head; delete sdlc.observed; }
|
|
@@ -5642,13 +5693,14 @@ export class Tasks {
|
|
|
5642
5693
|
private completionCurrent(id: unknown, by: PeerId, preEffect: boolean, refresh?: CompletionRefresh): Task {
|
|
5643
5694
|
const task = this.need(id, false, preEffect);
|
|
5644
5695
|
this.mine(task, by, "owner", preEffect);
|
|
5645
|
-
if (refresh && (task.state !==
|
|
5696
|
+
if (refresh && (task.state !== refresh.state || task.history.length !== refresh.revision))
|
|
5646
5697
|
throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: review changed during re-file; retry on current work`);
|
|
5647
5698
|
if (preEffect) {
|
|
5648
|
-
if (task.state === "in_review" && !refresh || isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
5699
|
+
if (task.state === "in_review" && !refresh || isClosedTaskState(task.state) && !(task.state === "approved" && refresh)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
5649
5700
|
} else {
|
|
5650
|
-
if (task.state === "in_review" && !refresh || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
5701
|
+
if (task.state === "in_review" && !refresh || isClosedTaskState(task.state) && !(task.state === "approved" && refresh)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
5651
5702
|
}
|
|
5703
|
+
if (task.sdlc?.merge?.state === "merged") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: PR is merged`);
|
|
5652
5704
|
this.ready(task, preEffect);
|
|
5653
5705
|
if (task.state === "proposed" || task.state === "changes_requested") this.acceptanceCapacity(task);
|
|
5654
5706
|
if (this.checking.has(task.id)) {
|
|
@@ -5658,18 +5710,42 @@ export class Tasks {
|
|
|
5658
5710
|
return this.completionDraft(task, refresh);
|
|
5659
5711
|
}
|
|
5660
5712
|
|
|
5713
|
+
/** An approval may reopen only while its linked PR is known not to be merged, even with lifecycle gates off. */
|
|
5714
|
+
private async unmergedForReopen(task: Task, valid: () => Task): Promise<void> {
|
|
5715
|
+
valid();
|
|
5716
|
+
if (task.sdlc?.merge?.state === "merged") throw new PreEffectToolRefusal(`task #${task.id}: PR is merged`);
|
|
5717
|
+
const { repo, pr } = task.sdlc ?? {};
|
|
5718
|
+
if (!repo || !pr) return;
|
|
5719
|
+
if (this.isPii(task)) throw new PreEffectToolRefusal(`task #${task.id}: PR merge state unavailable for private work`);
|
|
5720
|
+
const original = this.need(task.id, false, true);
|
|
5721
|
+
const identity = JSON.stringify([original.sdlc, original.refs, original.history.length]);
|
|
5722
|
+
let state: unknown;
|
|
5723
|
+
try {
|
|
5724
|
+
const reply = await (this.d.sdlc?.gh ?? sdlcGh)(["pr", "view", String(pr), "--repo", repo, "--json", "state"]);
|
|
5725
|
+
if (reply.code === 0) state = JSON.parse(reply.stdout).state;
|
|
5726
|
+
} catch { /* unknown merge state refuses before effects */ }
|
|
5727
|
+
valid();
|
|
5728
|
+
const current = this.need(task.id, false, true);
|
|
5729
|
+
if (JSON.stringify([current.sdlc, current.refs, current.history.length]) !== identity) throw new PreEffectToolRefusal(`task #${task.id}: task changed during merge-state read`);
|
|
5730
|
+
if (state === "MERGED") throw new PreEffectToolRefusal(`task #${task.id}: PR is merged`);
|
|
5731
|
+
if (state !== "OPEN" && state !== "CLOSED") throw new PreEffectToolRefusal(`task #${task.id}: PR merge state unavailable; cannot reopen approval`);
|
|
5732
|
+
}
|
|
5733
|
+
|
|
5661
5734
|
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown): Promise<Task> {
|
|
5662
5735
|
const requestCheck = this.requestCheck(by);
|
|
5663
5736
|
const initial = this.need(id, false, true);
|
|
5664
5737
|
this.mine(initial, by, "owner", true);
|
|
5665
5738
|
let refresh: CompletionRefresh | undefined;
|
|
5666
|
-
if (initial.state === "in_review") {
|
|
5739
|
+
if (initial.state === "in_review" || initial.state === "approved") {
|
|
5667
5740
|
const commit = cleanRefs(refs).commit;
|
|
5668
5741
|
if (!commit || !/^[a-f0-9]{40}$/.test(commit) || commit === this.reviewHead(initial))
|
|
5669
|
-
throw new PreEffectToolRefusal(`task #${initial.id} is already
|
|
5670
|
-
refresh = { revision: initial.history.length, commit };
|
|
5742
|
+
throw new PreEffectToolRefusal(`task #${initial.id} is already ${initial.state}; re-file requires a different full head SHA`);
|
|
5743
|
+
refresh = { revision: initial.history.length, commit, state: initial.state };
|
|
5671
5744
|
}
|
|
5672
|
-
const
|
|
5745
|
+
const submittedCommit = cleanRefs(refs).commit;
|
|
5746
|
+
const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
|
|
5747
|
+
const valid = () => { requestCheck(); return this.completionDraft(this.completionCurrent(id, by, true, refresh), draft); };
|
|
5748
|
+
if (initial.state === "approved") { await this.unmergedForReopen(initial, valid); valid(); }
|
|
5673
5749
|
// #509a: a bad claim (a claimed hash, an outside or denied path, a missing file) is refused before any effect, with nothing recorded.
|
|
5674
5750
|
const claims = deliverables === undefined ? undefined : this.deliverableClaims(deliverables, valid());
|
|
5675
5751
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
@@ -5691,7 +5767,7 @@ export class Tasks {
|
|
|
5691
5767
|
task = current();
|
|
5692
5768
|
let reviewEvidence: PrEvidence | undefined;
|
|
5693
5769
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
|
|
5694
|
-
const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged,
|
|
5770
|
+
const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !task.reviewer) : undefined;
|
|
5695
5771
|
task = current();
|
|
5696
5772
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
5697
5773
|
const cohort = this.cohorts.of(task.id);
|
|
@@ -5725,10 +5801,11 @@ export class Tasks {
|
|
|
5725
5801
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
5726
5802
|
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh);
|
|
5727
5803
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
5804
|
+
if (refresh?.state === "approved") { await this.unmergedForReopen(task, valid); task = current(); }
|
|
5728
5805
|
this.checking.set(task.id, task.owner);
|
|
5729
5806
|
const checkedRefs = cleanRefs(refs);
|
|
5730
5807
|
this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
|
|
5731
|
-
const pending = this.d.board.update(task.id, by, "done (checking)", { ...(refresh ? { state: "in_progress" as const } : {}), codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined, ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
5808
|
+
const pending = this.d.board.update(task.id, by, "done (checking)", { ...(refresh ? { state: "in_progress" as const } : {}), ...(refresh?.state === "approved" && !task.reviewer ? { reviewer: USER } : {}), codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined, ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
5732
5809
|
if (refresh) { this.ciWatchers.cancel(pending.id); this.heldReviews.delete(pending.id); }
|
|
5733
5810
|
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
5734
5811
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
@@ -5878,31 +5955,38 @@ export class Tasks {
|
|
|
5878
5955
|
requestCheck();
|
|
5879
5956
|
// A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
|
|
5880
5957
|
records ??= latestPendingDeliverables(task);
|
|
5958
|
+
const submittedCommit = cleanRefs(refs).commit;
|
|
5959
|
+
const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
|
|
5960
|
+
task = this.completionDraft(task, draft);
|
|
5881
5961
|
let selection = this.poolReview(task, refs);
|
|
5882
|
-
let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
5962
|
+
let reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined), reviewEvidence: PrEvidence | undefined;
|
|
5883
5963
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
5884
5964
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
5885
5965
|
requestCheck();
|
|
5886
|
-
task = this.withLink(this.completionCurrent(task.id, by, false, refresh), link, refs);
|
|
5966
|
+
task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
|
|
5887
5967
|
selection = this.poolReview(task, refs);
|
|
5888
|
-
reviewer = selection?.reviewer ?? task.reviewer;
|
|
5968
|
+
reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
|
|
5889
5969
|
}
|
|
5890
5970
|
// A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
|
|
5891
5971
|
if (!admission && ((reviewer && (this.d.sdlc?.config().mode ?? "off") !== "off") || this.d.prepareReviewBrief)) {
|
|
5892
|
-
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged,
|
|
5972
|
+
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !reviewer);
|
|
5893
5973
|
requestCheck();
|
|
5894
|
-
task = this.withLink(this.completionCurrent(task.id, by, false, refresh), link, refs);
|
|
5974
|
+
task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
|
|
5895
5975
|
}
|
|
5896
5976
|
selection = this.poolReview(task, refs);
|
|
5897
|
-
reviewer = selection?.reviewer ?? task.reviewer;
|
|
5977
|
+
reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
|
|
5898
5978
|
const reviewCost: ReviewCost | undefined = reviewer && task.plan && (task.plan.review || task.plan.ci)
|
|
5899
5979
|
? { ...cleanCostPlan(task.plan), ...(admission?.ready && admission.head ? { head: admission.head } : {}) } : undefined;
|
|
5900
5980
|
const routedSummary = selection?.reviewSelection?.blocked ? `${summary ?? ""}\nReview routing: ${this.assignmentNote("pool unavailable", selection)}`.trim() : summary;
|
|
5981
|
+
if (refresh?.state === "approved") {
|
|
5982
|
+
await this.unmergedForReopen(task, () => { requestCheck(); return this.completionCurrent(task.id, by, true, refresh); });
|
|
5983
|
+
task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, true, refresh), draft), link, refs);
|
|
5984
|
+
}
|
|
5901
5985
|
const completedRefs = cleanRefs(refs);
|
|
5902
5986
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
5903
5987
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
5904
5988
|
const capacity = this.capacityBefore(task);
|
|
5905
|
-
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection, { ...task, refs: { ...task.refs, ...completedRefs } }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
5989
|
+
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection || refresh?.state === "approved" ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection, { ...task, refs: { ...task.refs, ...completedRefs } }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
5906
5990
|
this.queueReviewWritten(next);
|
|
5907
5991
|
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
5908
5992
|
this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
|
|
@@ -6331,12 +6415,15 @@ export class Tasks {
|
|
|
6331
6415
|
|
|
6332
6416
|
/** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
|
|
6333
6417
|
briefText(task: Task): string {
|
|
6334
|
-
if (this.isPii(task)
|
|
6418
|
+
if (this.isPii(task)) return "";
|
|
6419
|
+
const approval = approvalText(approvalBinding(task));
|
|
6420
|
+
const prefix = (brief: string) => [approval, brief].filter(Boolean).join("\n");
|
|
6421
|
+
if (!task.refs.commit) return approval;
|
|
6335
6422
|
try {
|
|
6336
6423
|
const brief = this.d.reviewBrief?.(task);
|
|
6337
|
-
if (!brief) return
|
|
6338
|
-
return "unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief));
|
|
6339
|
-
} catch { return "Review brief unavailable: it could not be built."; }
|
|
6424
|
+
if (!brief) return approval;
|
|
6425
|
+
return prefix("unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief)));
|
|
6426
|
+
} catch { return prefix("Review brief unavailable: it could not be built."); }
|
|
6340
6427
|
}
|
|
6341
6428
|
|
|
6342
6429
|
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
|
|
@@ -6348,17 +6435,21 @@ export class Tasks {
|
|
|
6348
6435
|
let covered: { commit: string | undefined; receipt: string } | undefined;
|
|
6349
6436
|
let conformanceIdentity: string | undefined;
|
|
6350
6437
|
let reviewRound: number | undefined;
|
|
6438
|
+
let withdrawalRevision: number | undefined;
|
|
6439
|
+
const withdrawing = this.need(id, false, true).state === "approved" && verdict === "changes_requested";
|
|
6351
6440
|
const valid = (): Task => {
|
|
6352
6441
|
requestCheck();
|
|
6353
6442
|
const refused = authority?.();
|
|
6354
6443
|
if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
|
|
6355
6444
|
const t = this.need(id, false, true);
|
|
6445
|
+
if (withdrawalRevision !== undefined && t.history.length !== withdrawalRevision) throw new PreEffectToolRefusal(`task #${t.id}: approval changed during withdrawal`);
|
|
6356
6446
|
if (reviewRound !== undefined && this.reviewRequestRevision(t) !== reviewRound) throw new PreEffectToolRefusal(`task #${t.id}: review request changed during the verdict; review the new head`);
|
|
6357
6447
|
if (covered && (t.refs.commit !== covered.commit || JSON.stringify(t.sdlc ?? null) !== covered.receipt)) throw new PreEffectToolRefusal(`task #${t.id}'s commit or lifecycle receipt changed during the verdict: review the new head`);
|
|
6358
6448
|
this.mine(t, by, "reviewer", true);
|
|
6359
6449
|
if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
|
|
6360
|
-
if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
6450
|
+
if (t.state !== (withdrawing ? "approved" : "in_review")) throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
6361
6451
|
if (conformanceIdentity !== undefined && conformanceIdentity !== this.planIdentity(t)) throw new PreEffectToolRefusal("plan conformance identity changed during verdict");
|
|
6452
|
+
if (t.sdlc?.merge?.state === "merged") throw new PreEffectToolRefusal(`task #${t.id}: PR is merged`);
|
|
6362
6453
|
findingGuard?.(t);
|
|
6363
6454
|
return t;
|
|
6364
6455
|
};
|
|
@@ -6370,6 +6461,8 @@ export class Tasks {
|
|
|
6370
6461
|
const mode = this.d.sdlc?.config().mode ?? "off";
|
|
6371
6462
|
const initial = valid(), suppliedFindings = findings !== undefined;
|
|
6372
6463
|
reviewRound = this.reviewRequestRevision(initial);
|
|
6464
|
+
if (withdrawing) withdrawalRevision = initial.history.length;
|
|
6465
|
+
if (withdrawing) { await this.unmergedForReopen(initial, valid); valid(); }
|
|
6373
6466
|
let parsed: ReviewFinding[];
|
|
6374
6467
|
try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
|
|
6375
6468
|
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
|
|
@@ -6474,6 +6567,7 @@ export class Tasks {
|
|
|
6474
6567
|
task = valid();
|
|
6475
6568
|
if (cleared && context === JSON.stringify({ title: task.title, detail: task.detail, refs: task.refs, plan: task.plan, signals: task.signals })) candidate = input; // Async screening cannot authorize a verdict under an obsolete role/state.
|
|
6476
6569
|
}
|
|
6570
|
+
if (withdrawing) { await this.unmergedForReopen(task, valid); task = valid(); }
|
|
6477
6571
|
const pii = this.isPii(task);
|
|
6478
6572
|
const reviewContext = by === USER ? undefined : this.d.reviewContext?.(by, task.id);
|
|
6479
6573
|
const poolRequest = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
|
|
@@ -6483,7 +6577,7 @@ export class Tasks {
|
|
|
6483
6577
|
// #507b: unmet items that start with a recorded criterion id name that criterion. The ids come from the checklist on the board as it is at this
|
|
6484
6578
|
// write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
|
|
6485
6579
|
const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
|
|
6486
|
-
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(codeScope ? { reviewScope: "code" as const } : {}), ...(evidenceOnly ? { evidenceOnly: true as const } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
6580
|
+
const flag = { ...(findingHead ? { reviewedHead: findingHead } : {}), ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(codeScope ? { reviewScope: "code" as const } : {}), ...(evidenceOnly ? { evidenceOnly: true as const } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
6487
6581
|
if (verdict === "approved") {
|
|
6488
6582
|
const capacity = this.capacityBefore(task);
|
|
6489
6583
|
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
@@ -6509,7 +6603,7 @@ export class Tasks {
|
|
|
6509
6603
|
return next;
|
|
6510
6604
|
}
|
|
6511
6605
|
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
6512
|
-
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
|
|
6606
|
+
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag, task.history.length);
|
|
6513
6607
|
void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
|
|
6514
6608
|
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
6515
6609
|
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(rejected, by, withheld));
|
|
@@ -6522,6 +6616,11 @@ export class Tasks {
|
|
|
6522
6616
|
if (!evidenceOnly) this.contradict(rejected);
|
|
6523
6617
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
|
|
6524
6618
|
if (evidenceOnly) this.tellConductorEvidenceOnly(rejected, by);
|
|
6619
|
+
if (withdrawing) {
|
|
6620
|
+
this.releaseNotice(`task ${this.publicTitle(rejected)}: could not tell its owner the approval was withdrawn`, () =>
|
|
6621
|
+
this.tell(rejected, `Task #${rejected.id}: ${by} withdrew approval and requests changes. Review the findings, then call hub_task_done with the updated head.`, pii));
|
|
6622
|
+
return rejected;
|
|
6623
|
+
}
|
|
6525
6624
|
if (!evidenceOnly && rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
|
|
6526
6625
|
let moved: Task;
|
|
6527
6626
|
try {
|
package/src/hub/usage-rollup.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Database } from "
|
|
1
|
+
import type { Database } from "./db-schema.ts";
|
|
2
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
2
3
|
import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
|
|
3
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The usage roll-up (#683 U2): `events.jsonl` folded into `hub.db`, so a view never rescans the whole log.
|
|
@@ -77,8 +77,7 @@ export class UsageRollup {
|
|
|
77
77
|
private closed = false;
|
|
78
78
|
|
|
79
79
|
constructor(dbPath: string, private readonly eventsFile: string, private readonly opts: { maxBytesPerPass?: number } = {}) {
|
|
80
|
-
this.db =
|
|
81
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
80
|
+
this.db = openHubDatabase(dbPath, { create: true });
|
|
82
81
|
const counters = KINDS.map(kind => `${kind} INTEGER NOT NULL DEFAULT 0, ${kind}_n INTEGER NOT NULL DEFAULT 0`).join(", ");
|
|
83
82
|
this.db.run(`CREATE TABLE IF NOT EXISTS usage_rollup (day TEXT NOT NULL, peer TEXT NOT NULL, model TEXT NOT NULL, source TEXT NOT NULL, task INTEGER NOT NULL,
|
|
84
83
|
pii INTEGER NOT NULL, records INTEGER NOT NULL DEFAULT 0, with_usage INTEGER NOT NULL DEFAULT 0, ${counters}, PRIMARY KEY (day, peer, model, source, task, pii))`);
|
package/src/pi/tool-receipts.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { chmodSync } from "node:fs";
|
|
2
|
-
import { Database } from "
|
|
2
|
+
import type { Database } from "../hub/db-schema.ts";
|
|
3
|
+
import { openHubDatabase } from "../hub/db-schema.ts";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { PreEffectToolRefusal } from "../hub/tool-refusal.ts";
|
|
5
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
|
|
6
6
|
|
|
7
7
|
export const PI_TOOL_RESULT_CAP = 100_000;
|
|
8
8
|
/** Bound encoded text before wrapping it so escaping never cuts off trusted exit metadata. */
|
|
@@ -27,8 +27,7 @@ export class PiToolReceipts {
|
|
|
27
27
|
private closed = false;
|
|
28
28
|
private readonly pending = new Map<string, Promise<string>>();
|
|
29
29
|
constructor(file: string) {
|
|
30
|
-
this.db =
|
|
31
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
30
|
+
this.db = openHubDatabase(file, { create: true });
|
|
32
31
|
chmodSync(file, 0o600);
|
|
33
32
|
this.db.run(`CREATE TABLE IF NOT EXISTS pi_tool_receipts (
|
|
34
33
|
session TEXT NOT NULL, call_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
|