audit-tools 0.32.20 → 0.32.22
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/dist/audit/clarification/blastRadius.d.ts +23 -0
- package/dist/audit/clarification/blastRadius.d.ts.map +1 -0
- package/dist/audit/clarification/blastRadius.js +65 -0
- package/dist/audit/clarification/blastRadius.js.map +1 -0
- package/dist/audit/clarification/dials.d.ts +28 -0
- package/dist/audit/clarification/dials.d.ts.map +1 -0
- package/dist/audit/clarification/dials.js +37 -0
- package/dist/audit/clarification/dials.js.map +1 -0
- package/dist/audit/clarification/partition.d.ts +25 -0
- package/dist/audit/clarification/partition.d.ts.map +1 -0
- package/dist/audit/clarification/partition.js +77 -0
- package/dist/audit/clarification/partition.js.map +1 -0
- package/dist/audit/clarification/riskGate.d.ts +24 -0
- package/dist/audit/clarification/riskGate.d.ts.map +1 -0
- package/dist/audit/clarification/riskGate.js +38 -0
- package/dist/audit/clarification/riskGate.js.map +1 -0
- package/dist/audit/clarification/voiQueue.d.ts +17 -0
- package/dist/audit/clarification/voiQueue.d.ts.map +1 -0
- package/dist/audit/clarification/voiQueue.js +37 -0
- package/dist/audit/clarification/voiQueue.js.map +1 -0
- package/dist/audit/cli/auditStep.d.ts +2 -0
- package/dist/audit/cli/auditStep.d.ts.map +1 -1
- package/dist/audit/cli/auditStep.js +9 -1
- package/dist/audit/cli/auditStep.js.map +1 -1
- package/dist/audit/cli/charterClarificationPrompt.d.ts +19 -0
- package/dist/audit/cli/charterClarificationPrompt.d.ts.map +1 -0
- package/dist/audit/cli/charterClarificationPrompt.js +60 -0
- package/dist/audit/cli/charterClarificationPrompt.js.map +1 -0
- package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
- package/dist/audit/cli/nextStepCommand.js +74 -0
- package/dist/audit/cli/nextStepCommand.js.map +1 -1
- package/dist/audit/cli/nextStepHelpers.d.ts +62 -0
- package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
- package/dist/audit/cli/nextStepHelpers.js +129 -0
- package/dist/audit/cli/nextStepHelpers.js.map +1 -1
- package/dist/audit/cli/steps.d.ts +4 -4
- package/dist/audit/cli/steps.d.ts.map +1 -1
- package/dist/audit/cli/steps.js +2 -0
- package/dist/audit/cli/steps.js.map +1 -1
- package/dist/audit/io/artifacts.d.ts +6 -0
- package/dist/audit/io/artifacts.d.ts.map +1 -1
- package/dist/audit/io/artifacts.js +2 -0
- package/dist/audit/io/artifacts.js.map +1 -1
- package/dist/audit/orchestrator/advance.d.ts +23 -0
- package/dist/audit/orchestrator/advance.d.ts.map +1 -1
- package/dist/audit/orchestrator/advance.js +122 -4
- package/dist/audit/orchestrator/advance.js.map +1 -1
- package/dist/audit/orchestrator/advanceTypes.d.ts +22 -1
- package/dist/audit/orchestrator/advanceTypes.d.ts.map +1 -1
- package/dist/audit/orchestrator/charterClarificationExecutor.d.ts +28 -0
- package/dist/audit/orchestrator/charterClarificationExecutor.d.ts.map +1 -0
- package/dist/audit/orchestrator/charterClarificationExecutor.js +130 -0
- package/dist/audit/orchestrator/charterClarificationExecutor.js.map +1 -0
- package/dist/audit/orchestrator/dependencyMap.d.ts +2 -0
- package/dist/audit/orchestrator/dependencyMap.d.ts.map +1 -1
- package/dist/audit/orchestrator/dependencyMap.js +24 -0
- package/dist/audit/orchestrator/dependencyMap.js.map +1 -1
- package/dist/audit/orchestrator/designReviewPrompt.d.ts +17 -0
- package/dist/audit/orchestrator/designReviewPrompt.d.ts.map +1 -1
- package/dist/audit/orchestrator/designReviewPrompt.js +50 -0
- package/dist/audit/orchestrator/designReviewPrompt.js.map +1 -1
- package/dist/audit/orchestrator/executorRunners.d.ts.map +1 -1
- package/dist/audit/orchestrator/executorRunners.js +4 -0
- package/dist/audit/orchestrator/executorRunners.js.map +1 -1
- package/dist/audit/orchestrator/executors.d.ts.map +1 -1
- package/dist/audit/orchestrator/executors.js +20 -0
- package/dist/audit/orchestrator/executors.js.map +1 -1
- package/dist/audit/orchestrator/nextStep.d.ts +11 -1
- package/dist/audit/orchestrator/nextStep.d.ts.map +1 -1
- package/dist/audit/orchestrator/nextStep.js +6 -2
- package/dist/audit/orchestrator/nextStep.js.map +1 -1
- package/dist/audit/orchestrator/staleness.d.ts +27 -1
- package/dist/audit/orchestrator/staleness.d.ts.map +1 -1
- package/dist/audit/orchestrator/staleness.js +33 -16
- package/dist/audit/orchestrator/staleness.js.map +1 -1
- package/dist/audit/orchestrator/state.d.ts +12 -1
- package/dist/audit/orchestrator/state.d.ts.map +1 -1
- package/dist/audit/orchestrator/state.js +41 -2
- package/dist/audit/orchestrator/state.js.map +1 -1
- package/dist/audit/orchestrator/synthesisExecutors.d.ts.map +1 -1
- package/dist/audit/orchestrator/synthesisExecutors.js +1 -0
- package/dist/audit/orchestrator/synthesisExecutors.js.map +1 -1
- package/dist/audit/orchestrator/systemicChallengeExecutor.d.ts +25 -0
- package/dist/audit/orchestrator/systemicChallengeExecutor.d.ts.map +1 -0
- package/dist/audit/orchestrator/systemicChallengeExecutor.js +107 -0
- package/dist/audit/orchestrator/systemicChallengeExecutor.js.map +1 -0
- package/dist/audit/reporting/mergeFindings.d.ts +2 -1
- package/dist/audit/reporting/mergeFindings.d.ts.map +1 -1
- package/dist/audit/reporting/mergeFindings.js +6 -1
- package/dist/audit/reporting/mergeFindings.js.map +1 -1
- package/dist/audit/reporting/synthesis.d.ts +2 -0
- package/dist/audit/reporting/synthesis.d.ts.map +1 -1
- package/dist/audit/reporting/synthesis.js +1 -1
- package/dist/audit/reporting/synthesis.js.map +1 -1
- package/dist/audit/systemic/aggregateMetricsDigest.d.ts +11 -0
- package/dist/audit/systemic/aggregateMetricsDigest.d.ts.map +1 -0
- package/dist/audit/systemic/aggregateMetricsDigest.js +84 -0
- package/dist/audit/systemic/aggregateMetricsDigest.js.map +1 -0
- package/dist/audit/systemic/metricsDigestTypes.d.ts +35 -0
- package/dist/audit/systemic/metricsDigestTypes.d.ts.map +1 -0
- package/dist/audit/systemic/metricsDigestTypes.js +10 -0
- package/dist/audit/systemic/metricsDigestTypes.js.map +1 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.d.ts +16 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.d.ts.map +1 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.js +85 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.js.map +1 -0
- package/dist/audit/systemic/systemicChallengeLoop.d.ts +48 -0
- package/dist/audit/systemic/systemicChallengeLoop.d.ts.map +1 -0
- package/dist/audit/systemic/systemicChallengeLoop.js +107 -0
- package/dist/audit/systemic/systemicChallengeLoop.js.map +1 -0
- package/dist/audit/types/charterClarification.d.ts +52 -0
- package/dist/audit/types/charterClarification.d.ts.map +1 -0
- package/dist/audit/types/charterClarification.js +2 -0
- package/dist/audit/types/charterClarification.js.map +1 -0
- package/dist/audit/types/systemicChallenge.d.ts +69 -0
- package/dist/audit/types/systemicChallenge.d.ts.map +1 -0
- package/dist/audit/types/systemicChallenge.js +2 -0
- package/dist/audit/types/systemicChallenge.js.map +1 -0
- package/dist/remediate/steps/dispatch/acceptNode.d.ts +178 -0
- package/dist/remediate/steps/dispatch/acceptNode.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/acceptNode.js +351 -0
- package/dist/remediate/steps/dispatch/acceptNode.js.map +1 -0
- package/dist/remediate/steps/dispatch/common.d.ts +123 -0
- package/dist/remediate/steps/dispatch/common.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/common.js +236 -0
- package/dist/remediate/steps/dispatch/common.js.map +1 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.d.ts +37 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.js +19 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.js.map +1 -0
- package/dist/remediate/steps/dispatch/implementPrompt.d.ts +70 -0
- package/dist/remediate/steps/dispatch/implementPrompt.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/implementPrompt.js +577 -0
- package/dist/remediate/steps/dispatch/implementPrompt.js.map +1 -0
- package/dist/remediate/steps/dispatch/marshal.d.ts +32 -0
- package/dist/remediate/steps/dispatch/marshal.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/marshal.js +935 -0
- package/dist/remediate/steps/dispatch/marshal.js.map +1 -0
- package/dist/remediate/steps/dispatch/verifyCommands.d.ts +97 -0
- package/dist/remediate/steps/dispatch/verifyCommands.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/verifyCommands.js +204 -0
- package/dist/remediate/steps/dispatch/verifyCommands.js.map +1 -0
- package/dist/remediate/steps/dispatch/waveScheduling.d.ts +90 -0
- package/dist/remediate/steps/dispatch/waveScheduling.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/waveScheduling.js +352 -0
- package/dist/remediate/steps/dispatch/waveScheduling.js.map +1 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.d.ts +189 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.js +643 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.js.map +1 -0
- package/dist/remediate/steps/dispatch/writeScope.d.ts +151 -0
- package/dist/remediate/steps/dispatch/writeScope.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/writeScope.js +376 -0
- package/dist/remediate/steps/dispatch/writeScope.js.map +1 -0
- package/dist/remediate/steps/dispatch.d.ts +16 -871
- package/dist/remediate/steps/dispatch.d.ts.map +1 -1
- package/dist/remediate/steps/dispatch.js +18 -3623
- package/dist/remediate/steps/dispatch.js.map +1 -1
- package/dist/shared/decompose/charterClarification.d.ts +80 -0
- package/dist/shared/decompose/charterClarification.d.ts.map +1 -0
- package/dist/shared/decompose/charterClarification.js +105 -0
- package/dist/shared/decompose/charterClarification.js.map +1 -0
- package/dist/shared/decompose/systemicChallenge.d.ts +326 -0
- package/dist/shared/decompose/systemicChallenge.d.ts.map +1 -0
- package/dist/shared/decompose/systemicChallenge.js +30 -0
- package/dist/shared/decompose/systemicChallenge.js.map +1 -0
- package/dist/shared/index.d.ts +10 -5
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/index.js +6 -4
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/opencodePermissions.d.ts +73 -0
- package/dist/shared/opencodePermissions.d.ts.map +1 -1
- package/dist/shared/opencodePermissions.js +172 -0
- package/dist/shared/opencodePermissions.js.map +1 -1
- package/dist/shared/tooling/exec.d.ts +10 -1
- package/dist/shared/tooling/exec.d.ts.map +1 -1
- package/dist/shared/tooling/exec.js +13 -1
- package/dist/shared/tooling/exec.js.map +1 -1
- package/dist/shared/types/charter.d.ts +89 -0
- package/dist/shared/types/charter.d.ts.map +1 -1
- package/dist/shared/types/charter.js +68 -0
- package/dist/shared/types/charter.js.map +1 -1
- package/dist/shared/types/intentCheckpoint.d.ts +15 -0
- package/dist/shared/types/intentCheckpoint.d.ts.map +1 -1
- package/dist/shared/types/intentCheckpoint.js +11 -0
- package/dist/shared/types/intentCheckpoint.js.map +1 -1
- package/dist/shared/validation/charterGate.d.ts +20 -1
- package/dist/shared/validation/charterGate.d.ts.map +1 -1
- package/dist/shared/validation/charterGate.js +23 -0
- package/dist/shared/validation/charterGate.js.map +1 -1
- package/opencode.json +36 -20
- package/package.json +1 -1
- package/scripts/postinstall.mjs +2 -1
- package/wrapper/audit-code-wrapper-opencode.mjs +38 -12
- package/wrapper/remediate-code-wrapper-opencode.mjs +38 -12
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
import { mkdir, rename } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { OwnershipRegistry } from "../../dispatch/ownershipRegistry.js";
|
|
5
|
+
import { routeAmendmentRequest } from "../../dispatch/amendmentClaim.js";
|
|
6
|
+
import { fromBlockId } from "../../contractPipeline/idRegistry.js";
|
|
7
|
+
import { readContractArtifact } from "../../contractPipeline/artifactStore.js";
|
|
8
|
+
import { verifyPairingForFinding } from "../../contractPipeline/changeClassification.js";
|
|
9
|
+
import { StateStore } from "../../state/store.js";
|
|
10
|
+
import { REMEDIATION_STEP, isClarificationCategory, } from "../../state/types.js";
|
|
11
|
+
import { captureStepBoundaryFriction } from "audit-tools/shared";
|
|
12
|
+
import { readConfirmedCostPositions } from "audit-tools/shared";
|
|
13
|
+
import { AGENT_FEEDBACK_FILENAME, readJsonFile, readOptionalJsonFile, writeJsonFile, writeTextFile, withFsRetry, formatValidationIssues, toPromptPathToken, } from "audit-tools/shared";
|
|
14
|
+
import { validateImplementWorkerResult } from "../../validation/artifacts.js";
|
|
15
|
+
import { REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION, } from "../types.js";
|
|
16
|
+
import { specIndicatesNoChange, hasExecutableEvidence, dependencyVerifiedComplete, } from "../stepUtils.js";
|
|
17
|
+
import { isTerminalStatus, isVerifiedCompleteStatus, } from "../../state/itemStatus.js";
|
|
18
|
+
import { resnapshotAffectedFileHashes } from "../../utils/fileIntegrity.js";
|
|
19
|
+
import { createReservationLedger } from "audit-tools/shared";
|
|
20
|
+
import { runDir, dispatchPlanPath, getCachedConventions, estimateImplementSlotTokens, worktreeBranchForBlock, gitBranchExists, gitEditedFilesForBranch, gitHunksForBranch, toRepoRelative, } from "./common.js";
|
|
21
|
+
import { worktreePath, ensureRemediationBranchCheckedOut, } from "./worktreeLifecycle.js";
|
|
22
|
+
import { scheduleWave, buildDispatchQuota } from "./waveScheduling.js";
|
|
23
|
+
import { buildBlockAliasMap, collapseItemResults, buildNodeDisposition, attributeSiblingRed, detectOverlappingEdits, } from "./writeScope.js";
|
|
24
|
+
import { buildImplementDispatchItem, blockReadFiles, detectPackageRoot, buildTestFileIndex, collectReferencingTests, implementPrompt, } from "./implementPrompt.js";
|
|
25
|
+
import { loadNodeAcceptOutcome } from "./acceptNode.js";
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Marshalling helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
function uniquePaths(paths) {
|
|
30
|
+
return [...new Set(paths)];
|
|
31
|
+
}
|
|
32
|
+
function markStarted(item) {
|
|
33
|
+
item.started_at ??= new Date().toISOString();
|
|
34
|
+
delete item.completed_at;
|
|
35
|
+
}
|
|
36
|
+
function markTerminal(item) {
|
|
37
|
+
const now = new Date().toISOString();
|
|
38
|
+
item.started_at ??= now;
|
|
39
|
+
item.completed_at = now;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Load a worker's already-written implement result, distinguishing an ABSENT
|
|
43
|
+
* file (the worker hasn't run yet → re-dispatch from scratch) from a PRESENT but
|
|
44
|
+
* INVALID one (the worker ran but emitted malformed/unparseable JSON or a result
|
|
45
|
+
* that fails the contract). A bare `catch → undefined` conflated the two: a
|
|
46
|
+
* written-but-invalid file looked identical to "never produced", so the merge
|
|
47
|
+
* loop silently `continue`d past the block (the missing-file branch never fires
|
|
48
|
+
* because the file DOES exist) and the node could neither converge nor surface
|
|
49
|
+
* the corruption. We now archive the invalid file (so a clean re-dispatch can
|
|
50
|
+
* write a fresh one) and report it loudly, returning `undefined` only for the
|
|
51
|
+
* genuinely-absent case.
|
|
52
|
+
*/
|
|
53
|
+
async function tryLoadExistingImplementResult(resultPath) {
|
|
54
|
+
if (!existsSync(resultPath))
|
|
55
|
+
return undefined;
|
|
56
|
+
try {
|
|
57
|
+
const result = await readJsonFile(resultPath);
|
|
58
|
+
assertImplementWorkerResult(result, resultPath);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// Present but invalid: do NOT treat it as absent (which would let the block
|
|
63
|
+
// be silently dropped from the merge). Archive the corrupt file and surface
|
|
64
|
+
// the reason so a clean re-dispatch produces a valid result.
|
|
65
|
+
process.stderr.write(`[remediate-code] dispatch: existing implement result ${resultPath} is present but ` +
|
|
66
|
+
`invalid (${err instanceof Error ? err.message : String(err)}); archiving and re-dispatching\n`);
|
|
67
|
+
await archiveIncompleteImplementResult(resultPath);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function pendingOrDocumentedFindingIdsForBlock(block, state) {
|
|
72
|
+
return block.items.filter((findingId) => {
|
|
73
|
+
const item = state.items?.[findingId];
|
|
74
|
+
return item?.status === "pending" && !isTerminalStatus(item.status);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Bound on incomplete-coverage re-dispatch (E2): after this many merges observe a
|
|
79
|
+
* worker silently omitting an assigned finding from its `item_results`, the finding
|
|
80
|
+
* is blocked (→ triage) so the run converges instead of re-dispatching the same
|
|
81
|
+
* worker indefinitely. Mirrors the other small convergence caps (DAG/cyclic-seam = 2).
|
|
82
|
+
*/
|
|
83
|
+
const MAX_INCOMPLETE_COVERAGE_ATTEMPTS = 2;
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the set of finding ids a worker result actually covers, alias-aware:
|
|
86
|
+
* a worker may legitimately report a finding by its block id or an obligation
|
|
87
|
+
* alias (the exact resolution `collapseItemResults` applies). Coverage/completeness
|
|
88
|
+
* decisions MUST use this — a raw `finding_id` set would treat an alias-using-but-
|
|
89
|
+
* complete result as incomplete and re-dispatch it forever.
|
|
90
|
+
*/
|
|
91
|
+
function resolveCoveredFindingIds(result, block, state) {
|
|
92
|
+
const knownFindingIds = new Set(Object.keys(state.items ?? {}));
|
|
93
|
+
const aliasMap = buildBlockAliasMap(block, state);
|
|
94
|
+
const covered = new Set();
|
|
95
|
+
for (const entry of result.item_results) {
|
|
96
|
+
let targetId = entry.finding_id;
|
|
97
|
+
if (!knownFindingIds.has(targetId)) {
|
|
98
|
+
const nodeId = fromBlockId(targetId);
|
|
99
|
+
if (nodeId && knownFindingIds.has(nodeId)) {
|
|
100
|
+
targetId = nodeId;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const remapped = aliasMap.get(targetId);
|
|
104
|
+
if (!remapped)
|
|
105
|
+
continue;
|
|
106
|
+
targetId = remapped;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
covered.add(targetId);
|
|
110
|
+
}
|
|
111
|
+
return covered;
|
|
112
|
+
}
|
|
113
|
+
function implementResultCoversFindings(result, findingIds, block, state) {
|
|
114
|
+
const covered = resolveCoveredFindingIds(result, block, state);
|
|
115
|
+
return findingIds.every((findingId) => covered.has(findingId));
|
|
116
|
+
}
|
|
117
|
+
async function archiveIncompleteImplementResult(resultPath) {
|
|
118
|
+
if (!existsSync(resultPath))
|
|
119
|
+
return;
|
|
120
|
+
const archivedPath = `${resultPath}.stale-${Date.now()}`;
|
|
121
|
+
await withFsRetry(() => rename(resultPath, archivedPath));
|
|
122
|
+
}
|
|
123
|
+
async function loadStateOrThrow(artifactsDir) {
|
|
124
|
+
const state = await new StateStore(artifactsDir).loadState();
|
|
125
|
+
if (!state) {
|
|
126
|
+
throw new Error(`No remediation state found at ${join(artifactsDir, "state.json")}.`);
|
|
127
|
+
}
|
|
128
|
+
return state;
|
|
129
|
+
}
|
|
130
|
+
export async function prepareImplementDispatch(options, runId, onlyBlockId, waveOptions) {
|
|
131
|
+
const state = await loadStateOrThrow(options.artifactsDir);
|
|
132
|
+
if (!state.plan || !state.items) {
|
|
133
|
+
throw new Error("Cannot prepare implement dispatch without plan and items.");
|
|
134
|
+
}
|
|
135
|
+
const dir = runDir(options.artifactsDir, runId, "implement");
|
|
136
|
+
await mkdir(dir, { recursive: true });
|
|
137
|
+
// Use the module-level cache so repeated calls within the same process do not
|
|
138
|
+
// re-scan the filesystem for repo conventions.
|
|
139
|
+
const conventions = getCachedConventions(options.root);
|
|
140
|
+
const seenBlockIds = new Set();
|
|
141
|
+
const candidateBlocks = state.plan.blocks.filter((block) => {
|
|
142
|
+
if (onlyBlockId && block.block_id !== onlyBlockId)
|
|
143
|
+
return false;
|
|
144
|
+
if (seenBlockIds.has(block.block_id))
|
|
145
|
+
return false;
|
|
146
|
+
// Rolling eligibility (INV-RS-01): a dependent node is dispatched only once
|
|
147
|
+
// every prerequisite reached a VERIFIED-COMPLETE disposition
|
|
148
|
+
// (resolved / resolved_no_change). A skipped or blocked prerequisite never
|
|
149
|
+
// satisfies the edge, so the dependent is held back rather than racing the
|
|
150
|
+
// main tree against an upstream surface that never landed.
|
|
151
|
+
if (!dependencyVerifiedComplete(block, state))
|
|
152
|
+
return false;
|
|
153
|
+
const hasWork = block.items.some((findingId) => {
|
|
154
|
+
const item = state.items?.[findingId];
|
|
155
|
+
return item?.status === "pending";
|
|
156
|
+
});
|
|
157
|
+
if (hasWork) {
|
|
158
|
+
seenBlockIds.add(block.block_id);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
});
|
|
163
|
+
// Before any node is dispatched (and therefore before any accepted commit is
|
|
164
|
+
// cherry-picked into the main tree), switch the main checkout onto the dedicated
|
|
165
|
+
// remediation branch so all landed work accumulates there and the base branch is
|
|
166
|
+
// never modified. Idempotent across waves; only when there is work to land.
|
|
167
|
+
if (candidateBlocks.length > 0 && options.root) {
|
|
168
|
+
ensureRemediationBranchCheckedOut(options.root, runId, options.artifactsDir);
|
|
169
|
+
}
|
|
170
|
+
// Walk the repo for test files ONCE per dispatch (not once per block) and cache
|
|
171
|
+
// their contents; collectReferencingTests then matches in memory.
|
|
172
|
+
const testIndex = buildTestFileIndex(options.root);
|
|
173
|
+
const items = [];
|
|
174
|
+
const itemReadFileLists = [];
|
|
175
|
+
let reconciledCount = 0;
|
|
176
|
+
for (const block of candidateBlocks) {
|
|
177
|
+
const item = buildImplementDispatchItem(block, state, dir);
|
|
178
|
+
const readFiles = blockReadFiles(block, state);
|
|
179
|
+
// Detect the package root from this block's source files: walk up from the
|
|
180
|
+
// first source file to the nearest ancestor with a package.json (stop at root).
|
|
181
|
+
const packageRoot = detectPackageRoot(readFiles, options.root);
|
|
182
|
+
// Pull test files that reference this block's source into its access, so the
|
|
183
|
+
// worker that changes or removes a symbol also fixes the tests that assert it
|
|
184
|
+
// (otherwise their breakage is orphaned for a separate central mop-up).
|
|
185
|
+
// Scoped to the block's package to avoid pulling in unrelated package tests.
|
|
186
|
+
const referencingTests = collectReferencingTests(testIndex, readFiles, packageRoot);
|
|
187
|
+
if (referencingTests.length > 0 && item.access) {
|
|
188
|
+
item.access.read_paths = [
|
|
189
|
+
...new Set([...item.access.read_paths, ...referencingTests]),
|
|
190
|
+
];
|
|
191
|
+
item.access.write_paths = [
|
|
192
|
+
...new Set([...item.access.write_paths, ...referencingTests]),
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
// Reconcile an already-produced result regardless of wave packing.
|
|
196
|
+
const pendingFindingIds = pendingOrDocumentedFindingIdsForBlock(block, state);
|
|
197
|
+
const existingResult = await tryLoadExistingImplementResult(item.result_path);
|
|
198
|
+
if (existingResult) {
|
|
199
|
+
if (implementResultCoversFindings(existingResult, pendingFindingIds, block, state)) {
|
|
200
|
+
reconciledCount++;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
process.stderr.write(`[remediate-code] dispatch: existing implement result for block ${block.block_id} ` +
|
|
204
|
+
`does not cover ${pendingFindingIds.length} still-pending item(s); re-dispatching\n`);
|
|
205
|
+
await archiveIncompleteImplementResult(item.result_path);
|
|
206
|
+
}
|
|
207
|
+
// No wave-time file-conflict deferral heuristic: parallel blocks with
|
|
208
|
+
// overlapping files are both dispatched. Parallel safety comes from the planner
|
|
209
|
+
// (mergeBlocksSharingFiles) and rolling verified-complete dependency ordering
|
|
210
|
+
// (dependencyVerifiedComplete). Workers operate in isolated worktrees;
|
|
211
|
+
// verification prevents bad merges from dirtying the main tree.
|
|
212
|
+
await writeTextFile(item.prompt_path, implementPrompt(block, state, item.result_path, conventions, options.root, toPromptPathToken(join(options.artifactsDir, AGENT_FEEDBACK_FILENAME)), waveOptions?.worktreeRootedPrompts
|
|
213
|
+
? worktreePath(options.root, block.block_id, runId)
|
|
214
|
+
: undefined));
|
|
215
|
+
items.push(item);
|
|
216
|
+
itemReadFileLists.push([...readFiles, ...referencingTests]);
|
|
217
|
+
}
|
|
218
|
+
if (reconciledCount > 0) {
|
|
219
|
+
console.log(`Reconciliation: reused ${reconciledCount} existing implement results.`);
|
|
220
|
+
}
|
|
221
|
+
const plan = {
|
|
222
|
+
contract_version: REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION,
|
|
223
|
+
phase: "implement",
|
|
224
|
+
run_id: runId,
|
|
225
|
+
// Normalize to forward slashes so hosts running bash-like shells on Windows
|
|
226
|
+
// receive paths that survive shell expansion (backslash is an escape char).
|
|
227
|
+
repo_root: toPromptPathToken(options.root),
|
|
228
|
+
artifacts_dir: toPromptPathToken(options.artifactsDir),
|
|
229
|
+
items,
|
|
230
|
+
};
|
|
231
|
+
await writeJsonFile(dispatchPlanPath(options.artifactsDir, runId, "implement"), plan);
|
|
232
|
+
const estimatedSlotTokens = itemReadFileLists.map((files) => estimateImplementSlotTokens(files, options.root));
|
|
233
|
+
const schedule = await scheduleWave({
|
|
234
|
+
hostMaxConcurrent: waveOptions?.hostMaxConcurrent,
|
|
235
|
+
sessionConfig: waveOptions?.sessionConfig ?? null,
|
|
236
|
+
hostContextTokens: waveOptions?.hostContextTokens,
|
|
237
|
+
hostOutputTokens: waveOptions?.hostOutputTokens,
|
|
238
|
+
hostModels: waveOptions?.hostModels,
|
|
239
|
+
hostModelId: waveOptions?.hostModelId,
|
|
240
|
+
itemCount: items.length,
|
|
241
|
+
estimatedSlotTokens,
|
|
242
|
+
});
|
|
243
|
+
// Admission packets in plan order: id = the node's block id (what
|
|
244
|
+
// `admission.granted_packet_ids` references and the host matches to nodes),
|
|
245
|
+
// inputTokens = its estimated slot cost, complexity = the remediate default 0.5.
|
|
246
|
+
// Keyed by block_id (same filter the frontier builder uses), so a granted id always
|
|
247
|
+
// resolves to a frontier node.
|
|
248
|
+
const admissionPackets = items
|
|
249
|
+
.map((item, i) => ({ item, inputTokens: estimatedSlotTokens[i] ?? 0 }))
|
|
250
|
+
.filter((p) => typeof p.item.block_id === "string")
|
|
251
|
+
.map((p) => ({ id: p.item.block_id, inputTokens: p.inputTokens, complexity: 0.5 }));
|
|
252
|
+
process.stderr.write(`[remediate-code] dispatch: implement ${items.length} item(s) ` +
|
|
253
|
+
`source=${schedule.source} cap=${schedule.binding_cap ?? "none"}\n`);
|
|
254
|
+
// Cost-first routing rung 1: honor the operator-confirmed cost ordering from the
|
|
255
|
+
// shared Gate-0 confirmation (spec/cost-first-routing.md). Best-effort — absent /
|
|
256
|
+
// unreadable / roster-changed confirmation ⇒ costRank falls to real price then tier.
|
|
257
|
+
const confirmedCostPositions = await readConfirmedCostPositions(options.root, waveOptions?.sessionConfig ?? {});
|
|
258
|
+
const quota = await buildDispatchQuota(runId, "implement", schedule, admissionPackets, waveOptions?.grantLeases ?? true, null, confirmedCostPositions);
|
|
259
|
+
await writeJsonFile(join(dir, "dispatch-quota.json"), quota);
|
|
260
|
+
return plan;
|
|
261
|
+
}
|
|
262
|
+
function assertImplementWorkerResult(value, path) {
|
|
263
|
+
const issues = validateImplementWorkerResult(value, path).filter((i) => i.severity === "error");
|
|
264
|
+
if (issues.length > 0) {
|
|
265
|
+
throw new Error(formatValidationIssues(issues));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The contract-pipeline obligation ids a finding covers — the union of its
|
|
270
|
+
* `contract_obligation_ids` (satisfied) and `verification_obligation_ids`
|
|
271
|
+
* (verified). Empty for audit-findings intake (no contract overlay), so the DC-5
|
|
272
|
+
* verify gate is inert there.
|
|
273
|
+
*/
|
|
274
|
+
function obligationIdsForFinding(state, findingId) {
|
|
275
|
+
const finding = state.plan?.findings.find((f) => f.id === findingId);
|
|
276
|
+
if (!finding)
|
|
277
|
+
return [];
|
|
278
|
+
return [
|
|
279
|
+
...(finding.contract_obligation_ids ?? []),
|
|
280
|
+
...(finding.verification_obligation_ids ?? []),
|
|
281
|
+
];
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Reconcile (free) the reservation-ledger leases the dispatch grant took for this
|
|
285
|
+
* run's granted set — the "reconcile at result-ingest" half of admission control
|
|
286
|
+
* (spec/audit/dispatch-admission-control.md), the remediate analog of audit's
|
|
287
|
+
* `reconcileAdmissionLeases`. The host has now reported the granted set's results, so
|
|
288
|
+
* those reservations are no longer in flight and their budget returns to the shared
|
|
289
|
+
* account for the NEXT grant. Best-effort + token-checked (a missing/already-freed
|
|
290
|
+
* lease is a no-op), so a lost reconcile self-heals via the lease TTL and never blocks
|
|
291
|
+
* the merge. Only the host-subagent grant persists leases (`grantLeases: true`); the
|
|
292
|
+
* in-process path leases per-packet in the engine and reconciles there.
|
|
293
|
+
*/
|
|
294
|
+
async function reconcileAdmissionLeases(artifactsDir, runId) {
|
|
295
|
+
const quotaPath = join(runDir(artifactsDir, runId, "implement"), "dispatch-quota.json");
|
|
296
|
+
const quota = await readOptionalJsonFile(quotaPath);
|
|
297
|
+
const leases = quota?.admission?.leases;
|
|
298
|
+
if (!leases || leases.length === 0)
|
|
299
|
+
return;
|
|
300
|
+
const ledger = createReservationLedger();
|
|
301
|
+
for (const lease of leases) {
|
|
302
|
+
try {
|
|
303
|
+
await ledger.reconcile(lease.resource_key, lease.lease_id);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// Best-effort: the lease TTL reclaims budget if a reconcile is lost.
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
export async function mergeImplementResults(options, runId) {
|
|
311
|
+
// Free the grant's reservation-ledger leases now that the host has reported the
|
|
312
|
+
// granted set's results — returns the reserved budget for the next grant.
|
|
313
|
+
await reconcileAdmissionLeases(options.artifactsDir, runId);
|
|
314
|
+
const plan = await readJsonFile(dispatchPlanPath(options.artifactsDir, runId, "implement"));
|
|
315
|
+
if (plan.contract_version !== REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION ||
|
|
316
|
+
plan.phase !== "implement") {
|
|
317
|
+
throw new Error("Implement dispatch plan has an unsupported contract.");
|
|
318
|
+
}
|
|
319
|
+
const store = new StateStore(options.artifactsDir);
|
|
320
|
+
// OBL-INV-RSD-02 / OBL-SEAM-RSD-04: the entire read-modify-write of state.json
|
|
321
|
+
// is performed under a single held lock via StateStore.mutate, and committed
|
|
322
|
+
// exactly once after the full item loop. No partial state.json write happens
|
|
323
|
+
// mid-loop — a malformed/unknown finding_id no longer leaves a half-applied
|
|
324
|
+
// state (and never throws past the loop; see OBL-INV-RSD-01 below). Evidence
|
|
325
|
+
// artifacts (result_<id>_verify_code_against_documentation.json, the orphan
|
|
326
|
+
// diagnostic) are separate sidecar files, not state.json, so writing them
|
|
327
|
+
// inside the loop does not violate the single-state-commit invariant.
|
|
328
|
+
return store.mutate(async (loaded) => {
|
|
329
|
+
if (!loaded) {
|
|
330
|
+
throw new Error(`No remediation state found at ${join(options.artifactsDir, "state.json")}.`);
|
|
331
|
+
}
|
|
332
|
+
const state = loaded;
|
|
333
|
+
if (!state.items) {
|
|
334
|
+
throw new Error("Cannot merge implement results without items.");
|
|
335
|
+
}
|
|
336
|
+
return mergeImplementResultsIntoState(options, runId, plan, state);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Apply every dispatched implement worker result to `state` (mutated in place)
|
|
341
|
+
* and return it. Runs inside the StateStore.mutate lock so the caller commits
|
|
342
|
+
* the result exactly once (OBL-INV-RSD-02 / OBL-SEAM-RSD-04). Pure with respect
|
|
343
|
+
* to state.json: it mutates the in-memory `state` and writes only sidecar
|
|
344
|
+
* evidence/diagnostic artifacts.
|
|
345
|
+
*/
|
|
346
|
+
async function mergeImplementResultsIntoState(options, runId, plan, state) {
|
|
347
|
+
if (!state.items) {
|
|
348
|
+
throw new Error("Cannot merge implement results without items.");
|
|
349
|
+
}
|
|
350
|
+
const dir = runDir(options.artifactsDir, runId, "implement");
|
|
351
|
+
// Piece D — quota-paused strand set: block_ids stranded by a `quota_paused`
|
|
352
|
+
// partial-completion terminal (their worker rate-limited on a host session
|
|
353
|
+
// limit). Their result files are legitimately absent, but they are a RETRYABLE
|
|
354
|
+
// pause, NOT a failure — a later step redispatches them clean (worktrees were
|
|
355
|
+
// preserved). The merge must therefore LEAVE their items pending instead of
|
|
356
|
+
// marking them blocked on the missing result. Only the quota_paused reason is
|
|
357
|
+
// treated this way; `empty_pool` nodes are genuine failures and block as before.
|
|
358
|
+
const quotaPausedStrandedBlocks = state.partial_completion_terminal?.reason === "quota_paused"
|
|
359
|
+
? new Set(state.partial_completion_terminal.stranded_ids)
|
|
360
|
+
: new Set();
|
|
361
|
+
// DC-5 verify gate: load the obligation_ledger + test_validator_plan once so a
|
|
362
|
+
// resolved finding that covers a behavior-CHANGE obligation can be re-blocked
|
|
363
|
+
// when its test specs are only one polarity (a positive without a scoped
|
|
364
|
+
// negative, or a negative-only set). Absent for non-contract-pipeline runs
|
|
365
|
+
// (audit-findings intake), where the gate is inert. Read defensively: the
|
|
366
|
+
// payloads are the validated artifact bodies, or `undefined` when missing.
|
|
367
|
+
const obligationLedgerPayload = (await readContractArtifact(options.artifactsDir, "obligation_ledger"))?.payload;
|
|
368
|
+
const testValidatorPlanPayload = (await readContractArtifact(options.artifactsDir, "test_validator_plan"))?.payload;
|
|
369
|
+
const plannedBlockIds = new Set(plan.items.map((item) => item.block_id).filter((id) => typeof id === "string"));
|
|
370
|
+
const itemsToMerge = [...plan.items];
|
|
371
|
+
for (const block of state.plan?.blocks ?? []) {
|
|
372
|
+
if (plannedBlockIds.has(block.block_id)) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const hasDocumentedWork = block.items.some((findingId) => {
|
|
376
|
+
const stateItem = state.items?.[findingId];
|
|
377
|
+
return stateItem?.status === "pending";
|
|
378
|
+
});
|
|
379
|
+
if (!hasDocumentedWork) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const item = buildImplementDispatchItem(block, state, dir);
|
|
383
|
+
const existingResult = await tryLoadExistingImplementResult(item.result_path);
|
|
384
|
+
const pendingFindingIds = pendingOrDocumentedFindingIdsForBlock(block, state);
|
|
385
|
+
if (!existingResult ||
|
|
386
|
+
!implementResultCoversFindings(existingResult, pendingFindingIds, block, state)) {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
itemsToMerge.push(item);
|
|
390
|
+
}
|
|
391
|
+
// Build a lightweight ownership registry seeded from each block's declared
|
|
392
|
+
// write_paths so amended_files checks are correct even when no rolling-dispatch
|
|
393
|
+
// registry was persisted (interim path, until rollingDispatch replaces this).
|
|
394
|
+
const mergeRegistry = new OwnershipRegistry();
|
|
395
|
+
const dagNodes = itemsToMerge.flatMap((item) => {
|
|
396
|
+
if (!item.block_id || !item.access)
|
|
397
|
+
return [];
|
|
398
|
+
return [{ node_id: item.block_id, write_paths: item.access.write_paths }];
|
|
399
|
+
});
|
|
400
|
+
mergeRegistry.initialize(dagNodes);
|
|
401
|
+
// OBL-INV-RSD-01: a worker result whose finding_id is not in state.items is
|
|
402
|
+
// never silently dropped and never throws past the loop. Each such id is
|
|
403
|
+
// recorded here; if it belongs to a known block (via the result's owning
|
|
404
|
+
// task block_id) that block's non-terminal items are blocked, otherwise it is
|
|
405
|
+
// a true orphan recorded in the diagnostic artifact below. Either way the run
|
|
406
|
+
// cannot advance past an unaccounted result.
|
|
407
|
+
const orphanResults = [];
|
|
408
|
+
// Per-block ACTUAL edited file sets (resolved from each block's worktree
|
|
409
|
+
// branch), collected for post-loop lost-update / overlapping-edit detection
|
|
410
|
+
// (ARC-f378135d-2 / ARC-c1693139). Only blocks dispatched through an isolated
|
|
411
|
+
// worktree (their branch exists) contribute; the interim main-tree path has no
|
|
412
|
+
// per-worker diff to attribute, so it cannot be checked for cross-block overlap.
|
|
413
|
+
const editedByBlock = [];
|
|
414
|
+
for (const item of itemsToMerge) {
|
|
415
|
+
if (!existsSync(item.result_path)) {
|
|
416
|
+
// Piece D: a node stranded by a quota_paused terminal has no result file
|
|
417
|
+
// because its worker paused on a host session limit — leave its items
|
|
418
|
+
// PENDING so a later step (after the reset) redispatches them clean; never
|
|
419
|
+
// mark them blocked here.
|
|
420
|
+
if (item.block_id && quotaPausedStrandedBlocks.has(item.block_id)) {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
console.warn(`Missing implement worker result: ${item.result_path} — marking items blocked.`);
|
|
424
|
+
const block = item.block_id
|
|
425
|
+
? state.plan?.blocks.find((b) => b.block_id === item.block_id)
|
|
426
|
+
: undefined;
|
|
427
|
+
for (const findingId of block?.items ?? []) {
|
|
428
|
+
const stateItem = state.items[findingId];
|
|
429
|
+
// Don't flip a terminal item (resolved, or user-skipped
|
|
430
|
+
// deemed_inappropriate/ignored) to blocked — only items that were
|
|
431
|
+
// actually awaiting this worker's result.
|
|
432
|
+
if (!stateItem || isTerminalStatus(stateItem.status))
|
|
433
|
+
continue;
|
|
434
|
+
stateItem.status = "blocked";
|
|
435
|
+
markTerminal(stateItem);
|
|
436
|
+
stateItem.failure_reason =
|
|
437
|
+
`Implementation worker did not produce a result file: ${item.result_path}`;
|
|
438
|
+
}
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const result = await readJsonFile(item.result_path);
|
|
442
|
+
assertImplementWorkerResult(result, item.result_path);
|
|
443
|
+
// Gate amended_files through the ownership registry (N-R22).
|
|
444
|
+
// Unowned amended paths are granted and added to this block's effective scope
|
|
445
|
+
// for verification; owned/contended paths block the item with a seam conflict.
|
|
446
|
+
const blockId = item.block_id ?? "";
|
|
447
|
+
if (result.amended_files && result.amended_files.length > 0) {
|
|
448
|
+
const { granted, seam_routed } = routeAmendmentRequest(mergeRegistry, blockId, result.amended_files);
|
|
449
|
+
if (granted.length > 0 && item.access) {
|
|
450
|
+
// Expand the block's effective write scope for downstream verification.
|
|
451
|
+
item.access.write_paths = uniquePaths([...item.access.write_paths, ...granted]);
|
|
452
|
+
}
|
|
453
|
+
if (seam_routed.length > 0) {
|
|
454
|
+
// Mark all non-terminal items in this block as blocked with seam conflict detail.
|
|
455
|
+
const block = state.plan?.blocks.find((b) => b.block_id === blockId);
|
|
456
|
+
for (const findingId of block?.items ?? []) {
|
|
457
|
+
const stateItem = state.items[findingId];
|
|
458
|
+
if (!stateItem || isTerminalStatus(stateItem.status))
|
|
459
|
+
continue;
|
|
460
|
+
stateItem.status = "blocked";
|
|
461
|
+
markTerminal(stateItem);
|
|
462
|
+
stateItem.failure_reason =
|
|
463
|
+
`Seam conflict on amended_files: ${seam_routed
|
|
464
|
+
.map((r) => {
|
|
465
|
+
const reason = r.reason;
|
|
466
|
+
if (reason.outcome === "owned") {
|
|
467
|
+
return `${r.path} owned by ${reason.owner_node_id}`;
|
|
468
|
+
}
|
|
469
|
+
else if (reason.outcome === "contended") {
|
|
470
|
+
return `${r.path} contended by ${reason.sibling_node_id}`;
|
|
471
|
+
}
|
|
472
|
+
return r.path;
|
|
473
|
+
})
|
|
474
|
+
.join("; ")}`;
|
|
475
|
+
}
|
|
476
|
+
// Release any grants we just made before moving on (best-effort cleanup).
|
|
477
|
+
mergeRegistry.releaseAmendments(blockId);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// Tolerant seam: remap an obligation/node-alias finding_id to the owning
|
|
482
|
+
// node's finding, and collapse multi-entry results onto one entry per
|
|
483
|
+
// finding (blocked dominates), before applying any status. A mislabel can
|
|
484
|
+
// only ever resolve to a finding that belongs to THIS block.
|
|
485
|
+
const owningBlock = blockId
|
|
486
|
+
? state.plan?.blocks.find((b) => b.block_id === blockId)
|
|
487
|
+
: undefined;
|
|
488
|
+
const aliasMap = owningBlock
|
|
489
|
+
? buildBlockAliasMap(owningBlock, state)
|
|
490
|
+
: new Map();
|
|
491
|
+
const knownFindingIds = new Set(Object.keys(state.items));
|
|
492
|
+
const { collapsed, unresolved } = collapseItemResults(result.item_results, aliasMap, knownFindingIds);
|
|
493
|
+
// Track which findings in this block this worker flipped to a resolved
|
|
494
|
+
// status, so the write-scope gate below can re-block them if the worker's
|
|
495
|
+
// ACTUAL git edits fall outside the declared scope.
|
|
496
|
+
const resolvedFindingIds = [];
|
|
497
|
+
// The recorded per-node accept outcome is the ground truth (never the worker's
|
|
498
|
+
// result file). Absent on the interim main-tree path (which writes none) → the
|
|
499
|
+
// gates below stay inert there. A HARD accept failure (outcome=error|timeout with
|
|
500
|
+
// merged=false) means the node's committed edits were QUARANTINED and are NOT in the
|
|
501
|
+
// main tree; the worker's own status is then untrustworthy (proven 2026-07-03: a
|
|
502
|
+
// node whose accept failed on a dirty-main-tree collision reported resolved_no_change
|
|
503
|
+
// and silently stranded, because the resolvedFindingIds gate below only re-blocks
|
|
504
|
+
// actual-change `resolved` items). When hard-failed, the collapsed loop's resolve
|
|
505
|
+
// branch blocks the item outright so no dependent builds on missing code.
|
|
506
|
+
const acceptOutcome = await loadNodeAcceptOutcome(options.artifactsDir, runId, blockId);
|
|
507
|
+
const acceptHardFailed = !!acceptOutcome &&
|
|
508
|
+
!acceptOutcome.merged &&
|
|
509
|
+
(acceptOutcome.outcome === "error" || acceptOutcome.outcome === "timeout");
|
|
510
|
+
// M-FRICTION (node_quarantine): a node that committed real edits but hard-failed
|
|
511
|
+
// the tool's verify/scope/merge had its work QUARANTINED under a durable ref and
|
|
512
|
+
// NOT landed — a backend-observed step-boundary fact the per-category friction walk
|
|
513
|
+
// must account for (recovery is a `reverify-node` re-drive). A hard-fail carries a
|
|
514
|
+
// captured `diagnostic` ONLY on the quarantine paths; a plain worker error/timeout
|
|
515
|
+
// that never committed sets none, so guarding on it excludes the non-quarantine
|
|
516
|
+
// failures. Routed through the single CE-005 chokepoint keyed on the node id (one
|
|
517
|
+
// event per node, deduped across its findings). Best-effort / non-fatal.
|
|
518
|
+
if (acceptHardFailed && acceptOutcome.diagnostic) {
|
|
519
|
+
await captureStepBoundaryFriction(options.artifactsDir, runId, {
|
|
520
|
+
eventType: "node_quarantine",
|
|
521
|
+
discriminator: blockId,
|
|
522
|
+
note: `Node ${blockId} committed edits but hard-failed the tool's verify/scope/merge ` +
|
|
523
|
+
`(outcome=${acceptOutcome.outcome}); work quarantined and NOT landed — re-drive ` +
|
|
524
|
+
`with \`remediate-code reverify-node --id ${blockId} --run-id ${runId}\` once the ` +
|
|
525
|
+
`cause is fixed.`,
|
|
526
|
+
category: "bug",
|
|
527
|
+
}, "remediate-code");
|
|
528
|
+
}
|
|
529
|
+
for (const itemResult of unresolved) {
|
|
530
|
+
// OBL-INV-RSD-01: do NOT throw on an unknown finding_id that did not remap
|
|
531
|
+
// to a known node alias. Block the owning block's non-terminal items so the
|
|
532
|
+
// run cannot advance past an unaccounted result; record a diagnostic.
|
|
533
|
+
if (owningBlock) {
|
|
534
|
+
for (const findingId of owningBlock.items) {
|
|
535
|
+
const owningItem = state.items[findingId];
|
|
536
|
+
if (!owningItem || isTerminalStatus(owningItem.status))
|
|
537
|
+
continue;
|
|
538
|
+
owningItem.status = "blocked";
|
|
539
|
+
markTerminal(owningItem);
|
|
540
|
+
owningItem.failure_reason =
|
|
541
|
+
`Implementation worker for block ${blockId} reported an unknown ` +
|
|
542
|
+
`finding_id "${itemResult.finding_id}" not present in this plan ` +
|
|
543
|
+
`(and not a known obligation/node alias of this block); blocking the ` +
|
|
544
|
+
`block's items so the run does not advance past an unaccounted result.`;
|
|
545
|
+
}
|
|
546
|
+
orphanResults.push({
|
|
547
|
+
finding_id: itemResult.finding_id,
|
|
548
|
+
result_path: item.result_path,
|
|
549
|
+
owning_block_id: blockId,
|
|
550
|
+
disposition: "blocked_owning_block",
|
|
551
|
+
worker_status: itemResult.status,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
orphanResults.push({
|
|
556
|
+
finding_id: itemResult.finding_id,
|
|
557
|
+
result_path: item.result_path,
|
|
558
|
+
owning_block_id: null,
|
|
559
|
+
disposition: "orphan",
|
|
560
|
+
worker_status: itemResult.status,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
for (const itemResult of collapsed) {
|
|
565
|
+
const stateItem = state.items[itemResult.finding_id];
|
|
566
|
+
if (!stateItem)
|
|
567
|
+
continue;
|
|
568
|
+
// A worker may report a finding that is already terminal (user-skipped, or
|
|
569
|
+
// resolved in a prior wave) — never let a result resurrect or overwrite it.
|
|
570
|
+
if (isTerminalStatus(stateItem.status)) {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (itemResult.status === "resolved" || itemResult.status === "resolved_no_change") {
|
|
574
|
+
if (acceptHardFailed) {
|
|
575
|
+
// The tool-owned accept hard-failed (quarantined, not in the main tree), so
|
|
576
|
+
// this worker's resolved/resolved_no_change claim can't be trusted — block it
|
|
577
|
+
// regardless of label so a mislabeled no-change can't strand and no dependent
|
|
578
|
+
// builds on missing code. Routed to triage with the failing output.
|
|
579
|
+
stateItem.status = "blocked";
|
|
580
|
+
markTerminal(stateItem);
|
|
581
|
+
stateItem.failure_reason =
|
|
582
|
+
`Node ${blockId} reported finding ${itemResult.finding_id} ` +
|
|
583
|
+
`${itemResult.status}, but its tool-owned accept failed ` +
|
|
584
|
+
`(outcome=${acceptOutcome.outcome}, merged=false); the edits were quarantined ` +
|
|
585
|
+
`and are NOT in the main tree. Routed to triage so dependents never build on ` +
|
|
586
|
+
`missing code.` +
|
|
587
|
+
(acceptOutcome.diagnostic
|
|
588
|
+
? `\nFailing command output:\n${acceptOutcome.diagnostic}`
|
|
589
|
+
: "");
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const spec = stateItem.item_spec;
|
|
593
|
+
// The worker's explicit `resolved_no_change` is a no-change signal in its
|
|
594
|
+
// own right; the spec heuristic is the fallback for a plain `resolved`.
|
|
595
|
+
const isNoChange = itemResult.status === "resolved_no_change" || specIndicatesNoChange(spec);
|
|
596
|
+
// DC-5 verify gate: an actual-change closure for a finding that covers a
|
|
597
|
+
// behavior-CHANGE obligation must have a paired positive+scoped-negative
|
|
598
|
+
// test spec; only-one-polarity (or an unscoped repo-wide negative) is
|
|
599
|
+
// blocked, never silently resolved. The same single-source pairing/scoping
|
|
600
|
+
// evaluation the test-plan derivation gate uses. A no-change closure makes
|
|
601
|
+
// no edits, so it is exempt (the closure path above already proves it).
|
|
602
|
+
const pairingBlockReason = isNoChange
|
|
603
|
+
? null
|
|
604
|
+
: verifyPairingForFinding(obligationIdsForFinding(state, itemResult.finding_id), obligationLedgerPayload, testValidatorPlanPayload);
|
|
605
|
+
if (isNoChange && !hasExecutableEvidence(itemResult.evidence)) {
|
|
606
|
+
// No-prose closure: a "verified-already-satisfied" (no-change) claim must
|
|
607
|
+
// be backed by an executable assertion (a test/build/check command +
|
|
608
|
+
// result), not prose — otherwise a real requirement silently no-ops.
|
|
609
|
+
// Route an unproven no-change claim to triage instead of closing it.
|
|
610
|
+
stateItem.status = "blocked";
|
|
611
|
+
markTerminal(stateItem);
|
|
612
|
+
stateItem.failure_reason =
|
|
613
|
+
"verified-already-satisfied requires an executable regression test proving " +
|
|
614
|
+
"the behavior (a test/build/check command + result in evidence), not prose.";
|
|
615
|
+
}
|
|
616
|
+
else if (pairingBlockReason) {
|
|
617
|
+
stateItem.status = "blocked";
|
|
618
|
+
markTerminal(stateItem);
|
|
619
|
+
stateItem.failure_reason = pairingBlockReason;
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
stateItem.status = isNoChange ? "resolved_no_change" : "resolved";
|
|
623
|
+
markTerminal(stateItem);
|
|
624
|
+
// A no-change closure makes no edits, so it is exempt from the
|
|
625
|
+
// git-diff write-scope gate; an actual fix is subject to it.
|
|
626
|
+
if (!isNoChange) {
|
|
627
|
+
resolvedFindingIds.push(itemResult.finding_id);
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
// M-FRICTION (no_change_merge): a resolved_no_change node merged with
|
|
631
|
+
// no diff is a backend-observed step-boundary fact. Route it through
|
|
632
|
+
// the single CE-005 chokepoint with the pinned discriminator
|
|
633
|
+
// (node/block id + finding id) so the de-dup id is collision-free and
|
|
634
|
+
// re-recording the same fact is a guaranteed no-op (CE-006). Best-effort
|
|
635
|
+
// and non-fatal — capture never throws into the merge loop.
|
|
636
|
+
await captureStepBoundaryFriction(options.artifactsDir, runId, {
|
|
637
|
+
eventType: "no_change_merge",
|
|
638
|
+
discriminator: `${blockId}:${itemResult.finding_id}`,
|
|
639
|
+
note: `Node ${blockId} merged finding ${itemResult.finding_id} as ` +
|
|
640
|
+
`resolved_no_change (no diff landed).`,
|
|
641
|
+
category: "trap",
|
|
642
|
+
}, "remediate-code");
|
|
643
|
+
}
|
|
644
|
+
// OBL-INV-RSD-06 / OBL-SEAM-RSD-03: use the shared REMEDIATION_STEP
|
|
645
|
+
// constant, never the bare string literal, so this path and any other
|
|
646
|
+
// verify-against-documentation writer agree on one source of truth.
|
|
647
|
+
stateItem.last_successful_step =
|
|
648
|
+
REMEDIATION_STEP.VERIFY_AGAINST_DOCUMENTATION;
|
|
649
|
+
if (itemResult.evidence?.length) {
|
|
650
|
+
await writeJsonFile(join(options.artifactsDir, `result_${itemResult.finding_id}_verify_code_against_documentation.json`), {
|
|
651
|
+
finding_id: itemResult.finding_id,
|
|
652
|
+
passed: true,
|
|
653
|
+
reason: itemResult.evidence,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
else if (itemResult.status === "needs_clarification") {
|
|
659
|
+
// Mid-run escape hatch (note 3, part B): the worker hit scoping/judgment
|
|
660
|
+
// ambiguity. Route it to a clarification round (a real user question), not
|
|
661
|
+
// to triage's retry/ignore/halt. NOT terminal — the answer re-opens it.
|
|
662
|
+
stateItem.status = "needs_clarification";
|
|
663
|
+
const question = itemResult.clarification_question ??
|
|
664
|
+
itemResult.failure_reason ??
|
|
665
|
+
"The worker reported unresolved scoping/judgment ambiguity.";
|
|
666
|
+
stateItem.failure_reason = question;
|
|
667
|
+
const category = isClarificationCategory(itemResult.clarification_category)
|
|
668
|
+
? itemResult.clarification_category
|
|
669
|
+
: "scope_of_fix";
|
|
670
|
+
const clarifications = state.clarifications ?? [];
|
|
671
|
+
if (!clarifications.some((c) => c.finding_id === itemResult.finding_id)) {
|
|
672
|
+
clarifications.push({
|
|
673
|
+
finding_id: itemResult.finding_id,
|
|
674
|
+
category,
|
|
675
|
+
description: question,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
state.clarifications = clarifications;
|
|
679
|
+
// The run is paused for the batched clarification round at the single
|
|
680
|
+
// post-loop status decision below (a needs_clarification item outranks
|
|
681
|
+
// implementing/triage), so the answer is applied before any more work.
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
stateItem.status = "blocked";
|
|
685
|
+
markTerminal(stateItem);
|
|
686
|
+
stateItem.failure_reason =
|
|
687
|
+
itemResult.failure_reason ?? "Implementation worker blocked.";
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// E2 convergence: a worker may silently OMIT an assigned finding (return no
|
|
691
|
+
// item_results entry for it) — distinct from reporting it blocked or returning
|
|
692
|
+
// an unknown id (both handled above). The collapsed loop leaves an omitted
|
|
693
|
+
// finding untouched (still pending), so without accounting it re-dispatches
|
|
694
|
+
// forever. Bound it: count each omission and, at the cap, block the finding
|
|
695
|
+
// (→ triage) so a no-human run converges instead of looping (T2 termination).
|
|
696
|
+
if (owningBlock) {
|
|
697
|
+
const coveredFindingIds = new Set(collapsed.map((entry) => entry.finding_id));
|
|
698
|
+
for (const findingId of owningBlock.items) {
|
|
699
|
+
if (coveredFindingIds.has(findingId))
|
|
700
|
+
continue;
|
|
701
|
+
const stateItem = state.items[findingId];
|
|
702
|
+
// Only a still-`pending` item is genuinely awaiting this worker's result;
|
|
703
|
+
// terminal / needs_clarification / in-flight states are not "omitted".
|
|
704
|
+
if (!stateItem || stateItem.status !== "pending")
|
|
705
|
+
continue;
|
|
706
|
+
const attempts = (stateItem.incomplete_coverage_attempts ?? 0) + 1;
|
|
707
|
+
stateItem.incomplete_coverage_attempts = attempts;
|
|
708
|
+
if (attempts >= MAX_INCOMPLETE_COVERAGE_ATTEMPTS) {
|
|
709
|
+
stateItem.status = "blocked";
|
|
710
|
+
markTerminal(stateItem);
|
|
711
|
+
stateItem.failure_reason =
|
|
712
|
+
`Implementation worker for block ${blockId} omitted this finding from its ` +
|
|
713
|
+
`item_results across ${attempts} dispatch(es) (no entry returned, neither ` +
|
|
714
|
+
`resolved nor blocked); blocking to converge instead of re-dispatching indefinitely.`;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Per-block ACTUAL worktree-branch edits, collected for the post-loop
|
|
719
|
+
// lost-update / overlapping-edit detection (a file edited by more than one
|
|
720
|
+
// merged block). The write-scope gate itself is NOT applied here: it runs at
|
|
721
|
+
// ACCEPT time (`acceptNodeWorktree` → `enforceAcceptWriteScope`), BEFORE the
|
|
722
|
+
// cherry-pick, so an out-of-scope edit is prevented from landing rather than
|
|
723
|
+
// reported once already merged — and a node it blocks reaches the merge as
|
|
724
|
+
// `merged:false`, routed to triage by the merge-state gate below (with the
|
|
725
|
+
// write-scope reason carried in its diagnostic). A missing branch means the
|
|
726
|
+
// interim main-tree path was used (no per-worker diff): nothing to collect.
|
|
727
|
+
const worktreeBranch = worktreeBranchForBlock(blockId, runId);
|
|
728
|
+
const branchEdited = gitBranchExists(options.root, worktreeBranch)
|
|
729
|
+
? gitEditedFilesForBranch(options.root, worktreeBranch)
|
|
730
|
+
: null;
|
|
731
|
+
if (branchEdited?.available) {
|
|
732
|
+
// Resolve the block's ACTUAL edited hunks too, so overlap detection can
|
|
733
|
+
// spare same-file blocks whose real line-ranges are disjoint. Unavailable
|
|
734
|
+
// hunks fail closed inside detectOverlappingEdits (still flagged).
|
|
735
|
+
const branchHunks = gitHunksForBranch(options.root, worktreeBranch);
|
|
736
|
+
editedByBlock.push({
|
|
737
|
+
block_id: blockId,
|
|
738
|
+
files: branchEdited.files,
|
|
739
|
+
hunks: branchHunks,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
// Merge-state gate (authoritative, OBL-DS-06): a node that self-reported a
|
|
743
|
+
// finding "resolved" but whose tool-owned verify/merge did NOT land its edits
|
|
744
|
+
// (acceptNodeWorktree returned merged:false — verify failed, a cherry-pick
|
|
745
|
+
// conflict, or no actual edit) must never stand as resolved: its fix is not in
|
|
746
|
+
// the main tree. Keyed on resolvedFindingIds, so a legitimate no-change closure
|
|
747
|
+
// (which makes no edits by design, and is not in that set) stays exempt. This
|
|
748
|
+
// covers the outcome=success/merged:false case (worker reported an actual-change
|
|
749
|
+
// "resolved" but committed nothing); the hard-failure case (outcome=error|timeout)
|
|
750
|
+
// is caught earlier, in the collapsed loop's resolve branch, so a mislabeled
|
|
751
|
+
// `resolved_no_change` can't slip past this resolvedFindingIds keying.
|
|
752
|
+
if (resolvedFindingIds.length > 0) {
|
|
753
|
+
if (acceptOutcome && !acceptOutcome.merged) {
|
|
754
|
+
for (const findingId of resolvedFindingIds) {
|
|
755
|
+
const stateItem = state.items[findingId];
|
|
756
|
+
if (!stateItem || isTerminalStatus(stateItem.status))
|
|
757
|
+
continue;
|
|
758
|
+
stateItem.status = "blocked";
|
|
759
|
+
markTerminal(stateItem);
|
|
760
|
+
stateItem.failure_reason =
|
|
761
|
+
`Node ${blockId} reported finding ${findingId} resolved, but its tool-owned ` +
|
|
762
|
+
`verify/merge did not land the edits (outcome=${acceptOutcome.outcome}, ` +
|
|
763
|
+
`verify_passed=${acceptOutcome.verifyPassed}, merged=false); the fix is not in ` +
|
|
764
|
+
`the main tree. Routed to triage.` +
|
|
765
|
+
(acceptOutcome.diagnostic
|
|
766
|
+
? `\nFailing command output:\n${acceptOutcome.diagnostic}`
|
|
767
|
+
: "");
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
// Release this block's amendment claims after it has been merged or blocked.
|
|
772
|
+
mergeRegistry.releaseAmendments(blockId);
|
|
773
|
+
}
|
|
774
|
+
// OBL-INV-RSD-01: persist a deterministic diagnostic for every unmatched
|
|
775
|
+
// worker result so an orphan is auditable and never silently dropped. This is
|
|
776
|
+
// a sidecar artifact (not state.json), so it does not affect the single
|
|
777
|
+
// state-commit invariant (RSD-02).
|
|
778
|
+
if (orphanResults.length > 0) {
|
|
779
|
+
await writeJsonFile(join(dir, "orphaned-implement-results.json"), {
|
|
780
|
+
schema_version: "remediate-code-implement/orphaned-results/v1alpha1",
|
|
781
|
+
run_id: runId,
|
|
782
|
+
created_at: new Date().toISOString(),
|
|
783
|
+
orphans: orphanResults,
|
|
784
|
+
});
|
|
785
|
+
process.stderr.write(`[remediate-code] dispatch: ${orphanResults.length} unmatched implement ` +
|
|
786
|
+
`result finding_id(s) recorded as orphan dispositions (not dropped): ` +
|
|
787
|
+
`${orphanResults.map((o) => o.finding_id).join(", ")}\n`);
|
|
788
|
+
// M-FRICTION (artifact_rejected): each unmatched worker result is an artifact
|
|
789
|
+
// rejected from the merge (referential-integrity reject). Route each through
|
|
790
|
+
// the single CE-005 chokepoint with the pinned discriminator (artifact id +
|
|
791
|
+
// cause token = the orphan disposition) so the de-dup id is collision-free
|
|
792
|
+
// (CE-006). Best-effort / non-fatal — capture never throws into the merge.
|
|
793
|
+
for (const orphan of orphanResults) {
|
|
794
|
+
await captureStepBoundaryFriction(options.artifactsDir, runId, {
|
|
795
|
+
eventType: "artifact_rejected",
|
|
796
|
+
discriminator: `${orphan.finding_id}:${orphan.disposition}`,
|
|
797
|
+
note: `Implement worker result for finding ${orphan.finding_id} was rejected ` +
|
|
798
|
+
`from the merge (disposition=${orphan.disposition}, ` +
|
|
799
|
+
`worker_status=${orphan.worker_status}).`,
|
|
800
|
+
category: "trap",
|
|
801
|
+
}, "remediate-code");
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
// Lost-update / overlapping-edit detection (ARC-f378135d-2 / ARC-c1693139):
|
|
805
|
+
// when the rolling engine had multiple nodes in flight, two workers can each
|
|
806
|
+
// edit the SAME file in their own worktree; cherry-picking both would silently
|
|
807
|
+
// drop one change. Any file edited by more than one merged block is a
|
|
808
|
+
// lost-update hazard — block every involved block's still-non-terminal items
|
|
809
|
+
// and route them to triage so the conflict is reconciled, never lost. Recorded
|
|
810
|
+
// as a sidecar diagnostic. Single-block runs (the proven host-wave path)
|
|
811
|
+
// produce zero overlaps, so this is inert on the current default path.
|
|
812
|
+
const overlappingEdits = detectOverlappingEdits(editedByBlock);
|
|
813
|
+
if (overlappingEdits.length > 0) {
|
|
814
|
+
const involvedBlockIds = new Set(overlappingEdits.flatMap((o) => o.block_ids));
|
|
815
|
+
for (const blockId of involvedBlockIds) {
|
|
816
|
+
const block = state.plan?.blocks.find((b) => b.block_id === blockId);
|
|
817
|
+
const conflictPaths = overlappingEdits
|
|
818
|
+
.filter((o) => o.block_ids.includes(blockId))
|
|
819
|
+
.map((o) => o.path);
|
|
820
|
+
for (const findingId of block?.items ?? []) {
|
|
821
|
+
const stateItem = state.items[findingId];
|
|
822
|
+
if (!stateItem || isTerminalStatus(stateItem.status))
|
|
823
|
+
continue;
|
|
824
|
+
stateItem.status = "blocked";
|
|
825
|
+
markTerminal(stateItem);
|
|
826
|
+
stateItem.failure_reason =
|
|
827
|
+
`Lost-update hazard: this block's worker edited file(s) also edited by ` +
|
|
828
|
+
`another concurrently-dispatched block (${conflictPaths.join(", ")}). ` +
|
|
829
|
+
`Blocking both so the overlapping change is reconciled in triage rather ` +
|
|
830
|
+
`than silently dropped by a cherry-pick.`;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
await writeJsonFile(join(dir, "overlapping-edits.json"), {
|
|
834
|
+
schema_version: "remediate-code-implement/overlapping-edits/v1alpha1",
|
|
835
|
+
run_id: runId,
|
|
836
|
+
created_at: new Date().toISOString(),
|
|
837
|
+
overlaps: overlappingEdits,
|
|
838
|
+
});
|
|
839
|
+
process.stderr.write(`[remediate-code] dispatch: ${overlappingEdits.length} overlapping-edit ` +
|
|
840
|
+
`conflict(s) across concurrently-merged blocks; involved blocks routed to ` +
|
|
841
|
+
`triage: ${[...involvedBlockIds].join(", ")}\n`);
|
|
842
|
+
}
|
|
843
|
+
// Re-baseline affected-file hashes: the implement phase legitimately rewrites
|
|
844
|
+
// these files, so a later integrity check must not flag the run's own edits as
|
|
845
|
+
// a stale plan when re-attempting any remaining blocked findings.
|
|
846
|
+
if (state.plan?.findings?.length) {
|
|
847
|
+
resnapshotAffectedFileHashes(options.root, state.plan.findings);
|
|
848
|
+
}
|
|
849
|
+
// Per-node dispositions (INV-DS-15). One disposition per merged block/node; a
|
|
850
|
+
// SKIP disposition is never reported as verified_complete. This is a sidecar
|
|
851
|
+
// artifact (not state.json).
|
|
852
|
+
const mergedBlocks = itemsToMerge.flatMap((item) => {
|
|
853
|
+
if (!item.block_id)
|
|
854
|
+
return [];
|
|
855
|
+
const block = state.plan?.blocks.find((b) => b.block_id === item.block_id);
|
|
856
|
+
return block ? [{ block, item }] : [];
|
|
857
|
+
});
|
|
858
|
+
const dispositions = mergedBlocks.map(({ block }) => buildNodeDisposition(block, state));
|
|
859
|
+
// Sibling-red routing (INV-DS-14). For each merged block that ended red
|
|
860
|
+
// (blocked), attribute the failure against the OTHER merged blocks' write
|
|
861
|
+
// scopes: an attributable red (exactly one sibling owns the implicated
|
|
862
|
+
// surface) routes that sibling to triage; an unattributable red is deferred to
|
|
863
|
+
// the rolling-scheduler's coarse backstop. The state already advances to
|
|
864
|
+
// triage below; this records the attribution decision deterministically.
|
|
865
|
+
const siblingRedRoutes = [];
|
|
866
|
+
for (const { block, item } of mergedBlocks) {
|
|
867
|
+
const disposition = dispositions.find((d) => d.block_id === block.block_id);
|
|
868
|
+
if (!disposition || disposition.disposition !== "blocked")
|
|
869
|
+
continue;
|
|
870
|
+
// The files implicated by this red node = its declared write scope.
|
|
871
|
+
const implicatedFiles = item.access?.write_paths ?? [];
|
|
872
|
+
const siblingScopes = mergedBlocks
|
|
873
|
+
.filter((m) => m.block.block_id !== block.block_id)
|
|
874
|
+
.map((m) => ({
|
|
875
|
+
block_id: m.block.block_id,
|
|
876
|
+
write_paths: m.item.access?.write_paths ?? [],
|
|
877
|
+
}));
|
|
878
|
+
const attributed = attributeSiblingRed(implicatedFiles, siblingScopes, options.root);
|
|
879
|
+
siblingRedRoutes.push({
|
|
880
|
+
red_block_id: block.block_id,
|
|
881
|
+
implicated_files: implicatedFiles.map((p) => toRepoRelative(p, options.root)),
|
|
882
|
+
routed_to_triage_block_id: attributed,
|
|
883
|
+
backstop: attributed ? null : "rolling_scheduler_coarse",
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
if (dispositions.length > 0) {
|
|
887
|
+
await writeJsonFile(join(dir, "node-dispositions.json"), {
|
|
888
|
+
schema_version: "remediate-code-implement/node-dispositions/v1alpha1",
|
|
889
|
+
run_id: runId,
|
|
890
|
+
created_at: new Date().toISOString(),
|
|
891
|
+
dispositions,
|
|
892
|
+
sibling_red_routes: siblingRedRoutes,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
const mergedFindingIds = new Set(itemsToMerge.flatMap((item) => {
|
|
896
|
+
if (!item.block_id)
|
|
897
|
+
return [];
|
|
898
|
+
const block = state.plan?.blocks.find((b) => b.block_id === item.block_id);
|
|
899
|
+
return block?.items ?? [];
|
|
900
|
+
}));
|
|
901
|
+
let implementResolved = 0;
|
|
902
|
+
let implementRejected = 0;
|
|
903
|
+
for (const findingId of mergedFindingIds) {
|
|
904
|
+
const status = state.items[findingId]?.status;
|
|
905
|
+
if (isVerifiedCompleteStatus(status))
|
|
906
|
+
implementResolved++;
|
|
907
|
+
else if (status === "blocked")
|
|
908
|
+
implementRejected++;
|
|
909
|
+
}
|
|
910
|
+
process.stderr.write(`[remediate-code] dispatch: merged ${implementResolved} implement result(s), ` +
|
|
911
|
+
`${implementRejected} rejected\n`);
|
|
912
|
+
// A worker that reported needs_clarification (note 3, part B) outranks both
|
|
913
|
+
// implementing and triage: pause the run for the batched clarification round so
|
|
914
|
+
// the user's answer is applied before any more work is dispatched or triaged.
|
|
915
|
+
// Otherwise route back to implementing while pending work remains (later
|
|
916
|
+
// dependency waves, or blocks deferred this wave because a prerequisite was
|
|
917
|
+
// still running); else advance to triage.
|
|
918
|
+
const needsClarification = Object.values(state.items).some((it) => it.status === "needs_clarification");
|
|
919
|
+
const moreToImplement = Object.values(state.items).some((it) => it.status === "pending");
|
|
920
|
+
state.status = needsClarification
|
|
921
|
+
? "waiting_for_clarification"
|
|
922
|
+
: moreToImplement
|
|
923
|
+
? "implementing"
|
|
924
|
+
: "triage";
|
|
925
|
+
// Single commit: StateStore.mutate writes the returned state once, under the
|
|
926
|
+
// lock it already holds (OBL-INV-RSD-02 / OBL-SEAM-RSD-04). No saveState here.
|
|
927
|
+
return state;
|
|
928
|
+
}
|
|
929
|
+
export async function readExtractedPlanIfPresent(artifactsDir) {
|
|
930
|
+
return readOptionalJsonFile(join(artifactsDir, "extracted-plan.json"));
|
|
931
|
+
}
|
|
932
|
+
export async function readDispatchPlan(artifactsDir, runId, phase) {
|
|
933
|
+
return readJsonFile(dispatchPlanPath(artifactsDir, runId, phase));
|
|
934
|
+
}
|
|
935
|
+
//# sourceMappingURL=marshal.js.map
|