ai-foreman 1.7.19 → 1.7.21
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/README.md +29 -5
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +18 -6
- package/dist/adapters/claude.d.ts +16 -0
- package/dist/adapters/claude.js +28 -3
- package/dist/adapters/codex.d.ts +20 -0
- package/dist/adapters/codex.js +99 -5
- package/dist/adapters/recovering.d.ts +9 -0
- package/dist/adapters/recovering.js +10 -0
- package/dist/adapters/types.d.ts +15 -0
- package/dist/agentRun.d.ts +7 -0
- package/dist/agentRun.js +24 -13
- package/dist/branch/currentGuard.d.ts +1 -0
- package/dist/branch/currentGuard.js +1 -0
- package/dist/branch/finalization.js +6 -1
- package/dist/branch/git.d.ts +1 -0
- package/dist/branch/git.js +15 -1
- package/dist/branch/runner.d.ts +2 -0
- package/dist/branch/runner.js +92 -5
- package/dist/buildAdmission.d.ts +61 -0
- package/dist/buildAdmission.js +286 -0
- package/dist/buildApproval.d.ts +2 -0
- package/dist/buildApproval.js +17 -0
- package/dist/buildAssignment.d.ts +33 -0
- package/dist/buildAssignment.js +215 -0
- package/dist/buildInterventions.d.ts +90 -0
- package/dist/buildInterventions.js +334 -0
- package/dist/buildLaunchGate.d.ts +1 -0
- package/dist/buildLaunchGate.js +36 -0
- package/dist/buildOwnershipReconciliation.d.ts +19 -0
- package/dist/buildOwnershipReconciliation.js +41 -0
- package/dist/buildOwnershipRepair.d.ts +33 -0
- package/dist/buildOwnershipRepair.js +111 -0
- package/dist/buildRuns.d.ts +24 -1
- package/dist/buildRuns.js +216 -70
- package/dist/buildSyntheticWork.d.ts +2 -0
- package/dist/buildSyntheticWork.js +30 -0
- package/dist/buildWorkAdmission.d.ts +17 -0
- package/dist/buildWorkAdmission.js +57 -0
- package/dist/buildWorkAuthorization.d.ts +2 -0
- package/dist/buildWorkAuthorization.js +23 -0
- package/dist/buildWorkContext.d.ts +3 -0
- package/dist/buildWorkContext.js +6 -0
- package/dist/buildWorkMigration.d.ts +9 -0
- package/dist/buildWorkMigration.js +206 -0
- package/dist/buildWorkReconciliation.d.ts +12 -0
- package/dist/buildWorkReconciliation.js +70 -0
- package/dist/builderGuidanceFollowup.d.ts +15 -0
- package/dist/builderGuidanceFollowup.js +34 -0
- package/dist/cli/doctor.js +3 -2
- package/dist/cli/graph.d.ts +2 -0
- package/dist/cli/graph.js +75 -0
- package/dist/cli/manager.d.ts +8 -0
- package/dist/cli/manager.js +234 -12
- package/dist/cli/recovery.js +3 -2
- package/dist/cli/runtimeAuthPrompt.d.ts +5 -0
- package/dist/cli/runtimeAuthPrompt.js +41 -7
- package/dist/cli/start.d.ts +8 -0
- package/dist/cli/start.js +2091 -1849
- package/dist/cli/status.js +20 -5
- package/dist/cli/tickets.js +9 -7
- package/dist/continuity.d.ts +16 -1
- package/dist/continuity.js +68 -12
- package/dist/foreman.d.ts +12 -4
- package/dist/foreman.js +397 -60
- package/dist/graph/boundary.d.ts +15 -0
- package/dist/graph/boundary.js +50 -0
- package/dist/graph/bridge.d.ts +9 -0
- package/dist/graph/bridge.js +146 -0
- package/dist/graph/bridge.py +205 -0
- package/dist/graph/config.d.ts +11 -0
- package/dist/graph/config.js +40 -0
- package/dist/graph/corpus.d.ts +16 -0
- package/dist/graph/corpus.js +188 -0
- package/dist/graph/derived.d.ts +17 -0
- package/dist/graph/derived.js +73 -0
- package/dist/graph/host.d.ts +4 -0
- package/dist/graph/host.js +160 -0
- package/dist/graph/install.d.ts +3 -0
- package/dist/graph/install.js +50 -0
- package/dist/graph/lifecycle.d.ts +11 -0
- package/dist/graph/lifecycle.js +124 -0
- package/dist/graph/maintenance.d.ts +43 -0
- package/dist/graph/maintenance.js +301 -0
- package/dist/graph/manager.d.ts +9 -0
- package/dist/graph/manager.js +92 -0
- package/dist/graph/preparation.d.ts +41 -0
- package/dist/graph/preparation.js +43 -0
- package/dist/graph/read.d.ts +27 -0
- package/dist/graph/read.js +152 -0
- package/dist/graph/semantic.d.ts +17 -0
- package/dist/graph/semantic.js +27 -0
- package/dist/graph/session.d.ts +8 -0
- package/dist/graph/session.js +92 -0
- package/dist/graph/sources.d.ts +4 -0
- package/dist/graph/sources.js +11 -0
- package/dist/graph/storage.d.ts +38 -0
- package/dist/graph/storage.js +159 -0
- package/dist/graph/turn.d.ts +17 -0
- package/dist/graph/turn.js +360 -0
- package/dist/graph/util.d.ts +5 -0
- package/dist/graph/util.js +51 -0
- package/dist/handoffs.d.ts +1 -0
- package/dist/handoffs.js +5 -1
- package/dist/humanDecision.d.ts +21 -2
- package/dist/humanDecision.js +169 -10
- package/dist/index.js +4 -1
- package/dist/installOwnership.d.ts +46 -0
- package/dist/installOwnership.js +440 -0
- package/dist/linuxProbeInventory.d.ts +12 -0
- package/dist/linuxProbeInventory.js +48 -0
- package/dist/log.d.ts +1 -1
- package/dist/log.js +7 -1
- package/dist/managerEvidence.d.ts +25 -0
- package/dist/managerEvidence.js +326 -0
- package/dist/managerEvidenceArtifacts.d.ts +38 -0
- package/dist/managerEvidenceArtifacts.js +118 -0
- package/dist/managerPacket.d.ts +2 -0
- package/dist/managerPacket.js +23 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +2 -2
- package/dist/observability.js +1 -1
- package/dist/processIdentity.d.ts +21 -1
- package/dist/processIdentity.js +87 -5
- package/dist/providerPhase.d.ts +13 -0
- package/dist/providerPhase.js +26 -0
- package/dist/qaAuthorizedDisposition.d.ts +24 -0
- package/dist/qaAuthorizedDisposition.js +26 -0
- package/dist/qaBuildGate.d.ts +4 -0
- package/dist/qaBuildGate.js +216 -0
- package/dist/qaBuilderCoverage.d.ts +5 -0
- package/dist/qaBuilderCoverage.js +78 -0
- package/dist/qaContractCoverage.d.ts +10 -0
- package/dist/qaContractCoverage.js +55 -0
- package/dist/qaContractDelivery.d.ts +23 -0
- package/dist/qaContractDelivery.js +145 -0
- package/dist/qaContractFreshness.d.ts +15 -0
- package/dist/qaContractFreshness.js +36 -0
- package/dist/qaDeliveryJournal.d.ts +10 -0
- package/dist/qaDeliveryJournal.js +16 -1
- package/dist/qaEffectiveConfig.d.ts +20 -0
- package/dist/qaEffectiveConfig.js +40 -0
- package/dist/qaEvidenceReader.d.ts +14 -0
- package/dist/qaEvidenceReader.js +176 -0
- package/dist/qaFailureDelivery.d.ts +2 -0
- package/dist/qaFailureDelivery.js +107 -10
- package/dist/qaHandbackMigration.d.ts +1 -1
- package/dist/qaHandbackMigration.js +3 -3
- package/dist/qaPreparation.d.ts +26 -0
- package/dist/qaPreparation.js +150 -0
- package/dist/qaPreparationInputs.d.ts +8 -0
- package/dist/qaPreparationInputs.js +47 -0
- package/dist/qaPreparationMetrics.d.ts +145 -0
- package/dist/qaPreparationMetrics.js +120 -0
- package/dist/qaPreparationPolicy.d.ts +5 -0
- package/dist/qaPreparationPolicy.js +51 -0
- package/dist/qaPreparationStore.d.ts +157 -0
- package/dist/qaPreparationStore.js +412 -0
- package/dist/qaProtocolV2.d.ts +28 -0
- package/dist/qaProtocolV2.js +12 -0
- package/dist/qaReview.d.ts +6 -0
- package/dist/qaReview.js +293 -41
- package/dist/qaTimeline.d.ts +18 -0
- package/dist/qaTimeline.js +123 -0
- package/dist/qaVerificationContract.d.ts +17 -0
- package/dist/qaVerificationContract.js +236 -0
- package/dist/readinessCleanup.d.ts +19 -0
- package/dist/readinessCleanup.js +122 -0
- package/dist/readinessGate.d.ts +1 -0
- package/dist/readinessGate.js +82 -0
- package/dist/recoveryGuidance.d.ts +17 -0
- package/dist/recoveryGuidance.js +38 -0
- package/dist/runtimeAuth.d.ts +1 -0
- package/dist/runtimeAuth.js +5 -1
- package/dist/runtimeReadiness.d.ts +13 -0
- package/dist/runtimeReadiness.js +206 -10
- package/dist/sessionIdentity.d.ts +2 -0
- package/dist/sessionIdentity.js +14 -0
- package/dist/sessionLifecycle.js +3 -3
- package/dist/sources/sourceRegistry.js +7 -0
- package/dist/stateTransfer.js +85 -8
- package/dist/statusReporter.d.ts +1 -0
- package/dist/statusReporter.js +1 -0
- package/dist/supervisedStart.d.ts +6 -0
- package/dist/supervisedStart.js +178 -62
- package/dist/supervisor.d.ts +2 -0
- package/dist/supervisor.js +26 -2
- package/dist/terminalStyle.d.ts +21 -0
- package/dist/terminalStyle.js +50 -0
- package/dist/ticketPopulation.d.ts +1 -0
- package/dist/ticketPopulation.js +5 -1
- package/dist/tickets/commands.js +2 -2
- package/dist/tickets/queue.js +1 -1
- package/dist/tickets/ticketSchema.d.ts +198 -0
- package/dist/tickets/ticketSchema.js +2 -0
- package/dist/windowsProbeJob.d.ts +12 -0
- package/dist/windowsProbeJob.js +144 -0
- package/dist/workflowDb.d.ts +121 -6
- package/dist/workflowDb.js +874 -30
- package/dist/workflowReader.d.ts +116 -2
- package/dist/workflowReader.js +131 -4
- package/package.json +89 -8
- package/scripts/copy-graph-assets.mjs +3 -0
- package/scripts/graph-benchmark.mjs +20 -0
- package/scripts/graph-evaluation.mjs +85 -0
package/dist/workflowDb.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
+
import { registerQaGraphJournalWriter, ensureQaGraphJournalGuards } from "./qaDeliveryJournal.js";
|
|
2
|
+
import { canonicalJson } from "./qaProtocolV2.js";
|
|
3
|
+
import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
|
|
4
|
+
import { registerGraphDerived, graphDerivedAllowed, currentGraphDerivedAccess } from "./graph/derived.js";
|
|
5
|
+
import { parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
|
|
6
|
+
import { QaPreparationStore, migrateQaPreparation, migrateQaPreparationGuards } from "./qaPreparationStore.js";
|
|
7
|
+
import { GraphStore, migrateGraphStore } from "./graph/storage.js";
|
|
8
|
+
import { reconcileWork } from "./buildWorkReconciliation.js";
|
|
9
|
+
import { migrateBuildInterventions, hasQueuedBuilderGuidance, reconcileGuidance, decisionWorkId, queuedControls, completeControl, verifyGuidance, builderVerificationContext, verifyBuilderGuidance, assertFinalizationControls, reserveGuidance, finishGuidance, instruction } from "./buildInterventions.js";
|
|
10
|
+
import { migrateBuildWork } from "./buildWorkMigration.js";
|
|
11
|
+
import { admitWork, admittedWork, assertAdmittedWork } from "./buildWorkAdmission.js";
|
|
12
|
+
import { cleanupReadiness, inspectReadiness, readinessMetadata } from "./readinessCleanup.js";
|
|
13
|
+
import { originalBuildLease, rememberOriginalLease, forgetOriginalLease, registerLaunchChild, acknowledgeLaunchChild, reconcileLaunch, checkBuildOwnershipSchema, canonicalProject, launchDigest, localBuildAuthority, rememberBuildAuthority, forgetBuildAuthority, migrateBuildAdmission, acquireAdmission, readAdmission, assertAdmission, releaseAdmission, reserveLaunch, setLaunchState, claimLaunch, readLaunch } from "./buildAdmission.js";
|
|
1
14
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
15
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
16
|
import { hostname } from "node:os";
|
|
4
17
|
import { dirname, join, resolve } from "node:path";
|
|
5
18
|
import Database from "better-sqlite3";
|
|
19
|
+
import { validateQaFailureReport } from "rafi-spec";
|
|
6
20
|
import { migrateQaHandback, registerHandbackWriter, reportOccurrenceId } from "./qaHandbackMigration.js";
|
|
7
21
|
import { providerSessionKey } from "./sessionIdentity.js";
|
|
8
|
-
import { processStartIdentity } from "./processIdentity.js";
|
|
22
|
+
import { taggedProcesses, classifyProcess, processStartIdentity } from "./processIdentity.js";
|
|
9
23
|
import { initialQaReducerState, qaDigest, reduceQaState, } from "./qaProtocolV2.js";
|
|
10
24
|
export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
|
|
11
25
|
/** Read the current project lease without creating or migrating recovery state. */
|
|
@@ -34,6 +48,7 @@ export function heartbeatCurrentWorkflowLease(projectDir, lease, now = new Date(
|
|
|
34
48
|
if (!existsSync(path))
|
|
35
49
|
throw new Error("workflow recovery database not found");
|
|
36
50
|
const db = new Database(path, { fileMustExist: true });
|
|
51
|
+
db.function("rafi_protocol_v3", () => 1);
|
|
37
52
|
registerHandbackWriter(db);
|
|
38
53
|
try {
|
|
39
54
|
const at = now.toISOString();
|
|
@@ -50,30 +65,617 @@ export function heartbeatCurrentWorkflowLease(projectDir, lease, now = new Date(
|
|
|
50
65
|
/** Authoritative persistence for every resumable workflow in a project. */
|
|
51
66
|
export class WorkflowDb {
|
|
52
67
|
projectDir;
|
|
68
|
+
readinessAccess;
|
|
53
69
|
path;
|
|
54
70
|
db;
|
|
55
|
-
|
|
71
|
+
writerAuthority;
|
|
72
|
+
writerLease;
|
|
73
|
+
coordinatorTransition = 0;
|
|
74
|
+
lineageTransition = 0;
|
|
75
|
+
workAuthority = 0;
|
|
76
|
+
constructor(projectDir, path = join(resolve(projectDir), WORKFLOW_DB_FILE), readinessAccess) {
|
|
56
77
|
this.projectDir = projectDir;
|
|
78
|
+
this.readinessAccess = readinessAccess;
|
|
57
79
|
this.path = path;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
80
|
+
if (existsSync(path)) {
|
|
81
|
+
const preview = new Database(path, { readonly: true, fileMustExist: true });
|
|
82
|
+
try {
|
|
83
|
+
checkBuildOwnershipSchema(preview);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
preview.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!readinessAccess) {
|
|
90
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
91
|
+
ensureRecoveryGitignore(resolve(projectDir));
|
|
92
|
+
}
|
|
93
|
+
this.db = new Database(path, { fileMustExist: Boolean(readinessAccess) });
|
|
94
|
+
this.db.function("rafi_protocol_v3", () => 1);
|
|
95
|
+
this.writerAuthority = localBuildAuthority(projectDir);
|
|
96
|
+
this.writerLease = originalBuildLease(projectDir);
|
|
97
|
+
this.db.function("rafi_build_lease_owner", () => this.writerLease?.owner ?? "");
|
|
98
|
+
this.db.function("rafi_build_lease_generation", () => this.writerLease?.generation ?? -1);
|
|
99
|
+
this.db.function("rafi_build_writer_token", () => this.writerAuthority?.token ?? "");
|
|
100
|
+
this.db.function("rafi_build_writer_run", () => this.writerAuthority?.runId ?? "");
|
|
101
|
+
this.db.function("rafi_work_authority", () => this.workAuthority);
|
|
61
102
|
registerHandbackWriter(this.db);
|
|
62
|
-
this.db
|
|
103
|
+
registerQaGraphJournalWriter(this.db);
|
|
104
|
+
if (!readinessAccess)
|
|
105
|
+
this.db.pragma("journal_mode = WAL");
|
|
63
106
|
this.db.pragma("synchronous = FULL");
|
|
64
107
|
this.db.pragma("foreign_keys = ON");
|
|
65
108
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
109
|
+
if (readinessAccess)
|
|
110
|
+
this.restrictReadinessConnection(readinessAccess);
|
|
111
|
+
else {
|
|
112
|
+
this.migrate();
|
|
113
|
+
migrateQaPreparation(this.db);
|
|
114
|
+
migrateBuildAdmission(this.db);
|
|
115
|
+
this.importLegacyOnce();
|
|
116
|
+
migrateBuildWork(this.db, projectDir);
|
|
117
|
+
migrateBuildInterventions(this.db);
|
|
118
|
+
migrateQaPreparationGuards(this.db);
|
|
119
|
+
}
|
|
68
120
|
}
|
|
69
121
|
catch (error) {
|
|
70
122
|
this.db.close();
|
|
71
123
|
throw error;
|
|
72
124
|
}
|
|
73
125
|
}
|
|
126
|
+
/** Connection-local SQL fences: helper/reconciler cannot gain general writer rights. */
|
|
127
|
+
restrictReadinessConnection(access) {
|
|
128
|
+
const value = "probeId" in access ? access.probeId : access.runId;
|
|
129
|
+
const literal = this.db.prepare("SELECT quote(?) AS literal").get(value).literal;
|
|
130
|
+
for (const { name } of this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all()) {
|
|
131
|
+
if (!/^[a-z_]+$/.test(name))
|
|
132
|
+
throw new Error("Unexpected readiness storage table");
|
|
133
|
+
for (const action of ["INSERT", "UPDATE", "DELETE"]) {
|
|
134
|
+
const allowed = name === "build_owned_processes" && action === "UPDATE";
|
|
135
|
+
const condition = allowed ? `WHEN OLD.${"probeId" in access ? "id" : "run_id"}<>${literal} OR NEW.id<>OLD.id OR NEW.run_id<>OLD.run_id OR NEW.owner<>OLD.owner OR NEW.host<>OLD.host` : "";
|
|
136
|
+
this.db.exec(`CREATE TEMP TRIGGER readiness_scope_${name}_${action} BEFORE ${action} ON main.${name} ${condition} BEGIN SELECT RAISE(ABORT,'Restricted readiness connection cannot mutate this record'); END`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
74
140
|
close() { this.db.close(); }
|
|
141
|
+
qaPreparationStore() { return new QaPreparationStore(this.db, runId => this.assertInstructionOwner(runId), this.projectDir); }
|
|
142
|
+
graphStore() { migrateGraphStore(this.db); return new GraphStore(this.db); }
|
|
143
|
+
admitWork(input) {
|
|
144
|
+
this.assertInstructionOwner(input.runId);
|
|
145
|
+
this.workAuthority++;
|
|
146
|
+
try {
|
|
147
|
+
const result = admitWork(this.db, input);
|
|
148
|
+
this.qaPreparationStore().metric(input.runId, result.workId, result.requirementsDigest, `metric:approved:${input.runId}:${result.workId}:${result.requirementsDigest}`, "approved");
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
this.workAuthority--;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
assertInstructionOwner(runId) {
|
|
156
|
+
if (this.writerAuthority) {
|
|
157
|
+
assertAdmission(this.db, this.writerAuthority);
|
|
158
|
+
if (this.writerAuthority.runId !== runId && !this.db.prepare("SELECT 1 FROM build_child_runs WHERE child=? AND parent=?").get(runId, this.writerAuthority.runId))
|
|
159
|
+
throw new Error("Instruction execution authority belongs to another run");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (this.buildAdmission())
|
|
163
|
+
throw new Error("Instruction consumption requires the original build owner");
|
|
164
|
+
if (this.db.prepare("SELECT 1 FROM build_runtime_runs WHERE run_id=?").get(runId)) {
|
|
165
|
+
const held = this.currentLease();
|
|
166
|
+
if (!held || !this.writerLease || held.owner !== this.writerLease.owner || held.generation !== this.writerLease.generation || held.runId !== runId)
|
|
167
|
+
throw new Error("Instruction execution requires the original workflow lease");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
reserveGuidance(runId, workId, recipient, operationId, sourceDigest, text, reserve = true) {
|
|
171
|
+
this.assertInstructionOwner(runId);
|
|
172
|
+
return reserveGuidance(this.db, runId, workId, recipient, operationId, sourceDigest, text, reserve);
|
|
173
|
+
}
|
|
174
|
+
reconcileInstructionDeliveries(runId, workId) { this.assertInstructionOwner(runId); this.atomic(() => reconcileGuidance(this.db, runId, workId)); }
|
|
175
|
+
decisionWorkId(decision) { return decisionWorkId(this.db, decision); }
|
|
176
|
+
finishGuidance(ids, recipient, result) { for (const id of ids)
|
|
177
|
+
this.assertInstructionOwner(instruction(this.db, id).request.runId); finishGuidance(this.db, ids, recipient, result); }
|
|
178
|
+
builderVerificationContext(runId, workId, sourceDigest) { return builderVerificationContext(this.db, runId, workId, sourceDigest); }
|
|
179
|
+
verifyBuilderGuidance(ids, certificate, response, receiptDigest) { for (const id of ids)
|
|
180
|
+
this.assertInstructionOwner(instruction(this.db, id).request.runId); return this.atomic(() => verifyBuilderGuidance(this.db, ids, certificate, response, receiptDigest)); }
|
|
181
|
+
verifyGuidance(ids, certificate, response) { for (const id of ids)
|
|
182
|
+
this.assertInstructionOwner(instruction(this.db, id).request.runId); return this.atomic(() => verifyGuidance(this.db, ids, certificate, response)); }
|
|
183
|
+
hasQueuedBuilderGuidance(runId, workId) { return hasQueuedBuilderGuidance(this.db, runId, workId); }
|
|
184
|
+
instruction(id) { return instruction(this.db, id); }
|
|
185
|
+
assertFinalizationControls(runId, workId) { assertFinalizationControls(this.db, runId, workId); }
|
|
186
|
+
consumeInstructionControls(runId, workId, kind = "questions") {
|
|
187
|
+
return this.atomic(() => {
|
|
188
|
+
this.assertInstructionOwner(runId);
|
|
189
|
+
const admission = this.assertAdmittedWork(runId, workId);
|
|
190
|
+
const latestReview = this.qaReviewAttempts(runId, workId).at(-1);
|
|
191
|
+
let authorization = kind === "attempts" && latestReview && ["failed", "passed"].includes(latestReview.status) ? this.db.prepare("SELECT a.authorization_id FROM qa_remediation_authorizations a JOIN build_instruction_deliveries d ON json_extract(d.record_json,'$.receipt.authorizationId')=a.authorization_id JOIN build_instructions i USING(instruction_id) WHERE a.run_id=? AND a.ticket_id=? AND a.review_attempt_id=? AND a.consumed_by IS NULL AND d.state='applied' AND json_extract(i.record_json,'$.requirementsDigest')=? AND json_extract(i.record_json,'$.basis.assignmentId')=? AND json_extract(i.record_json,'$.basis.scopeRevision')=? ORDER BY i.sequence LIMIT 1").get(runId, workId, latestReview.attemptId, admission.requirementsDigest, admission.assignmentId, admission.scopeRevision)?.authorization_id : undefined;
|
|
192
|
+
if (authorization) {
|
|
193
|
+
const head = this.qaTicketHead(runId, workId);
|
|
194
|
+
const eligible = latestReview?.status === "passed"
|
|
195
|
+
? head.state === "passed" && this.hasQueuedBuilderGuidance(runId, workId)
|
|
196
|
+
: head.state === "review-failed" && this.unresolvedQaReports(runId, workId).some(report => report.reviewNumber === latestReview.reviewNumber && report.reportDigest === latestReview.reportDigest);
|
|
197
|
+
if (!eligible)
|
|
198
|
+
authorization = undefined;
|
|
199
|
+
}
|
|
200
|
+
for (const record of queuedControls(this.db, runId, workId).filter(record => kind === "questions" ? record.request.action === "answer_question" : record.request.action === "request_attempt")) {
|
|
201
|
+
const priorAuthorization = authorization;
|
|
202
|
+
try {
|
|
203
|
+
this.atomic(() => {
|
|
204
|
+
if (record.requirementsDigest !== admission.requirementsDigest)
|
|
205
|
+
throw new Error("Instruction requirements are stale");
|
|
206
|
+
if (record.request.action === "answer_question") {
|
|
207
|
+
const decision = this.humanDecision(record.request.decisionId);
|
|
208
|
+
if (!decision || decision.runId !== runId || this.decisionWorkId(decision) !== workId || createHash("sha256").update(json(decision)).digest("hex") !== record.request.decisionRevision)
|
|
209
|
+
throw new Error("Pending question identity or revision changed");
|
|
210
|
+
const choice = decision.choices.find(choice => choice.id === record.request.text || choice.label === record.request.text) ?? decision.choices.find(choice => choice.id === "custom" || choice.id === "answer");
|
|
211
|
+
if (!choice)
|
|
212
|
+
throw new Error("Answer must name an offered choice or use a question accepting custom text");
|
|
213
|
+
const answer = this.answerHumanDecision(runId, decision.decisionId, choice.id, new Date(), choice.id === "custom" || choice.id === "answer" ? record.request.text : undefined);
|
|
214
|
+
completeControl(this.db, record.instructionId, { decisionId: answer.decisionId, status: answer.status });
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
const latest = this.qaReviewAttempts(runId, workId).at(-1);
|
|
218
|
+
if (!latest || !["failed", "passed"].includes(latest.status) || latest.attemptId !== record.basis.reviewAttemptId || authorization)
|
|
219
|
+
throw new Error("One extra attempt requires the current eligible review; another authorization is already available");
|
|
220
|
+
if (record.basis.assignmentId !== admission.assignmentId || record.basis.scopeRevision !== admission.scopeRevision)
|
|
221
|
+
throw new Error("Attempt authorization scope changed");
|
|
222
|
+
const head = this.qaTicketHead(runId, workId);
|
|
223
|
+
if (latest.status === "passed") {
|
|
224
|
+
if (head.state !== "passed" || !this.hasQueuedBuilderGuidance(runId, workId))
|
|
225
|
+
throw new Error("Follow-up authorization requires waiting Builder guidance on the current unfinalized pass");
|
|
226
|
+
authorization = this.authorizeQaRemediation(runId, workId, latest.attemptId, record.request.text);
|
|
227
|
+
completeControl(this.db, record.instructionId, { authorizationId: authorization, reviewAttemptId: latest.attemptId, consumed: false, kind: "builder-guidance-followup" });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const report = this.unresolvedQaReports(runId, workId).find(report => report.reviewNumber === latest.reviewNumber && report.reportDigest === latest.reportDigest && report.sourceStateDigest === latest.sourceDigest && report.reviewBasisDigest === head.reviewBasisDigest);
|
|
231
|
+
if (!report || !validateQaFailureReport(report.report).valid || head.reviewNumber !== latest.reviewNumber || head.sourceStateDigest !== latest.sourceDigest || !["review-failed", "operator-menu"].includes(head.state) || this.qaRemediationAttempts(runId, workId).some(attempt => attempt.reviewAttemptId === latest.attemptId))
|
|
232
|
+
throw new Error("Extra attempt requires an unconsumed validated failed-review basis; reconcile uncertain execution separately");
|
|
233
|
+
const decisions = this.db.prepare("SELECT decision_id,status,decision_json FROM human_decisions WHERE run_id=? AND decision_key LIKE ?").all(runId, `${runId}:qa-nonconvergence:%`).filter(row => this.decisionWorkId(parseJson(row.decision_json)) === workId && parseJson(row.decision_json).createdAt >= latest.createdAt);
|
|
234
|
+
if (decisions.some(row => row.status === "answered"))
|
|
235
|
+
throw new Error("The nonconvergence decision was already answered; resume that decision before requesting another attempt");
|
|
236
|
+
authorization = this.authorizeQaRemediation(runId, workId, latest.attemptId, record.request.text);
|
|
237
|
+
for (const row of decisions.filter(row => row.status === "pending")) {
|
|
238
|
+
const decision = parseJson(row.decision_json);
|
|
239
|
+
const at = new Date().toISOString();
|
|
240
|
+
this.db.prepare("UPDATE human_decisions SET status='cancelled',decision_json=?,updated_at=? WHERE decision_id=? AND status='pending'").run(json({ ...decision, status: "cancelled", cancellationReason: `Superseded by scoped attempt instruction ${record.instructionId}`, cancelledAt: at }), at, row.decision_id);
|
|
241
|
+
this.insertEvent(runId, "human_decision_cancelled", "attempt-authorized", { decisionId: row.decision_id, instructionId: record.instructionId }, at);
|
|
242
|
+
}
|
|
243
|
+
if (head.state === "operator-menu")
|
|
244
|
+
this.transitionQa(runId, workId, head.revision, { type: "failed-review-restored", reviewNumber: latest.reviewNumber, sourceStateDigest: latest.sourceDigest, reportDigest: report.reportDigest });
|
|
245
|
+
completeControl(this.db, record.instructionId, { authorizationId: authorization, reviewAttemptId: latest.attemptId, consumed: false });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
authorization = priorAuthorization;
|
|
251
|
+
completeControl(this.db, record.instructionId, undefined, String(error));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return authorization;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
reconcileWork(request, input) {
|
|
258
|
+
this.assertInstructionOwner(request.runId);
|
|
259
|
+
if (this.writerAuthority)
|
|
260
|
+
assertAdmission(this.db, this.writerAuthority);
|
|
261
|
+
this.workAuthority++;
|
|
262
|
+
try {
|
|
263
|
+
return this.atomic(() => reconcileWork(this.db, request, input));
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
this.workAuthority--;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
reconciliation(id) {
|
|
270
|
+
const row = this.db.prepare("SELECT record_json FROM build_reconciliations WHERE reconciliation_id=?").get(id);
|
|
271
|
+
return row ? JSON.parse(row.record_json) : undefined;
|
|
272
|
+
}
|
|
273
|
+
workDefinitions(runId) {
|
|
274
|
+
return this.db.prepare("SELECT work_id,kind,definition_json FROM build_work_scope WHERE run_id=? AND state='admitted' ORDER BY rowid").all(runId).map(row => ({ workId: row.work_id, kind: row.kind, definition: JSON.parse(row.definition_json) }));
|
|
275
|
+
}
|
|
276
|
+
recordWorkAssignment(runId, workId, operationId, record) {
|
|
277
|
+
this.assertInstructionOwner(runId);
|
|
278
|
+
const admission = this.assertAdmittedWork(runId, workId);
|
|
279
|
+
this.qaPreparationStore().metric(runId, workId, admission.requirementsDigest, `metric:started:${runId}:${workId}:${admission.requirementsDigest}`, "implementation-started");
|
|
280
|
+
this.workAuthority++;
|
|
281
|
+
try {
|
|
282
|
+
this.db.prepare("INSERT INTO build_assignments VALUES(?,?,?,?,?)").run(operationId, runId, workId, operationId, json(record));
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
this.workAuthority--;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
admittedWork(runId, workId) { return admittedWork(this.db, runId, workId); }
|
|
289
|
+
assertAdmittedWork(runId, workId) { return assertAdmittedWork(this.db, runId, workId); }
|
|
75
290
|
/** Atomic local changes only; never await provider or filesystem work here. */
|
|
76
291
|
atomic(work) { return this.db.transaction(work).immediate(); }
|
|
292
|
+
buildAdmission() { return readAdmission(this.db); }
|
|
293
|
+
assertNoLegacyBuildOwner() {
|
|
294
|
+
for (const row of this.db.prepare("SELECT run_id,state_json FROM workflow_runs WHERE kind='build' AND legacy=1").all()) {
|
|
295
|
+
const state = parseJson(row.state_json);
|
|
296
|
+
if (!state.lease || ["completed", "cancelled", "superseded"].includes(state.status ?? ""))
|
|
297
|
+
continue;
|
|
298
|
+
const owner = classifyProcess(state.lease.pid, state.lease.processStart, state.lease.hostname ?? "unknown-host");
|
|
299
|
+
if (owner.state !== "dead")
|
|
300
|
+
throw new Error(`Legacy build ${row.run_id} ownership is ${owner.state}: ${owner.reason}; reconcile before starting another writer`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
assertExecutionProtocol() {
|
|
304
|
+
if (this.db.prepare("SELECT version FROM build_ownership_schema").get().version !== 3)
|
|
305
|
+
throw new Error("Ownership migration is pending legacy execution cleanup; use rafi resume before starting work");
|
|
306
|
+
}
|
|
307
|
+
acquireBuildAdmission(runId, phase) {
|
|
308
|
+
return this.atomic(() => {
|
|
309
|
+
this.assertNoLegacyBuildOwner();
|
|
310
|
+
const lease = this.currentLease();
|
|
311
|
+
if (lease && classifyProcess(lease.pid, lease.processStart, lease.host).state !== "dead")
|
|
312
|
+
throw new Error(`project workflow lease is held by ${lease.owner} for run ${lease.runId}`);
|
|
313
|
+
for (const owner of this.runningSupervisors()) {
|
|
314
|
+
if (owner.state.pid && classifyProcess(owner.state.pid, owner.state.processStart).state !== "dead")
|
|
315
|
+
throw new Error(`supervisor already active for project (run ${owner.runId})`);
|
|
316
|
+
}
|
|
317
|
+
const uncertain = this.db.prepare("SELECT run_id FROM operation_journal WHERE kind='provider-dispatch' AND status IN ('in_progress','uncertain') LIMIT 1").get();
|
|
318
|
+
if (uncertain)
|
|
319
|
+
throw new Error(`Build ${uncertain.run_id} has unresolved provider dispatch; reconcile before another launch`);
|
|
320
|
+
for (const row of this.db.prepare("SELECT DISTINCT run_id FROM build_owned_processes WHERE state<>'quiescent'").all()) {
|
|
321
|
+
if (this.unresolvedPreparationProcesses(row.run_id).length)
|
|
322
|
+
throw new Error(`Build ${row.run_id} has unverified preparation descendants; reconcile before another launch`);
|
|
323
|
+
}
|
|
324
|
+
const prior = this.buildAdmission();
|
|
325
|
+
if (prior && this.unresolvedPreparationProcesses(prior.runId).length)
|
|
326
|
+
throw new Error(`Build ${prior.runId} has unverified preparation descendants; reconcile before another launch`);
|
|
327
|
+
if (prior && [...this.unresolvedRoleDispatches(prior.runId, "builder"), ...this.unresolvedRoleDispatches(prior.runId, "qa")].length)
|
|
328
|
+
throw new Error(`Build ${prior.runId} has unresolved provider dispatch; reconcile before another launch`);
|
|
329
|
+
this.assertExecutionProtocol();
|
|
330
|
+
this.ensureRun(runId, "build");
|
|
331
|
+
const authority = acquireAdmission(this.db, this.projectDir, runId, phase);
|
|
332
|
+
this.writerAuthority = authority;
|
|
333
|
+
rememberBuildAuthority(authority);
|
|
334
|
+
return authority;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
acquireBuildRecoveryAdmission(runId) {
|
|
338
|
+
return this.atomic(() => {
|
|
339
|
+
this.assertExecutionProtocol();
|
|
340
|
+
this.assertNoLegacyBuildOwner();
|
|
341
|
+
const otherDispatch = this.db.prepare("SELECT run_id FROM operation_journal WHERE run_id<>? AND kind='provider-dispatch' AND status IN ('in_progress','uncertain') LIMIT 1").get(runId);
|
|
342
|
+
if (otherDispatch)
|
|
343
|
+
throw new Error(`Build ${otherDispatch.run_id} has unresolved provider dispatch; reconcile before starting another recovery workflow`);
|
|
344
|
+
const current = localBuildAuthority(this.projectDir);
|
|
345
|
+
if (current && current.runId === runId && this.buildAdmission()?.token === current.token) {
|
|
346
|
+
this.writerAuthority = current;
|
|
347
|
+
return current;
|
|
348
|
+
}
|
|
349
|
+
const prior = this.buildAdmission();
|
|
350
|
+
if (prior && prior.runId !== runId)
|
|
351
|
+
return this.acquireBuildAdmission(runId, "coordinator");
|
|
352
|
+
if (this.unresolvedPreparationProcesses(runId).length)
|
|
353
|
+
throw new Error("Owned preparation descendants require reconciliation before recovery");
|
|
354
|
+
const lease = this.currentLease();
|
|
355
|
+
if (lease && classifyProcess(lease.pid, lease.processStart, lease.host).state !== "dead")
|
|
356
|
+
throw new Error(`project workflow lease is held by ${lease.owner} for run ${lease.runId}`);
|
|
357
|
+
for (const owner of this.runningSupervisors())
|
|
358
|
+
if (owner.state.pid && classifyProcess(owner.state.pid, owner.state.processStart).state !== "dead")
|
|
359
|
+
throw new Error(`supervisor already active for project (run ${owner.runId})`);
|
|
360
|
+
const authority = acquireAdmission(this.db, this.projectDir, runId, "coordinator");
|
|
361
|
+
this.writerAuthority = authority;
|
|
362
|
+
rememberBuildAuthority(authority);
|
|
363
|
+
return authority;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
admitLeasedBuild(lease) {
|
|
367
|
+
return this.atomic(() => {
|
|
368
|
+
this.assertExecutionProtocol();
|
|
369
|
+
const held = this.currentLease();
|
|
370
|
+
if (!held || held.owner !== lease.owner || held.generation !== lease.generation || held.runId !== lease.runId)
|
|
371
|
+
throw new Error("workflow lease ownership changed");
|
|
372
|
+
const existing = localBuildAuthority(this.projectDir);
|
|
373
|
+
const authority = existing && existing.runId === lease.runId && this.buildAdmission()?.token === existing.token ? existing : acquireAdmission(this.db, this.projectDir, lease.runId, "coordinator");
|
|
374
|
+
this.writerAuthority = authority;
|
|
375
|
+
rememberBuildAuthority(authority);
|
|
376
|
+
return authority;
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
reacquireBuildCoordinator(runId) {
|
|
380
|
+
return this.atomic(() => {
|
|
381
|
+
const owner = this.supervisorState(runId);
|
|
382
|
+
if (owner?.pid !== process.pid || owner.processStart !== processStartIdentity())
|
|
383
|
+
throw new Error("Supervisor ownership changed");
|
|
384
|
+
const authority = acquireAdmission(this.db, this.projectDir, runId, "coordinator");
|
|
385
|
+
this.writerAuthority = authority;
|
|
386
|
+
rememberBuildAuthority(authority);
|
|
387
|
+
return authority;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
assertBuildAdmission(authority) { assertAdmission(this.db, authority); }
|
|
391
|
+
releaseBuildAdmission(authority) {
|
|
392
|
+
this.atomic(() => {
|
|
393
|
+
assertAdmission(this.db, authority);
|
|
394
|
+
if (this.unresolvedPreparationProcesses(authority.runId).length)
|
|
395
|
+
throw new Error("Unresolved owned processes or provider dispatch require reconciliation");
|
|
396
|
+
releaseAdmission(this.db, authority);
|
|
397
|
+
forgetBuildAuthority(authority);
|
|
398
|
+
if (this.writerAuthority?.token === authority.token)
|
|
399
|
+
this.writerAuthority = undefined;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
reserveBuildLaunch(authority, role, digest, protocol) { return reserveLaunch(this.db, authority, role, digest, protocol); }
|
|
403
|
+
dispatchBuildLaunch(authority, token) { setLaunchState(this.db, authority, token, "dispatching"); }
|
|
404
|
+
failBuildLaunch(authority, token) { setLaunchState(this.db, authority, token, "failed"); }
|
|
405
|
+
claimBuildLaunch(runId, token, role, digest) { const authority = claimLaunch(this.db, this.projectDir, runId, token, role, digest); this.writerAuthority = authority; rememberBuildAuthority(authority); return authority; }
|
|
406
|
+
registerBuildLaunchChild(token) { registerLaunchChild(this.db, token, this.projectDir); }
|
|
407
|
+
acknowledgeBuildLaunchChild(authority, token, pid) { acknowledgeLaunchChild(this.db, authority, token, pid); }
|
|
408
|
+
reconcileBuildLaunches(runId, original) {
|
|
409
|
+
return this.atomic(() => {
|
|
410
|
+
let result = "retired";
|
|
411
|
+
for (const row of this.db.prepare("SELECT token FROM build_launches WHERE run_id=? AND state IN ('reserved','dispatching')").all(runId)) {
|
|
412
|
+
const state = reconcileLaunch(this.db, row.token, original);
|
|
413
|
+
if (state === "unknown")
|
|
414
|
+
return state;
|
|
415
|
+
if (state === "claimed")
|
|
416
|
+
result = state;
|
|
417
|
+
}
|
|
418
|
+
return result;
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
buildLaunch(token) { return readLaunch(this.db, token); }
|
|
422
|
+
preparationSuccessor(runId) {
|
|
423
|
+
const seen = new Set();
|
|
424
|
+
let current = runId;
|
|
425
|
+
while (true) {
|
|
426
|
+
if (seen.has(current))
|
|
427
|
+
throw new Error("Preparation retry lineage contains a cycle");
|
|
428
|
+
seen.add(current);
|
|
429
|
+
const link = this.db.prepare("SELECT successor,project FROM build_retry_lineage WHERE predecessor=?").get(current);
|
|
430
|
+
if (!link)
|
|
431
|
+
return current;
|
|
432
|
+
if (link.project !== canonicalProject(this.projectDir))
|
|
433
|
+
throw new Error("Preparation lineage belongs to another project");
|
|
434
|
+
current = link.successor;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
reservePreparationRetry(predecessor, args, expectedStartArgs) {
|
|
438
|
+
return this.atomic(() => {
|
|
439
|
+
this.lineageTransition++;
|
|
440
|
+
try {
|
|
441
|
+
const existing = this.preparationSuccessor(predecessor);
|
|
442
|
+
if (existing !== predecessor)
|
|
443
|
+
throw new Error(`Preparation already retried as ${existing}; select that successor instead`);
|
|
444
|
+
const eligibility = this.preparationEligibility(predecessor);
|
|
445
|
+
if (!eligibility.eligible)
|
|
446
|
+
throw new Error(`Preparation requires reconciliation: ${eligibility.reasons.join("; ")}`);
|
|
447
|
+
const previous = this.getRun(predecessor);
|
|
448
|
+
if (expectedStartArgs !== undefined && JSON.stringify(previous.state.startArgs ?? null) !== expectedStartArgs)
|
|
449
|
+
throw new Error("Saved preparation options changed before launch reservation; select the build again with rafi resume");
|
|
450
|
+
const authority = this.acquireBuildAdmission(predecessor, "coordinator");
|
|
451
|
+
if (typeof previous.state.predecessor === "string") {
|
|
452
|
+
this.transition(predecessor, { checkpoint: "preparing", state: { ...previous.state, startArgs: args }, event: "preparation_relaunch_reserved" });
|
|
453
|
+
return { runId: predecessor, authority, launch: reserveLaunch(this.db, authority, "coordinator", launchDigest(args), "registered-v2") };
|
|
454
|
+
}
|
|
455
|
+
const runId = randomUUID();
|
|
456
|
+
this.ensureRun(runId, "build");
|
|
457
|
+
for (const row of this.db.prepare("SELECT decision_key,decision_json,created_at,updated_at FROM human_decisions WHERE run_id=? AND status='answered'").all(predecessor)) {
|
|
458
|
+
if (!row.decision_key.startsWith(`${predecessor}:`))
|
|
459
|
+
throw new Error("Legacy approval key cannot be attributed safely to retry scope");
|
|
460
|
+
const decision = { ...JSON.parse(row.decision_json), decisionId: randomUUID(), runId };
|
|
461
|
+
this.db.prepare("INSERT INTO human_decisions VALUES(?,?,?,'answered',?,?,?)").run(decision.decisionId, `${runId}:${row.decision_key.slice(predecessor.length + 1)}`, runId, JSON.stringify(decision), row.created_at, row.updated_at);
|
|
462
|
+
}
|
|
463
|
+
const policy = this.autonomyPolicy(predecessor);
|
|
464
|
+
if (policy)
|
|
465
|
+
this.freezeAutonomyPolicy(runId, policy);
|
|
466
|
+
const supervisor = this.supervisorState(predecessor);
|
|
467
|
+
if (supervisor)
|
|
468
|
+
this.putSupervisorState(runId, { ...supervisor, status: "stopped", pid: undefined, workerPid: undefined, processStart: undefined });
|
|
469
|
+
this.transition(runId, { checkpoint: "preparing", state: { ...previous.state, startArgs: args, predecessor }, event: "preparation_retry_reserved" });
|
|
470
|
+
this.db.prepare("INSERT INTO build_retry_lineage VALUES(?,?,?,?)").run(predecessor, runId, canonicalProject(this.projectDir), new Date().toISOString());
|
|
471
|
+
this.transition(predecessor, { status: "superseded", checkpoint: "preparation-superseded", state: { ...previous.state, successor: runId }, event: "preparation_superseded", payload: { successor: runId } });
|
|
472
|
+
const next = { ...authority, runId };
|
|
473
|
+
this.db.prepare("UPDATE build_admission SET record_json=? WHERE singleton=1").run(JSON.stringify(next));
|
|
474
|
+
this.writerAuthority = next;
|
|
475
|
+
rememberBuildAuthority(next);
|
|
476
|
+
return { runId, authority: next, launch: reserveLaunch(this.db, next, "coordinator", launchDigest(args), "registered-v2") };
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
this.lineageTransition--;
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
beginOwnedPreparationProcess(authority, tag, gated = false) {
|
|
484
|
+
return this.atomic(() => {
|
|
485
|
+
assertAdmission(this.db, authority);
|
|
486
|
+
const id = tag ?? randomUUID();
|
|
487
|
+
if (gated && this.db.prepare("SELECT version FROM build_ownership_schema").get().version !== 3)
|
|
488
|
+
throw new Error("Legacy build ownership needs reconciliation before readiness protocol migration");
|
|
489
|
+
if (this.unresolvedPreparationProcesses(authority.runId).length)
|
|
490
|
+
throw new Error("Previous readiness cleanup is unresolved");
|
|
491
|
+
this.db.prepare("INSERT INTO build_owned_processes(id,run_id,owner,pid,process_start,host,state,outcome_json) VALUES(?,?,?,NULL,NULL,?,'intended',?)").run(id, authority.runId, authority.token, authority.host, JSON.stringify(gated ? { protocol: "gated-v3", startup: "intended", revision: 0, authority, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } : { protocol: process.platform === "win32" ? "windows-job-v1" : "tagged-v2" }));
|
|
492
|
+
return id;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
recordOwnedPreparationProcess(authority, id, pid) {
|
|
496
|
+
this.atomic(() => {
|
|
497
|
+
assertAdmission(this.db, authority);
|
|
498
|
+
const row = this.readinessProcesses().find(row => row.id === id);
|
|
499
|
+
if (row && readinessMetadata(row).protocol === "gated-v3")
|
|
500
|
+
throw new Error("Gated readiness requires helper registration");
|
|
501
|
+
if (this.db.prepare("UPDATE build_owned_processes SET pid=?,process_start=?,state='running' WHERE id=? AND owner=? AND state='intended'").run(pid, processStartIdentity(pid), id, authority.token).changes !== 1)
|
|
502
|
+
throw new Error("Owned process launch changed");
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
finishOwnedPreparationProcess(authority, id, noChild = false, outcome) {
|
|
506
|
+
this.atomic(() => {
|
|
507
|
+
assertAdmission(this.db, authority);
|
|
508
|
+
const row = this.db.prepare("SELECT * FROM build_owned_processes WHERE id=? AND owner=?").get(id, authority.token);
|
|
509
|
+
if (!row)
|
|
510
|
+
throw new Error("Owned process record disappeared");
|
|
511
|
+
const processRow = this.db.prepare("SELECT * FROM build_owned_processes WHERE id=?").get(id);
|
|
512
|
+
const meta = readinessMetadata(processRow);
|
|
513
|
+
if (outcome)
|
|
514
|
+
this.db.prepare("UPDATE build_owned_processes SET outcome_json=? WHERE id=?").run(JSON.stringify({ ...meta, ...outcome }), id);
|
|
515
|
+
if ((noChild && row.pid === null && meta.protocol !== "gated-v3") || inspectReadiness(processRow).state === "quiescent")
|
|
516
|
+
this.db.prepare("UPDATE build_owned_processes SET state='quiescent' WHERE id=?").run(id);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
readinessProcesses(runId) {
|
|
520
|
+
return this.db.prepare("SELECT * FROM build_owned_processes WHERE state<>'quiescent'" + (runId ? " AND run_id=?" : "")).all(...(runId ? [runId] : []));
|
|
521
|
+
}
|
|
522
|
+
unresolvedPreparationProcesses(runId) {
|
|
523
|
+
const parent = this.db.prepare("SELECT parent FROM build_child_runs WHERE child=?").get(runId)?.parent;
|
|
524
|
+
return this.readinessProcesses().filter(row => row.run_id === runId || row.run_id === parent).map(row => row.id);
|
|
525
|
+
}
|
|
526
|
+
registerReadinessHelper(id) {
|
|
527
|
+
this.atomic(() => {
|
|
528
|
+
const row = this.readinessProcesses().find(row => row.id === id);
|
|
529
|
+
const meta = row && readinessMetadata(row);
|
|
530
|
+
const start = processStartIdentity();
|
|
531
|
+
if (!row || meta?.protocol !== "gated-v3" || meta.startup !== "intended" || row.pid || row.host !== hostname() || start === "unavailable")
|
|
532
|
+
throw new Error("Readiness registration rejected");
|
|
533
|
+
this.db.prepare("UPDATE build_owned_processes SET pid=?,process_start=?,outcome_json=? WHERE id=?").run(process.pid, start, JSON.stringify({ ...meta, startup: "registered", revision: meta.revision + 1, updatedAt: new Date().toISOString() }), id);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
authorizeReadinessHelper(authority, id, pid) {
|
|
537
|
+
this.atomic(() => {
|
|
538
|
+
assertAdmission(this.db, authority);
|
|
539
|
+
const row = this.readinessProcesses().find(row => row.id === id);
|
|
540
|
+
const meta = row && readinessMetadata(row);
|
|
541
|
+
if (!row || row.owner !== authority.token || meta?.startup !== "registered" || row.pid !== pid || classifyProcess(pid, row.process_start ?? undefined, row.host).state !== "live")
|
|
542
|
+
throw new Error("Readiness authorization rejected");
|
|
543
|
+
this.db.prepare("UPDATE build_owned_processes SET outcome_json=? WHERE id=?").run(JSON.stringify({ ...meta, startup: "authorized", revision: meta.revision + 1, updatedAt: new Date().toISOString() }), id);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
assertReadinessHelper(id) {
|
|
547
|
+
const row = this.readinessProcesses().find(row => row.id === id);
|
|
548
|
+
if (!row || readinessMetadata(row).startup !== "authorized" || row.pid !== process.pid || row.process_start !== processStartIdentity() || row.host !== hostname())
|
|
549
|
+
throw new Error("Readiness execution capability rejected");
|
|
550
|
+
return row;
|
|
551
|
+
}
|
|
552
|
+
recordReadinessCreator(id, pid) {
|
|
553
|
+
this.atomic(() => {
|
|
554
|
+
const row = this.assertReadinessHelper(id);
|
|
555
|
+
const meta = readinessMetadata(row);
|
|
556
|
+
const start = processStartIdentity(pid);
|
|
557
|
+
if (meta.creator || start === "unavailable")
|
|
558
|
+
throw new Error("Windows creator registration rejected");
|
|
559
|
+
this.db.prepare("UPDATE build_owned_processes SET outcome_json=? WHERE id=?").run(JSON.stringify({ ...meta, creator: { pid, start }, revision: meta.revision + 1, updatedAt: new Date().toISOString() }), id);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
assertReadinessRelationships(runId) {
|
|
563
|
+
// Inspect each directed lineage separately; cleanup never broadens to siblings.
|
|
564
|
+
for (const relation of ["parent", "successor", "predecessor"]) {
|
|
565
|
+
let current = runId;
|
|
566
|
+
const seen = new Set();
|
|
567
|
+
while (true) {
|
|
568
|
+
if (seen.has(current))
|
|
569
|
+
throw new Error("Readiness run relationship contains a cycle");
|
|
570
|
+
seen.add(current);
|
|
571
|
+
const link = relation === "parent"
|
|
572
|
+
? this.db.prepare("SELECT parent AS target FROM build_child_runs WHERE child=?").get(current)
|
|
573
|
+
: this.db.prepare(`SELECT ${relation} AS target,project FROM build_retry_lineage WHERE ${relation === "successor" ? "predecessor" : "successor"}=?`).get(current);
|
|
574
|
+
if (!link)
|
|
575
|
+
break;
|
|
576
|
+
if (!this.getRun(link.target) || (link.project && canonicalProject(link.project) !== canonicalProject(this.projectDir)))
|
|
577
|
+
throw new Error("Readiness relationship is missing or belongs to another project");
|
|
578
|
+
current = link.target;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
readinessCleanupDetails(runId) {
|
|
583
|
+
return this.readinessProcesses(runId).map(row => {
|
|
584
|
+
const meta = readinessMetadata(row);
|
|
585
|
+
try {
|
|
586
|
+
this.assertReadinessRelationships(runId);
|
|
587
|
+
this.assertReadinessCleanupOwner(row);
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
return `${row.id}: ${error.message}`;
|
|
591
|
+
}
|
|
592
|
+
return `${row.id}: ${meta.cleanup?.reason ?? inspectReadiness(row).reason}`;
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
assertReadinessCleanupOwner(row, original) {
|
|
596
|
+
const held = this.buildAdmission();
|
|
597
|
+
if (original && held?.token === original.token && row.owner === original.token && original.project === canonicalProject(this.projectDir) && held.project === original.project)
|
|
598
|
+
return;
|
|
599
|
+
const recorded = readinessMetadata(row).authority;
|
|
600
|
+
const owner = recorded?.token === row.owner ? recorded : held?.token === row.owner ? held : undefined;
|
|
601
|
+
if (owner && (owner.project !== canonicalProject(this.projectDir) || owner.host !== row.host))
|
|
602
|
+
throw new Error("Readiness ownership belongs to another project or host");
|
|
603
|
+
if (!owner || classifyProcess(owner.pid, owner.processStart, owner.host).state !== "dead")
|
|
604
|
+
throw new Error("Readiness owner is live, unknown, or lacks attributable provenance");
|
|
605
|
+
}
|
|
606
|
+
revokeReadinessHelper(id, original) {
|
|
607
|
+
this.atomic(() => {
|
|
608
|
+
const row = this.readinessProcesses().find(row => row.id === id);
|
|
609
|
+
if (!row)
|
|
610
|
+
return;
|
|
611
|
+
this.assertReadinessCleanupOwner(row, original);
|
|
612
|
+
const meta = readinessMetadata(row);
|
|
613
|
+
if (meta.protocol !== "gated-v3" || !["intended", "registered", "revoked"].includes(meta.startup))
|
|
614
|
+
throw new Error("Authorized readiness cannot be revoked; verify cleanup instead");
|
|
615
|
+
if (meta.startup === "revoked")
|
|
616
|
+
return;
|
|
617
|
+
this.db.prepare("UPDATE build_owned_processes SET outcome_json=? WHERE id=?").run(JSON.stringify({ ...meta, startup: "revoked", revision: meta.revision + 1, updatedAt: new Date().toISOString() }), id);
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
async reconcileReadiness(runId, original, deadline = Date.now() + 5000) {
|
|
621
|
+
// Exact run scope: related/different blockers remain independently selectable.
|
|
622
|
+
for (const initial of this.readinessProcesses(runId)) {
|
|
623
|
+
if (Date.now() >= deadline)
|
|
624
|
+
break;
|
|
625
|
+
let row = initial;
|
|
626
|
+
try {
|
|
627
|
+
this.atomic(() => {
|
|
628
|
+
this.assertReadinessRelationships(runId);
|
|
629
|
+
this.assertReadinessCleanupOwner(row, original);
|
|
630
|
+
const meta = readinessMetadata(row);
|
|
631
|
+
if (meta.protocol === "gated-v3" && ["intended", "registered"].includes(meta.startup))
|
|
632
|
+
this.revokeReadinessHelper(row.id, original);
|
|
633
|
+
});
|
|
634
|
+
row = this.readinessProcesses(runId).find(item => item.id === row.id);
|
|
635
|
+
if (!row)
|
|
636
|
+
continue;
|
|
637
|
+
const meta = readinessMetadata(row);
|
|
638
|
+
let evidence = await cleanupReadiness(row, deadline);
|
|
639
|
+
// tagged-v2 launched directly from its recorded owner, without a delayed helper.
|
|
640
|
+
if (!row.pid && meta.protocol === "tagged-v2" && row.host === hostname() && (() => { const owner = this.buildAdmission(); return owner?.token === row.owner && classifyProcess(owner.pid, owner.processStart, owner.host).state === "dead"; })() && taggedProcesses(row.id)?.length === 0)
|
|
641
|
+
evidence = { state: "quiescent", reason: "verified dead direct-spawn owner and complete empty tag inventory" };
|
|
642
|
+
this.atomic(() => {
|
|
643
|
+
this.assertReadinessRelationships(runId);
|
|
644
|
+
this.assertReadinessCleanupOwner(row, original);
|
|
645
|
+
const current = this.readinessProcesses(runId).find(item => item.id === row.id);
|
|
646
|
+
if (!current || current.outcome_json !== row.outcome_json || current.pid !== row.pid)
|
|
647
|
+
return;
|
|
648
|
+
this.db.prepare("UPDATE build_owned_processes SET state=?,outcome_json=? WHERE id=?").run(evidence.state === "quiescent" ? "quiescent" : row.state, JSON.stringify({ ...meta, cleanup: { ...evidence, checkedAt: new Date().toISOString() }, revision: (meta.revision ?? 0) + 1, updatedAt: new Date().toISOString() }), row.id);
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
catch { /* Preserve durable blocker, including after storage/authority failures. */ }
|
|
652
|
+
}
|
|
653
|
+
return this.readinessProcesses(runId).map(row => row.id);
|
|
654
|
+
}
|
|
655
|
+
preparationEligibility(runId) {
|
|
656
|
+
const run = this.getRun(runId);
|
|
657
|
+
const reasons = [];
|
|
658
|
+
for (const row of this.db.prepare("SELECT state_json FROM workflow_runs WHERE kind='legacy' AND legacy=1").all()) {
|
|
659
|
+
const legacy = parseJson(row.state_json);
|
|
660
|
+
if (legacy.source && /delivery-sessions/.test(legacy.source) && existsSync(legacy.source) && legacy.record?.sessionId)
|
|
661
|
+
reasons.push("unattributed legacy branch session requires reconciliation");
|
|
662
|
+
}
|
|
663
|
+
if (this.pendingHumanDecisions(runId).length)
|
|
664
|
+
reasons.push("pending decisions require an answer");
|
|
665
|
+
if (this.unresolvedPreparationProcesses(runId).length)
|
|
666
|
+
reasons.push("owned preparation processes are still running or unverified");
|
|
667
|
+
if (this.incompletePublications().some(item => item.runId === runId))
|
|
668
|
+
reasons.push("unfinished publication requires reconciliation");
|
|
669
|
+
if (!run || ["completed", "cancelled", "superseded"].includes(run.status))
|
|
670
|
+
reasons.push("run is missing or terminal");
|
|
671
|
+
if (run?.state.version !== undefined || run?.state.runId)
|
|
672
|
+
reasons.push("implementation snapshot exists");
|
|
673
|
+
const tables = ["operation_journal", "provider_sessions", "continuity_heads", "handoffs", "role_mutation_leases", "branch_resume_sessions", "qa_ticket_heads", "qa_recovery_heads", "recovery_attempts"];
|
|
674
|
+
for (const table of tables)
|
|
675
|
+
if (this.db.prepare(`SELECT 1 FROM ${table} WHERE run_id=? LIMIT 1`).get(runId))
|
|
676
|
+
reasons.push(`${table} contains execution evidence or decisions`);
|
|
677
|
+
return { eligible: reasons.length === 0, reasons };
|
|
678
|
+
}
|
|
77
679
|
/** Ensure non-workflow build records can use the same durable event store. */
|
|
78
680
|
ensureRun(runId, kind = "build", now = new Date()) {
|
|
79
681
|
const existing = this.getRun(runId);
|
|
@@ -81,8 +683,14 @@ export class WorkflowDb {
|
|
|
81
683
|
return existing;
|
|
82
684
|
const at = now.toISOString();
|
|
83
685
|
this.db.transaction(() => {
|
|
686
|
+
if (this.writerAuthority)
|
|
687
|
+
assertAdmission(this.db, this.writerAuthority);
|
|
84
688
|
const inserted = this.db.prepare(`INSERT OR IGNORE INTO workflow_runs(run_id,kind,status,checkpoint,original_work_json,remaining_work_json,state_json,legacy,created_at,updated_at)
|
|
85
689
|
VALUES(?,?,'running','durable-baseline','{}','{}','{}',0,?,?)`).run(runId, kind, at, at);
|
|
690
|
+
if (inserted.changes && this.writerAuthority && this.writerAuthority.runId !== runId) {
|
|
691
|
+
this.db.prepare("INSERT INTO build_child_runs VALUES(?,?)").run(runId, this.writerAuthority.runId);
|
|
692
|
+
this.db.prepare("INSERT OR IGNORE INTO build_runtime_runs VALUES(?)").run(runId);
|
|
693
|
+
}
|
|
86
694
|
if (inserted.changes)
|
|
87
695
|
this.insertEvent(runId, "durable_baseline", "durable-baseline", { source: "host" }, at);
|
|
88
696
|
})();
|
|
@@ -96,8 +704,14 @@ export class WorkflowDb {
|
|
|
96
704
|
legacy: Boolean(input.legacy), createdAt: at, updatedAt: at,
|
|
97
705
|
};
|
|
98
706
|
this.db.transaction(() => {
|
|
707
|
+
if (this.writerAuthority)
|
|
708
|
+
assertAdmission(this.db, this.writerAuthority);
|
|
99
709
|
this.db.prepare(`INSERT INTO workflow_runs(run_id,kind,status,checkpoint,original_work_json,remaining_work_json,state_json,legacy,created_at,updated_at)
|
|
100
710
|
VALUES(?,?,?,?,?,?,?,?,?,?)`).run(run.runId, run.kind, run.status, run.checkpoint, json(run.originalWork), json(run.remainingWork), json(run.state), run.legacy ? 1 : 0, at, at);
|
|
711
|
+
if (this.writerAuthority && this.writerAuthority.runId !== run.runId) {
|
|
712
|
+
this.db.prepare("INSERT INTO build_child_runs VALUES(?,?)").run(run.runId, this.writerAuthority.runId);
|
|
713
|
+
this.db.prepare("INSERT OR IGNORE INTO build_runtime_runs VALUES(?)").run(run.runId);
|
|
714
|
+
}
|
|
101
715
|
this.insertEvent(run.runId, "run_created", run.checkpoint, { kind: run.kind }, at);
|
|
102
716
|
})();
|
|
103
717
|
return run;
|
|
@@ -126,12 +740,45 @@ export class WorkflowDb {
|
|
|
126
740
|
: this.db.prepare("SELECT * FROM workflow_runs WHERE status NOT IN ('completed','cancelled','superseded') ORDER BY created_at").all();
|
|
127
741
|
return rows.map(rowToRun);
|
|
128
742
|
}
|
|
743
|
+
/** Supervisors may pause a worker generation; they cannot publish worker state. */
|
|
744
|
+
transitionSupervisor(runId, workerGeneration, update) {
|
|
745
|
+
return this.atomic(() => {
|
|
746
|
+
const owner = this.supervisorState(runId);
|
|
747
|
+
if (!owner || owner.pid !== process.pid || owner.processStart !== processStartIdentity() || owner.workerGeneration !== workerGeneration)
|
|
748
|
+
throw new Error("Supervisor generation no longer owns the lifecycle transition");
|
|
749
|
+
this.coordinatorTransition++;
|
|
750
|
+
try {
|
|
751
|
+
return this.transition(runId, update);
|
|
752
|
+
}
|
|
753
|
+
finally {
|
|
754
|
+
this.coordinatorTransition--;
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
}
|
|
129
758
|
transition(runId, update, now = new Date()) {
|
|
130
759
|
const at = now.toISOString();
|
|
131
760
|
return this.db.transaction(() => {
|
|
761
|
+
if (!this.coordinatorTransition && (this.writerAuthority || this.buildAdmission())) {
|
|
762
|
+
if (!this.writerAuthority)
|
|
763
|
+
throw new Error("Workflow transition requires original build admission authority");
|
|
764
|
+
assertAdmission(this.db, this.writerAuthority);
|
|
765
|
+
const supersession = update.status === "superseded" && update.state?.supersededBy === this.writerAuthority.runId && this.getRun(this.writerAuthority.runId)?.kind === "recovery";
|
|
766
|
+
if (!this.lineageTransition && this.writerAuthority.runId !== runId && !this.db.prepare("SELECT 1 FROM build_child_runs WHERE child=? AND parent=?").get(runId, this.writerAuthority.runId) && !supersession)
|
|
767
|
+
throw new Error("Workflow authority belongs to another run");
|
|
768
|
+
}
|
|
132
769
|
const current = this.getRun(runId);
|
|
133
770
|
if (!current)
|
|
134
771
|
throw new Error(`workflow run not found: ${runId}`);
|
|
772
|
+
if (["completed", "cancelled", "superseded"].includes(current.status) && update.status && update.status !== current.status)
|
|
773
|
+
throw new Error(`Cannot reactivate terminal ${current.status} workflow`);
|
|
774
|
+
if (!this.coordinatorTransition && this.db.prepare("SELECT 1 FROM build_runtime_runs WHERE run_id=?").get(runId) && !this.writerAuthority) {
|
|
775
|
+
const lease = this.currentLease();
|
|
776
|
+
if (!this.writerLease || !lease || lease.owner !== this.writerLease.owner || lease.generation !== this.writerLease.generation)
|
|
777
|
+
throw new Error("Workflow transition requires original build authority");
|
|
778
|
+
const supersession = update.status === "superseded" && update.state?.supersededBy === lease.runId && this.getRun(lease.runId)?.kind === "recovery";
|
|
779
|
+
if (lease.runId !== runId && !supersession)
|
|
780
|
+
throw new Error("Workflow authority belongs to another run");
|
|
781
|
+
}
|
|
135
782
|
const next = { ...current, status: update.status ?? current.status, checkpoint: update.checkpoint, remainingWork: update.remainingWork ?? current.remainingWork, state: update.state ?? current.state, updatedAt: at };
|
|
136
783
|
this.db.prepare("UPDATE workflow_runs SET status=?,checkpoint=?,remaining_work_json=?,state_json=?,updated_at=? WHERE run_id=?")
|
|
137
784
|
.run(next.status, next.checkpoint, json(next.remainingWork), json(next.state), at, runId);
|
|
@@ -206,6 +853,9 @@ export class WorkflowDb {
|
|
|
206
853
|
this.ensureRun(input.runId, "build", now);
|
|
207
854
|
const at = now.toISOString();
|
|
208
855
|
const safePayload = sanitizeContinuityValue(input.payload);
|
|
856
|
+
const graphAccess = currentGraphDerivedAccess();
|
|
857
|
+
if (graphAccess)
|
|
858
|
+
registerGraphDerived(this.db, json(safePayload), graphAccess);
|
|
209
859
|
const session = sessionParts(input.sessionRef, undefined, input.sessionKey);
|
|
210
860
|
const digest = digestJson({ runId: input.runId, role: input.role, kind: input.kind, payload: safePayload, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key, at });
|
|
211
861
|
const result = this.db.prepare(`INSERT INTO continuity_events(run_id,role,kind,payload_json,digest,authoritative_state_revision,session_key,session_ref_json,created_at)
|
|
@@ -214,6 +864,8 @@ export class WorkflowDb {
|
|
|
214
864
|
}
|
|
215
865
|
continuityEvents(runId, afterSequence = 0) {
|
|
216
866
|
const rows = this.db.prepare("SELECT * FROM continuity_events WHERE run_id=? AND sequence>? ORDER BY sequence").all(runId, afterSequence);
|
|
867
|
+
for (const row of rows)
|
|
868
|
+
this.assertGraphContinuityAccess(row.payload_json);
|
|
217
869
|
return rows.map(continuityEventFromRow);
|
|
218
870
|
}
|
|
219
871
|
publishContinuityCheckpoint(input, now = new Date()) {
|
|
@@ -223,6 +875,9 @@ export class WorkflowDb {
|
|
|
223
875
|
const previous = this.continuityHead(input.runId, input.role);
|
|
224
876
|
const latestEvent = this.db.prepare("SELECT COALESCE(MAX(sequence),0) AS sequence FROM continuity_events WHERE run_id=?").get(input.runId);
|
|
225
877
|
const safeDelta = sanitizeContinuityValue(input.delta);
|
|
878
|
+
const graphAccess = currentGraphDerivedAccess();
|
|
879
|
+
if (graphAccess)
|
|
880
|
+
registerGraphDerived(this.db, json(safeDelta), graphAccess);
|
|
226
881
|
const session = sessionParts(input.sessionRef, undefined, input.sessionKey);
|
|
227
882
|
const digest = digestJson({ runId: input.runId, role: input.role, sequence: latestEvent.sequence, predecessorDigest: previous?.digest, delta: safeDelta, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key });
|
|
228
883
|
const result = this.db.prepare(`INSERT INTO continuity_checkpoints(run_id,role,event_sequence,state,delta_json,digest,predecessor_digest,authoritative_state_revision,session_key,session_ref_json,created_at)
|
|
@@ -244,12 +899,21 @@ export class WorkflowDb {
|
|
|
244
899
|
const rows = role
|
|
245
900
|
? this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id").all(runId, role)
|
|
246
901
|
: this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? ORDER BY checkpoint_id").all(runId);
|
|
902
|
+
for (const row of rows)
|
|
903
|
+
this.assertGraphContinuityAccess(row.delta_json);
|
|
247
904
|
return rows.map(continuityCheckpointFromRow);
|
|
248
905
|
}
|
|
249
906
|
latestContinuityCheckpoint(runId, role) {
|
|
250
907
|
const row = this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id DESC LIMIT 1").get(runId, role);
|
|
908
|
+
if (row)
|
|
909
|
+
this.assertGraphContinuityAccess(row.delta_json);
|
|
251
910
|
return row ? continuityCheckpointFromRow(row) : undefined;
|
|
252
911
|
}
|
|
912
|
+
assertGraphContinuityAccess(value) {
|
|
913
|
+
const digest = createHash("sha256").update(value).digest("hex");
|
|
914
|
+
if (!graphDerivedAllowed(this.db, this.projectDir, digest))
|
|
915
|
+
throw new Error("Continuity contains revoked graph-derived evidence; explicit source-based recovery is required");
|
|
916
|
+
}
|
|
253
917
|
continuityHead(runId, role) {
|
|
254
918
|
const row = this.db.prepare("SELECT * FROM continuity_heads WHERE run_id=? AND role=?").get(runId, role);
|
|
255
919
|
return row ? continuityHeadFromRow(row) : undefined;
|
|
@@ -379,6 +1043,18 @@ export class WorkflowDb {
|
|
|
379
1043
|
})();
|
|
380
1044
|
return decision;
|
|
381
1045
|
}
|
|
1046
|
+
cancelPendingHumanDecisions(runId, reason, now = new Date()) {
|
|
1047
|
+
this.atomic(() => {
|
|
1048
|
+
for (const decision of this.pendingHumanDecisions(runId)) {
|
|
1049
|
+
const next = { ...decision, status: "cancelled", cancellationReason: reason, cancelledAt: now.toISOString() };
|
|
1050
|
+
this.db.prepare("UPDATE human_decisions SET status='cancelled',decision_json=?,updated_at=? WHERE decision_id=? AND status='pending'").run(json(next), now.toISOString(), decision.decisionId);
|
|
1051
|
+
this.insertEvent(runId, "human_decision_cancelled", "cancelled", { decisionId: decision.decisionId, reason }, now.toISOString());
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
humanDecisionKey(decisionId) {
|
|
1056
|
+
return this.db.prepare("SELECT decision_key FROM human_decisions WHERE decision_id=?").get(decisionId)?.decision_key;
|
|
1057
|
+
}
|
|
382
1058
|
humanDecision(decisionId) {
|
|
383
1059
|
const row = this.db.prepare("SELECT decision_json FROM human_decisions WHERE decision_id=?").get(decisionId);
|
|
384
1060
|
return row ? parseJson(row.decision_json) : undefined;
|
|
@@ -387,11 +1063,50 @@ export class WorkflowDb {
|
|
|
387
1063
|
return this.db.prepare("SELECT decision_json FROM human_decisions WHERE run_id=? AND status='pending' ORDER BY created_at").all(runId)
|
|
388
1064
|
.map((row) => parseJson(row.decision_json));
|
|
389
1065
|
}
|
|
1066
|
+
decisionContinuationAvailable(runId, decisionId) {
|
|
1067
|
+
const prior = this.operations(runId).filter(op => op.kind === "decision-continuation" && op.intent.decisionId === decisionId);
|
|
1068
|
+
return prior.every(op => op.status === "failed" && op.result?.dispatchState === "not-sent");
|
|
1069
|
+
}
|
|
1070
|
+
nextDecisionContinuationKey(runId, decisionId) {
|
|
1071
|
+
if (!this.decisionContinuationAvailable(runId, decisionId))
|
|
1072
|
+
throw new Error("Decision continuation requires reconciliation before replay");
|
|
1073
|
+
const count = this.operations(runId).filter(op => op.kind === "decision-continuation" && op.intent.decisionId === decisionId).length;
|
|
1074
|
+
return `decision-continuation:${decisionId}${count ? `:retry-${count}` : ""}`;
|
|
1075
|
+
}
|
|
390
1076
|
answeredTicketDecisions(runId, scopeRevision) {
|
|
391
1077
|
return this.db.prepare("SELECT decision_key,decision_json FROM human_decisions WHERE run_id=? AND status='answered'").all(runId)
|
|
392
1078
|
.filter(row => row.decision_key.includes(`:${scopeRevision}:`))
|
|
393
1079
|
.map(row => parseJson(row.decision_json))
|
|
394
|
-
.filter(decision => decision.interruptionId.startsWith("ticket:") && !this.operation(`decision-
|
|
1080
|
+
.filter(decision => decision.interruptionId.startsWith("ticket:") && this.decisionContinuationAvailable(runId, decision.decisionId) && !this.operation(`decision-supersession:${decision.decisionId}`));
|
|
1081
|
+
}
|
|
1082
|
+
/** Unconsumed answers to an older definition must not silently reopen a ticket. */
|
|
1083
|
+
staleTicketDecisions(runId, scopeRevision) {
|
|
1084
|
+
return this.db.prepare("SELECT decision_key,decision_json FROM human_decisions WHERE run_id=? AND status='answered'").all(runId)
|
|
1085
|
+
.filter(row => !row.decision_key.includes(`:${scopeRevision}:`))
|
|
1086
|
+
.map(row => parseJson(row.decision_json))
|
|
1087
|
+
.filter(decision => decision.interruptionId.startsWith("ticket:") && this.decisionContinuationAvailable(runId, decision.decisionId) && !this.operation(`decision-supersession:${decision.decisionId}`));
|
|
1088
|
+
}
|
|
1089
|
+
refreshStaleTicketDecisions(runId, scopeRevision, tickets) {
|
|
1090
|
+
this.atomic(() => {
|
|
1091
|
+
for (const prior of this.pendingHumanDecisions(runId)) {
|
|
1092
|
+
const key = this.humanDecisionKey(prior.decisionId) ?? "";
|
|
1093
|
+
if (!prior.interruptionId.startsWith("ticket:") || tickets && !tickets.includes(prior.interruptionId.slice(7)) || key.includes(`:${scopeRevision}:`) || !(key.startsWith(`${runId}:builder:`) || key.startsWith(`${runId}:ticket-question:`)))
|
|
1094
|
+
continue;
|
|
1095
|
+
this.ensureHumanDecision({ runId, decisionKey: `${runId}:ticket-question:${scopeRevision}:supersedes:${prior.decisionId}`, interruptionId: prior.interruptionId, prompt: prior.prompt, choices: prior.choices, evidence: prior.evidence });
|
|
1096
|
+
this.db.prepare("UPDATE human_decisions SET status='cancelled',decision_json=?,updated_at=? WHERE decision_id=? AND status='pending'").run(json({ ...prior, status: "cancelled", cancellationReason: "ticket scope changed before the question was answered" }), new Date().toISOString(), prior.decisionId);
|
|
1097
|
+
this.insertEvent(runId, "human_decision_superseded", "question-revised", { decisionId: prior.decisionId, scopeRevision }, new Date().toISOString());
|
|
1098
|
+
}
|
|
1099
|
+
for (const prior of this.staleTicketDecisions(runId, scopeRevision)) {
|
|
1100
|
+
if (tickets && !tickets.includes(prior.interruptionId.slice(7)))
|
|
1101
|
+
continue;
|
|
1102
|
+
const next = this.ensureHumanDecision({ runId, decisionKey: `${runId}:ticket-question:${scopeRevision}:supersedes:${prior.decisionId}`, interruptionId: prior.interruptionId,
|
|
1103
|
+
prompt: prior.prompt, choices: prior.choices, evidence: prior.evidence });
|
|
1104
|
+
const key = `decision-supersession:${prior.decisionId}`;
|
|
1105
|
+
this.planOperation({ runId, idempotencyKey: key, kind: "decision-supersession", intent: { priorDecisionId: prior.decisionId, replacementDecisionId: next.decisionId, reason: "ticket scope changed before answer was used" } });
|
|
1106
|
+
this.updateOperation(key, "in_progress");
|
|
1107
|
+
this.updateOperation(key, "confirmed");
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
395
1110
|
}
|
|
396
1111
|
answerHumanDecision(runId, decisionId, choiceId, now = new Date(), answer) {
|
|
397
1112
|
return this.atomic(() => {
|
|
@@ -431,6 +1146,8 @@ export class WorkflowDb {
|
|
|
431
1146
|
}
|
|
432
1147
|
planOperation(input, now = new Date()) {
|
|
433
1148
|
const at = now.toISOString();
|
|
1149
|
+
if (input.kind === "provider-dispatch" && this.unresolvedPreparationProcesses(input.runId).length)
|
|
1150
|
+
throw new Error("Readiness cleanup must be reconciled before provider dispatch");
|
|
434
1151
|
this.db.prepare(`INSERT INTO operation_journal(idempotency_key,run_id,kind,status,intent_json,created_at,updated_at)
|
|
435
1152
|
VALUES(?,?,?,'planned',?,?,?) ON CONFLICT(idempotency_key) DO NOTHING`).run(input.idempotencyKey, input.runId, input.kind, json(input.intent), at, at);
|
|
436
1153
|
const operation = this.operation(input.idempotencyKey);
|
|
@@ -441,6 +1158,8 @@ export class WorkflowDb {
|
|
|
441
1158
|
}
|
|
442
1159
|
updateOperation(idempotencyKey, status, details = {}, now = new Date()) {
|
|
443
1160
|
const prior = this.operation(idempotencyKey);
|
|
1161
|
+
if (prior?.kind === "provider-dispatch" && status === "in_progress" && this.unresolvedPreparationProcesses(prior.runId).length)
|
|
1162
|
+
throw new Error("Readiness cleanup must be reconciled before provider dispatch");
|
|
444
1163
|
if (!prior)
|
|
445
1164
|
throw new Error(`operation not found: ${idempotencyKey}`);
|
|
446
1165
|
const allowed = {
|
|
@@ -458,6 +1177,12 @@ export class WorkflowDb {
|
|
|
458
1177
|
this.db.transaction(() => {
|
|
459
1178
|
this.db.prepare("UPDATE operation_journal SET status=?,result_json=?,external_id=?,error=?,updated_at=? WHERE idempotency_key=?")
|
|
460
1179
|
.run(status, details.result === undefined ? null : json(details.result), details.externalId ?? null, details.error ?? null, at, idempotencyKey);
|
|
1180
|
+
if (status === "confirmed" && ["ticket-complete", "synthetic-completion"].includes(prior.kind)) {
|
|
1181
|
+
const workId = prior.intent.workId ?? prior.intent.ticket ?? prior.intent.ticketId;
|
|
1182
|
+
const admission = workId && this.admittedWork(prior.runId, workId);
|
|
1183
|
+
if (admission)
|
|
1184
|
+
this.qaPreparationStore().metric(prior.runId, workId, admission.requirementsDigest, `metric:completed:${prior.runId}:${workId}`, "completed", { status: this.getRun(prior.runId)?.state?.qaEnabled === false ? "disabled" : undefined, at });
|
|
1185
|
+
}
|
|
461
1186
|
this.appendContinuityEvent({ runId: prior.runId, role: "host", kind: "operation_receipt", payload: { idempotencyKey, kind: prior.kind, status, externalId: details.externalId, error: details.error }, authoritativeStateRevision: this.continuityHead(prior.runId, "run")?.authoritativeStateRevision ?? 0 }, now);
|
|
462
1187
|
})();
|
|
463
1188
|
return this.operation(idempotencyKey);
|
|
@@ -491,9 +1216,15 @@ export class WorkflowDb {
|
|
|
491
1216
|
throw new Error(`${kind} evidence exceeds the durable ${maximum}-byte item limit`);
|
|
492
1217
|
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
493
1218
|
this.db.prepare("INSERT OR IGNORE INTO content_refs(digest,kind,content,created_at) VALUES(?,?,?,?)").run(digest, kind, bytes, now.toISOString());
|
|
1219
|
+
const graphAccess = currentGraphDerivedAccess();
|
|
1220
|
+
if (graphAccess && (kind === "qa" || kind === "handoff"))
|
|
1221
|
+
registerGraphDerived(this.db, bytes, graphAccess);
|
|
494
1222
|
return digest;
|
|
495
1223
|
}
|
|
1224
|
+
registerGraphEvidence(value, access) { registerGraphDerived(this.db, value, access); }
|
|
496
1225
|
getEvidence(digest) {
|
|
1226
|
+
if (!graphDerivedAllowed(this.db, this.projectDir, digest))
|
|
1227
|
+
return undefined;
|
|
497
1228
|
const row = this.db.prepare("SELECT content FROM content_refs WHERE digest=?").get(digest);
|
|
498
1229
|
return row?.content;
|
|
499
1230
|
}
|
|
@@ -548,6 +1279,11 @@ export class WorkflowDb {
|
|
|
548
1279
|
const next = { ...prior, ...patch, updatedAt: now.toISOString() };
|
|
549
1280
|
this.db.prepare("UPDATE qa_review_attempts SET status=?,report_digest=?,record_json=?,updated_at=? WHERE attempt_id=?")
|
|
550
1281
|
.run(next.status, next.reportDigest ?? null, json({ findingIds: next.findingIds, namespacedFindingIds: next.namespacedFindingIds, detail: next.detail }), next.updatedAt, attemptId);
|
|
1282
|
+
const admission = this.admittedWork(next.runId, next.ticketId);
|
|
1283
|
+
const turns = this.db.prepare("SELECT receipt_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND review_number=? AND status='completed'").all(next.runId, next.ticketId, next.reviewNumber);
|
|
1284
|
+
const terminalBlock = turns.some(row => { const receipt = parseJson(row.receipt_json); const response = receipt.cleanedResponseDigest && this.getEvidence(receipt.cleanedResponseDigest); return receipt.terminalEventObserved && response && /STEP_STATUS:\s*(blocked|needs_input)\b/.test(response.toString()); });
|
|
1285
|
+
if (admission && (next.status === "passed" || next.status === "failed" || terminalBlock))
|
|
1286
|
+
this.qaPreparationStore().metric(next.runId, next.ticketId, admission.requirementsDigest, `metric:review:${attemptId}`, "substantive-review", { status: next.status === "passed" ? "passed" : next.status === "failed" ? "failed" : "blocked", at: now.toISOString(), evidenceRefs: next.reportDigest ? [next.reportDigest] : [] });
|
|
551
1287
|
return next;
|
|
552
1288
|
}
|
|
553
1289
|
qaReviewAttempts(runId, ticketId) {
|
|
@@ -603,6 +1339,8 @@ export class WorkflowDb {
|
|
|
603
1339
|
.run(record.invocationId, record.runId, record.ticketId, qaDigest("handback-invocation", intent), json(intent), record.status, json(record), record.startedAt, record.completedAt ?? new Date().toISOString());
|
|
604
1340
|
}
|
|
605
1341
|
recordQaDeliveryTurn(turn) {
|
|
1342
|
+
if (turn.version === 4)
|
|
1343
|
+
ensureQaGraphJournalGuards(this.db);
|
|
606
1344
|
const prior = this.qaDeliveryTurns(turn.operationId).find(t => t.turnRecordId === turn.turnRecordId);
|
|
607
1345
|
if (prior) {
|
|
608
1346
|
if (prior.status !== "intended") {
|
|
@@ -642,7 +1380,8 @@ export class WorkflowDb {
|
|
|
642
1380
|
if (linked.size !== recoveries.length)
|
|
643
1381
|
throw new Error("QA remediation budget requires legacy attempt reconciliation");
|
|
644
1382
|
const authorized = new Set(this.db.prepare("SELECT consumed_by FROM qa_remediation_authorizations WHERE run_id=? AND ticket_id=? AND consumed_by IS NOT NULL").all(runId, ticketId).map(r => r.consumed_by));
|
|
645
|
-
|
|
1383
|
+
const followups = this.operations(runId).filter(operation => operation.kind === "build-assignment" && operation.intent.ticketId === ticketId && operation.intent.managerFollowup);
|
|
1384
|
+
return attempts.filter(a => !authorized.has(a.attemptId)).length + followups.filter(operation => !authorized.has(operation.idempotencyKey)).length;
|
|
646
1385
|
}
|
|
647
1386
|
authorizeQaRemediation(runId, ticketId, reviewAttemptId, reason) {
|
|
648
1387
|
const authorizationId = randomUUID();
|
|
@@ -893,6 +1632,12 @@ export class WorkflowDb {
|
|
|
893
1632
|
return this.db.transaction(() => {
|
|
894
1633
|
const predecessors = this.unresolvedQaReports(input.runId, input.ticketId);
|
|
895
1634
|
const current = this.recordQaReport(input, findingIds, now);
|
|
1635
|
+
const admission = this.admittedWork(input.runId, input.ticketId), preparation = this.qaPreparationStore();
|
|
1636
|
+
if (admission && preparation.policy(input.runId)) {
|
|
1637
|
+
const evidence = preparation.putArtifact("finding-classification-input", { report: input, contractHead: preparation.head(input.runId, input.ticketId, admission.requirementsDigest), evidenceDisposition: "Cause remains unknown until supported assessment" });
|
|
1638
|
+
for (const findingId of findingIds)
|
|
1639
|
+
preparation.metric(input.runId, input.ticketId, admission.requirementsDigest, `metric:finding-unknown:${current.reportOccurrenceId}:${findingId}`, "finding-classification", { findingId, classifier: "host-evidence-boundary", cause: "unknown", confidence: "unknown", evidenceRefs: [evidence], at: now.toISOString() });
|
|
1640
|
+
}
|
|
896
1641
|
for (const predecessor of predecessors) {
|
|
897
1642
|
if (predecessor.reportOccurrenceId === current.reportOccurrenceId)
|
|
898
1643
|
continue;
|
|
@@ -964,13 +1709,38 @@ export class WorkflowDb {
|
|
|
964
1709
|
for (const report of this.unresolvedQaReports(runId, ticketId))
|
|
965
1710
|
this.setQaReportDisposition(report.reportOccurrenceId, "verified-fixed", "subsequent bound QA review passed", now);
|
|
966
1711
|
}
|
|
967
|
-
|
|
1712
|
+
authorizeContractQaWaiver(runId, workId, decisionId) {
|
|
1713
|
+
this.assertInstructionOwner(runId);
|
|
1714
|
+
const decision = this.humanDecision(decisionId);
|
|
1715
|
+
if (!decision || decision.runId !== runId || decision.status !== "answered" || decision.selectedChoiceId !== "yes" || decision.interruptionId !== `ticket:${workId}` || !this.humanDecisionKey(decisionId)?.startsWith(`${runId}:qa-nonconvergence:`) || !decision.prompt.includes("QA waiver confirmation"))
|
|
1716
|
+
throw new Error("Whole-work waiver requires the actual answered operator confirmation");
|
|
1717
|
+
const admission = this.assertAdmittedWork(runId, workId), store = this.qaPreparationStore(), head = store.head(runId, workId, admission.requirementsDigest), qa = this.qaTicketHead(runId, workId);
|
|
1718
|
+
if (head.state !== "ready" || !head.digest)
|
|
1719
|
+
throw new Error("Whole-work waiver requires a current ready contract");
|
|
1720
|
+
return store.putArtifact("waiver-authorization", { decisionId, runId, workId, admissionDigest: admission.requirementsDigest, contractDigest: head.digest, qaRevision: qa.revision, sourceDigest: qa.sourceStateDigest, reviewBasisDigest: qa.reviewBasisDigest, authorizedAt: decision.answeredAt });
|
|
1721
|
+
}
|
|
1722
|
+
commitQaWaiver(runId, ticketId, expectedRevision, reason, now = new Date(), authorizationRef) {
|
|
968
1723
|
return this.db.transaction(() => {
|
|
969
1724
|
const head = this.qaTicketHead(runId, ticketId);
|
|
970
1725
|
if (head.revision !== expectedRevision)
|
|
971
1726
|
throw new Error(`QA waiver raced for ${runId}/${ticketId}`);
|
|
1727
|
+
const store = this.qaPreparationStore();
|
|
1728
|
+
if (store.policy(runId)?.mode === "enforce") {
|
|
1729
|
+
const admission = this.assertAdmittedWork(runId, ticketId), contractHead = store.head(runId, ticketId, admission.requirementsDigest);
|
|
1730
|
+
if (contractHead.state !== "ready" || !contractHead.digest || !reason.trim())
|
|
1731
|
+
throw new Error("Whole-work waiver requires exact contract scope and operator reason");
|
|
1732
|
+
const authority = authorizationRef ? store.artifact(authorizationRef, "waiver-authorization") : undefined;
|
|
1733
|
+
const decision = authority && this.humanDecision(authority.decisionId);
|
|
1734
|
+
if (!authority || authority.runId !== runId || authority.workId !== ticketId || authority.admissionDigest !== admission.requirementsDigest || authority.contractDigest !== contractHead.digest || authority.qaRevision !== expectedRevision || authority.sourceDigest !== head.sourceStateDigest || authority.reviewBasisDigest !== head.reviewBasisDigest || decision?.status !== "answered" || decision.selectedChoiceId !== "yes")
|
|
1735
|
+
throw new Error("Missing or stale whole-work waiver authorization");
|
|
1736
|
+
const contract = store.contract(contractHead.digest), review = this.qaReviewAttempts(runId, ticketId).at(-1);
|
|
1737
|
+
store.event(runId, ticketId, `whole-work-waiver:${runId}:${ticketId}:${head.revision}`, "whole-work-waiver", { version: 1, runId, workId: ticketId, admissionDigest: admission.requirementsDigest, contractDigest: contract.contentDigest, revision: contract.revision, sourceDigest: review?.sourceDigest ?? null, reviewBasisDigest: head.reviewBasisDigest ?? null, mandatoryCheckIds: contract.checks.filter(check => check.obligation === "mandatory").map(check => check.id), unresolvedReports: this.unresolvedQaReports(runId, ticketId).map(report => report.reportOccurrenceId), reason, actor: "operator", authorizationRef, decisionId: authority.decisionId, mechanism: "existing-qa-nonconvergence-decision", at: now.toISOString(), completion: "waived" });
|
|
1738
|
+
}
|
|
972
1739
|
for (const report of this.unresolvedQaReports(runId, ticketId))
|
|
973
1740
|
this.setQaReportDisposition(report.reportOccurrenceId, "waived", reason, now);
|
|
1741
|
+
const admission = this.admittedWork(runId, ticketId);
|
|
1742
|
+
if (admission)
|
|
1743
|
+
store.metric(runId, ticketId, admission.requirementsDigest, `metric:waiver:${runId}:${ticketId}:${head.revision}`, "waived", { at: now.toISOString() });
|
|
974
1744
|
return this.transitionQa(runId, ticketId, head.revision, { type: "waived" }, now);
|
|
975
1745
|
})();
|
|
976
1746
|
}
|
|
@@ -995,6 +1765,11 @@ export class WorkflowDb {
|
|
|
995
1765
|
return this.db.transaction(() => {
|
|
996
1766
|
this.recordBuilderRemediationReceipt(receipt);
|
|
997
1767
|
this.markQaReportsRecheckRequired(receipt.runId, receipt.ticketId, "Builder remediation received; QA recheck required", now);
|
|
1768
|
+
const admission = this.admittedWork(receipt.runId, receipt.ticketId);
|
|
1769
|
+
const remediation = this.qaRemediationAttempt(receipt.operationId);
|
|
1770
|
+
const review = remediation ? this.qaReviewAttempt(remediation.reviewAttemptId) : undefined;
|
|
1771
|
+
if (admission)
|
|
1772
|
+
this.qaPreparationStore().metric(receipt.runId, receipt.ticketId, admission.requirementsDigest, `metric:remediation:${receipt.operationId}`, "remediation", { phase: "remediation", findingIds: review?.namespacedFindingIds ?? [], at: now.toISOString() });
|
|
998
1773
|
return this.transitionQa(receipt.runId, receipt.ticketId, expectedRevision, { type: "remediation-received" }, now);
|
|
999
1774
|
})();
|
|
1000
1775
|
}
|
|
@@ -1016,7 +1791,60 @@ export class WorkflowDb {
|
|
|
1016
1791
|
return this.transitionQa(remediation.runId, remediation.ticketId, head.revision, { type: input.outcome === "uncertain" ? "remediation-uncertain" : "remediation-failed" }, now);
|
|
1017
1792
|
})();
|
|
1018
1793
|
}
|
|
1794
|
+
assertContractCertificate(input) {
|
|
1795
|
+
const store = this.qaPreparationStore();
|
|
1796
|
+
if (store.policy(input.runId)?.mode !== "enforce")
|
|
1797
|
+
return;
|
|
1798
|
+
const binding = input.contractCoverage;
|
|
1799
|
+
if (!binding || binding.version !== 1)
|
|
1800
|
+
throw new Error("Enforcing pass requires tagged contract coverage authority");
|
|
1801
|
+
const admission = this.assertAdmittedWork(input.runId, input.ticketId);
|
|
1802
|
+
const head = store.head(input.runId, input.ticketId, admission.requirementsDigest);
|
|
1803
|
+
if (head.state !== "ready" || head.digest !== binding.contractDigest)
|
|
1804
|
+
throw new Error("Pass contract is stale or incomplete");
|
|
1805
|
+
const contract = store.contract(binding.contractDigest);
|
|
1806
|
+
if (contract.revision !== binding.revision)
|
|
1807
|
+
throw new Error("Pass revision mismatch");
|
|
1808
|
+
const basisRow = this.db.prepare("SELECT basis_json FROM qa_review_bases WHERE digest=? AND run_id=? AND ticket_id=?").get(input.reviewBasisDigest, input.runId, input.ticketId);
|
|
1809
|
+
if (!basisRow)
|
|
1810
|
+
throw new Error("Pass lacks immutable input basis");
|
|
1811
|
+
const basis = parseJson(basisRow.basis_json);
|
|
1812
|
+
const definition = this.workDefinitions(input.runId).find(work => work.workId === input.ticketId)?.definition;
|
|
1813
|
+
if (!definition || basis.ticketDigest !== qaDigest("ticket", definition))
|
|
1814
|
+
throw new Error("Pass ticket basis differs from frozen admitted scope");
|
|
1815
|
+
const { digest, ...fields } = basis;
|
|
1816
|
+
if (digest !== qaDigest("review-basis-fields", fields) || basis.contractBinding?.digest !== contract.contentDigest || basis.contractBinding.admissionDigest !== admission.requirementsDigest)
|
|
1817
|
+
throw new Error("Pass basis/contract binding mismatch");
|
|
1818
|
+
const configuredMake = this.getRun(input.runId)?.state?.qa?.settings?.make;
|
|
1819
|
+
if (configuredMake && resolveEffectiveQaConfiguration(this.projectDir, { make: configuredMake }).digest !== basis.contractBinding.commonConfigDigest)
|
|
1820
|
+
throw new Error("Effective QA configuration changed before pass authority; reconcile contract");
|
|
1821
|
+
const attempt = this.qaReviewAttempt(binding.attemptId);
|
|
1822
|
+
if (!attempt || attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.sourceDigest !== input.sourceStateDigest || attempt.reviewNumber !== this.qaTicketHead(input.runId, input.ticketId).reviewNumber)
|
|
1823
|
+
throw new Error("Coverage review attempt is stale or foreign");
|
|
1824
|
+
const coverage = store.artifact(binding.coverageDigest, "final-coverage");
|
|
1825
|
+
for (const check of contract.checks)
|
|
1826
|
+
for (const method of check.verification)
|
|
1827
|
+
if (method.equivalentAuthorityId && !store.equivalentResolver()(method.equivalentAuthorityId, contract, check, method, input.sourceStateDigest))
|
|
1828
|
+
throw new Error("Equivalent verification authority is stale or foreign");
|
|
1829
|
+
const errors = validateContractCoverage(contract, coverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: binding.attemptId, sessionId: binding.sessionId });
|
|
1830
|
+
if (errors.length)
|
|
1831
|
+
throw new Error(errors.join("; "));
|
|
1832
|
+
const initialTurns = this.db.prepare("SELECT intent_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND review_number=? AND source_state_digest=? AND review_basis_digest=?").all(input.runId, input.ticketId, attempt.reviewNumber, input.sourceStateDigest, input.reviewBasisDigest);
|
|
1833
|
+
const initial = initialTurns.map(row => parseJson(row.intent_json)).find(turn => turn.slot === "initial");
|
|
1834
|
+
if (!initial || initial.providerSession.sessionId !== binding.sessionId)
|
|
1835
|
+
throw new Error("Coverage lacks the actual fresh review dispatch identity");
|
|
1836
|
+
const dispatchedInstruction = this.getEvidence(initial.instructionDigest)?.toString();
|
|
1837
|
+
const envelope = `\nImmutable input-basis digest: ${basis.digest}. Coverage must bind this inputBasisDigest.`;
|
|
1838
|
+
if (basis.contractBinding.transportVersion !== 1 || !dispatchedInstruction?.endsWith(envelope) || qaDigest("instruction", dispatchedInstruction.slice(0, -envelope.length)) !== basis.instructionDigest)
|
|
1839
|
+
throw new Error("Actual review dispatch differs from frozen base instruction and tagged input-basis envelope");
|
|
1840
|
+
const receipts = this.db.prepare("SELECT receipt_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND source_state_digest=? AND review_basis_digest=? AND status='completed'").all(input.runId, input.ticketId, input.sourceStateDigest, input.reviewBasisDigest);
|
|
1841
|
+
const receipt = receipts.map(row => parseJson(row.receipt_json)).find(row => qaDigest("turn-receipt", row) === input.turnReceiptDigest && row.terminalEventObserved);
|
|
1842
|
+
const response = receipt?.cleanedResponseDigest ? this.getEvidence(receipt.cleanedResponseDigest) : undefined;
|
|
1843
|
+
if (!receipt || !response || canonicalJson(parseContractCoverage(response.toString())) !== canonicalJson(coverage))
|
|
1844
|
+
throw new Error("Coverage is not bound to the retained terminal review receipt");
|
|
1845
|
+
}
|
|
1019
1846
|
issueQaPassCertificate(input, now = new Date()) {
|
|
1847
|
+
this.assertContractCertificate(input);
|
|
1020
1848
|
if (this.unresolvedQaReports(input.runId, input.ticketId).length)
|
|
1021
1849
|
throw new Error("cannot issue QA pass certificate while reports remain unresolved");
|
|
1022
1850
|
const certificate = { version: 2, certificateId: qaDigest("pass-certificate", { ...input, issuedAt: now.toISOString() }), ...input, unresolvedReportCount: 0, issuedAt: now.toISOString() };
|
|
@@ -1041,6 +1869,7 @@ export class WorkflowDb {
|
|
|
1041
1869
|
|| attempt.sourceDigest !== input.sourceStateDigest || attempt.status !== "started") {
|
|
1042
1870
|
throw new Error(`QA pass attempt binding mismatch: ${attemptId}`);
|
|
1043
1871
|
}
|
|
1872
|
+
this.assertContractCertificate(input);
|
|
1044
1873
|
this.finishQaReviewAttempt(attemptId, { status: "passed", detail }, now);
|
|
1045
1874
|
return this.commitQaPass(input, expectedRevision, now);
|
|
1046
1875
|
})();
|
|
@@ -1052,6 +1881,7 @@ export class WorkflowDb {
|
|
|
1052
1881
|
throw new Error("QA pass certificate is missing or scoped to another run/ticket");
|
|
1053
1882
|
if (row.consumed_at)
|
|
1054
1883
|
throw new Error("QA pass certificate has already been consumed");
|
|
1884
|
+
this.assertContractCertificate(parseJson(row.certificate_json));
|
|
1055
1885
|
if (this.unresolvedQaReports(runId, ticketId).length)
|
|
1056
1886
|
throw new Error("QA pass certificate cannot be consumed with unresolved reports");
|
|
1057
1887
|
const at = now.toISOString();
|
|
@@ -1062,9 +1892,9 @@ export class WorkflowDb {
|
|
|
1062
1892
|
return certificate;
|
|
1063
1893
|
})();
|
|
1064
1894
|
}
|
|
1065
|
-
invalidateUnconsumedQaPassCertificates(runId, ticketId, reason, now = new Date()) {
|
|
1895
|
+
invalidateUnconsumedQaPassCertificates(runId, ticketId, reason, now = new Date(), issuedBefore) {
|
|
1066
1896
|
const at = now.toISOString();
|
|
1067
|
-
this.db.prepare("UPDATE qa_pass_certificates SET consumed_at=?,consumed_by=? WHERE run_id=? AND ticket_id=? AND consumed_at IS NULL").run(at, `invalidated:${reason}`, runId, ticketId);
|
|
1897
|
+
this.db.prepare("UPDATE qa_pass_certificates SET consumed_at=?,consumed_by=? WHERE run_id=? AND ticket_id=? AND consumed_at IS NULL AND (? IS NULL OR issued_at<=?)").run(at, `invalidated:${reason}`, runId, ticketId, issuedBefore ?? null, issuedBefore ?? null);
|
|
1068
1898
|
}
|
|
1069
1899
|
invalidateQaPassBeforeFinalization(input, now = new Date()) {
|
|
1070
1900
|
return this.db.transaction(() => {
|
|
@@ -1080,6 +1910,7 @@ export class WorkflowDb {
|
|
|
1080
1910
|
}
|
|
1081
1911
|
beginQaFinalization(input, now = new Date()) {
|
|
1082
1912
|
return this.db.transaction(() => {
|
|
1913
|
+
this.assertFinalizationControls(input.runId, input.ticketId);
|
|
1083
1914
|
const head = this.qaTicketHead(input.runId, input.ticketId);
|
|
1084
1915
|
if (head.revision !== input.expectedRevision || head.state !== "passed")
|
|
1085
1916
|
throw new Error(`QA finalization start raced for ${input.runId}/${input.ticketId}`);
|
|
@@ -1156,10 +1987,14 @@ export class WorkflowDb {
|
|
|
1156
1987
|
}
|
|
1157
1988
|
completeQaFinalization(runId, ticketId, expectedRevision, receipt, now = new Date()) {
|
|
1158
1989
|
return this.db.transaction(() => {
|
|
1990
|
+
this.assertFinalizationControls(runId, ticketId);
|
|
1159
1991
|
const head = this.qaTicketHead(runId, ticketId);
|
|
1160
1992
|
if (head.revision !== expectedRevision || head.state !== "finalizing")
|
|
1161
1993
|
throw new Error(`QA finalization completion raced for ${runId}/${ticketId}`);
|
|
1162
1994
|
this.finishPendingQaFinalizationSteps(runId, ticketId, receipt, now);
|
|
1995
|
+
const admission = this.admittedWork(runId, ticketId);
|
|
1996
|
+
if (admission)
|
|
1997
|
+
this.qaPreparationStore().metric(runId, ticketId, admission.requirementsDigest, `metric:completed:${runId}:${ticketId}`, "completed", { at: now.toISOString() });
|
|
1163
1998
|
return this.transitionQa(runId, ticketId, head.revision, { type: "completed" }, now);
|
|
1164
1999
|
})();
|
|
1165
2000
|
}
|
|
@@ -1475,16 +2310,36 @@ export class WorkflowDb {
|
|
|
1475
2310
|
const pid = process.pid;
|
|
1476
2311
|
const processStart = processStartIdentity();
|
|
1477
2312
|
return this.db.transaction(() => {
|
|
2313
|
+
const admission = this.buildAdmission();
|
|
2314
|
+
if (admission) {
|
|
2315
|
+
const local = localBuildAuthority(this.projectDir);
|
|
2316
|
+
if (!local || local.token !== admission.token || local.runId !== runId) {
|
|
2317
|
+
const state = classifyProcess(admission.pid, admission.processStart, admission.host);
|
|
2318
|
+
if (state.state !== "dead")
|
|
2319
|
+
throw new Error(`Build ${admission.runId} admission is ${state.state}: ${state.reason}`);
|
|
2320
|
+
if (this.db.prepare("SELECT 1 FROM build_launches WHERE run_id=? AND state IN ('reserved','dispatching')").get(admission.runId))
|
|
2321
|
+
throw new Error("Unresolved build launch requires reconciliation");
|
|
2322
|
+
}
|
|
2323
|
+
else
|
|
2324
|
+
assertAdmission(this.db, local);
|
|
2325
|
+
}
|
|
1478
2326
|
const current = this.currentLease();
|
|
1479
2327
|
if (current && leaseVerifiedLive(current, now, staleMs))
|
|
1480
2328
|
throw new Error(`project workflow lease is held by ${current.owner} for run ${current.runId}`);
|
|
1481
|
-
const
|
|
2329
|
+
const sequence = this.db.prepare("SELECT generation FROM workflow_lease_sequence WHERE singleton=1").get();
|
|
2330
|
+
const generation = Math.max(sequence.generation, current?.generation ?? 0) + 1;
|
|
2331
|
+
this.db.prepare("UPDATE workflow_lease_sequence SET generation=? WHERE singleton=1").run(generation);
|
|
1482
2332
|
this.db.prepare(`INSERT INTO project_lease(singleton,owner,generation,pid,host,process_start,heartbeat_at,run_id)
|
|
1483
2333
|
VALUES(1,?,?,?,?,?,?,?) ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner,generation=excluded.generation,pid=excluded.pid,host=excluded.host,process_start=excluded.process_start,heartbeat_at=excluded.heartbeat_at,run_id=excluded.run_id`)
|
|
1484
2334
|
.run(owner, generation, pid, host, processStart, at, runId);
|
|
1485
2335
|
this.db.prepare("UPDATE workflow_runs SET lease_generation=? WHERE run_id=?").run(generation, runId);
|
|
1486
2336
|
this.insertEvent(runId, current ? "lease_takeover" : "lease_acquired", "lease", { owner, generation, previous: current?.owner }, at);
|
|
1487
|
-
|
|
2337
|
+
const lease = { owner, generation, pid, host, processStart, heartbeatAt: at, runId };
|
|
2338
|
+
this.writerLease = lease;
|
|
2339
|
+
rememberOriginalLease(this.projectDir, lease);
|
|
2340
|
+
if (this.getRun(runId)?.kind === "build")
|
|
2341
|
+
this.db.prepare("INSERT OR IGNORE INTO build_runtime_runs VALUES(?)").run(runId);
|
|
2342
|
+
return lease;
|
|
1488
2343
|
}).immediate();
|
|
1489
2344
|
}
|
|
1490
2345
|
heartbeatLease(lease, now = new Date()) {
|
|
@@ -1497,6 +2352,8 @@ export class WorkflowDb {
|
|
|
1497
2352
|
releaseLease(lease, now = new Date()) {
|
|
1498
2353
|
this.db.transaction(() => {
|
|
1499
2354
|
const result = this.db.prepare("DELETE FROM project_lease WHERE singleton=1 AND owner=? AND generation=?").run(lease.owner, lease.generation);
|
|
2355
|
+
if (result.changes === 1)
|
|
2356
|
+
forgetOriginalLease(this.projectDir, lease);
|
|
1500
2357
|
if (result.changes === 1)
|
|
1501
2358
|
this.insertEvent(lease.runId, "lease_released", "lease", { generation: lease.generation }, now.toISOString());
|
|
1502
2359
|
})();
|
|
@@ -1741,25 +2598,12 @@ function sanitizeContinuityValue(value, depth = 0) {
|
|
|
1741
2598
|
function leaseVerifiedLive(lease, now, staleMs) {
|
|
1742
2599
|
void now;
|
|
1743
2600
|
void staleMs;
|
|
1744
|
-
|
|
1745
|
-
// identities require operator reconciliation; only proven-dead owners move.
|
|
1746
|
-
if (lease.host !== hostname())
|
|
1747
|
-
return true;
|
|
1748
|
-
try {
|
|
1749
|
-
process.kill(lease.pid, 0);
|
|
1750
|
-
}
|
|
1751
|
-
catch (error) {
|
|
1752
|
-
return error.code !== "ESRCH";
|
|
1753
|
-
}
|
|
1754
|
-
const actual = processStartIdentity(lease.pid);
|
|
1755
|
-
if (actual === "unavailable" || lease.processStart === "unavailable")
|
|
1756
|
-
return true;
|
|
1757
|
-
return actual === lease.processStart;
|
|
2601
|
+
return classifyProcess(lease.pid, lease.processStart, lease.host).state !== "dead";
|
|
1758
2602
|
}
|
|
1759
2603
|
function ensureRecoveryGitignore(projectDir) {
|
|
1760
2604
|
const localExclude = join(projectDir, ".git", "info", "exclude");
|
|
1761
2605
|
const path = existsSync(localExclude) ? localExclude : join(projectDir, ".gitignore");
|
|
1762
|
-
const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/"];
|
|
2606
|
+
const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/", ".rafi/backups/work-admission-v4/"];
|
|
1763
2607
|
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
1764
2608
|
const missing = entries.filter((entry) => !existing.split(/\r?\n/).includes(entry));
|
|
1765
2609
|
if (missing.length)
|