audit-tools 0.42.1 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/dispatch/merge-results.mjs +229 -140
  2. package/dispatch/validate-result.mjs +98 -40
  3. package/dist/audit/cli/conceptualDispatch.d.ts.map +1 -1
  4. package/dist/audit/cli/conceptualDispatch.js +35 -3
  5. package/dist/audit/cli/conceptualDispatch.js.map +1 -1
  6. package/dist/audit/cli/dispatch/hostHandoff.d.ts +9 -1
  7. package/dist/audit/cli/dispatch/hostHandoff.d.ts.map +1 -1
  8. package/dist/audit/cli/dispatch/hostHandoff.js +94 -38
  9. package/dist/audit/cli/dispatch/hostHandoff.js.map +1 -1
  10. package/dist/audit/cli/laneSubmissions.d.ts +2 -2
  11. package/dist/audit/cli/laneSubmissions.js +2 -2
  12. package/dist/audit/cli/nextStepCommand.d.ts +64 -0
  13. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  14. package/dist/audit/cli/nextStepCommand.js +877 -783
  15. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  16. package/dist/audit/cli/nextStepHelpers.d.ts +12 -36
  17. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  18. package/dist/audit/cli/nextStepHelpers.js +34 -77
  19. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  20. package/dist/audit/cli/steps.d.ts +2 -2
  21. package/dist/audit/extractors/graph.d.ts +28 -2
  22. package/dist/audit/extractors/graph.d.ts.map +1 -1
  23. package/dist/audit/extractors/graph.js +122 -11
  24. package/dist/audit/extractors/graph.js.map +1 -1
  25. package/dist/audit/extractors/graphRoutes.d.ts +8 -0
  26. package/dist/audit/extractors/graphRoutes.d.ts.map +1 -1
  27. package/dist/audit/extractors/graphRoutes.js +71 -4
  28. package/dist/audit/extractors/graphRoutes.js.map +1 -1
  29. package/dist/audit/io/artifacts.d.ts +55 -0
  30. package/dist/audit/io/artifacts.d.ts.map +1 -1
  31. package/dist/audit/io/artifacts.js +189 -12
  32. package/dist/audit/io/artifacts.js.map +1 -1
  33. package/dist/audit/io/toolingManifest.d.ts +11 -0
  34. package/dist/audit/io/toolingManifest.d.ts.map +1 -1
  35. package/dist/audit/io/toolingManifest.js +13 -3
  36. package/dist/audit/io/toolingManifest.js.map +1 -1
  37. package/dist/audit/orchestrator/advance.d.ts +64 -0
  38. package/dist/audit/orchestrator/advance.d.ts.map +1 -1
  39. package/dist/audit/orchestrator/advance.js +191 -57
  40. package/dist/audit/orchestrator/advance.js.map +1 -1
  41. package/dist/audit/orchestrator/charterClarificationExecutor.d.ts.map +1 -1
  42. package/dist/audit/orchestrator/charterClarificationExecutor.js +55 -21
  43. package/dist/audit/orchestrator/charterClarificationExecutor.js.map +1 -1
  44. package/dist/audit/orchestrator/flowCoverage.d.ts.map +1 -1
  45. package/dist/audit/orchestrator/flowCoverage.js +7 -10
  46. package/dist/audit/orchestrator/flowCoverage.js.map +1 -1
  47. package/dist/audit/orchestrator/flowPlanning.d.ts +54 -1
  48. package/dist/audit/orchestrator/flowPlanning.d.ts.map +1 -1
  49. package/dist/audit/orchestrator/flowPlanning.js +77 -18
  50. package/dist/audit/orchestrator/flowPlanning.js.map +1 -1
  51. package/dist/audit/orchestrator/flowRequeue.d.ts.map +1 -1
  52. package/dist/audit/orchestrator/flowRequeue.js +6 -10
  53. package/dist/audit/orchestrator/flowRequeue.js.map +1 -1
  54. package/dist/audit/orchestrator/requeueFold.d.ts.map +1 -1
  55. package/dist/audit/orchestrator/requeueFold.js +18 -5
  56. package/dist/audit/orchestrator/requeueFold.js.map +1 -1
  57. package/dist/audit/orchestrator/staleness.d.ts +24 -1
  58. package/dist/audit/orchestrator/staleness.d.ts.map +1 -1
  59. package/dist/audit/orchestrator/staleness.js +147 -16
  60. package/dist/audit/orchestrator/staleness.js.map +1 -1
  61. package/dist/audit/orchestrator/syntaxResolutionExecutor.d.ts +26 -0
  62. package/dist/audit/orchestrator/syntaxResolutionExecutor.d.ts.map +1 -1
  63. package/dist/audit/orchestrator/syntaxResolutionExecutor.js +97 -12
  64. package/dist/audit/orchestrator/syntaxResolutionExecutor.js.map +1 -1
  65. package/dist/audit/orchestrator/taskBuilder.d.ts.map +1 -1
  66. package/dist/audit/orchestrator/taskBuilder.js +111 -21
  67. package/dist/audit/orchestrator/taskBuilder.js.map +1 -1
  68. package/dist/audit/orchestrator/trivialAudit.d.ts +17 -1
  69. package/dist/audit/orchestrator/trivialAudit.d.ts.map +1 -1
  70. package/dist/audit/orchestrator/trivialAudit.js +31 -4
  71. package/dist/audit/orchestrator/trivialAudit.js.map +1 -1
  72. package/dist/audit/types/analyzerCapability.d.ts +6 -6
  73. package/dist/audit/types/charterRegister.d.ts +30 -12
  74. package/dist/audit/types/charterRegister.d.ts.map +1 -1
  75. package/dist/audit/types/charterRegister.js +22 -9
  76. package/dist/audit/types/charterRegister.js.map +1 -1
  77. package/dist/audit/validation/anchorGrounding.d.ts.map +1 -1
  78. package/dist/audit/validation/anchorGrounding.js +17 -0
  79. package/dist/audit/validation/anchorGrounding.js.map +1 -1
  80. package/dist/audit/validation/auditResults.d.ts +17 -0
  81. package/dist/audit/validation/auditResults.d.ts.map +1 -1
  82. package/dist/audit/validation/auditResults.js +50 -17
  83. package/dist/audit/validation/auditResults.js.map +1 -1
  84. package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts +48 -0
  85. package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts.map +1 -1
  86. package/dist/remediate/contractPipeline/cyclicSeamResolution.js +128 -0
  87. package/dist/remediate/contractPipeline/cyclicSeamResolution.js.map +1 -1
  88. package/dist/remediate/contractPipeline/derive.d.ts +8 -5
  89. package/dist/remediate/contractPipeline/derive.d.ts.map +1 -1
  90. package/dist/remediate/contractPipeline/derive.js +8 -16
  91. package/dist/remediate/contractPipeline/derive.js.map +1 -1
  92. package/dist/remediate/index.d.ts.map +1 -1
  93. package/dist/remediate/index.js +43 -2
  94. package/dist/remediate/index.js.map +1 -1
  95. package/dist/remediate/intent/intentOrdering.d.ts +13 -5
  96. package/dist/remediate/intent/intentOrdering.d.ts.map +1 -1
  97. package/dist/remediate/intent/intentOrdering.js +56 -25
  98. package/dist/remediate/intent/intentOrdering.js.map +1 -1
  99. package/dist/remediate/phases/close.d.ts +57 -2
  100. package/dist/remediate/phases/close.d.ts.map +1 -1
  101. package/dist/remediate/phases/close.js +260 -36
  102. package/dist/remediate/phases/close.js.map +1 -1
  103. package/dist/remediate/phases/triage.d.ts.map +1 -1
  104. package/dist/remediate/phases/triage.js +14 -2
  105. package/dist/remediate/phases/triage.js.map +1 -1
  106. package/dist/remediate/state/disposition.d.ts +26 -4
  107. package/dist/remediate/state/disposition.d.ts.map +1 -1
  108. package/dist/remediate/state/itemStatus.d.ts +33 -6
  109. package/dist/remediate/state/itemStatus.d.ts.map +1 -1
  110. package/dist/remediate/state/itemStatus.js +154 -39
  111. package/dist/remediate/state/itemStatus.js.map +1 -1
  112. package/dist/remediate/state/types.d.ts +41 -2
  113. package/dist/remediate/state/types.d.ts.map +1 -1
  114. package/dist/remediate/state/types.js.map +1 -1
  115. package/dist/remediate/steps/contractPipeline.d.ts +296 -15
  116. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  117. package/dist/remediate/steps/contractPipeline.js +1783 -1001
  118. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  119. package/dist/remediate/steps/dispatch/hostHandoff.d.ts +137 -2
  120. package/dist/remediate/steps/dispatch/hostHandoff.d.ts.map +1 -1
  121. package/dist/remediate/steps/dispatch/hostHandoff.js +657 -59
  122. package/dist/remediate/steps/dispatch/hostHandoff.js.map +1 -1
  123. package/dist/remediate/steps/finalGate.d.ts +136 -37
  124. package/dist/remediate/steps/finalGate.d.ts.map +1 -1
  125. package/dist/remediate/steps/finalGate.js +120 -86
  126. package/dist/remediate/steps/finalGate.js.map +1 -1
  127. package/dist/remediate/steps/gateCommands.d.ts +8 -0
  128. package/dist/remediate/steps/gateCommands.d.ts.map +1 -1
  129. package/dist/remediate/steps/gateCommands.js +40 -11
  130. package/dist/remediate/steps/gateCommands.js.map +1 -1
  131. package/dist/remediate/steps/nextStep.d.ts +96 -5
  132. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  133. package/dist/remediate/steps/nextStep.js +668 -147
  134. package/dist/remediate/steps/nextStep.js.map +1 -1
  135. package/dist/remediate/steps/types.d.ts +4 -3
  136. package/dist/remediate/steps/types.d.ts.map +1 -1
  137. package/dist/remediate/steps/types.js +10 -2
  138. package/dist/remediate/steps/types.js.map +1 -1
  139. package/dist/remediate/validation/artifacts.d.ts +20 -2
  140. package/dist/remediate/validation/artifacts.d.ts.map +1 -1
  141. package/dist/remediate/validation/artifacts.js +224 -130
  142. package/dist/remediate/validation/artifacts.js.map +1 -1
  143. package/dist/remediate/validation/contractPipelineGates.d.ts +58 -0
  144. package/dist/remediate/validation/contractPipelineGates.d.ts.map +1 -1
  145. package/dist/remediate/validation/contractPipelineGates.js +143 -27
  146. package/dist/remediate/validation/contractPipelineGates.js.map +1 -1
  147. package/dist/shared/analyzers/acquisitionEngine.d.ts +72 -25
  148. package/dist/shared/analyzers/acquisitionEngine.d.ts.map +1 -1
  149. package/dist/shared/analyzers/acquisitionEngine.js +149 -15
  150. package/dist/shared/analyzers/acquisitionEngine.js.map +1 -1
  151. package/dist/shared/analyzers/binaryAcquisition.d.ts +27 -2
  152. package/dist/shared/analyzers/binaryAcquisition.d.ts.map +1 -1
  153. package/dist/shared/analyzers/binaryAcquisition.js +149 -43
  154. package/dist/shared/analyzers/binaryAcquisition.js.map +1 -1
  155. package/dist/shared/analyzers/candidates.d.ts +9 -8
  156. package/dist/shared/analyzers/candidates.d.ts.map +1 -1
  157. package/dist/shared/analyzers/candidates.js +96 -64
  158. package/dist/shared/analyzers/candidates.js.map +1 -1
  159. package/dist/shared/analyzers/normalizeExternal.d.ts +40 -12
  160. package/dist/shared/analyzers/normalizeExternal.d.ts.map +1 -1
  161. package/dist/shared/analyzers/normalizeExternal.js +74 -18
  162. package/dist/shared/analyzers/normalizeExternal.js.map +1 -1
  163. package/dist/shared/analyzers/rubocop.d.ts +14 -13
  164. package/dist/shared/analyzers/rubocop.d.ts.map +1 -1
  165. package/dist/shared/analyzers/rubocop.js +29 -6
  166. package/dist/shared/analyzers/rubocop.js.map +1 -1
  167. package/dist/shared/analyzers/types.d.ts +196 -13
  168. package/dist/shared/analyzers/types.d.ts.map +1 -1
  169. package/dist/shared/analyzers/types.js +122 -9
  170. package/dist/shared/analyzers/types.js.map +1 -1
  171. package/dist/shared/decompose/charterClarification.d.ts +12 -1
  172. package/dist/shared/decompose/charterClarification.d.ts.map +1 -1
  173. package/dist/shared/decompose/charterClarification.js +11 -4
  174. package/dist/shared/decompose/charterClarification.js.map +1 -1
  175. package/dist/shared/decompose/charterExtraction.d.ts +20 -10
  176. package/dist/shared/decompose/charterExtraction.d.ts.map +1 -1
  177. package/dist/shared/decompose/charterExtraction.js +52 -23
  178. package/dist/shared/decompose/charterExtraction.js.map +1 -1
  179. package/dist/shared/index.d.ts +1 -0
  180. package/dist/shared/index.d.ts.map +1 -1
  181. package/dist/shared/index.js +5 -0
  182. package/dist/shared/index.js.map +1 -1
  183. package/dist/shared/intent/freeFormIntentInterpreter.d.ts +12 -2
  184. package/dist/shared/intent/freeFormIntentInterpreter.d.ts.map +1 -1
  185. package/dist/shared/intent/freeFormIntentInterpreter.js +27 -22
  186. package/dist/shared/intent/freeFormIntentInterpreter.js.map +1 -1
  187. package/dist/shared/intent/pathScope.d.ts +12 -1
  188. package/dist/shared/intent/pathScope.d.ts.map +1 -1
  189. package/dist/shared/intent/pathScope.js +56 -19
  190. package/dist/shared/intent/pathScope.js.map +1 -1
  191. package/dist/shared/intent/sharedIntentData.d.ts +19 -1
  192. package/dist/shared/intent/sharedIntentData.d.ts.map +1 -1
  193. package/dist/shared/intent/sharedIntentData.js +33 -8
  194. package/dist/shared/intent/sharedIntentData.js.map +1 -1
  195. package/dist/shared/io/fileLock.d.ts +12 -0
  196. package/dist/shared/io/fileLock.d.ts.map +1 -1
  197. package/dist/shared/io/fileLock.js +48 -5
  198. package/dist/shared/io/fileLock.js.map +1 -1
  199. package/dist/shared/io/json.d.ts +13 -0
  200. package/dist/shared/io/json.d.ts.map +1 -1
  201. package/dist/shared/io/json.js +19 -1
  202. package/dist/shared/io/json.js.map +1 -1
  203. package/dist/shared/steps/stepEmissionScaffold.d.ts +69 -0
  204. package/dist/shared/steps/stepEmissionScaffold.d.ts.map +1 -0
  205. package/dist/shared/steps/stepEmissionScaffold.js +83 -0
  206. package/dist/shared/steps/stepEmissionScaffold.js.map +1 -0
  207. package/dist/shared/submission/handRecovery.d.ts.map +1 -1
  208. package/dist/shared/submission/handRecovery.js +69 -4
  209. package/dist/shared/submission/handRecovery.js.map +1 -1
  210. package/dist/shared/submission/submissionIdentity.d.ts +10 -1
  211. package/dist/shared/submission/submissionIdentity.d.ts.map +1 -1
  212. package/dist/shared/submission/submissionIdentity.js +12 -3
  213. package/dist/shared/submission/submissionIdentity.js.map +1 -1
  214. package/dist/shared/submission/submissionLedger.d.ts +43 -2
  215. package/dist/shared/submission/submissionLedger.d.ts.map +1 -1
  216. package/dist/shared/submission/submissionLedger.js +50 -7
  217. package/dist/shared/submission/submissionLedger.js.map +1 -1
  218. package/dist/shared/tooling/allowlistedExec.d.ts +9 -0
  219. package/dist/shared/tooling/allowlistedExec.d.ts.map +1 -1
  220. package/dist/shared/tooling/allowlistedExec.js +19 -2
  221. package/dist/shared/tooling/allowlistedExec.js.map +1 -1
  222. package/dist/shared/tooling/commandShape.d.ts +21 -0
  223. package/dist/shared/tooling/commandShape.d.ts.map +1 -0
  224. package/dist/shared/tooling/commandShape.js +120 -0
  225. package/dist/shared/tooling/commandShape.js.map +1 -0
  226. package/dist/shared/types/charter.d.ts +24 -0
  227. package/dist/shared/types/charter.d.ts.map +1 -1
  228. package/dist/shared/types/charter.js +9 -0
  229. package/dist/shared/types/charter.js.map +1 -1
  230. package/dist/shared/types/remediationOutcome.d.ts +282 -9
  231. package/dist/shared/types/remediationOutcome.d.ts.map +1 -1
  232. package/dist/shared/types/remediationOutcome.js +174 -1
  233. package/dist/shared/types/remediationOutcome.js.map +1 -1
  234. package/package.json +2 -1
  235. package/remediate-code.mjs +15 -0
  236. package/wrapper/remediate-code-wrapper-install-hosts.mjs +45 -4
  237. package/wrapper/remediate-code-wrapper-install-renderers.mjs +12 -2
@@ -1,13 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
- import { headCommit, FindingSchema, SUBMISSION_ISSUE_CODES, assertSubmissionRunId, hashContent, isGitRepo, normalizeRepoPath, readSubmissionDocument, repoRelativePath, resolveContainedPath, spawnSyncHidden, stableStringify, submissionPathFor, writeJsonFile, } from "audit-tools/shared";
4
+ import { headCommit, commandLeavesDeclaredShape, FindingSchema, SUBMISSION_ISSUE_CODES, SUBMISSION_LEDGER_EVENT_CONTRACT_VERSION, appendSubmissionEvent, assertSubmissionRunId, hashContent, isGitRepo, normalizeRepoPath, readSubmissionDocument, readSubmissionLedger, repoRelativePath, resolveContainedPath, spawnSyncHidden, stableStringify, submissionPathFor, writeJsonFile, } from "audit-tools/shared";
5
5
  import { RemediationHostHandoffRecordSchema, RemediationPlanSchema, isClarificationCategory, } from "../../state/types.js";
6
- import { ITEM_STATUSES, isVerifiedCompleteStatus } from "../../state/itemStatus.js";
6
+ import { ITEM_STATUSES, isTerminalStatus, isVerifiedCompleteStatus, } from "../../state/itemStatus.js";
7
+ import { REMEDIATION_HOST_DECISION_CONTRACT_VERSION as DECISION_CONTRACT_VERSION, REMEDIATION_HOST_RESULT_CONTRACT_VERSION as RESULT_CONTRACT_VERSION, REMEDIATION_HOST_WORKLOAD_CONTRACT_VERSION as WORKLOAD_CONTRACT_VERSION, } from "../types.js";
7
8
  const STATE_CONTRACT_VERSION = "remediate-code-state/v1alpha1";
8
- const WORKLOAD_CONTRACT_VERSION = "remediation-host-workload/v1alpha1";
9
- const RESULT_CONTRACT_VERSION = "remediation-host-result/v1alpha1";
10
- const DECISION_CONTRACT_VERSION = "remediation-host-decision/v1alpha1";
11
9
  const HANDOFF_RECORD_CONTRACT_VERSION = "remediation-host-handoff-record/v1alpha1";
12
10
  /**
13
11
  * Remediation's issue vocabulary: the SHARED submission codes plus this draw's
@@ -31,6 +29,47 @@ export const REMEDIATION_ISSUE_CODES = [
31
29
  "changed_files_mismatch",
32
30
  "run_start_dirty_overlap",
33
31
  "required_test_failed",
32
+ /**
33
+ * A required test exceeded its deadline. DISTINCT from `required_test_failed`
34
+ * by code alone: a hung suite and a genuine red are different facts about the
35
+ * work, and telling them apart must not require parsing a joined message.
36
+ */
37
+ "required_test_timed_out",
38
+ /**
39
+ * A required test produced more output than the capture buffer holds, so the
40
+ * runner killed it. NOT a verdict on the tests: the child was terminated by
41
+ * the capture cap, and whether the suite would have passed is unknown. It has
42
+ * its own code because it was previously indistinguishable from a hang — node
43
+ * kills an over-buffer child with a signal, which the old discriminator read
44
+ * as a deadline miss.
45
+ */
46
+ "required_test_output_overflow",
47
+ /**
48
+ * A plan block declares a dependency id that exists in NO block of the plan.
49
+ * The block is unschedulable — never level 0 — and the producer bug is named
50
+ * rather than absorbed.
51
+ */
52
+ "dependency_missing",
53
+ /**
54
+ * A block arrived outside the normalized write-scope / declared-command shape
55
+ * this boundary consumes (artifact:normalized-block-write-scope). Refused, not
56
+ * silently normalized: a silently sorted, deduped or re-rooted write scope
57
+ * hides the producer bug and widens what a host may touch.
58
+ */
59
+ "block_contract_invalid",
60
+ /**
61
+ * A recovery-mode acceptance could not be marked on the submission ledger, so
62
+ * it was refused. An acceptance that used the relaxation MUST stay
63
+ * distinguishable from a clean one; an unrecordable mark is a refusal, never
64
+ * a silent acceptance.
65
+ */
66
+ "recovery_unrecorded",
67
+ /**
68
+ * The repository HEAD moved between the recovery verb's unlocked test phase
69
+ * and its locked write phase, so the pre-computed test verdicts describe a
70
+ * tree that is no longer current. The whole recovery aborts.
71
+ */
72
+ "tree_moved_between_phases",
34
73
  ];
35
74
  const CURRENT_STATE_KEYS = new Set([
36
75
  "applied_edit_surface",
@@ -140,6 +179,151 @@ function normalizeDeclaredPath(root, candidate, label) {
140
179
  }
141
180
  return repoRelativePath(root, candidate, label);
142
181
  }
182
+ /**
183
+ * A block that arrived outside the shape this boundary CONSUMES.
184
+ *
185
+ * The producer half of the write-scope contract is owned upstream
186
+ * (artifact:normalized-block-write-scope). This is the consumer half, and it
187
+ * exists because absorbing a malformed block is worse than refusing it: an
188
+ * absolute or escaping `touched_files` entry silently widens what a host may
189
+ * write, and a shell-chained `targeted_command` is executed verbatim. Both are
190
+ * producer bugs, and a boundary that normalizes them away means neither ever
191
+ * surfaces. The refusal is CLASSIFIED (`block_contract_invalid`) and names the
192
+ * block, so the bug is attributable to the module that wrote it.
193
+ */
194
+ class BlockContractError extends Error {
195
+ blockId;
196
+ detail;
197
+ constructor(blockId, detail) {
198
+ super(`block '${blockId}' is outside the normalized write-scope contract: ${detail}`);
199
+ this.blockId = blockId;
200
+ this.detail = detail;
201
+ this.name = "BlockContractError";
202
+ }
203
+ }
204
+ /**
205
+ * Refuse a block whose declared write scope or commands leave the consumed
206
+ * shape. Throws {@link BlockContractError}; callers turn it into a classified
207
+ * issue. Runs BEFORE anything is built from the block, so a refused block never
208
+ * becomes a work item and its commands never run.
209
+ *
210
+ * COVERS THE HANDOFF BOUNDARY ONLY — state the uncovered half rather than let
211
+ * the covered half read as a close. `reverifyBlockedItemAgainstTree` in
212
+ * `src/remediate/phases/triage.ts` spawns the SAME `block.targeted_commands`
213
+ * through `shell: true` with no gate in front of it, so a command this boundary
214
+ * would refuse still reaches a shell on the triage path. Routing that spawn
215
+ * through this gate is tracked as backlog work, not covered here.
216
+ */
217
+ function assertBlockContract(root, block) {
218
+ for (const raw of block.touched_files) {
219
+ if (typeof raw !== "string" || raw.trim().length === 0) {
220
+ throw new BlockContractError(block.block_id, "touched_files carries an empty entry");
221
+ }
222
+ if (isAbsolute(raw)) {
223
+ throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} is absolute, not repository-relative`);
224
+ }
225
+ let normalized;
226
+ try {
227
+ normalized = repoRelativePath(root, raw, `${block.block_id}.touched_files[]`);
228
+ }
229
+ catch {
230
+ throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} does not resolve beneath the repository root`);
231
+ }
232
+ if (normalized !== raw) {
233
+ throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} is not in normalized repo-relative form ` +
234
+ `(${JSON.stringify(normalized)})`);
235
+ }
236
+ }
237
+ for (const command of block.targeted_commands ?? []) {
238
+ if (typeof command !== "string" || command.trim().length === 0) {
239
+ throw new BlockContractError(block.block_id, "targeted_commands carries an empty command");
240
+ }
241
+ // THE declared-command-shape rule (`audit-tools/shared`), not a local copy:
242
+ // the producer that promotes these commands and the triage path that also
243
+ // spawns them ask the same predicate, so a command cannot clear one boundary
244
+ // and dead-end at another.
245
+ if (commandLeavesDeclaredShape(command)) {
246
+ throw new BlockContractError(block.block_id, `targeted_command ${JSON.stringify(command)} leaves the declared shape — it chains, ` +
247
+ "redirects or substitutes, and this boundary executes commands verbatim through a shell");
248
+ }
249
+ }
250
+ }
251
+ /**
252
+ * Every block of the plan that cannot be scheduled, with the reason, as
253
+ * classified ingest issues. Two producer bugs live here: a dependency id that
254
+ * resolves to no block (unschedulable forever — see `hostDependencyLevels`),
255
+ * and a block outside the consumed write-scope/command shape.
256
+ *
257
+ * The scanned set is BOUND ∪ UNSETTLED, and it is that union because those are
258
+ * exactly the blocks something else re-derives:
259
+ * - UNSETTLED (any item not terminal) — the blocks still to be scheduled. A
260
+ * settled block's historical shape is not this ingest's business, and
261
+ * reporting it would turn every later ingest into a repeat of the same noise.
262
+ * - BOUND (`block_id` in `host_handoff.work_item_ids`, WHATEVER its items'
263
+ * statuses) — because `parseWorkItem` re-derives every bound item through
264
+ * `buildWorkItem` regardless of status. A status filter alone therefore
265
+ * scanned a DIFFERENT set than the one that can throw: a bound block whose
266
+ * items had all reached terminal still failed the workload parse when its
267
+ * contract was malformed, and surfaced as a bare `workload_invalid` naming no
268
+ * block. Scanning the union is what makes "the block that broke the parse is
269
+ * always named" true rather than usually true.
270
+ */
271
+ function planBlockIssues(root, state) {
272
+ const blockIds = new Set(state.plan.blocks.map((block) => block.block_id));
273
+ const boundIds = new Set(state.host_handoff?.work_item_ids ?? []);
274
+ const issues = [];
275
+ for (const block of state.plan.blocks) {
276
+ const unsettled = block.items.some((findingId) => {
277
+ const status = state.items[findingId]?.status;
278
+ return status !== undefined && !isTerminalStatus(status);
279
+ });
280
+ if (!unsettled && !boundIds.has(block.block_id))
281
+ continue;
282
+ const missing = (block.dependencies ?? []).filter((dependencyId) => !blockIds.has(dependencyId));
283
+ if (missing.length > 0) {
284
+ issues.push({
285
+ code: "dependency_missing",
286
+ work_item_id: block.block_id,
287
+ message: `block '${block.block_id}' declares ${missing.length === 1 ? "a dependency" : "dependencies"} ` +
288
+ `${missing.map((id) => `'${id}'`).join(", ")} present in no block of the plan, so it can ` +
289
+ "never be dependency-verified and is never scheduled",
290
+ });
291
+ continue;
292
+ }
293
+ try {
294
+ assertBlockContract(root, block);
295
+ }
296
+ catch (error) {
297
+ if (!(error instanceof BlockContractError))
298
+ throw error;
299
+ issues.push({
300
+ code: "block_contract_invalid",
301
+ work_item_id: block.block_id,
302
+ message: error.message,
303
+ });
304
+ }
305
+ }
306
+ return issues;
307
+ }
308
+ /**
309
+ * The classified "cannot prepare" message for a producer defect that reached the
310
+ * build path as a throw. Aggregates the whole plan scan so an operator sees
311
+ * EVERY malformed block, not just the first one the builder tripped on.
312
+ *
313
+ * The THROWER is attributed by name, not by whether the scan happened to find
314
+ * anything. Falling back only on an EMPTY scan silently dropped the raised error
315
+ * whenever the scan named some OTHER block — a plan with one ghost dependency
316
+ * elsewhere was enough to make the message describe a block that did not throw
317
+ * and omit the one that did.
318
+ */
319
+ function cannotPrepareMessage(root, state, raised) {
320
+ const scanned = planBlockIssues(root, state);
321
+ const messages = scanned.map((issue) => issue.message);
322
+ if (!scanned.some((issue) => issue.work_item_id === raised.blockId)) {
323
+ messages.push(raised.message);
324
+ }
325
+ return `Cannot prepare a remediation host workload: ${messages.join("; ")}`;
326
+ }
143
327
  function resolveBoundaryPaths(params) {
144
328
  assertSubmissionRunId(params.runId, "remediation host run id");
145
329
  const root = resolve(params.root);
@@ -224,7 +408,14 @@ export function hostDependencyLevels(state) {
224
408
  const permanentlyIneligible = (block) => {
225
409
  for (const dependencyId of block.dependencies ?? []) {
226
410
  const dependency = blockById.get(dependencyId);
227
- if (dependency && !isVerifiedNow(dependency) && !isPending(dependency)) {
411
+ // An id that resolves to NO block is not a harmless declaration — it is a
412
+ // prerequisite that can never be verified, so the block can never become
413
+ // eligible. Guarding on `dependency &&` skipped exactly this case, which
414
+ // is the second half of the same hole as the readiness predicate below:
415
+ // closing only one leaves the block reaching the host anyway.
416
+ if (dependency === undefined)
417
+ return true;
418
+ if (!isVerifiedNow(dependency) && !isPending(dependency)) {
228
419
  return true;
229
420
  }
230
421
  }
@@ -237,7 +428,13 @@ export function hostDependencyLevels(state) {
237
428
  const ready = remaining.filter((block) => phaseBarrierClear(phaseOf(block)) &&
238
429
  (block.dependencies ?? []).every((dependencyId) => {
239
430
  const dependency = blockById.get(dependencyId);
240
- if (!dependency || isVerifiedNow(dependency))
431
+ // DEPENDENCY READINESS REQUIRES EXISTENCE. `!dependency` used to read
432
+ // as "satisfied", so a plan naming a block that does not exist had its
433
+ // dependent placed at level 0 and dispatched with the prerequisite
434
+ // never verified — silently, because no other check looks at it.
435
+ if (dependency === undefined)
436
+ return false;
437
+ if (isVerifiedNow(dependency))
241
438
  return true;
242
439
  return dependency.items.every((findingId) => isVerifiedCompleteStatus(items[findingId]?.status) ||
243
440
  (items[findingId]?.status === "pending" &&
@@ -292,6 +489,10 @@ function buildFindingAssignments(state, block) {
292
489
  });
293
490
  }
294
491
  function buildWorkItem(paths, block, baselineCommit, state) {
492
+ // The consumed-shape gate runs FIRST: a block outside the write-scope /
493
+ // command contract must never become a work item, so nothing downstream can
494
+ // dispatch it or execute its commands.
495
+ assertBlockContract(paths.root, block);
295
496
  const allowedFiles = [...new Set(block.touched_files)].map((path) => normalizeDeclaredPath(paths.root, path, `${block.block_id}.touched_files[]`)).sort(compareCodeUnits);
296
497
  const resultPath = resultPathFor(paths, block.block_id);
297
498
  const requiredTests = [...(block.targeted_commands ?? [])];
@@ -557,6 +758,31 @@ function gitCommitIsAncestor(root, ancestor, descendant) {
557
758
  const result = spawnSyncHidden("git", ["merge-base", "--is-ancestor", ancestor, descendant], { cwd: root, encoding: "utf8", shell: false });
558
759
  return !result.error && result.status === 0;
559
760
  }
761
+ /**
762
+ * Is this commit ORPHANED — unreachable from anything the repository still
763
+ * keeps?
764
+ *
765
+ * "Not an ancestor of HEAD" is NOT orphanhood. A baseline sitting on an
766
+ * unmerged `feature` branch while the work landed on trunk fails the ancestry
767
+ * test exactly like a rewritten-away commit does, and treating that as orphaned
768
+ * would hand the relaxation to the ordinary cross-branch case — precisely the
769
+ * stale-worker situation the ancestry check exists to catch.
770
+ *
771
+ * So orphanhood is the CONJUNCTION of two probes: `git for-each-ref --contains`
772
+ * lists every branch/tag/remote ref whose history contains the commit (empty
773
+ * output = no live ref keeps it), and the HEAD ancestry check rides alongside
774
+ * it because a detached HEAD is not a ref `for-each-ref` enumerates and would
775
+ * otherwise scan clean. A failed scan is not evidence of orphanhood — it fails
776
+ * closed, so a git that cannot answer never unlocks the relaxation.
777
+ */
778
+ function gitCommitIsOrphaned(root, commit) {
779
+ if (gitCommitIsAncestor(root, commit, "HEAD"))
780
+ return false;
781
+ const result = spawnSyncHidden("git", ["for-each-ref", "--contains", commit, "--format=%(refname)"], { cwd: root, encoding: "utf8", shell: false });
782
+ if (result.error || result.status !== 0)
783
+ return false;
784
+ return (result.stdout ?? "").trim().length === 0;
785
+ }
560
786
  function gitChangedFilesOfCommit(root, commit) {
561
787
  const result = spawnSyncHidden("git", [
562
788
  "diff-tree",
@@ -571,26 +797,246 @@ function gitChangedFilesOfCommit(root, commit) {
571
797
  return null;
572
798
  return [...new Set((result.stdout ?? "").split("\0").filter(Boolean))].sort(compareCodeUnits);
573
799
  }
574
- function rerunRequiredTests(root, commands) {
800
+ /**
801
+ * Per-command deadline. A required test is host-authored and may legitimately be
802
+ * a full suite, so the bound is generous; what changed is that hitting it is now
803
+ * a NAMED outcome instead of an unlabelled failure string.
804
+ */
805
+ const REQUIRED_TEST_TIMEOUT_MS = 10 * 60 * 1_000;
806
+ /**
807
+ * Captured output is bounded and TAIL-biased: a failing suite's verdict is at
808
+ * the end, and an unbounded capture would put a whole test log into state and
809
+ * into every rendered issue.
810
+ */
811
+ const CAPTURED_OUTPUT_LIMIT = 4_000;
812
+ /**
813
+ * The spawn's raw capture buffer. Exceeding it does not truncate — node KILLS
814
+ * the child — so the cap is a named constant the `output_overflow` message can
815
+ * quote, rather than a literal buried in the spawn options.
816
+ */
817
+ const REQUIRED_TEST_MAX_BUFFER_BYTES = 8 * 1_024 * 1_024;
818
+ function tail(value) {
819
+ const text = value ?? "";
820
+ return text.length <= CAPTURED_OUTPUT_LIMIT
821
+ ? text
822
+ : `…${text.slice(text.length - CAPTURED_OUTPUT_LIMIT)}`;
823
+ }
824
+ /**
825
+ * Render one classified failure for a host-facing issue message.
826
+ *
827
+ * `output_overflow` says the verdict is UNKNOWN, not that the tests were fine: a
828
+ * child killed at the buffer cap may have been heading for exit 0 or exit 3, and
829
+ * the runner cannot tell which. Either way the item is refused — the honest
830
+ * report is "we could not find out", and it fails closed.
831
+ *
832
+ * A signal-killed child renders the SIGNAL, not `exit null`: `exit_code` is null
833
+ * for every non-exit outcome, so printing it there described nothing.
834
+ */
835
+ function describeRequiredTestFailure(failure) {
836
+ const head = failure.outcome === "timed_out"
837
+ ? `${failure.command} (timed out)`
838
+ : failure.outcome === "output_overflow"
839
+ ? `${failure.command} (killed after exceeding the ${String(REQUIRED_TEST_MAX_BUFFER_BYTES)}-byte ` +
840
+ "output buffer — the run ended at the capture cap, so whether the tests pass is UNKNOWN)"
841
+ : failure.outcome === "spawn_error"
842
+ ? `${failure.command} (could not be started)`
843
+ : failure.exit_code === null
844
+ ? `${failure.command} (terminated by ${failure.signal ?? "an unreported signal"})`
845
+ : `${failure.command} (exit ${String(failure.exit_code)})`;
846
+ const captured = [failure.stdout, failure.stderr]
847
+ .filter((stream) => stream.trim().length > 0)
848
+ .join("\n");
849
+ return captured.length > 0 ? `${head}: ${captured}` : head;
850
+ }
851
+ /**
852
+ * Length-prefixed so the root/command boundary is unambiguous for any path, and
853
+ * printable so the source stays text (a raw separator byte would make the file
854
+ * binary to git and invisible to grep). The root is part of the key because a
855
+ * verdict is a fact about one command in one working tree, and nothing
856
+ * guarantees a single process only ever ingests for one root.
857
+ */
858
+ function requiredTestVerdictKey(root, command) {
859
+ return `${String(root.length)}:${root}:${command}`;
860
+ }
861
+ /**
862
+ * The ONE place a required-test command is spawned.
863
+ *
864
+ * `timeoutMs` is a parameter so the deadline is exercisable: a hang is a
865
+ * first-class outcome of this function, and an outcome that can only be reached
866
+ * by waiting ten real minutes is an outcome nothing ever tests.
867
+ */
868
+ export function runRequiredTest(root, command, timeoutMs = REQUIRED_TEST_TIMEOUT_MS) {
869
+ const result = spawnSync(command, {
870
+ cwd: root,
871
+ shell: true,
872
+ // Captured, not discarded: without it a red ingest reports that something
873
+ // failed and nothing about why.
874
+ encoding: "utf8",
875
+ maxBuffer: REQUIRED_TEST_MAX_BUFFER_BYTES,
876
+ timeout: timeoutMs,
877
+ windowsHide: true,
878
+ });
879
+ const stdout = tail(result.stdout);
880
+ const stderr = tail(result.stderr);
881
+ // The ERROR CODE discriminates, never `signal`. node kills an over-deadline
882
+ // child AND an over-`maxBuffer` child, and an external `kill` sets `signal`
883
+ // too — so `signal !== null` was true for three unrelated facts and reported
884
+ // all of them as a hang, including a command killed purely for printing more
885
+ // than the buffer holds.
886
+ //
887
+ // ASSUMPTION, stated: a deadline miss reports `ETIMEDOUT`. Verified on win32;
888
+ // it is node's documented contract, not a platform quirk this code confirmed
889
+ // everywhere. On a platform that killed a child at the deadline WITHOUT that
890
+ // code, the case degrades to `spawn_error` — a less specific refusal, still a
891
+ // refusal, so the fail direction holds and only the label is lost.
892
+ const code = result.error?.code;
893
+ if (code === "ETIMEDOUT") {
894
+ return { command, outcome: "timed_out", exit_code: null, stdout, stderr };
895
+ }
896
+ if (code === "ENOBUFS") {
897
+ return {
898
+ command,
899
+ outcome: "output_overflow",
900
+ exit_code: null,
901
+ stdout,
902
+ stderr,
903
+ };
904
+ }
905
+ if (result.error) {
906
+ return {
907
+ command,
908
+ outcome: "spawn_error",
909
+ exit_code: null,
910
+ stdout,
911
+ stderr: stderr.length > 0 ? stderr : result.error.message,
912
+ };
913
+ }
914
+ // Killed by something outside this runner (an operator `kill`, an OOM reaper).
915
+ // Reported as FAILED with the signal named: the command did not complete, and
916
+ // calling it a deadline miss would attribute it to a bound this runner set.
917
+ //
918
+ // POSIX-ONLY IN PRACTICE, and UNTESTED for that reason: Windows has no signal
919
+ // delivery to report here — a killed child surfaces as an ordinary non-zero
920
+ // `status` with `signal` null — so this branch is unreachable on the platform
921
+ // this repo runs its suites on, and no test exercises it. It is kept because
922
+ // the runner is OS-agnostic by contract, not because it has been observed.
923
+ if (result.signal !== null) {
924
+ return {
925
+ command,
926
+ outcome: "failed",
927
+ exit_code: null,
928
+ stdout,
929
+ stderr,
930
+ signal: result.signal,
931
+ };
932
+ }
933
+ if (result.status !== 0) {
934
+ return {
935
+ command,
936
+ outcome: "failed",
937
+ exit_code: result.status,
938
+ stdout,
939
+ stderr,
940
+ };
941
+ }
942
+ return null;
943
+ }
944
+ function rerunRequiredTests(root, commands,
945
+ /** `null` on the normal lane — see {@link RemediationRequiredTestVerdicts}. */
946
+ verdicts) {
575
947
  const failures = [];
576
948
  for (const command of commands) {
577
- const result = spawnSync(command, {
578
- cwd: root,
579
- shell: true,
580
- stdio: "ignore",
581
- timeout: 10 * 60 * 1_000,
582
- windowsHide: true,
583
- });
584
- if (result.error || result.status !== 0) {
585
- failures.push(`${command} (${result.error?.message ?? `exit ${String(result.status)}`})`);
949
+ if (verdicts) {
950
+ const verdict = verdicts.get(requiredTestVerdictKey(root, command));
951
+ if (verdict === undefined) {
952
+ failures.push({
953
+ command,
954
+ outcome: "spawn_error",
955
+ exit_code: null,
956
+ stdout: "",
957
+ stderr: "no pre-computed verdict — refusing to spawn a test while the state lock is held",
958
+ });
959
+ }
960
+ else if (verdict !== null) {
961
+ failures.push(verdict);
962
+ }
963
+ continue;
586
964
  }
965
+ const failure = runRequiredTest(root, command);
966
+ if (failure !== null)
967
+ failures.push(failure);
587
968
  }
588
969
  return failures;
589
970
  }
971
+ /**
972
+ * The classified issue for a set of required-test failures. An ENVIRONMENT fact
973
+ * anywhere in the set wins over a red sibling, timeout first: a hung or
974
+ * buffer-killed suite is the fact that explains the ingest, and burying it under
975
+ * a sibling's exit code is exactly the conflation the code split exists to end.
976
+ * Only a set where every failure is a genuine non-zero exit reads as
977
+ * `required_test_failed`.
978
+ */
979
+ function requiredTestIssue(workItem, failures) {
980
+ return {
981
+ code: failures.some((failure) => failure.outcome === "timed_out")
982
+ ? "required_test_timed_out"
983
+ : failures.some((failure) => failure.outcome === "output_overflow")
984
+ ? "required_test_output_overflow"
985
+ : "required_test_failed",
986
+ work_item_id: workItem.id,
987
+ result_path: workItem.result_path,
988
+ message: `mechanical required-test rerun failed: ${failures
989
+ .map(describeRequiredTestFailure)
990
+ .join("; ")}`,
991
+ };
992
+ }
993
+ /**
994
+ * Run every required-test command a recovery ingest could need, ONCE each, and
995
+ * return the finished verdict table. Call this OUTSIDE the state lock — that is
996
+ * the entire point (see {@link RemediationRequiredTestVerdicts}).
997
+ *
998
+ * Candidates are the work items with at least one still-pending finding whose
999
+ * result file is present and parses as JSON; an item with no result file is
1000
+ * refused before its tests would ever run, so spawning for it is pure cost. The
1001
+ * filter is deliberately generous otherwise — over-inclusion costs one spawn,
1002
+ * while under-inclusion becomes a fail-closed refusal of a good result.
1003
+ */
1004
+ export async function precomputeRecoveryTestVerdicts(params) {
1005
+ const state = parseCurrentState(params.state);
1006
+ if (!state)
1007
+ return "unsupported_retired_state";
1008
+ const paths = resolveBoundaryPaths(params);
1009
+ const verdicts = new Map();
1010
+ const workloadRead = await readSubmissionDocument(paths.workloadPath);
1011
+ if (workloadRead.kind !== "value")
1012
+ return verdicts;
1013
+ const workload = parseWorkload(workloadRead.value, paths, params.runId, state);
1014
+ if (!workload)
1015
+ return verdicts;
1016
+ const commands = [];
1017
+ for (const workItem of workload.work_items) {
1018
+ const hasPending = workItem.finding_ids.some((findingId) => state.items[findingId]?.status === "pending");
1019
+ if (!hasPending)
1020
+ continue;
1021
+ const absoluteResultPath = resolveContainedPath(paths.root, workItem.result_path, `result path for ${workItem.id}`);
1022
+ const resultRead = await readSubmissionDocument(absoluteResultPath);
1023
+ if (resultRead.kind !== "value")
1024
+ continue;
1025
+ for (const command of workItem.required_tests) {
1026
+ if (!commands.includes(command))
1027
+ commands.push(command);
1028
+ }
1029
+ }
1030
+ for (const command of commands) {
1031
+ verdicts.set(requiredTestVerdictKey(paths.root, command), runRequiredTest(paths.root, command));
1032
+ }
1033
+ return verdicts;
1034
+ }
590
1035
  function corroborateHostResult(params) {
591
- const { root, state, workItem, result } = params;
1036
+ const { root, state, workItem, result, verdicts } = params;
592
1037
  const baseline = workItem.baseline_commit;
593
1038
  const landed = result.commit_evidence.after;
1039
+ let usedRecovery = false;
594
1040
  if (!gitCommitExists(root, baseline) || !gitCommitExists(root, landed)) {
595
1041
  return {
596
1042
  ok: false,
@@ -599,11 +1045,32 @@ function corroborateHostResult(params) {
599
1045
  };
600
1046
  }
601
1047
  if (!gitCommitIsAncestor(root, baseline, landed)) {
602
- return {
603
- ok: false,
604
- code: "baseline_not_ancestor",
605
- message: "the trusted workload baseline is not an ancestor of the claimed landed commit",
606
- };
1048
+ if (!params.recovery) {
1049
+ return {
1050
+ ok: false,
1051
+ code: "baseline_not_ancestor",
1052
+ message: "the trusted workload baseline is not an ancestor of the claimed landed commit",
1053
+ };
1054
+ }
1055
+ // The relaxation is precondition-bound: it applies ONLY when the trusted
1056
+ // baseline is genuinely ORPHANED — contained by no ref AND unreachable from
1057
+ // HEAD (see gitCommitIsOrphaned). That is the one state in which no landed
1058
+ // commit could ever descend from it, so the item is unacceptable under
1059
+ // every preparable binding. A baseline the repository still keeps — on an
1060
+ // unmerged branch, a tag, a remote ref, or HEAD itself — is a HEALTHY
1061
+ // binding, and a landed commit that does not descend from it is exactly the
1062
+ // stale-worker case the ancestry check exists to catch; recovery refuses it
1063
+ // identically to the normal lane.
1064
+ if (!gitCommitIsOrphaned(root, baseline)) {
1065
+ return {
1066
+ ok: false,
1067
+ code: "baseline_not_ancestor",
1068
+ message: "the trusted workload baseline is not an ancestor of the claimed landed commit, " +
1069
+ "and the baseline is NOT orphaned (a ref still contains it, or it is reachable " +
1070
+ "from HEAD), so the stale-worker protection stands and recovery cannot waive it",
1071
+ };
1072
+ }
1073
+ usedRecovery = true;
607
1074
  }
608
1075
  if (!gitCommitIsAncestor(root, landed, "HEAD")) {
609
1076
  return {
@@ -636,15 +1103,12 @@ function corroborateHostResult(params) {
636
1103
  message: `landed files overlap pre-existing run-start dirt: ${dirtyOverlap.join(", ")}`,
637
1104
  };
638
1105
  }
639
- const failedTests = rerunRequiredTests(root, workItem.required_tests);
1106
+ const failedTests = rerunRequiredTests(root, workItem.required_tests, verdicts);
640
1107
  if (failedTests.length > 0) {
641
- return {
642
- ok: false,
643
- code: "required_test_failed",
644
- message: `mechanical required-test rerun failed: ${failedTests.join("; ")}`,
645
- };
1108
+ const issue = requiredTestIssue(workItem, failedTests);
1109
+ return { ok: false, code: issue.code, message: issue.message };
646
1110
  }
647
- return { ok: true, changedFiles: actualFiles };
1111
+ return { ok: true, changedFiles: actualFiles, usedRecovery };
648
1112
  }
649
1113
  export async function prepareRemediationHostHandoff(params) {
650
1114
  const state = parseCurrentState(params.state);
@@ -665,17 +1129,39 @@ export async function prepareRemediationHostHandoff(params) {
665
1129
  }
666
1130
  }
667
1131
  const baselineCommit = existingRecord?.baseline_commit ?? params.baselineCommit;
668
- const workload = buildCanonicalWorkload({
669
- paths,
670
- state,
671
- runId: params.runId,
672
- baselineCommit,
673
- ...(existingRecord
674
- ? { workItemIds: existingRecord.work_item_ids }
675
- : {}),
676
- });
1132
+ let workload;
1133
+ try {
1134
+ workload = buildCanonicalWorkload({
1135
+ paths,
1136
+ state,
1137
+ runId: params.runId,
1138
+ baselineCommit,
1139
+ ...(existingRecord
1140
+ ? { workItemIds: existingRecord.work_item_ids }
1141
+ : {}),
1142
+ });
1143
+ }
1144
+ catch (error) {
1145
+ // A malformed block ON the frontier reaches this as a raw BlockContractError
1146
+ // — an uncaught throw whose stack says nothing about which producer wrote the
1147
+ // bad block, and which every retry reproduces. Re-raised in the SAME
1148
+ // classified aggregate form the empty-workload branch below uses, so both
1149
+ // producer-defect exits read alike.
1150
+ if (!(error instanceof BlockContractError))
1151
+ throw error;
1152
+ throw new Error(cannotPrepareMessage(paths.root, state, error));
1153
+ }
677
1154
  if (workload.work_items.length === 0) {
678
- throw new Error("Cannot prepare an empty remediation host workload");
1155
+ // Name the producer defect when it is the cause. An empty level 0 that is
1156
+ // really "every candidate block declares a prerequisite that does not
1157
+ // exist" used to surface as a bare "empty workload", sending the operator
1158
+ // to look at scheduling rather than at the plan.
1159
+ const blocked = planBlockIssues(paths.root, state);
1160
+ throw new Error(blocked.length === 0
1161
+ ? "Cannot prepare an empty remediation host workload"
1162
+ : `Cannot prepare a remediation host workload: ${blocked
1163
+ .map((issue) => issue.message)
1164
+ .join("; ")}`);
679
1165
  }
680
1166
  const workloadDigest = hostWorkloadSha256(workload);
681
1167
  if (existingRecord &&
@@ -697,6 +1183,48 @@ export async function prepareRemediationHostHandoff(params) {
697
1183
  handoff_record: handoffRecord,
698
1184
  };
699
1185
  }
1186
+ /**
1187
+ * Consume the host's landed results for the trusted workload.
1188
+ *
1189
+ * ## The `recovery` option, and what it actually buys
1190
+ *
1191
+ * A trusted binding can be stranded: a post-prepare `git commit --amend` (or
1192
+ * any history rewrite) re-mints the baseline the workload was bound to, leaving
1193
+ * it ORPHANED — contained by no ref and unreachable from HEAD. Every commit the
1194
+ * host then lands sits on the re-minted line, so `baseline → landed` ancestry is
1195
+ * false for all of them, and re-preparing does not help: a fresh binding must be
1196
+ * minted at HEAD, and HEAD is a DESCENDANT of the landed work. The items are
1197
+ * unacceptable under every preparable binding, with real, reachable,
1198
+ * correctly-scoped commits on disk.
1199
+ *
1200
+ * `recovery` waives ONE check — baseline→landed ancestry — and only when
1201
+ * the baseline is genuinely orphaned by BOTH probes in `gitCommitIsOrphaned`: no
1202
+ * branch/tag/remote ref contains it, and it is not reachable from HEAD. A
1203
+ * baseline the repository still keeps (an unmerged feature branch, a tag, a
1204
+ * remote ref) also fails the ancestry test when work lands elsewhere, and that
1205
+ * is the ordinary stale-worker case — recovery refuses it. Every other
1206
+ * corroboration check runs unchanged (the landed commit exists and is reachable
1207
+ * from HEAD; its mechanically derived changed files exactly equal
1208
+ * `changed_files` and lie within the prompt-bound `allowed_files`; no overlap
1209
+ * with run-start dirt; the required tests rerun green), `parseResult` stays
1210
+ * fully strict, and dependency/phase eligibility is enforced exactly as on the
1211
+ * normal lane.
1212
+ *
1213
+ * RESIDUAL RISK, stated plainly: under an orphaned baseline the evidence bar
1214
+ * drops to "the claimed commit is reachable from the current green HEAD and
1215
+ * matches this item's scope exactly". That CANNOT prove the work was built on
1216
+ * the trusted baseline — a commit landed from a stale or unrelated starting
1217
+ * tree satisfies it as long as its own file set stays in scope. Ancestry is the
1218
+ * check that would have caught that, and it is the one being waived. Which is
1219
+ * precisely why the relaxation costs an explicit operator verb, is gated on the
1220
+ * orphan precondition, and is marked `accepted_via_recovery` on the submission
1221
+ * ledger before the item lands — and why the normal lane keeps the full check.
1222
+ *
1223
+ * In recovery mode this function performs NO required-test spawn: the verdicts
1224
+ * arrive pre-computed on the `recovery` option, and a command missing from that
1225
+ * table is treated as failed. Its caller runs the tests first, unlocked — see
1226
+ * `recoverIngestHostResults`.
1227
+ */
700
1228
  export async function ingestRemediationHostResults(params) {
701
1229
  const state = parseCurrentState(params.state);
702
1230
  if (!state)
@@ -725,33 +1253,48 @@ export async function ingestRemediationHostResults(params) {
725
1253
  state: nextState,
726
1254
  };
727
1255
  }
1256
+ // Producer-side plan defects are reported BEFORE the workload is parsed: a
1257
+ // block with an unresolvable dependency or an unnormalized write scope makes
1258
+ // the whole workload fail to re-derive, and `workload_invalid` alone would
1259
+ // name the symptom while hiding which block caused it.
1260
+ //
1261
+ // REPORTED, never fatal. A defect in a NON-frontier block says nothing about a
1262
+ // frontier item's landed result, and refusing the whole ingest over one made
1263
+ // the run unadvanceable: every ingest returned zero acceptances, `next-step`
1264
+ // read `state_changed: false` and re-emitted the same items against the same
1265
+ // malformed plan, forever. The frontier's OWN defect is enforced elsewhere and
1266
+ // does not rely on this: `parseWorkItem` re-derives each bound work item
1267
+ // through `buildWorkItem`, whose `assertBlockContract` throws, so a malformed
1268
+ // bound block fails the workload parse and its commands never run.
1269
+ issues.push(...planBlockIssues(paths.root, state));
728
1270
  if (isGitRepo(paths.root) && !state.host_handoff) {
1271
+ issues.push({
1272
+ code: "trusted_binding_missing",
1273
+ message: "a git-backed remediation workload requires the tool-owned host_handoff state binding",
1274
+ });
729
1275
  return {
730
1276
  accepted_count: 0,
731
1277
  completed_work_item_ids: [],
732
1278
  pending_work_item_ids: [],
733
- issues: [
734
- {
735
- code: "trusted_binding_missing",
736
- message: "a git-backed remediation workload requires the tool-owned host_handoff state binding",
737
- },
738
- ],
1279
+ issues,
739
1280
  state_changed: false,
740
1281
  state: nextState,
741
1282
  };
742
1283
  }
743
1284
  const workload = parseWorkload(workloadRead.value, paths, params.runId, state);
744
1285
  if (!workload) {
1286
+ // Accumulated, not replaced: when a block-contract defect is WHY the
1287
+ // canonical re-derivation failed, the block-attributed issue is the only
1288
+ // thing that names the cause.
1289
+ issues.push({
1290
+ code: "workload_invalid",
1291
+ message: "the workload does not match its canonical state shape and persisted digest binding",
1292
+ });
745
1293
  return {
746
1294
  accepted_count: 0,
747
1295
  completed_work_item_ids: [],
748
1296
  pending_work_item_ids: state.host_handoff?.work_item_ids ?? [],
749
- issues: [
750
- {
751
- code: "workload_invalid",
752
- message: "the workload does not match its canonical state shape and persisted digest binding",
753
- },
754
- ],
1297
+ issues,
755
1298
  state_changed: false,
756
1299
  state: nextState,
757
1300
  };
@@ -759,6 +1302,15 @@ export async function ingestRemediationHostResults(params) {
759
1302
  const eligibleIds = new Set((hostDependencyLevels(state)[0] ?? []).map((block) => block.block_id));
760
1303
  const resultIds = new Set();
761
1304
  const completed = [];
1305
+ // Recovery-only answer table; the normal lane gets null and spawns exactly as
1306
+ // it always has. See RemediationRequiredTestVerdicts.
1307
+ const requiredTestVerdicts = params.recovery?.requiredTestVerdicts ?? null;
1308
+ // Lazily loaded on the first recovery-marked acceptance: the recovery marks
1309
+ // the ledger ALREADY carries. A crash between the append and the state write
1310
+ // leaves a mark whose item is still pending, and the natural response is to
1311
+ // re-run the verb — which must converge, not accumulate a second record of
1312
+ // the same acceptance.
1313
+ let recordedRecoveryMarks = null;
762
1314
  const landedFiles = new Set(nextState.applied_edit_surface ?? []);
763
1315
  const requireRepositoryCorroboration = state.host_handoff !== undefined || isGitRepo(paths.root);
764
1316
  for (const workItem of workload.work_items) {
@@ -820,14 +1372,9 @@ export async function ingestRemediationHostResults(params) {
820
1372
  const result = parsed.result;
821
1373
  const outcome = result.outcome;
822
1374
  if (outcome.status === "resolved_no_change") {
823
- const failedTests = rerunRequiredTests(paths.root, workItem.required_tests);
1375
+ const failedTests = rerunRequiredTests(paths.root, workItem.required_tests, requiredTestVerdicts);
824
1376
  if (failedTests.length > 0) {
825
- issues.push({
826
- code: "required_test_failed",
827
- work_item_id: workItem.id,
828
- result_path: workItem.result_path,
829
- message: `mechanical required-test rerun failed: ${failedTests.join("; ")}`,
830
- });
1377
+ issues.push(requiredTestIssue(workItem, failedTests));
831
1378
  continue;
832
1379
  }
833
1380
  }
@@ -874,6 +1421,8 @@ export async function ingestRemediationHostResults(params) {
874
1421
  state,
875
1422
  workItem,
876
1423
  result,
1424
+ verdicts: requiredTestVerdicts,
1425
+ recovery: params.recovery !== undefined,
877
1426
  });
878
1427
  if (!corroborated.ok) {
879
1428
  issues.push({
@@ -884,6 +1433,55 @@ export async function ingestRemediationHostResults(params) {
884
1433
  });
885
1434
  continue;
886
1435
  }
1436
+ if (corroborated.usedRecovery) {
1437
+ // No acceptance without a record. The mark goes down BEFORE the item is
1438
+ // marked resolved, and an append that throws refuses this item rather
1439
+ // than landing an acceptance the ledger cannot account for — the run
1440
+ // must never read as one that never drifted. The refusal is per item:
1441
+ // an unwritable ledger is not a reason to discard the whole ingest.
1442
+ try {
1443
+ recordedRecoveryMarks ??= (await readSubmissionLedger(paths.artifactsDir)).filter((event) => event.kind === "accepted_via_recovery");
1444
+ // The mark's identity is (run, item, LANDED COMMIT), not just
1445
+ // (run, item): an item re-opened and later re-accepted from a
1446
+ // DIFFERENT landing is a different relaxed acceptance and earns its
1447
+ // own record. Only a retry of the SAME landing is a duplicate. The
1448
+ // landed sha is matched inside the message because the shared event
1449
+ // contract carries no commit field, and a 40-hex sha this writer
1450
+ // itself emitted is an unambiguous token to match on.
1451
+ const landedCommit = result.commit_evidence.after;
1452
+ const alreadyMarked = recordedRecoveryMarks.some((event) => event.run_id === params.runId &&
1453
+ event.submission_id === workItem.id &&
1454
+ (event.message ?? "").includes(landedCommit));
1455
+ if (!alreadyMarked) {
1456
+ const event = {
1457
+ contract_version: SUBMISSION_LEDGER_EVENT_CONTRACT_VERSION,
1458
+ run_id: params.runId,
1459
+ submission_id: workItem.id,
1460
+ lane: workItem.id,
1461
+ kind: "accepted_via_recovery",
1462
+ // Derived from what was actually probed, never asserted: the
1463
+ // baseline was found in no ref and unreachable from HEAD.
1464
+ message: `accepted under recovery: the trusted baseline ${workItem.baseline_commit} is ` +
1465
+ "contained by no ref and unreachable from HEAD, so landed commit " +
1466
+ `${landedCommit} was corroborated against HEAD and this ` +
1467
+ "item's bound scope instead of against baseline ancestry",
1468
+ recorded_at: new Date().toISOString(),
1469
+ };
1470
+ await appendSubmissionEvent(paths.artifactsDir, event);
1471
+ recordedRecoveryMarks.push(event);
1472
+ }
1473
+ }
1474
+ catch (error) {
1475
+ issues.push({
1476
+ code: "recovery_unrecorded",
1477
+ work_item_id: workItem.id,
1478
+ result_path: workItem.result_path,
1479
+ message: "the recovery acceptance could not be recorded on the submission ledger, so it " +
1480
+ `was refused: ${error instanceof Error ? error.message : String(error)}`,
1481
+ });
1482
+ continue;
1483
+ }
1484
+ }
887
1485
  acceptedFiles = corroborated.changedFiles;
888
1486
  }
889
1487
  const completedAt = new Date().toISOString();