ai-foreman 1.7.20 → 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 +12 -2
- 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 +90 -4
- package/dist/buildAdmission.js +2 -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/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 +6 -0
- package/dist/buildRuns.js +23 -17
- 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.js +28 -8
- package/dist/cli/start.js +152 -48
- package/dist/cli/status.js +20 -5
- package/dist/cli/tickets.js +9 -7
- package/dist/continuity.d.ts +11 -0
- package/dist/continuity.js +22 -6
- package/dist/foreman.d.ts +10 -2
- package/dist/foreman.js +330 -39
- 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 -11
- package/dist/index.js +2 -0
- 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.js +7 -0
- 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 +234 -24
- 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.js +12 -5
- package/dist/recoveryGuidance.d.ts +9 -0
- package/dist/recoveryGuidance.js +13 -0
- package/dist/runtimeAuth.d.ts +1 -0
- package/dist/runtimeAuth.js +5 -1
- package/dist/runtimeReadiness.d.ts +1 -0
- package/dist/runtimeReadiness.js +13 -1
- 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 +50 -1
- package/dist/statusReporter.d.ts +1 -0
- package/dist/statusReporter.js +1 -0
- package/dist/supervisedStart.js +18 -8
- 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/workflowDb.d.ts +51 -5
- package/dist/workflowDb.js +338 -7
- package/dist/workflowReader.d.ts +109 -2
- package/dist/workflowReader.js +48 -4
- package/package.json +84 -7
- package/scripts/copy-graph-assets.mjs +3 -0
- package/scripts/graph-benchmark.mjs +20 -0
- package/scripts/graph-evaluation.mjs +85 -0
package/dist/qaReview.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
+
import { withGraphDerivedAccess } from "./graph/derived.js";
|
|
2
|
+
import { dispatchWithGraphAccess } from "./graph/session.js";
|
|
3
|
+
import { finalQaRoleInstructions, resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
|
|
4
|
+
import { COVERAGE_INSTRUCTIONS, parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
|
|
5
|
+
import { contractDigest, renderVerificationContract } from "./qaVerificationContract.js";
|
|
6
|
+
import { finalReviewContractContext } from "./qaContractDelivery.js";
|
|
7
|
+
import { graphContext, parseGraphRequest, prepareGraphInstruction, sendGraphTurn } from "./graph/turn.js";
|
|
8
|
+
import { BuildControlBoundary } from "./buildInterventions.js";
|
|
1
9
|
import { formatRecoveryCommand } from "./recoveryGuidance.js";
|
|
10
|
+
import { assertBuildAssignmentReconciled, BuildAssignmentRejected, captureBuildSource } from "./buildAssignment.js";
|
|
2
11
|
import { checkQaPrerequisites } from "./qaPrerequisites.js";
|
|
3
12
|
import { boundedQaHistory } from "./qaHandbackHistory.js";
|
|
4
13
|
import { QA_FAILURE_REPORT_END, QA_FAILURE_REPORT_START, parseQaResponseContract, qaFailureReportV1Schema, } from "rafi-spec";
|
|
@@ -127,6 +136,24 @@ function loadDurableQaHistory(db, runId, ticketId) {
|
|
|
127
136
|
return { reviews, remediations, history };
|
|
128
137
|
}
|
|
129
138
|
export async function runIsolatedQa(opts) {
|
|
139
|
+
try {
|
|
140
|
+
return await withGraphDerivedAccess([], () => runIsolatedQaOwned(opts));
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error instanceof BuildControlBoundary)
|
|
144
|
+
return { outcome: "needs-human", detail: error.message };
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function runIsolatedQaOwned(opts) {
|
|
149
|
+
try {
|
|
150
|
+
assertBuildAssignmentReconciled(opts.recovery.projectDir, opts.recovery.runId);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof BuildAssignmentRejected)
|
|
154
|
+
return { outcome: "needs-human", detail: error.message };
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
130
157
|
if (opts.resumedRecovery && (opts.resumedRecovery.manifest.runId !== opts.recovery.runId || opts.resumedRecovery.manifest.ticketId !== opts.ticket.id)) {
|
|
131
158
|
return { outcome: "needs-human", detail: `QA recovery packet ${opts.resumedRecovery.manifest.runId}/${opts.resumedRecovery.manifest.ticketId} does not match requested scope ${opts.recovery.runId}/${opts.ticket.id}; no QA work was dispatched` };
|
|
132
159
|
}
|
|
@@ -135,8 +162,10 @@ export async function runIsolatedQa(opts) {
|
|
|
135
162
|
let history;
|
|
136
163
|
let durableReviews;
|
|
137
164
|
let durableRemediations;
|
|
165
|
+
let durableRemediationGeneration;
|
|
138
166
|
try {
|
|
139
167
|
scopeDb.ensureRun(opts.recovery.runId);
|
|
168
|
+
scopeDb.assertAdmittedWork(opts.recovery.runId, opts.ticket.id);
|
|
140
169
|
durablePolicy = scopeDb.autonomyPolicy(opts.recovery.runId) ?? scopeDb.freezeAutonomyPolicy(opts.recovery.runId, resolveAutonomyPolicy(loadProjectAutonomyConfig(opts.recovery.projectDir)));
|
|
141
170
|
const stop = scopeDb.qaRemediationStop(opts.recovery.runId, opts.ticket.id);
|
|
142
171
|
if (stop) {
|
|
@@ -183,6 +212,7 @@ export async function runIsolatedQa(opts) {
|
|
|
183
212
|
scopeDb.updateQaRemediationAttempt(uncertain.attemptId, "uncertain", { detail: "host restarted without a durable Builder completion receipt" });
|
|
184
213
|
durableRemediations = scopeDb.qaRemediationAttempts(opts.recovery.runId, opts.ticket.id);
|
|
185
214
|
({ history } = loadDurableQaHistory(scopeDb, opts.recovery.runId, opts.ticket.id));
|
|
215
|
+
durableRemediationGeneration = scopeDb.qaTicketHead(opts.recovery.runId, opts.ticket.id).remediationGeneration;
|
|
186
216
|
}
|
|
187
217
|
finally {
|
|
188
218
|
scopeDb.close();
|
|
@@ -197,13 +227,57 @@ export async function runIsolatedQa(opts) {
|
|
|
197
227
|
const maxBuilderFixes = Math.min(opts.maxCycles, durableLimit);
|
|
198
228
|
let automaticFixes = persistedQaFixCount(opts);
|
|
199
229
|
let reviewAttempt = Math.max(opts.state.reviews, ...durableReviews.map((item) => item.reviewNumber), 0);
|
|
200
|
-
let remediationGeneration = Math.max(opts.state.remediationGeneration ?? 0, ...durableReviews.map((item) => item.remediationGeneration), ...durableRemediations.map((item) => item.generation), 0);
|
|
230
|
+
let remediationGeneration = Math.max(opts.state.remediationGeneration ?? 0, durableRemediationGeneration, ...durableReviews.map((item) => item.remediationGeneration), ...durableRemediations.map((item) => item.generation), 0);
|
|
201
231
|
const resumedHeadDb = new WorkflowDb(opts.recovery.projectDir);
|
|
232
|
+
resumedHeadDb.reconcileInstructionDeliveries(opts.recovery.runId, opts.ticket.id);
|
|
233
|
+
opts.qaRemediationAuthorization ??= resumedHeadDb.consumeInstructionControls(opts.recovery.runId, opts.ticket.id, "attempts");
|
|
202
234
|
let resumedHead = resumedHeadDb.qaTicketHead(opts.recovery.runId, opts.ticket.id);
|
|
203
235
|
let resumedReport = resumedHead.state === "review-failed"
|
|
204
236
|
? resumedHeadDb.unresolvedQaReports(opts.recovery.runId, opts.ticket.id).at(-1)
|
|
205
237
|
: undefined;
|
|
206
238
|
resumedHeadDb.close();
|
|
239
|
+
if (opts.resumeBuilderGuidance && resumedHead.state === "passed") {
|
|
240
|
+
const controlDb = new WorkflowDb(opts.recovery.projectDir);
|
|
241
|
+
let waiting = false;
|
|
242
|
+
try {
|
|
243
|
+
const preview = controlDb.reserveGuidance(opts.recovery.runId, opts.ticket.id, "builder", randomUUID(), captureBuildSource(opts.builderWorktree).digest, "Builder follow-up", false);
|
|
244
|
+
waiting = preview.ids.length > 0;
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
controlDb.close();
|
|
248
|
+
}
|
|
249
|
+
if (waiting) {
|
|
250
|
+
if (automaticFixes >= maxBuilderFixes && !opts.qaRemediationAuthorization) {
|
|
251
|
+
const detail = `Builder guidance is waiting, but the attempt allowance is exhausted. Authorize one follow-up with /request-attempt ${opts.recovery.runId} ${opts.ticket.id} <reason>, then resume.`;
|
|
252
|
+
ensureProtocolPausePacket(opts, resumedHead, "builder-guidance-budget", detail);
|
|
253
|
+
return { outcome: "needs-human", detail };
|
|
254
|
+
}
|
|
255
|
+
if (!opts.deliverBuilderFollowup)
|
|
256
|
+
return { outcome: "needs-human", detail: "Builder guidance is waiting; resume through the build owner with a validated Builder session" };
|
|
257
|
+
const latest = durableReviews.at(-1);
|
|
258
|
+
const instruction = `Builder guidance follow-up after explicit resume.\nTicket scope: ${opts.ticket.id}. Do not substitute another ticket.\nPreserve the previously completed work and all mandatory requirements. Apply only the authorized waiting guidance within the approved ticket scope. If broader permission is needed, stop and explain it.\nApproved ticket requirements:\n${JSON.stringify(opts.ticket)}\nReport the instruction IDs you applied with evidence, then finish with STEP_STATUS: done | ticket="${opts.ticket.id}" summary="...".`;
|
|
259
|
+
const fix = await opts.deliverBuilderFollowup(instruction, { reviewAttemptId: latest.attemptId, requirementsDigest: qaDigest("admitted-requirements", opts.ticket), maximum: maxBuilderFixes, authorizationId: opts.qaRemediationAuthorization });
|
|
260
|
+
opts.qaRemediationAuthorization = undefined;
|
|
261
|
+
if (!fix.ok) {
|
|
262
|
+
const db = new WorkflowDb(opts.recovery.projectDir);
|
|
263
|
+
try {
|
|
264
|
+
ensureProtocolPausePacket(opts, db.qaTicketHead(opts.recovery.runId, opts.ticket.id), "builder-guidance-stopped", fix.detail ?? "Builder follow-up did not complete");
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
db.close();
|
|
268
|
+
}
|
|
269
|
+
return { outcome: "needs-human", detail: fix.detail ?? "Builder follow-up did not complete; reconcile its outcome before resuming" };
|
|
270
|
+
}
|
|
271
|
+
opts.builderSummary = fix.response;
|
|
272
|
+
opts.state.builderResponseHistory.push({ ticketId: opts.ticket.id, cycle: latest.cycle, kind: "remediation", response: fix.response, summary: boundedBuilderSummary(fix.summary) });
|
|
273
|
+
automaticFixes = persistedQaFixCount(opts);
|
|
274
|
+
remediationGeneration += 1;
|
|
275
|
+
opts.state.remediationGeneration = remediationGeneration;
|
|
276
|
+
// The assignment transaction invalidated the old certificate. A complete
|
|
277
|
+
// fresh review now checks the exact post-follow-up source.
|
|
278
|
+
opts.resumedRecovery = undefined;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
207
281
|
if (resumedHead.state === "review-failed") {
|
|
208
282
|
const resumedReviewNumber = resumedReport?.reviewNumber;
|
|
209
283
|
const causing = resumedReviewNumber === undefined ? undefined : durableReviews.find((item) => item.reviewNumber === resumedReviewNumber)?.attemptId;
|
|
@@ -230,7 +304,14 @@ export async function runIsolatedQa(opts) {
|
|
|
230
304
|
}
|
|
231
305
|
if (resumedHead.state === "review-failed" && resumedReport) {
|
|
232
306
|
const durableReport = resumedReport;
|
|
233
|
-
|
|
307
|
+
const attemptControlDb = new WorkflowDb(opts.recovery.projectDir);
|
|
308
|
+
try {
|
|
309
|
+
opts.qaRemediationAuthorization ??= attemptControlDb.consumeInstructionControls(opts.recovery.runId, opts.ticket.id, "attempts");
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
attemptControlDb.close();
|
|
313
|
+
}
|
|
314
|
+
if (automaticFixes >= maxBuilderFixes && !opts.qaRemediationAuthorization)
|
|
234
315
|
return pauseQaProtocol(opts, "qa-nonconvergence", `QA remediation budget is exhausted with unresolved report ${durableReport.reportDigest}`, "nonconverged");
|
|
235
316
|
const report = durableReport.report;
|
|
236
317
|
const causing = durableReviews.find((item) => item.reviewNumber === durableReport.reviewNumber)?.attemptId;
|
|
@@ -239,7 +320,8 @@ export async function runIsolatedQa(opts) {
|
|
|
239
320
|
const request = {
|
|
240
321
|
kind: "validated-report", report, reportDigest: resumedReport.reportDigest, history: [...history], latestBuilderResult: latestBuilderResponse(opts),
|
|
241
322
|
};
|
|
242
|
-
const fix = await observedQaFix(opts, "resuming pending QA remediation", request, causing);
|
|
323
|
+
const fix = await observedQaFix(opts, "resuming pending QA remediation", request, causing, Boolean(opts.qaRemediationAuthorization));
|
|
324
|
+
opts.qaRemediationAuthorization = undefined;
|
|
243
325
|
if (!fix.ok)
|
|
244
326
|
return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA remediation failed during restart recovery", fix.outcome === "needs-input" ? "needs-human" : "blocked");
|
|
245
327
|
const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? "Builder reported remediation complete");
|
|
@@ -292,9 +374,17 @@ export async function runIsolatedQa(opts) {
|
|
|
292
374
|
return review;
|
|
293
375
|
reviewAttempt = Math.max(reviewAttempt, review.reviewAttempt);
|
|
294
376
|
history.push({ cycle, reviewAttempt: review.reviewAttempt, remediationGeneration, attemptId: review.reviewAttemptId, outcome: "qa_fail", detail: review.detail, reportDigest: review.reportDigest, report: review.report, findingIds: review.report?.findings.map((finding) => finding.id) });
|
|
295
|
-
|
|
377
|
+
const controlDb = new WorkflowDb(opts.recovery.projectDir);
|
|
378
|
+
try {
|
|
379
|
+
opts.qaRemediationAuthorization ??= controlDb.consumeInstructionControls(opts.recovery.runId, opts.ticket.id, "attempts");
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
controlDb.close();
|
|
383
|
+
}
|
|
384
|
+
if (automaticFixes < maxBuilderFixes || opts.qaRemediationAuthorization) {
|
|
296
385
|
const request = { kind: "validated-report", report: review.report, reportDigest: review.reportDigest, history: [...history], latestBuilderResult: latestBuilderResponse(opts) };
|
|
297
|
-
const fix = await observedQaFix(opts, "applying QA fixes", request, review.reviewAttemptId);
|
|
386
|
+
const fix = await observedQaFix(opts, "applying QA fixes", request, review.reviewAttemptId, Boolean(opts.qaRemediationAuthorization));
|
|
387
|
+
opts.qaRemediationAuthorization = undefined;
|
|
298
388
|
if (!fix.ok)
|
|
299
389
|
return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA fix failed", fix.outcome === "needs-input" ? "needs-human" : "blocked");
|
|
300
390
|
const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? fix.detail ?? "Builder reported remediation complete");
|
|
@@ -326,7 +416,7 @@ export async function runIsolatedQa(opts) {
|
|
|
326
416
|
if (decision.action === "pause")
|
|
327
417
|
return pauseQaProtocol(opts, "qa-nonconvergence", detail, "nonconverged");
|
|
328
418
|
if (decision.action === "waive") {
|
|
329
|
-
waiveV2Reports(opts, "Operator explicitly waived unresolved QA findings");
|
|
419
|
+
waiveV2Reports(opts, "Operator explicitly waived unresolved QA findings", decision.authorizationRef);
|
|
330
420
|
markQaRecoveryResolved(opts);
|
|
331
421
|
return { outcome: "waived", detail: history.at(-1)?.detail ?? detail, summary: "QA explicitly waived by user" };
|
|
332
422
|
}
|
|
@@ -610,7 +700,7 @@ async function oneReview(opts, identity) {
|
|
|
610
700
|
const missingPrerequisites = prerequisiteEvidence.checks.filter(check => check.outcome === "not_run");
|
|
611
701
|
if (missingPrerequisites.length && !prerequisiteEvidence.sourceDefects.length)
|
|
612
702
|
return { outcome: "blocked", detail: `QA verification prerequisites unavailable; required checks were not_run: ${missingPrerequisites.map(check => check.evidence).join("; ")}. Restore the required environment with existing authority, then resume for fresh QA.` };
|
|
613
|
-
let handoff = buildQaReviewHandoff(opts.ticket, opts.builderSummary, snapshot.manifest.diffDigest, loadTicketSetupConfigWithDefaults(opts.
|
|
703
|
+
let handoff = buildQaReviewHandoff(opts.ticket, opts.builderSummary, snapshot.manifest.diffDigest, (opts.frozenValidationChecklist ??= [...loadTicketSetupConfigWithDefaults(opts.recovery.projectDir).build.validation_checklist]), snapshot.frozenState.changeSummary, opts.qaHistory);
|
|
614
704
|
handoff += `\nHost prerequisite evidence (availability does not prove provider sandbox access): ${JSON.stringify(prerequisiteEvidence)}\nRecord tests prevented from executing as not_run with the actual reason. Never install dependencies, create a lockfile, provision services, or claim an unconditional pass when required verification was not run.`;
|
|
615
705
|
// Every disposable snapshot has a distinct cwd and therefore must have a
|
|
616
706
|
// fresh provider conversation. Cumulative QA state remains in the durable
|
|
@@ -633,7 +723,11 @@ async function oneReview(opts, identity) {
|
|
|
633
723
|
return { outcome: "retry-modification" };
|
|
634
724
|
return { outcome: "needs-human", detail };
|
|
635
725
|
}
|
|
726
|
+
let graphReviewContext = graphContext(opts.recovery.projectDir, snapshot.path, "final-qa", handoff, { accessWorkspace: opts.builderWorktree, operationId: `graph-review:${attemptId}`, logicalTaskId: attemptId, sourceRef: snapshot.frozenState.digest, runId: opts.recovery.runId, workId: opts.ticket.id });
|
|
727
|
+
let graphPrepared = opts.resumedRecovery ? { instruction: handoff, bytes: 0 } : await prepareGraphInstruction(handoff, graphReviewContext, await qa.contextUsage?.());
|
|
728
|
+
handoff = graphPrepared.instruction;
|
|
636
729
|
const v2Binding = beginV2Review(opts, snapshot.frozenState, snapshot.path, handoff, qaHandle, { attemptId, cycle, remediationGeneration });
|
|
730
|
+
handoff = v2Binding.reviewInstruction;
|
|
637
731
|
bindQaTurnAdapter(qa, v2Binding);
|
|
638
732
|
reviewAttempt = v2Binding.reviewNumber;
|
|
639
733
|
identity = { ...identity, reviewAttempt };
|
|
@@ -780,12 +874,18 @@ async function oneReview(opts, identity) {
|
|
|
780
874
|
opts.resumedRecovery = undefined;
|
|
781
875
|
}
|
|
782
876
|
if (resumedFullReviewNeedsBinding) {
|
|
783
|
-
const
|
|
877
|
+
const resumedAttemptId = randomUUID();
|
|
878
|
+
graphReviewContext = { ...graphReviewContext, operationId: `graph-review:${resumedAttemptId}`, logicalTaskId: resumedAttemptId };
|
|
879
|
+
graphPrepared = await prepareGraphInstruction(handoff, graphReviewContext, await qa.contextUsage?.());
|
|
880
|
+
handoff = graphPrepared.instruction;
|
|
881
|
+
const resumedReviewBinding = beginV2Review(opts, snapshot.frozenState, snapshot.path, handoff, qaHandle, { attemptId: resumedAttemptId, cycle, remediationGeneration });
|
|
784
882
|
bindQaTurnAdapter(qa, resumedReviewBinding);
|
|
785
883
|
resumedPacket = resumedReviewBinding.recoveryPacket;
|
|
786
884
|
reviewAttempt = resumedReviewBinding.reviewNumber;
|
|
787
885
|
}
|
|
788
|
-
let
|
|
886
|
+
let graphSubturn = 0;
|
|
887
|
+
const activeGraphBinding = qaTurnBindings.get(qa);
|
|
888
|
+
let turn = preparedTurn ?? await sendGraphTurn(qa, handoff, { ...graphReviewContext, reviewBasisRef: activeGraphBinding.reviewBasisDigest }, undefined, (prompt) => sendDurableQaTurn(qa, prompt, qaEvents, graphSubturn++ === 0 ? "initial" : `graph-evidence-${graphSubturn}`), { ...graphPrepared, instruction: activeGraphBinding.reviewInstruction });
|
|
789
889
|
let status = parseStepStatus(turn.text);
|
|
790
890
|
recoveryContext = qaTurnBindings.get(qa)?.recoveryContext;
|
|
791
891
|
try {
|
|
@@ -1471,14 +1571,53 @@ const qaTurnBindings = new WeakMap();
|
|
|
1471
1571
|
function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle, attempt) {
|
|
1472
1572
|
const db = new WorkflowDb(opts.recovery.projectDir);
|
|
1473
1573
|
try {
|
|
1574
|
+
const verification = db.builderVerificationContext(opts.recovery.runId, opts.ticket.id, source.digest);
|
|
1575
|
+
instruction += verification.text;
|
|
1474
1576
|
const sourceV2 = {
|
|
1475
1577
|
version: 2, runId: opts.recovery.runId, ticketId: opts.ticket.id,
|
|
1476
1578
|
originDigest: source.originDigest, contentDigest: source.contentDigest, digest: source.digest,
|
|
1477
1579
|
capturedAt: source.capturedAt, paths: source.pathInventory,
|
|
1478
1580
|
};
|
|
1479
|
-
const checklist = loadTicketSetupConfigWithDefaults(opts.
|
|
1581
|
+
const checklist = (opts.frozenValidationChecklist ??= [...loadTicketSetupConfigWithDefaults(opts.recovery.projectDir).build.validation_checklist]);
|
|
1582
|
+
if (canonicalJson(checklist) !== canonicalJson(loadTicketSetupConfigWithDefaults(opts.recovery.projectDir).build.validation_checklist))
|
|
1583
|
+
throw new Error("Canonical checklist changed before QA dispatch; contract/input reconciliation required");
|
|
1584
|
+
const preparationStore = db.qaPreparationStore();
|
|
1585
|
+
const admission = db.assertAdmittedWork(opts.recovery.runId, opts.ticket.id);
|
|
1586
|
+
const contractHead = preparationStore.head(opts.recovery.runId, opts.ticket.id, admission.requirementsDigest);
|
|
1587
|
+
const contract = preparationStore.policy(opts.recovery.runId)?.mode === "enforce" && contractHead.state === "ready" && contractHead.digest ? preparationStore.contract(contractHead.digest) : undefined;
|
|
1588
|
+
if (preparationStore.policy(opts.recovery.runId)?.mode === "enforce" && !contract)
|
|
1589
|
+
throw new Error("Final QA requires the retained ready contract; exact QA recovery must not replay preparation");
|
|
1590
|
+
if (contract) {
|
|
1591
|
+
if (qaDigest("admitted-requirements", opts.ticket) !== admission.requirementsDigest)
|
|
1592
|
+
throw new Error("Final QA ticket differs from the frozen admitted scope; renewed approval is required");
|
|
1593
|
+
const identity = handle.sessionIdentity();
|
|
1594
|
+
const priorAssessment = preparationStore.artifact(contract.semanticAssessmentDigest, "semantic-assessment");
|
|
1595
|
+
const involvedSessions = new Set([...contract.preparationEvidence.map(evidence => evidence.sessionId), priorAssessment.sessionId, priorAssessment.authorSessionId, ...preparationStore.receipts(contract.contentDigest).map(receipt => receipt.sessionId)]);
|
|
1596
|
+
if (contract.challengeReceiptDigest)
|
|
1597
|
+
involvedSessions.add(preparationStore.artifact(contract.challengeReceiptDigest, "approach-challenge").sessionId);
|
|
1598
|
+
if (involvedSessions.has(identity.sessionId))
|
|
1599
|
+
throw new Error("Final reviewer must be independent of preparation, assessment, challenge and Builder conversations");
|
|
1600
|
+
const effective = resolveEffectiveQaConfiguration(opts.recovery.projectDir, { make: handle.adapter.agent });
|
|
1601
|
+
if (effective.digest !== contract.inputs.find(input => input.kind === "rules")?.digest || handle.effectiveRoleInstructions !== finalQaRoleInstructions(effective.qaRules) || canonicalJson(handle.skills.map(skill => ({ name: skill.name, path: skill.path, content: skill.content, digest: skill.digest }))) !== canonicalJson(effective.skills.map(skill => ({ name: skill.name, path: skill.path, content: skill.content, digest: sha(skill.content) }))))
|
|
1602
|
+
throw new Error("Actual final QA rules/skills differ from the frozen canonical contract configuration");
|
|
1603
|
+
const expected = contract.inputs.find(input => input.kind === "checklist");
|
|
1604
|
+
if (expected?.digest !== contractDigest("input-checklist", checklist))
|
|
1605
|
+
throw new Error("Final QA checklist differs from delivered contract");
|
|
1606
|
+
const builderClaims = db.operations(opts.recovery.runId).flatMap(operation => {
|
|
1607
|
+
const intent = operation.intent;
|
|
1608
|
+
const result = operation.result;
|
|
1609
|
+
return operation.kind === "builder-coverage" && operation.status === "confirmed" && intent.workId === opts.ticket.id && intent.contractDigest === contract.contentDigest && result?.binding?.sourceDigest === source.digest && result.coverageDigest
|
|
1610
|
+
? [{ operationId: operation.idempotencyKey, digest: result.coverageDigest, coverage: preparationStore.artifact(result.coverageDigest, "builder-coverage") }] : [];
|
|
1611
|
+
});
|
|
1612
|
+
instruction += finalReviewContractContext(contract, preparationStore, reviewedSnapshotPath, builderClaims);
|
|
1613
|
+
instruction += `\n${renderVerificationContract(contract)}\n${COVERAGE_INSTRUCTIONS}\nCoverage binding: ${JSON.stringify({ phase: "qa", runId: opts.recovery.runId, workId: opts.ticket.id, contractDigest: contract.contentDigest, revision: contract.revision, sourceDigest: source.digest, attemptId: attempt.attemptId, sessionId: handle.sessionIdentity().sessionId })}`;
|
|
1614
|
+
}
|
|
1615
|
+
const originalInstruction = instruction;
|
|
1616
|
+
const guidance = db.reserveGuidance(opts.recovery.runId, opts.ticket.id, "qa", attempt.attemptId, source.digest, instruction, false);
|
|
1617
|
+
instruction = guidance.text;
|
|
1480
1618
|
const basisWithoutDigest = {
|
|
1481
1619
|
version: 2,
|
|
1620
|
+
...(contract ? { contractBinding: { version: 1, transportVersion: 1, revision: contract.revision, digest: contract.contentDigest, admissionDigest: admission.requirementsDigest, commonConfigDigest: contract.inputs.find(input => input.kind === "rules").digest } } : {}),
|
|
1482
1621
|
ticketDigest: qaDigest("ticket", opts.ticket),
|
|
1483
1622
|
instructionDigest: qaDigest("instruction", instruction),
|
|
1484
1623
|
roleInstructionsDigest: qaDigest("role-instructions", handle.effectiveRoleInstructions),
|
|
@@ -1488,6 +1627,8 @@ function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle,
|
|
|
1488
1627
|
confinementDigest: handle.confinement.digest,
|
|
1489
1628
|
};
|
|
1490
1629
|
const basis = { ...basisWithoutDigest, digest: qaDigest("review-basis-fields", basisWithoutDigest) };
|
|
1630
|
+
const inputBasisEnvelope = contract ? `\nImmutable input-basis digest: ${basis.digest}. Coverage must bind this inputBasisDigest.` : "";
|
|
1631
|
+
instruction += inputBasisEnvelope;
|
|
1491
1632
|
let head = db.qaTicketHead(opts.recovery.runId, opts.ticket.id);
|
|
1492
1633
|
if (head.state === "waived" || head.state === "completed")
|
|
1493
1634
|
throw new Error(`QA protocol for ${opts.ticket.id} is already terminal`);
|
|
@@ -1518,7 +1659,7 @@ function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle,
|
|
|
1518
1659
|
head = db.commitQaReviewReady(sourceV2, basis, identity, handle.confinement, attempt, head.revision);
|
|
1519
1660
|
if (recoveryPacket)
|
|
1520
1661
|
recoveryPacket = updateQaRecoveryReviewIdentity(recoveryPacket, head.reviewNumber, attempt.attemptId, attempt.cycle);
|
|
1521
|
-
return { projectDir: opts.recovery.projectDir, runId: opts.recovery.runId, ticketId: opts.ticket.id, reviewNumber: head.reviewNumber, reviewAttemptId: attempt.attemptId, sourceStateDigest: source.digest, reviewBasisDigest: basis.digest, sessionGeneration: identity.generation, sessionRef: identity, reviewedSnapshotPath: identity.cwd, frozenSource: source, recoveryPacket, nextTurn: 0 };
|
|
1662
|
+
return { projectDir: opts.recovery.projectDir, runId: opts.recovery.runId, ticketId: opts.ticket.id, reviewNumber: head.reviewNumber, reviewAttemptId: attempt.attemptId, sourceStateDigest: source.digest, reviewBasisDigest: basis.digest, sessionGeneration: identity.generation, sessionRef: identity, reviewedSnapshotPath: identity.cwd, frozenSource: source, recoveryPacket, nextTurn: 0, originalInstruction, inputBasisEnvelope, reviewInstruction: instruction, guidanceIds: guidance.ids, verificationIds: verification.ids };
|
|
1522
1663
|
}
|
|
1523
1664
|
finally {
|
|
1524
1665
|
db.close();
|
|
@@ -1566,6 +1707,14 @@ function bindQaRecoveryContext(adapter, context) {
|
|
|
1566
1707
|
binding.recoveryContext = context;
|
|
1567
1708
|
}
|
|
1568
1709
|
async function sendDurableQaTurn(adapter, instruction, events, slot) {
|
|
1710
|
+
const binding = qaTurnBindings.get(adapter);
|
|
1711
|
+
if (!binding)
|
|
1712
|
+
throw new Error("QA provider dispatch requires a durable source/review-basis binding");
|
|
1713
|
+
// Repairs and recovery acknowledgements bypass graph navigation, but their
|
|
1714
|
+
// provider history and journal artifacts retain the original access grants.
|
|
1715
|
+
return dispatchWithGraphAccess(binding.projectDir, adapter, instruction, undefined, () => sendDurableQaTurnOwned(adapter, instruction, events, slot));
|
|
1716
|
+
}
|
|
1717
|
+
async function sendDurableQaTurnOwned(adapter, instruction, events, slot) {
|
|
1569
1718
|
const binding = qaTurnBindings.get(adapter);
|
|
1570
1719
|
if (!binding)
|
|
1571
1720
|
throw new Error("QA provider dispatch requires a durable source/review-basis binding");
|
|
@@ -1601,17 +1750,27 @@ async function sendDurableQaTurn(adapter, instruction, events, slot) {
|
|
|
1601
1750
|
const providerSession = { version: 2, provider: session.provider, sessionId: session.sessionId, role: "qa", stream: "qa", generation: session.generation, cwd: session.cwd, configRoot: session.configRoot, createdAt: session.createdAt, validatedAt: session.validatedAt ?? intendedAt };
|
|
1602
1751
|
const db = new WorkflowDb(binding.projectDir);
|
|
1603
1752
|
try {
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1753
|
+
db.atomic(() => {
|
|
1754
|
+
if (slot === "initial") {
|
|
1755
|
+
const guidance = db.reserveGuidance(binding.runId, binding.ticketId, "qa", operationId, binding.sourceStateDigest, binding.originalInstruction);
|
|
1756
|
+
if (guidance.text + (binding.inputBasisEnvelope ?? "") !== binding.reviewInstruction)
|
|
1757
|
+
throw new BuildControlBoundary("Manager guidance changed during QA preparation; restart a full review with the current instruction basis");
|
|
1758
|
+
binding.guidanceIds = guidance.ids;
|
|
1759
|
+
instruction = binding.reviewInstruction;
|
|
1760
|
+
}
|
|
1761
|
+
const instructionDigest = db.putEvidence("qa", Buffer.from(instruction));
|
|
1762
|
+
const intent = { version: 2, operationId, runId: binding.runId, ticketId: binding.ticketId, reviewNumber: binding.reviewNumber, sessionGeneration: binding.sessionGeneration, slot: retrySlot, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest, providerSession, instructionDigest, intendedAt };
|
|
1763
|
+
const head = db.qaTicketHead(binding.runId, binding.ticketId);
|
|
1764
|
+
db.commitQaTurnIntent(intent, head.revision);
|
|
1765
|
+
});
|
|
1608
1766
|
}
|
|
1609
1767
|
finally {
|
|
1610
1768
|
db.close();
|
|
1611
1769
|
}
|
|
1612
1770
|
let result;
|
|
1771
|
+
const providerStartedAt = Date.now();
|
|
1613
1772
|
try {
|
|
1614
|
-
result = await adapter.sendTurn(instruction);
|
|
1773
|
+
result = await adapter.sendTurn(instruction, slot === "session-initialization" ? { purpose: "initialization", responseOnly: true } : slot?.includes("acknowledgement") || slot?.includes("correction") ? { purpose: "response-repair", responseOnly: true } : undefined);
|
|
1615
1774
|
}
|
|
1616
1775
|
catch (error) {
|
|
1617
1776
|
const failedDb = new WorkflowDb(binding.projectDir);
|
|
@@ -1682,8 +1841,25 @@ async function sendDurableQaTurn(adapter, instruction, events, slot) {
|
|
|
1682
1841
|
providerInstructionDigest, rawResponseDigest, cleanedResponseDigest, eventStreamDigest,
|
|
1683
1842
|
terminalEventObserved, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest, completedAt,
|
|
1684
1843
|
};
|
|
1685
|
-
receiptDb.
|
|
1844
|
+
receiptDb.atomic(() => {
|
|
1845
|
+
receiptDb.finishQaTurn(receipt);
|
|
1846
|
+
const metricAdmission = receiptDb.admittedWork(binding.runId, binding.ticketId);
|
|
1847
|
+
if (metricAdmission)
|
|
1848
|
+
receiptDb.qaPreparationStore().metric(binding.runId, binding.ticketId, metricAdmission.requirementsDigest, `metric:qa-turn:${operationId}`, slot?.includes("correction") ? "format-repair" : "phase-observation", { phase: slot?.includes("correction") || slot === "session-initialization" ? "repair" : "review", durationMs: Math.max(0, Date.now() - providerStartedAt), costUsd: result.costAuthoritative ? result.costUsd : null, inputTokens: result.inputTokens ?? null, outputTokens: result.outputTokens ?? null });
|
|
1849
|
+
if (slot === "initial")
|
|
1850
|
+
receiptDb.finishGuidance(binding.guidanceIds, "qa", { submitted: terminalEventObserved ? true : undefined, receipt, applied: terminalEventObserved && binding.guidanceIds.every(id => providerInstruction.includes(id)) });
|
|
1851
|
+
});
|
|
1686
1852
|
binding.lastReceiptDigest = qaDigest("turn-receipt", receipt);
|
|
1853
|
+
let graphRequest = false;
|
|
1854
|
+
try {
|
|
1855
|
+
graphRequest = Boolean(parseGraphRequest(result.cleanedResponse ?? result.text));
|
|
1856
|
+
}
|
|
1857
|
+
catch {
|
|
1858
|
+
graphRequest = true;
|
|
1859
|
+
}
|
|
1860
|
+
if (!slot?.startsWith("session-") && !graphRequest)
|
|
1861
|
+
binding.lastBusinessReceiptDigest = binding.lastReceiptDigest;
|
|
1862
|
+
binding.lastResponse = result.text;
|
|
1687
1863
|
if (!terminalEventObserved) {
|
|
1688
1864
|
const head = receiptDb.qaTicketHead(binding.runId, binding.ticketId);
|
|
1689
1865
|
receiptDb.transitionQa(binding.runId, binding.ticketId, head.revision, { type: "turn-uncertain" });
|
|
@@ -1744,29 +1920,55 @@ function finishV2Failure(opts, adapter, reportDigest, report, rawFindingIds, fin
|
|
|
1744
1920
|
}
|
|
1745
1921
|
function finishV2Pass(opts, adapter, detail) {
|
|
1746
1922
|
const binding = qaTurnBindings.get(adapter);
|
|
1747
|
-
if (!binding?.
|
|
1748
|
-
throw new Error("QA pass has no completed
|
|
1923
|
+
if (!binding?.lastBusinessReceiptDigest)
|
|
1924
|
+
throw new Error("QA pass has no completed business-verdict receipt");
|
|
1749
1925
|
const db = new WorkflowDb(binding.projectDir);
|
|
1750
1926
|
try {
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1927
|
+
return db.atomic(() => {
|
|
1928
|
+
const head = db.qaTicketHead(binding.runId, binding.ticketId);
|
|
1929
|
+
let contractCoverage;
|
|
1930
|
+
if (db.qaPreparationStore().policy(binding.runId)?.mode === "enforce") {
|
|
1931
|
+
const admission = db.assertAdmittedWork(binding.runId, binding.ticketId);
|
|
1932
|
+
const contractHead = db.qaPreparationStore().head(binding.runId, binding.ticketId, admission.requirementsDigest);
|
|
1933
|
+
if (!contractHead.digest || contractHead.state !== "ready")
|
|
1934
|
+
throw new Error("Final coverage requires the current ready contract");
|
|
1935
|
+
const contract = db.qaPreparationStore().contract(contractHead.digest);
|
|
1936
|
+
const coverage = parseContractCoverage(binding.lastResponse ?? "");
|
|
1937
|
+
const errors = validateContractCoverage(contract, coverage, { phase: "qa", sourceDigest: binding.sourceStateDigest, inputBasisDigest: binding.reviewBasisDigest, attemptId: binding.reviewAttemptId, sessionId: binding.sessionRef.sessionId });
|
|
1938
|
+
if (errors.length)
|
|
1939
|
+
throw new Error(`Final QA coverage incomplete: ${errors.join("; ")}`);
|
|
1940
|
+
const coverageDigest = db.qaPreparationStore().putArtifact("final-coverage", coverage);
|
|
1941
|
+
contractCoverage = { version: 1, contractDigest: contract.contentDigest, revision: contract.revision, coverageDigest, attemptId: binding.reviewAttemptId, sessionId: binding.sessionRef.sessionId };
|
|
1942
|
+
}
|
|
1943
|
+
const certificate = db.commitQaPassAttempt(binding.reviewAttemptId, { runId: binding.runId, ticketId: binding.ticketId, qaRevision: head.revision + 1, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest, turnReceiptDigest: binding.lastBusinessReceiptDigest, ...(contractCoverage ? { contractCoverage } : {}) }, detail, head.revision);
|
|
1944
|
+
db.verifyGuidance(binding.guidanceIds, certificate, binding.lastResponse ?? "");
|
|
1945
|
+
db.verifyBuilderGuidance(binding.verificationIds, certificate, binding.lastResponse ?? "", binding.lastBusinessReceiptDigest);
|
|
1946
|
+
return { certificateId: certificate.certificateId, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest };
|
|
1947
|
+
});
|
|
1754
1948
|
}
|
|
1755
1949
|
finally {
|
|
1756
1950
|
db.close();
|
|
1757
1951
|
}
|
|
1758
1952
|
}
|
|
1759
|
-
function waiveV2Reports(opts, reason) {
|
|
1953
|
+
function waiveV2Reports(opts, reason, authorizationRef) {
|
|
1760
1954
|
const db = new WorkflowDb(opts.recovery.projectDir);
|
|
1761
1955
|
try {
|
|
1762
1956
|
const head = db.qaTicketHead(opts.recovery.runId, opts.ticket.id);
|
|
1763
|
-
db.commitQaWaiver(opts.recovery.runId, opts.ticket.id, head.revision, reason);
|
|
1957
|
+
db.commitQaWaiver(opts.recovery.runId, opts.ticket.id, head.revision, reason, undefined, authorizationRef);
|
|
1764
1958
|
}
|
|
1765
1959
|
finally {
|
|
1766
1960
|
db.close();
|
|
1767
1961
|
}
|
|
1768
1962
|
}
|
|
1769
1963
|
export async function beginQaFinalization(projectDir, sourceWorktree, runId, ticketId, certificateId, expectedSourceStateDigest, consumer, allowedProjectionPaths = []) {
|
|
1964
|
+
assertBuildAssignmentReconciled(projectDir, runId);
|
|
1965
|
+
const controlDb = new WorkflowDb(projectDir);
|
|
1966
|
+
try {
|
|
1967
|
+
controlDb.assertFinalizationControls(runId, ticketId);
|
|
1968
|
+
}
|
|
1969
|
+
finally {
|
|
1970
|
+
controlDb.close();
|
|
1971
|
+
}
|
|
1770
1972
|
const invalidatePass = (reason) => {
|
|
1771
1973
|
const db = new WorkflowDb(projectDir);
|
|
1772
1974
|
try {
|
|
@@ -1808,6 +2010,14 @@ export async function beginQaFinalization(projectDir, sourceWorktree, runId, tic
|
|
|
1808
2010
|
}
|
|
1809
2011
|
}
|
|
1810
2012
|
export async function verifyPendingQaFinalizationSource(projectDir, sourceWorktree, runId, ticketId, allowProjection = false, projectionOperationId) {
|
|
2013
|
+
assertBuildAssignmentReconciled(projectDir, runId);
|
|
2014
|
+
const controlDb = new WorkflowDb(projectDir);
|
|
2015
|
+
try {
|
|
2016
|
+
controlDb.assertFinalizationControls(runId, ticketId);
|
|
2017
|
+
}
|
|
2018
|
+
finally {
|
|
2019
|
+
controlDb.close();
|
|
2020
|
+
}
|
|
1811
2021
|
const db = new WorkflowDb(projectDir);
|
|
1812
2022
|
let step;
|
|
1813
2023
|
let projectionAuthorized = !projectionOperationId;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type QaEvidenceSnapshot } from "./qaEvidenceReader.js";
|
|
2
|
+
export interface QaTicketTimeline {
|
|
3
|
+
counters: {
|
|
4
|
+
failedReviews: number;
|
|
5
|
+
passedReviews: number;
|
|
6
|
+
blockedReviews: number;
|
|
7
|
+
interruptedReviews: number;
|
|
8
|
+
pendingReviews: number;
|
|
9
|
+
malformedResponses: number;
|
|
10
|
+
corrections: number;
|
|
11
|
+
remediationFailures: number;
|
|
12
|
+
uncertainRemediations: number;
|
|
13
|
+
};
|
|
14
|
+
events: Array<Record<string, unknown>>;
|
|
15
|
+
gaps: string[];
|
|
16
|
+
}
|
|
17
|
+
/** All counters are scoped rows. A changed digest is never a code-change explanation. */
|
|
18
|
+
export declare function buildQaTimeline(snapshot: QaEvidenceSnapshot, workId: string): QaTicketTimeline;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { decisionBelongsToWork } from "./buildInterventions.js";
|
|
2
|
+
import { evidenceRecord } from "./qaEvidenceReader.js";
|
|
3
|
+
import { parseQaResponseContract } from "rafi-spec";
|
|
4
|
+
/** All counters are scoped rows. A changed digest is never a code-change explanation. */
|
|
5
|
+
export function buildQaTimeline(snapshot, workId) {
|
|
6
|
+
const scoped = (table) => (snapshot.rows[table] ?? []).filter(row => row.ticket_id === workId);
|
|
7
|
+
const counters = { failedReviews: 0, passedReviews: 0, blockedReviews: 0, interruptedReviews: 0, pendingReviews: 0, malformedResponses: 0, corrections: 0, remediationFailures: 0, uncertainRemediations: 0 };
|
|
8
|
+
const events = [];
|
|
9
|
+
for (const row of scoped("qa_review_attempts")) {
|
|
10
|
+
const record = evidenceRecord(row);
|
|
11
|
+
const status = String(row.status);
|
|
12
|
+
if (status === "failed")
|
|
13
|
+
counters.failedReviews++;
|
|
14
|
+
else if (status === "passed")
|
|
15
|
+
counters.passedReviews++;
|
|
16
|
+
else if (status === "started")
|
|
17
|
+
counters.pendingReviews++;
|
|
18
|
+
else if (status === "interrupted")
|
|
19
|
+
counters.interruptedReviews++;
|
|
20
|
+
events.push({ kind: "review", id: row.attempt_id, reviewNumber: row.review_number, status, sourceDigest: row.source_digest, detail: record?.detail, asOf: row.updated_at, sequence: Number(row.review_number), order: 0 });
|
|
21
|
+
}
|
|
22
|
+
for (const row of scoped("qa_turns")) {
|
|
23
|
+
const receipt = evidenceRecord(row, "receipt_json");
|
|
24
|
+
const slot = String(row.retry_slot);
|
|
25
|
+
if (/correction|repair/.test(slot))
|
|
26
|
+
counters.corrections++;
|
|
27
|
+
const bytes = typeof receipt?.cleanedResponseDigest === "string" ? snapshot.blobs.get(receipt.cleanedResponseDigest) : undefined;
|
|
28
|
+
const contract = bytes ? parseQaResponseContract(bytes.toString("utf8")) : undefined;
|
|
29
|
+
const classification = contract ? contract.valid ? contract.status : "malformed" : "unavailable";
|
|
30
|
+
if (slot === "initial" && classification === "malformed")
|
|
31
|
+
counters.malformedResponses++;
|
|
32
|
+
if (slot === "initial" && classification === "blocked")
|
|
33
|
+
counters.blockedReviews++;
|
|
34
|
+
events.push({ kind: "qa_turn", id: row.operation_id, reviewNumber: row.review_number, slot, status: row.status, classification, responseDigest: receipt?.rawResponseDigest, sequence: Number(row.review_number), order: 1 });
|
|
35
|
+
}
|
|
36
|
+
for (const row of scoped("qa_remediation_attempts")) {
|
|
37
|
+
if (row.status === "failed")
|
|
38
|
+
counters.remediationFailures++;
|
|
39
|
+
if (row.status === "uncertain")
|
|
40
|
+
counters.uncertainRemediations++;
|
|
41
|
+
const parent = scoped("qa_review_attempts").find(attempt => attempt.attempt_id === row.review_attempt_id);
|
|
42
|
+
events.push({ kind: "remediation", id: row.attempt_id, reviewAttemptId: row.review_attempt_id, generation: row.generation, status: row.status, requestDigest: row.request_digest, responseDigest: row.response_digest, verification: "requires subsequent source-bound QA", sequence: Number(parent?.review_number ?? -1), order: 3 });
|
|
43
|
+
}
|
|
44
|
+
const prior = new Map();
|
|
45
|
+
for (const row of scoped("qa_reports").sort((a, b) => Number(a.review_number) - Number(b.review_number))) {
|
|
46
|
+
const report = evidenceRecord(row, "report_json");
|
|
47
|
+
const findings = Array.isArray(report?.findings) ? report.findings : [];
|
|
48
|
+
const correlations = findings.map((value, ordinal) => {
|
|
49
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
50
|
+
return { ordinal, label: "unavailable", confidence: "unestablished", limitation: "Corrupt retained finding; no issue correlation was inferred" };
|
|
51
|
+
const finding = value;
|
|
52
|
+
const key = JSON.stringify([finding.requirement, finding.locations, finding.problem, finding.verification]).toLowerCase().replace(/\s+/g, " ");
|
|
53
|
+
const previous = prior.get(key);
|
|
54
|
+
prior.set(key, { occurrenceId: row.report_occurrence_id, ordinal, reviewNumber: row.review_number, disposition: row.disposition });
|
|
55
|
+
const previousAttempt = previous ? scoped("qa_review_attempts").find(attempt => attempt.review_number === previous.reviewNumber) : undefined;
|
|
56
|
+
const linkedRemediations = previousAttempt ? scoped("qa_remediation_attempts").filter(remediation => remediation.review_attempt_id === previousAttempt.attempt_id).map(remediation => ({ attemptId: remediation.attempt_id, status: remediation.status, responseDigest: remediation.response_digest })) : [];
|
|
57
|
+
return { ordinal, rawId: finding.id, label: previous ? "possibly_recurring" : "newly_observed", confidence: previous ? "exact_normalized_content" : "unestablished", previous, linkedRemediations, limitation: "Matching content is correlation; review-local IDs and changed source digests do not prove issue identity or exact code changes" };
|
|
58
|
+
});
|
|
59
|
+
events.push({ kind: "report", id: row.report_occurrence_id ?? row.report_digest, reviewNumber: row.review_number, reportDigest: row.report_digest, disposition: row.disposition, correlations, sequence: Number(row.review_number), order: 2 });
|
|
60
|
+
}
|
|
61
|
+
for (const table of ["qa_transitions", "qa_pass_certificates", "qa_finalization_steps", "qa_recovery_heads"])
|
|
62
|
+
for (const row of scoped(table)) {
|
|
63
|
+
events.push({ kind: table, id: row.operation_id ?? row.certificate_id ?? row.sequence ?? row.packet_id, revision: row.to_revision ?? row.qa_revision ?? row.revision, state: row.status ?? row.pending_action, event: row.event_json ? evidenceRecord(row, "event_json") : undefined, certificateId: row.certificate_id, sourceDigest: row.source_state_digest ?? row.reviewed_state_digest, sequence: Number(row.sequence ?? row.qa_revision ?? row.revision), order: 4 });
|
|
64
|
+
}
|
|
65
|
+
for (const row of snapshot.rows.human_decisions ?? []) {
|
|
66
|
+
const decision = evidenceRecord(row, "decision_json");
|
|
67
|
+
if (!decisionBelongsToWork(decision, snapshot.runId, workId, (snapshot.rows.qa_remediation_stops ?? []).map(stop => ({ runId: String(stop.run_id), workId: String(stop.ticket_id), operationId: String(stop.operation_id), decisionId: evidenceRecord(stop)?.decisionId }))))
|
|
68
|
+
continue;
|
|
69
|
+
events.push({ kind: "human_decision", id: row.decision_id, status: row.status, decision, sequence: -1, order: 5 });
|
|
70
|
+
}
|
|
71
|
+
for (const row of snapshot.rows.operation_journal ?? []) {
|
|
72
|
+
const intent = evidenceRecord(row, "intent_json");
|
|
73
|
+
if (row.kind !== "build-assignment" || intent?.ticketId !== workId)
|
|
74
|
+
continue;
|
|
75
|
+
const result = evidenceRecord(row, "result_json");
|
|
76
|
+
const diffReferences = [];
|
|
77
|
+
for (const side of ["before", "after"]) {
|
|
78
|
+
const source = (side === "before" ? intent?.before : result?.after);
|
|
79
|
+
if (!source)
|
|
80
|
+
continue;
|
|
81
|
+
for (const kind of ["staged", "unstaged"])
|
|
82
|
+
if (source[`${kind}DiffDigest`] || Array.isArray(source[`${kind}DiffChunks`]))
|
|
83
|
+
diffReferences.push({ id: `${row.idempotency_key}:${side}:${kind}`, sourceDigest: source.digest, byteLength: source[`${kind}DiffBytes`], digest: source[`${kind}DiffDigest`], chunks: source[`${kind}DiffChunks`] });
|
|
84
|
+
for (const [index, file] of (source.untracked ?? []).entries())
|
|
85
|
+
diffReferences.push({ id: `${row.idempotency_key}:${side}:untracked-${index}`, sourceDigest: source.digest, path: file.path, byteLength: file.byteLength, digest: file.evidenceDigest, chunks: file.evidenceChunks });
|
|
86
|
+
}
|
|
87
|
+
events.push({ kind: "builder_assignment", id: row.idempotency_key, status: row.status, intent, result, diffReferences, sequence: -1, order: 5 });
|
|
88
|
+
}
|
|
89
|
+
for (const row of snapshot.rows.continuity_events ?? []) {
|
|
90
|
+
const payload = evidenceRecord(row, "payload_json");
|
|
91
|
+
if (payload?.ticketId !== workId && payload?.ticket !== workId)
|
|
92
|
+
continue;
|
|
93
|
+
events.push({ kind: "continuity", id: row.sequence, type: row.kind, payload, sequence: row.sequence, order: 5 });
|
|
94
|
+
}
|
|
95
|
+
const occurrences = new Set(scoped("qa_reports").map(row => row.report_occurrence_id));
|
|
96
|
+
for (const table of ["qa_report_dispositions", "qa_report_chains", "qa_delivery_turns"])
|
|
97
|
+
for (const row of snapshot.rows[table] ?? []) {
|
|
98
|
+
if (!occurrences.has(row.report_occurrence_id ?? row.predecessor_occurrence_id))
|
|
99
|
+
continue;
|
|
100
|
+
const record = evidenceRecord(row);
|
|
101
|
+
const parsed = typeof record?.parsedResponseDigest === "string" ? snapshot.blobs.get(record.parsedResponseDigest) : undefined;
|
|
102
|
+
let reportedFindings;
|
|
103
|
+
if (parsed)
|
|
104
|
+
try {
|
|
105
|
+
reportedFindings = JSON.parse(parsed.toString("utf8")).findings;
|
|
106
|
+
}
|
|
107
|
+
catch { /* snapshot discloses retained corruption */ }
|
|
108
|
+
events.push({ kind: table, id: row.sequence ?? row.turn_record_id ?? row.successor_occurrence_id, ...row, record, reportedFindings, applicationEvidence: "Builder-reported dispositions; independent source-bound QA establishes verification", providerElapsedMs: record?.providerElapsedMs ?? null, sequence: Number(row.sequence ?? -1), order: 6 });
|
|
109
|
+
}
|
|
110
|
+
for (const row of scoped("qa_source_states"))
|
|
111
|
+
events.push({ kind: "source_basis", id: row.digest, originDigest: row.origin_digest, contentDigest: row.content_digest, paths: evidenceRecord(row, "state_json")?.paths, limitation: "Content changes require retained diff evidence for exact code-change explanations", sequence: -1, order: 6 });
|
|
112
|
+
for (const row of snapshot.rows.build_instructions ?? [])
|
|
113
|
+
if (row.work_id === workId)
|
|
114
|
+
events.push({ kind: "manager_instruction", id: row.instruction_id, record: evidenceRecord(row), deliveries: (snapshot.rows.build_instruction_deliveries ?? []).filter(delivery => delivery.instruction_id === row.instruction_id).map(delivery => evidenceRecord(delivery)), sequence: Number(row.sequence), order: 6 });
|
|
115
|
+
const committed = (snapshot.rows.build_work_events ?? []).filter(row => row.work_id === workId).sort((a, b) => Number(a.sequence) - Number(b.sequence));
|
|
116
|
+
if (committed.length) {
|
|
117
|
+
const ordered = committed.map(row => ({ kind: "committed_work_event", id: row.sequence, commitSequence: row.sequence, table: row.table_name, recordKey: row.record_key, action: row.action, stateAtEvent: evidenceRecord(row), createdAt: row.created_at, ordering: "durable_commit_sequence" }));
|
|
118
|
+
return { counters, events: [...ordered, ...events.map(event => ({ ...event, ordering: "snapshot_record_or_legacy_history", commitSequence: null }))], gaps: [...snapshot.gaps, "Snapshot records and pre-upgrade history are separately labeled; only indexed events have a proven cross-table commit order. Exact fixes require retained diffs; absent timing evidence leaves duration unknown."] };
|
|
119
|
+
}
|
|
120
|
+
// Independent event streams retain their own explicit sequence; timestamps do not establish ownership.
|
|
121
|
+
events.sort((a, b) => Number(a.sequence) - Number(b.sequence) || Number(a.order) - Number(b.order) || String(a.id).localeCompare(String(b.id)));
|
|
122
|
+
return { counters, events, gaps: [...snapshot.gaps, "No retained diff is interpreted as proof of the exact fix; durations are unavailable without correlated timing evidence"] };
|
|
123
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EquivalentAuthorityResolver } from "./qaAuthorizedDisposition.js";
|
|
2
|
+
import type { QaContractCandidateV1, QaVerificationContractV1, QaSemanticAssessmentV1, QaChallengeReceiptV1, ContractInputRef, RequirementRef } from "rafi-spec";
|
|
3
|
+
import type { TicketDef } from "./tickets/ticketSchema.js";
|
|
4
|
+
export declare function canonicalContractJson(value: unknown): string;
|
|
5
|
+
export declare function contractDigest(domain: string, value: unknown): string;
|
|
6
|
+
export declare function candidatePayload(contract: QaContractCandidateV1 | QaVerificationContractV1): QaContractCandidateV1;
|
|
7
|
+
export declare function draftDigest(candidate: QaContractCandidateV1): string;
|
|
8
|
+
/** Allocate identities from meaning, replacing proposed agent IDs throughout the draft. */
|
|
9
|
+
export declare function allocateStableCheckIds(candidate: QaContractCandidateV1): QaContractCandidateV1;
|
|
10
|
+
export declare function inventoryDigest(requirements: RequirementRef[]): string;
|
|
11
|
+
export declare function authoritativeInventory(ticket: TicketDef, checklist: string[], inputs: ContractInputRef[]): RequirementRef[];
|
|
12
|
+
export declare function parsePreparationArtifact<T>(text: string, tag: string, maximum?: number): T;
|
|
13
|
+
export declare function validateCandidate(candidate: QaContractCandidateV1, inventory: RequirementRef[], resolveEquivalent?: EquivalentAuthorityResolver): string[];
|
|
14
|
+
export declare function validateAssessment(candidate: QaContractCandidateV1, assessment: QaSemanticAssessmentV1, authorSessionId: string): string[];
|
|
15
|
+
export declare function assembleContract(candidate: QaContractCandidateV1, inventory: RequirementRef[], assessment: QaSemanticAssessmentV1, authorSessionId: string, challenge?: QaChallengeReceiptV1, resolveEquivalent?: EquivalentAuthorityResolver): QaVerificationContractV1;
|
|
16
|
+
export declare function verifyContractDigest(contract: QaVerificationContractV1): void;
|
|
17
|
+
export declare function renderVerificationContract(contract: QaVerificationContractV1): string;
|