onto-mcp 0.4.11 → 0.4.13

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 (139) hide show
  1. package/.onto/authority/core-lexicon.yaml +1 -1
  2. package/.onto/authority/supported-models.yaml +153 -0
  3. package/.onto/domains/accounting-kr/competency_qs.md +153 -153
  4. package/.onto/processes/reconstruct/obligation-coverage-ledger.yaml +842 -0
  5. package/.onto/processes/reconstruct/obligation-coverage-recorded.yaml +225 -0
  6. package/.onto/processes/reconstruct/ontology-seeding-and-maturation-design.md +93 -54
  7. package/.onto/processes/reconstruct/reconstruct-contract-registry.yaml +202 -49
  8. package/.onto/processes/reconstruct/reconstruct-execution-ux-contract.md +72 -1
  9. package/.onto/processes/reconstruct/source-profiles/spreadsheet.md +35 -0
  10. package/.onto/processes/review/nesting-batch-worker-contract.md +1 -1
  11. package/.onto/processes/review/productized-live-path.md +9 -5
  12. package/.onto/processes/review/prompt-execution-runner-contract.md +32 -8
  13. package/.onto/processes/review/review-target-profile-contract.md +47 -4
  14. package/.onto/processes/shared/pipeline-execution-ledger-contract.md +11 -0
  15. package/.onto/processes/shared/target-material-kind-contract.md +1 -1
  16. package/README.md +64 -1
  17. package/dist/cli.js +11 -1
  18. package/dist/core-api/reconstruct-api.js +679 -18
  19. package/dist/core-api/review-api.js +70 -5
  20. package/dist/core-api/review-progress.js +7 -0
  21. package/dist/core-api/runtime-observation.js +7 -0
  22. package/dist/core-runtime/artifact-io.js +131 -0
  23. package/dist/core-runtime/cli/claude-code-review-unit-executor.js +21 -9
  24. package/dist/core-runtime/cli/claude-nesting-batch-worker.js +4 -2
  25. package/dist/core-runtime/cli/materialize-review-prompt-packets.js +68 -14
  26. package/dist/core-runtime/cli/prepare-review-session.js +34 -1
  27. package/dist/core-runtime/cli/render-review-final-output.js +2 -1
  28. package/dist/core-runtime/cli/run-review-prompt-execution.js +758 -48
  29. package/dist/core-runtime/cli/run-seats.js +18 -0
  30. package/dist/core-runtime/cli/runtime-submit-context.js +10 -2
  31. package/dist/core-runtime/cli/unit-resubmit.js +269 -0
  32. package/dist/core-runtime/cli/worker-structured-output.js +80 -2
  33. package/dist/core-runtime/discovery/host-detection.js +71 -22
  34. package/dist/core-runtime/discovery/review-cert-assemble.js +208 -0
  35. package/dist/core-runtime/discovery/review-cert-record.js +442 -0
  36. package/dist/core-runtime/discovery/review-execution-units.js +22 -0
  37. package/dist/core-runtime/discovery/seat-inventory.js +167 -0
  38. package/dist/core-runtime/discovery/settings-chain.js +311 -52
  39. package/dist/core-runtime/discovery/supported-models.js +289 -25
  40. package/dist/core-runtime/discovery/synthesize-cert-assemble.js +197 -0
  41. package/dist/core-runtime/discovery/synthesize-cert-capsule.js +399 -0
  42. package/dist/core-runtime/discovery/synthesize-cert-judge.js +340 -0
  43. package/dist/core-runtime/discovery/synthesize-cert-loop.js +234 -0
  44. package/dist/core-runtime/discovery/synthesize-cert-mutation.js +117 -0
  45. package/dist/core-runtime/discovery/synthesize-cert-packet.js +182 -0
  46. package/dist/core-runtime/discovery/synthesize-cert-record.js +831 -0
  47. package/dist/core-runtime/discovery/synthesize-cert-sampler.js +336 -0
  48. package/dist/core-runtime/effort-calibration-ingest.js +326 -0
  49. package/dist/core-runtime/effort-calibration-reconstruct.js +47 -0
  50. package/dist/core-runtime/effort-calibration-review.js +22 -0
  51. package/dist/core-runtime/effort-calibration-sweep.js +110 -0
  52. package/dist/core-runtime/effort-frontier.js +134 -0
  53. package/dist/core-runtime/llm/claude-bin.js +77 -0
  54. package/dist/core-runtime/llm/dispatch-breaker.js +412 -0
  55. package/dist/core-runtime/llm/dispatch-fallback-adapter-capabilities.js +33 -0
  56. package/dist/core-runtime/llm/llm-caller.js +316 -35
  57. package/dist/core-runtime/llm/mock-llm-realization.js +35 -4
  58. package/dist/core-runtime/llm/model-switcher.js +1 -1
  59. package/dist/core-runtime/llm/openai-responses-incomplete-error.js +46 -0
  60. package/dist/core-runtime/llm/sealed-dispatch-capability.js +359 -0
  61. package/dist/core-runtime/llm/structured-dispatch-error.js +15 -0
  62. package/dist/core-runtime/onboard/claude-profile-scan.js +65 -0
  63. package/dist/core-runtime/onboard/cli-host.js +5 -2
  64. package/dist/core-runtime/onboard/configure-provider.js +33 -1
  65. package/dist/core-runtime/onboard/host-target.js +28 -3
  66. package/dist/core-runtime/onboard/register.js +54 -12
  67. package/dist/core-runtime/pipeline-execution-ledger.js +10 -0
  68. package/dist/core-runtime/reconstruct/artifact-types.js +37 -0
  69. package/dist/core-runtime/reconstruct/benchmark-evidence.js +16 -0
  70. package/dist/core-runtime/reconstruct/claim-projection-validation.js +19 -5
  71. package/dist/core-runtime/reconstruct/competency-projection-contract.js +63 -0
  72. package/dist/core-runtime/reconstruct/comprehension-artifact.js +321 -0
  73. package/dist/core-runtime/reconstruct/comprehension-reduce.js +367 -0
  74. package/dist/core-runtime/reconstruct/comprehension-semantic-map.js +771 -0
  75. package/dist/core-runtime/reconstruct/contract-registry.js +20 -0
  76. package/dist/core-runtime/reconstruct/directive-validation.js +4 -3
  77. package/dist/core-runtime/reconstruct/dispatch-fallback-artifacts.js +640 -0
  78. package/dist/core-runtime/reconstruct/execution-telemetry.js +84 -9
  79. package/dist/core-runtime/reconstruct/final-output-sections.js +89 -0
  80. package/dist/core-runtime/reconstruct/governing-snapshot.js +36 -1
  81. package/dist/core-runtime/reconstruct/leaf-reader.js +305 -0
  82. package/dist/core-runtime/reconstruct/llm-dispatch-failure.js +270 -0
  83. package/dist/core-runtime/reconstruct/llm-touch-fingerprint.js +94 -0
  84. package/dist/core-runtime/reconstruct/material-admission-validation.js +20 -5
  85. package/dist/core-runtime/reconstruct/material-profile-validation.js +24 -6
  86. package/dist/core-runtime/reconstruct/materialize-preparation.js +380 -23
  87. package/dist/core-runtime/reconstruct/maturation-validation.js +1522 -67
  88. package/dist/core-runtime/reconstruct/mock-llm-realization.js +120 -1
  89. package/dist/core-runtime/reconstruct/obligation-assertion.js +18 -0
  90. package/dist/core-runtime/reconstruct/ontology-seed-validation.js +39 -7
  91. package/dist/core-runtime/reconstruct/output-budget.js +6 -0
  92. package/dist/core-runtime/reconstruct/pipeline-execution-ledger.js +112 -5
  93. package/dist/core-runtime/reconstruct/post-seed-validation.js +122 -11
  94. package/dist/core-runtime/reconstruct/proof-authority-validation.js +2 -6
  95. package/dist/core-runtime/reconstruct/purpose-authority-validation.js +119 -7
  96. package/dist/core-runtime/reconstruct/record.js +49 -3
  97. package/dist/core-runtime/reconstruct/registry-verification-validation.js +41 -5
  98. package/dist/core-runtime/reconstruct/run-control-validation.js +1019 -37
  99. package/dist/core-runtime/reconstruct/run.js +5409 -517
  100. package/dist/core-runtime/reconstruct/seed-authoring-readiness-validation.js +56 -8
  101. package/dist/core-runtime/reconstruct/semantic-quality-gate.js +112 -7
  102. package/dist/core-runtime/reconstruct/source-observation-delta-validation.js +53 -5
  103. package/dist/core-runtime/reconstruct/source-observations.js +166 -0
  104. package/dist/core-runtime/reconstruct/source-safety-validation.js +77 -188
  105. package/dist/core-runtime/reconstruct/source-scout-pack-validation.js +50 -6
  106. package/dist/core-runtime/reconstruct/terminal-validation.js +145 -13
  107. package/dist/core-runtime/review/continuation-plan.js +12 -5
  108. package/dist/core-runtime/review/issue-artifact-runtime.js +4 -1
  109. package/dist/core-runtime/review/materializers.js +100 -11
  110. package/dist/core-runtime/review/nesting-batch.js +6 -2
  111. package/dist/core-runtime/review/obligation-shardability.js +138 -0
  112. package/dist/core-runtime/review/pipeline-execution-ledger.js +42 -2
  113. package/dist/core-runtime/review/review-artifact-utils.js +219 -7
  114. package/dist/core-runtime/review/review-execution-steps.js +32 -8
  115. package/dist/core-runtime/review/review-progress-contract.js +8 -0
  116. package/dist/core-runtime/review/review-prompt-budget.js +97 -0
  117. package/dist/core-runtime/review/semantic-quality-gate.js +33 -7
  118. package/dist/core-runtime/review/spreadsheet-review-disposition.js +94 -0
  119. package/dist/core-runtime/route-identity.js +182 -0
  120. package/dist/core-runtime/spreadsheet-header-escalation.js +204 -0
  121. package/dist/core-runtime/spreadsheet-structure-observer.js +2689 -0
  122. package/dist/core-runtime/target-material-kind.js +58 -0
  123. package/dist/mcp/server.js +27 -5
  124. package/dist/mcp/tool-schemas.js +8 -0
  125. package/dist/tui/app.js +167 -0
  126. package/dist/tui/data/event-follower.js +116 -0
  127. package/dist/tui/data/node-detail.js +42 -0
  128. package/dist/tui/data/projection-poll.js +26 -0
  129. package/dist/tui/data/session-discovery.js +65 -0
  130. package/dist/tui/index.js +108 -0
  131. package/dist/tui/screens/log.js +25 -0
  132. package/dist/tui/screens/node-detail.js +17 -0
  133. package/dist/tui/screens/session-selector.js +26 -0
  134. package/dist/tui/screens/workflow-tree.js +90 -0
  135. package/dist/tui/view-model/reconstruct-adapter.js +162 -0
  136. package/dist/tui/view-model/review-adapter.js +270 -0
  137. package/dist/tui/view-model/tree-view-model.js +19 -0
  138. package/package.json +20 -3
  139. package/settings.example.json +77 -16
@@ -13,7 +13,7 @@ import { lensIdFromRound1ArtifactPath, readValidatedLensSidecarArtifact, writeLe
13
13
  import { allLensOutputsAreSidecars, renderRuntimeFindingLedgerPacket, writeFindingLedgerFromLensSidecars, } from "../review/lens-sidecar-ledger.js";
14
14
  import { effectiveReviewUnitLlmRef } from "../review/review-execution-profile.js";
15
15
  import { buildInitialExecutionResultScaffold, computeReviewFrontier, mergeUnitResultIntoExecutionResult, validateUnitSeatToResult, } from "../review/review-execution-steps.js";
16
- import { isTrustedLedgerUnit } from "../pipeline-execution-ledger.js";
16
+ import { isResolvedLedgerUnit, } from "../pipeline-execution-ledger.js";
17
17
  import { buildReviewExecutionRoute, buildReviewRuntimeRouteArtifactProjection, } from "../review/review-execution-route.js";
18
18
  import { buildIssueScopedDeliberationWorklist, buildIssueScopedLensDeliberationPrompt, buildNoPlannedDeliberationResolution, buildRuntimeIssueDeliberationUnavailableResponse, buildRuntimeUnavailableDeliberationResolution, buildTeamleadIssueResolutionPrompt, deliberationResolutionPath, renderDeliberationMarkdownProjection, validateDeliberationResolutionObject, validateIssueDeliberationResponseObject, } from "../review/controlled-lens-deliberation.js";
19
19
  import { computeLensCompletionBarrier, resolveRequiredParticipatingLensCount, } from "../review/lens-completion-policy.js";
@@ -29,6 +29,11 @@ import { renderReviewUnitBoundaryDetailsSection, } from "../review/unit-boundary
29
29
  import { renderIssueSynthesisPrompt, buildRuntimeIssueSynthesisUnavailableResponse, synthesisLedgerPath, synthesisWorkItemsPath, validateIssueSynthesisResponseOnDisk, writeReviewSynthesisLedger, writeReviewSynthesisWorkItems, writeSynthesisMarkdownFromLedger, } from "../review/synthesis-map-reduce.js";
30
30
  import { isDirectModelCallSelection, normalizeLlmModelSwitcher, } from "../llm/model-switcher.js";
31
31
  import { parseRuntimeIssueDeliberationSchemaContext } from "./runtime-submit-context.js";
32
+ import { parseRuntimeIssueStanceSchemaContext } from "./runtime-submit-context.js";
33
+ import { parseRuntimeIssueSynthesisSchemaContext } from "./runtime-submit-context.js";
34
+ import { salvageInputPathFor } from "./submit-salvage.js";
35
+ import { DispatchBreakerState, TRANSIENT_TRANSPORT_MESSAGE_PATTERNS, buildDispatchIncompleteArtifact, classifySystemicDispatchFailure, dispatchIncompleteArtifactPath, } from "../llm/dispatch-breaker.js";
36
+ import { CORRELATED_VALIDATION_HALT_REASON, applyResubmitErrorSpecToPacket, buildResubmitErrorSpec, classifyUnsupportedEvidenceRefFailure, classifyDeliberationUnsupportedEvidenceRefFailure, classifySynthesisUnsupportedSourceRefFailure, isUnsupportedEvidenceRefFailureMessage, correlatedValidationExceeded, } from "./unit-resubmit.js";
32
37
  import { isReviewArtifactGenerationRealization, semanticQualityEvidenceForArtifactGeneration, } from "../review/artifact-generation-realization.js";
33
38
  function errorMessage(error) {
34
39
  return error instanceof Error ? error.message : String(error);
@@ -204,11 +209,21 @@ class ReviewUnitOutputContractError extends Error {
204
209
  class ReviewIssueArtifactDispatchError extends Error {
205
210
  outcome;
206
211
  originalError;
207
- constructor(message, outcome, originalError) {
212
+ /** Explicit halt_reason override (e.g. `correlated_validation: …`);
213
+ * null keeps the default `Issue artifact generation failed: …` phrasing. */
214
+ haltReason;
215
+ /** 설계 B 트립 전용: halt 시점까지 기록된 배치 outcome 전체(완료+최종
216
+ * 실패). 완료 유닛의 행이 execution-result에 남아야 continuation ledger가
217
+ * 그 유닛을 재디스패치하지 않는다 — 회복 집합 == 미완료 집합 (규칙 5).
218
+ * 트립 외 halt 경로는 빈 배열(현행 동작 보존). */
219
+ batchOutcomes;
220
+ constructor(message, outcome, originalError, haltReason = null, batchOutcomes = []) {
208
221
  super(message);
209
222
  this.name = "ReviewIssueArtifactDispatchError";
210
223
  this.outcome = outcome;
211
224
  this.originalError = originalError;
225
+ this.haltReason = haltReason;
226
+ this.batchOutcomes = batchOutcomes;
212
227
  }
213
228
  }
214
229
  function issueArtifactOutcomeFromError(error) {
@@ -944,18 +959,15 @@ function failureKindFromError(error) {
944
959
  return failureKindFromMessage(error.message);
945
960
  return "unknown";
946
961
  }
962
+ // Shared transient-transport vocabulary is single-sourced in the dispatch
963
+ // breaker module; this consumer adds only its executor-specific extra.
964
+ const TRANSIENT_EXECUTOR_FAILURE_PATTERNS = [
965
+ ...TRANSIENT_TRANSPORT_MESSAGE_PATTERNS,
966
+ "responses_retry",
967
+ ];
947
968
  function isTransientExecutorFailureMessage(message) {
948
969
  const normalized = message.toLowerCase();
949
- return [
950
- "stream disconnected before completion",
951
- "connection reset by peer",
952
- "error sending request",
953
- "failed to connect to websocket",
954
- "transport channel closed",
955
- "http/request failed",
956
- "request failed after",
957
- "responses_retry",
958
- ].some((pattern) => normalized.includes(pattern));
970
+ return TRANSIENT_EXECUTOR_FAILURE_PATTERNS.some((pattern) => normalized.includes(pattern));
959
971
  }
960
972
  function failureKindFromMessage(message) {
961
973
  const normalized = message.toLowerCase();
@@ -992,16 +1004,48 @@ function failureKindFromMessage(message) {
992
1004
  }
993
1005
  return "executor_exit";
994
1006
  }
995
- function shouldRetryUnitFailure(args) {
1007
+ export function shouldRetryUnitFailure(args) {
996
1008
  if (args.attempt >= args.maxRetries)
997
1009
  return false;
998
1010
  const failureKind = failureKindFromError(args.error);
999
- if (failureKind === "empty_output" ||
1000
- failureKind === "output_contract") {
1011
+ if (failureKind === "empty_output")
1001
1012
  return false;
1013
+ if (failureKind === "output_contract") {
1014
+ // §4-2c structural retry gate: an output_contract failure is normally
1015
+ // terminal, but a resubmit-correctable whitelist rejection is
1016
+ // substring-misclassified as output_contract when its message contains an
1017
+ // envelope field name (synthesis: always; deliberation: rare hallucinated
1018
+ // ref). Route it back to a corrective retry only when resubmit will actually
1019
+ // fire on it — otherwise keep the terminal, byte-identical behavior.
1020
+ return isResubmitCorrectableRetry(args);
1002
1021
  }
1003
1022
  return true;
1004
1023
  }
1024
+ /**
1025
+ * Allow an output_contract retry iff resubmit is enabled AND the unit is
1026
+ * gate-eligible AND the precise structural classifier matches — making the
1027
+ * gate's activation a strict subset of the resubmit strategy's activation
1028
+ * (design §10: F-1 retry ⟺ strategy fires). Reads the shared
1029
+ * RESUBMIT_UNIT_ROUTING table so the gate and dispatcher cannot diverge (M-1).
1030
+ * OFF (resubmit disabled) → false → byte-identical to output_contract being
1031
+ * terminal. Issue-stance is gate-eligible as of the rare-poison hardening cut:
1032
+ * final stance demote/correlated decisions still read the terminal outcome, so
1033
+ * an infra final failure remains a whole-run halt rather than being
1034
+ * reinterpreted as validation.
1035
+ */
1036
+ function isResubmitCorrectableRetry(args) {
1037
+ if (args.reviewExecutionProfile?.retry?.resubmit?.enabled !== true) {
1038
+ return false;
1039
+ }
1040
+ const outputFormat = args.dispatch.output_format;
1041
+ const routing = outputFormat
1042
+ ? RESUBMIT_UNIT_ROUTING[outputFormat]
1043
+ : undefined;
1044
+ if (!routing || !routing.gateEligible)
1045
+ return false;
1046
+ const message = args.error instanceof Error ? args.error.message : String(args.error);
1047
+ return routing.classify(message) !== null;
1048
+ }
1005
1049
  function retryTimeoutMs(baseTimeoutMs, attempt) {
1006
1050
  const multiplier = attempt + 1;
1007
1051
  const expanded = baseTimeoutMs * multiplier;
@@ -1009,6 +1053,369 @@ function retryTimeoutMs(baseTimeoutMs, attempt) {
1009
1053
  ? Math.floor(expanded)
1010
1054
  : baseTimeoutMs;
1011
1055
  }
1056
+ /**
1057
+ * 설계 B: 리뷰 fan-out 풀(lens/stance)의 dispatch breaker (opt-in,
1058
+ * `review.execution.retry.dispatch_breaker`). 리뷰는 per-unit bounded retry가
1059
+ * 이미 있으므로 backoff 재시도는 얹지 않는다(규칙 1은 기존 유닛 재시도
1060
+ * 예산으로 충족; policy의 backoff_* 필드는 리뷰 배선에서 미소비) — 최종
1061
+ * outcome 기록 + 계통 임계 감지 + 배치 halt + 미완료 아티팩트만 추가한다.
1062
+ * OFF(기본) = 현행 halt/배리어 동작 보존.
1063
+ */
1064
+ function reviewDispatchBreakerFromProfile(profile, args) {
1065
+ const policy = profile?.retry?.dispatch_breaker;
1066
+ // Review lens/stance pools can be Promise.all fan-outs, so completion order
1067
+ // must not decide poison-vs-outage recovery classification. This is
1068
+ // runtime-owned; it is deliberately not a user-facing settings key.
1069
+ return policy?.enabled === true
1070
+ ? new DispatchBreakerState({ ...policy, concurrent: args.concurrent })
1071
+ : null;
1072
+ }
1073
+ /** 리뷰 경로는 `invokeExecutor` 직행이라 dispatch 마커가 없다 — 최종
1074
+ * failure.message 기반 분류를 쓴다. stderr 기반이라 content-derived 오분류
1075
+ * 리스크는 낮으나 잔여 리스크로 기록 (handoff §3.2). */
1076
+ function reviewSystemicFailureClassFromOutcome(outcome) {
1077
+ return classifySystemicDispatchFailure(outcome.failure?.message);
1078
+ }
1079
+ /** §4-1: record a nested-pool unit's FINAL outcome to the dispatch breaker.
1080
+ *
1081
+ * A batch-window SUCCESS (`nestedBatchWindow` + success) is recorded as skipped:
1082
+ * completed for the recovery set, but no proof the provider lane is alive THIS
1083
+ * run, so it must not reset a systemic streak. A FAILURE — whether from the
1084
+ * batch window or a real flat dispatch — is classified like the flat path
1085
+ * (item-local → dead-letter, systemic → recovery victim); it is never skipped
1086
+ * (교차검증 F2/Finding A: skipping a failure mis-labels it completed and drops
1087
+ * it from the recovery set, diverging from the flat path). A real flat success
1088
+ * drives `recordItemSuccess`. Returns the trip state on the threshold crossing so
1089
+ * the caller can freeze halt attribution to it. */
1090
+ export function recordNestedUnitOutcomeToBreaker(breaker, outcome) {
1091
+ if (outcome.nestedBatchWindow && outcome.success) {
1092
+ breaker.recordItemSkipped(outcome.dispatch.unit_id);
1093
+ return null;
1094
+ }
1095
+ if (outcome.success) {
1096
+ breaker.recordItemSuccess(outcome.dispatch.unit_id);
1097
+ return null;
1098
+ }
1099
+ return breaker.recordItemFailure({
1100
+ item_id: outcome.dispatch.unit_id,
1101
+ failure_class: reviewSystemicFailureClassFromOutcome(outcome),
1102
+ failure_message: outcome.failure?.message ?? "unknown error",
1103
+ attempt_count: outcome.attemptCount ?? 1,
1104
+ });
1105
+ }
1106
+ /** halt_reason vocabulary for a review-side breaker trip — the prefix is the
1107
+ * grep/consumer key (reconstruct의 DispatchBreakerTrippedError 문구와 동형). */
1108
+ export const REVIEW_DISPATCH_BREAKER_HALT_REASON_PREFIX = "dispatch_breaker";
1109
+ function reviewDispatchBreakerHaltReason(trip, incompleteArtifactPath) {
1110
+ return `${REVIEW_DISPATCH_BREAKER_HALT_REASON_PREFIX}: ${trip.failure_class} failed ${trip.consecutive_item_count} consecutive units (threshold ${trip.threshold}) — batch halted, incomplete units persisted for exact re-dispatch (${incompleteArtifactPath})`;
1111
+ }
1112
+ /** 규칙 6 관측 상시화 + 규칙 5 정확 재디스패치 집합: breaker-ON 배치는
1113
+ * 트립이든 완주든 end-state를 리뷰 세션 루트에 영속한다 (reconstruct의
1114
+ * persistDispatchIncompleteArtifact 동형; 경로는 breaker 모듈이 단일소스). */
1115
+ async function persistReviewDispatchIncompleteArtifact(args) {
1116
+ const artifactPath = dispatchIncompleteArtifactPath(args.sessionRoot);
1117
+ await writeYamlDocument(artifactPath, buildDispatchIncompleteArtifact({
1118
+ pipeline: "review",
1119
+ batchLabel: args.batchLabel,
1120
+ createdAt: isoFromTimestamp(Date.now()),
1121
+ plannedItemIds: args.plannedItemIds,
1122
+ state: args.state,
1123
+ }));
1124
+ return artifactPath;
1125
+ }
1126
+ /**
1127
+ * 설계 A (bounded resubmit): before the next retry of an issue-stance unit
1128
+ * whose submit was rejected by the `issue_evidence_refs` whitelist, inject
1129
+ * the error spec into the unit's packet so the retry is a corrective
1130
+ * resubmit instead of a blind re-run. The retry budget itself is unchanged
1131
+ * (`issue_artifact_max_retries`); only the packet content differs. Opt-in
1132
+ * via `review.execution.retry.resubmit.enabled`; returns true when a spec
1133
+ * was applied.
1134
+ */
1135
+ async function applyStanceResubmitErrorSpec(args) {
1136
+ if (args.reviewExecutionProfile?.retry?.resubmit?.enabled !== true) {
1137
+ return false;
1138
+ }
1139
+ if (args.dispatch.output_format !== "issue-stance-response")
1140
+ return false;
1141
+ const violation = classifyUnsupportedEvidenceRefFailure(args.error instanceof Error ? args.error.message : String(args.error)) ?? (await readFrozenUnsupportedRefViolation(args.dispatch.output_path));
1142
+ if (!violation)
1143
+ return false;
1144
+ let packetText;
1145
+ try {
1146
+ packetText = await fs.readFile(args.dispatch.packet_path, "utf8");
1147
+ }
1148
+ catch {
1149
+ return false;
1150
+ }
1151
+ let allowedRefs = [];
1152
+ try {
1153
+ const context = parseRuntimeIssueStanceSchemaContext(packetText);
1154
+ allowedRefs = context.issue_evidence_refs[violation.issueId] ?? [];
1155
+ }
1156
+ catch {
1157
+ allowedRefs = [];
1158
+ }
1159
+ const resubmitAttempt = args.attempt + 1;
1160
+ await fs.writeFile(args.dispatch.packet_path, applyResubmitErrorSpecToPacket(packetText, buildResubmitErrorSpec({
1161
+ violation,
1162
+ allowedEvidenceRefs: allowedRefs,
1163
+ resubmitAttempt,
1164
+ })), "utf8");
1165
+ await appendExecutionProgress(args.errorLogPath, `runner stance resubmit: ${args.dispatch.unit_id}`, [
1166
+ `resubmit_attempt: ${resubmitAttempt}`,
1167
+ `issue_id: ${violation.issueId}`,
1168
+ `unsupported_ref: ${violation.evidenceRef}`,
1169
+ ]);
1170
+ return true;
1171
+ }
1172
+ /** Worker adapters exit before stderr reliably carries the validation text;
1173
+ * the per-attempt frozen salvage input is the structural evidence source. */
1174
+ async function readFrozenUnsupportedRefViolation(outputPath) {
1175
+ try {
1176
+ const raw = await fs.readFile(salvageInputPathFor(outputPath), "utf8");
1177
+ const frozen = JSON.parse(raw);
1178
+ return typeof frozen.error === "string"
1179
+ ? classifyUnsupportedEvidenceRefFailure(frozen.error)
1180
+ : null;
1181
+ }
1182
+ catch {
1183
+ return null;
1184
+ }
1185
+ }
1186
+ export const RESUBMIT_UNIT_ROUTING = Object.freeze({
1187
+ "issue-stance-response": {
1188
+ classify: classifyUnsupportedEvidenceRefFailure,
1189
+ apply: applyStanceResubmitErrorSpec,
1190
+ gateEligible: true,
1191
+ },
1192
+ "issue-deliberation-response": {
1193
+ classify: classifyDeliberationUnsupportedEvidenceRefFailure,
1194
+ apply: applyDeliberationResubmitErrorSpec,
1195
+ gateEligible: true,
1196
+ },
1197
+ "issue-synthesis-response": {
1198
+ classify: classifySynthesisUnsupportedSourceRefFailure,
1199
+ apply: applySynthesisResubmitErrorSpec,
1200
+ gateEligible: true,
1201
+ },
1202
+ });
1203
+ /**
1204
+ * 설계 A / §4-6a / §4-2c: unit-agnostic entry for bounded resubmit error-spec
1205
+ * injection. Routes by `output_format` through the shared routing table to the
1206
+ * per-unit strategy; unrouted formats (ledgers, lenses, …) are a no-op, so the
1207
+ * retry stays blind for units without a classifiable, spec-correctable
1208
+ * validation rejection. The opt-in gate is re-checked here so OFF returns before
1209
+ * any per-unit work.
1210
+ */
1211
+ export async function applyResubmitErrorSpec(args) {
1212
+ if (args.reviewExecutionProfile?.retry?.resubmit?.enabled !== true) {
1213
+ return false;
1214
+ }
1215
+ const outputFormat = args.dispatch.output_format;
1216
+ const routing = outputFormat
1217
+ ? RESUBMIT_UNIT_ROUTING[outputFormat]
1218
+ : undefined;
1219
+ const applied = routing ? await routing.apply(args) : false;
1220
+ if (applied && args.executionPlan !== undefined) {
1221
+ await refreshManifestPacketHash({
1222
+ executionPlan: args.executionPlan,
1223
+ packetPath: args.dispatch.packet_path,
1224
+ });
1225
+ }
1226
+ return applied;
1227
+ }
1228
+ /** K2 (20260712-stance-ref-vocabulary-unification-design.md §5): re-pin the
1229
+ * manifest packet_sha256 after a legitimate runtime-owned packet mutation
1230
+ * (resubmit error spec). No-op when the packet has no manifest ref yet. */
1231
+ async function refreshManifestPacketHash(args) {
1232
+ let manifestPath;
1233
+ let manifest;
1234
+ try {
1235
+ ({ manifestPath, manifest } = await readReviewContextManifest(args.executionPlan));
1236
+ }
1237
+ catch {
1238
+ // No materialized manifest (e.g. unit-scoped test harnesses) → nothing is
1239
+ // pinned, so there is nothing to re-pin.
1240
+ return;
1241
+ }
1242
+ const resolvedPacketPath = path.resolve(args.packetPath);
1243
+ const entry = manifest.packet_refs.find((ref) => path.resolve(ref.packet_ref) === resolvedPacketPath);
1244
+ if (!entry)
1245
+ return;
1246
+ const packetSha256 = await optionalFileDigest(args.packetPath);
1247
+ if (!packetSha256 || packetSha256 === entry.packet_sha256)
1248
+ return;
1249
+ const updatedManifest = {
1250
+ ...manifest,
1251
+ packet_refs: manifest.packet_refs.map((ref) => path.resolve(ref.packet_ref) === resolvedPacketPath
1252
+ ? { ...ref, packet_sha256: packetSha256 }
1253
+ : ref),
1254
+ };
1255
+ await writeYamlDocument(manifestPath, updatedManifest);
1256
+ }
1257
+ /** deliberation unit_id is `deliberation:<issueId>:<lensId>` (the live colon
1258
+ * form built by buildIssueScopedLensDeliberationPrompt); mirrors the split
1259
+ * convention in haltLensIdFromOutcome. */
1260
+ function deliberationUnitIdParts(unitId) {
1261
+ if (!unitId.startsWith("deliberation:"))
1262
+ return null;
1263
+ const [, issueId, lensId] = unitId.split(":");
1264
+ if (!issueId || !lensId)
1265
+ return null;
1266
+ return { issueId, lensId };
1267
+ }
1268
+ /**
1269
+ * §4-6a deliberation strategy: inject the evidence_refs error spec before the
1270
+ * next retry of a deliberation-response unit whose submit was rejected by the
1271
+ * `allowed_evidence_refs` whitelist. The deliberation throw carries only the
1272
+ * ref, so issue_id/lens_id come from the dispatch unit_id and the allowed set
1273
+ * from the packet's runtime projection (flat, single-(issue,lens) scope).
1274
+ * Cap exhaustion keeps deliberation's existing non-halting degrade
1275
+ * (completeUnavailableDeliberationResponseUnit) — no demotion machinery.
1276
+ */
1277
+ async function applyDeliberationResubmitErrorSpec(args) {
1278
+ if (args.reviewExecutionProfile?.retry?.resubmit?.enabled !== true) {
1279
+ return false;
1280
+ }
1281
+ if (args.dispatch.output_format !== "issue-deliberation-response") {
1282
+ return false;
1283
+ }
1284
+ const violation = classifyDeliberationUnsupportedEvidenceRefFailure(args.error instanceof Error ? args.error.message : String(args.error)) ??
1285
+ (await readFrozenDeliberationUnsupportedRefViolation(args.dispatch.output_path));
1286
+ if (!violation)
1287
+ return false;
1288
+ const parts = deliberationUnitIdParts(args.dispatch.unit_id);
1289
+ if (!parts)
1290
+ return false;
1291
+ let packetText;
1292
+ try {
1293
+ packetText = await fs.readFile(args.dispatch.packet_path, "utf8");
1294
+ }
1295
+ catch {
1296
+ return false;
1297
+ }
1298
+ let allowedRefs = [];
1299
+ try {
1300
+ allowedRefs =
1301
+ parseRuntimeIssueDeliberationSchemaContext(packetText).allowed_evidence_refs;
1302
+ }
1303
+ catch {
1304
+ allowedRefs = [];
1305
+ }
1306
+ const resubmitAttempt = args.attempt + 1;
1307
+ await fs.writeFile(args.dispatch.packet_path, applyResubmitErrorSpecToPacket(packetText, buildResubmitErrorSpec({
1308
+ violation: {
1309
+ stanceIndex: null,
1310
+ issueId: parts.issueId,
1311
+ evidenceRef: violation.evidenceRef,
1312
+ },
1313
+ allowedEvidenceRefs: allowedRefs,
1314
+ resubmitAttempt,
1315
+ unit: { kind: "deliberation", lensId: parts.lensId },
1316
+ })), "utf8");
1317
+ await appendExecutionProgress(args.errorLogPath, `runner deliberation resubmit: ${args.dispatch.unit_id}`, [
1318
+ `resubmit_attempt: ${resubmitAttempt}`,
1319
+ `issue_id: ${parts.issueId}`,
1320
+ `lens_id: ${parts.lensId}`,
1321
+ `unsupported_ref: ${violation.evidenceRef}`,
1322
+ ]);
1323
+ return true;
1324
+ }
1325
+ /** Deliberation counterpart of readFrozenUnsupportedRefViolation: the salvage
1326
+ * freeze is output_format-agnostic, so a deliberation submit rejection is
1327
+ * recoverable from the frozen error even when the adapter swallowed stderr. */
1328
+ async function readFrozenDeliberationUnsupportedRefViolation(outputPath) {
1329
+ try {
1330
+ const raw = await fs.readFile(salvageInputPathFor(outputPath), "utf8");
1331
+ const frozen = JSON.parse(raw);
1332
+ return typeof frozen.error === "string"
1333
+ ? classifyDeliberationUnsupportedEvidenceRefFailure(frozen.error)
1334
+ : null;
1335
+ }
1336
+ catch {
1337
+ return null;
1338
+ }
1339
+ }
1340
+ /** synthesis unit_id is `synthesis:<issueId>` (workItem.work_item_id, built in
1341
+ * synthesis-map-reduce as `synthesis:${issue_id}`); the source_refs_used
1342
+ * rejection text carries no issue_id, so it is recovered here. */
1343
+ function synthesisIssueIdFromUnitId(unitId) {
1344
+ if (!unitId.startsWith("synthesis:"))
1345
+ return null;
1346
+ const issueId = unitId.slice("synthesis:".length);
1347
+ return issueId.length > 0 ? issueId : null;
1348
+ }
1349
+ /**
1350
+ * §4-2c/2-A synthesis strategy: inject the source_refs_used error spec before
1351
+ * the next retry of a synthesis-response unit whose submit was rejected by the
1352
+ * `allowed_source_refs` whitelist (bad ref) or the "must include at least one"
1353
+ * guard. issue_id comes from the dispatch unit_id, the allowed set from the
1354
+ * packet's runtime projection. Cap exhaustion keeps synthesis's existing
1355
+ * non-halting degrade (completeUnavailableSynthesisResponseUnit) — no demotion
1356
+ * machinery. In-loop retry reachability depends on the structural retry gate
1357
+ * (shouldRetryUnitFailure), because the synthesis rejection message always
1358
+ * substring-classifies as output_contract.
1359
+ */
1360
+ async function applySynthesisResubmitErrorSpec(args) {
1361
+ if (args.reviewExecutionProfile?.retry?.resubmit?.enabled !== true) {
1362
+ return false;
1363
+ }
1364
+ if (args.dispatch.output_format !== "issue-synthesis-response")
1365
+ return false;
1366
+ const violation = classifySynthesisUnsupportedSourceRefFailure(args.error instanceof Error ? args.error.message : String(args.error)) ??
1367
+ (await readFrozenSynthesisUnsupportedRefViolation(args.dispatch.output_path));
1368
+ if (!violation)
1369
+ return false;
1370
+ const issueId = synthesisIssueIdFromUnitId(args.dispatch.unit_id);
1371
+ if (!issueId)
1372
+ return false;
1373
+ let packetText;
1374
+ try {
1375
+ packetText = await fs.readFile(args.dispatch.packet_path, "utf8");
1376
+ }
1377
+ catch {
1378
+ return false;
1379
+ }
1380
+ let allowedRefs = [];
1381
+ try {
1382
+ allowedRefs =
1383
+ parseRuntimeIssueSynthesisSchemaContext(packetText).allowed_source_refs;
1384
+ }
1385
+ catch {
1386
+ allowedRefs = [];
1387
+ }
1388
+ const resubmitAttempt = args.attempt + 1;
1389
+ await fs.writeFile(args.dispatch.packet_path, applyResubmitErrorSpecToPacket(packetText, buildResubmitErrorSpec({
1390
+ violation: {
1391
+ stanceIndex: null,
1392
+ issueId,
1393
+ evidenceRef: violation.sourceRef ?? "",
1394
+ },
1395
+ allowedEvidenceRefs: allowedRefs,
1396
+ resubmitAttempt,
1397
+ unit: { kind: "synthesis", issueId },
1398
+ })), "utf8");
1399
+ await appendExecutionProgress(args.errorLogPath, `runner synthesis resubmit: ${args.dispatch.unit_id}`, [
1400
+ `resubmit_attempt: ${resubmitAttempt}`,
1401
+ `issue_id: ${issueId}`,
1402
+ `unsupported_ref: ${violation.sourceRef ?? "(none — must cite >=1 allowed source ref)"}`,
1403
+ ]);
1404
+ return true;
1405
+ }
1406
+ /** Synthesis counterpart of readFrozenUnsupportedRefViolation. */
1407
+ async function readFrozenSynthesisUnsupportedRefViolation(outputPath) {
1408
+ try {
1409
+ const raw = await fs.readFile(salvageInputPathFor(outputPath), "utf8");
1410
+ const frozen = JSON.parse(raw);
1411
+ return typeof frozen.error === "string"
1412
+ ? classifySynthesisUnsupportedSourceRefFailure(frozen.error)
1413
+ : null;
1414
+ }
1415
+ catch {
1416
+ return null;
1417
+ }
1418
+ }
1012
1419
  function parseExecutorRunMetadata(stdout) {
1013
1420
  const trimmed = stdout.trim();
1014
1421
  if (!trimmed.startsWith("{") || !trimmed.endsWith("}"))
@@ -1358,7 +1765,10 @@ extraArgs = []) {
1358
1765
  });
1359
1766
  return parseExecutorRunMetadata(stdout);
1360
1767
  }
1361
- function toUnitExecutionResult(outcome) {
1768
+ // Exported for the resubmit-marker falsifiability tests (review-cert/v2
1769
+ // §5.3): the outcome→unit-result projection is where the marker would be
1770
+ // silently dropped if the wiring regressed.
1771
+ export function toUnitExecutionResult(outcome) {
1362
1772
  if (outcome.preservedResult) {
1363
1773
  return normalizePreservedUnitExecutionResult(outcome.preservedResult);
1364
1774
  }
@@ -1379,6 +1789,7 @@ function toUnitExecutionResult(outcome) {
1379
1789
  ...(outcome.attemptCount !== undefined
1380
1790
  ? { attempt_count: outcome.attemptCount }
1381
1791
  : {}),
1792
+ ...(outcome.resubmitApplied ? { resubmit_applied: true } : {}),
1382
1793
  ...(outcome.packetBytes !== undefined
1383
1794
  ? { packet_bytes: outcome.packetBytes }
1384
1795
  : {}),
@@ -1656,6 +2067,7 @@ function inferFailureLensId(artifact, result) {
1656
2067
  return null;
1657
2068
  }
1658
2069
  function collectFailedUnits(artifact) {
2070
+ const includeAttemptCount = artifact.retry_policy?.resubmit?.enabled === true;
1659
2071
  return allUnitExecutionResults(artifact)
1660
2072
  .filter((result) => result.status === "failed")
1661
2073
  .map((result) => ({
@@ -1666,6 +2078,9 @@ function collectFailedUnits(artifact) {
1666
2078
  output_path: result.output_path,
1667
2079
  failure_kind: result.failure_kind ?? null,
1668
2080
  failure_message: result.failure_message ?? "unknown failure",
2081
+ ...(includeAttemptCount && result.attempt_count !== undefined
2082
+ ? { attempt_count: result.attempt_count }
2083
+ : {}),
1669
2084
  }));
1670
2085
  }
1671
2086
  function degradationKindsFor(artifact, failedUnits) {
@@ -2427,6 +2842,9 @@ async function resetExecutionOutputs(executionPlan) {
2427
2842
  executionPlan.final_output_path,
2428
2843
  executionPlan.lens_completion_barrier_path ??
2429
2844
  path.join(executionPlan.session_root, "lens-completion-barrier.yaml"),
2845
+ // 설계 B: 이전 run의 breaker end-state는 폐기되는 run의 기록이다 —
2846
+ // 남기면 fresh 재실행(특히 breaker OFF)이 낡은 트립/회복 집합을 주장한다.
2847
+ dispatchIncompleteArtifactPath(executionPlan.session_root),
2430
2848
  executionPlan.teamlead_deliberation_prompt_packet_path,
2431
2849
  ...executionPlan.lens_execution_seats.map((seat) => seat.output_path),
2432
2850
  ...executionPlan.lens_execution_seats
@@ -2511,8 +2929,23 @@ async function runSingleDispatchWithRetries(args) {
2511
2929
  dispatch,
2512
2930
  fallback: unitTimeoutMs,
2513
2931
  });
2932
+ let resubmitApplied = false;
2514
2933
  for (let attempt = 0; attempt <= effectiveMaxRetries; attempt += 1) {
2515
2934
  attemptsUsed = attempt + 1;
2935
+ if (attempt === 0) {
2936
+ // 설계 A: nested-batch 1차 시도 실패의 flat 폴백과 halted 세션 resume은
2937
+ // 이 루프 밖에서 실패해 frozen salvage input만 남는다 — 그 구조적
2938
+ // 근거가 있으면 첫 flat 시도 전에 오류 명세를 주입해 blind 재실행을
2939
+ // 막는다. (스위치 OFF·미지원 output_format·freeze 부재 시 no-op)
2940
+ resubmitApplied = (await applyResubmitErrorSpec({
2941
+ dispatch,
2942
+ error: null,
2943
+ attempt: 0,
2944
+ reviewExecutionProfile,
2945
+ errorLogPath: executionPlan.error_log_path,
2946
+ executionPlan,
2947
+ })) || resubmitApplied;
2948
+ }
2516
2949
  try {
2517
2950
  const executorMetadata = await invokeExecutor(executorConfig, projectRoot, sessionRoot, executionPlan, dispatch, retryTimeoutMs(effectiveUnitTimeoutMs, attempt), reviewExecutionProfile);
2518
2951
  const completedAtMs = Date.now();
@@ -2528,6 +2961,7 @@ async function runSingleDispatchWithRetries(args) {
2528
2961
  startedAtMs,
2529
2962
  completedAtMs,
2530
2963
  attemptCount: attempt + 1,
2964
+ ...(resubmitApplied ? { resubmitApplied: true } : {}),
2531
2965
  ...(executorMetadata !== undefined ? { executorMetadata } : {}),
2532
2966
  artifactGenerationRealization: executorMetadata?.artifact_generation_realization ??
2533
2967
  artifactGenerationRealization,
@@ -2538,7 +2972,7 @@ async function runSingleDispatchWithRetries(args) {
2538
2972
  }
2539
2973
  catch (error) {
2540
2974
  lastError = error;
2541
- if (shouldRetryUnitFailure({ error, attempt, maxRetries: effectiveMaxRetries })) {
2975
+ if (shouldRetryUnitFailure({ error, attempt, maxRetries: effectiveMaxRetries, dispatch, reviewExecutionProfile })) {
2542
2976
  const retryDelay = effectiveRetryInitialDelayMs * (attempt + 1);
2543
2977
  console.log(`[review runner] ${dispatch.unit_id} attempt ${attempt + 1} failed, retrying in ${retryDelay}ms...`);
2544
2978
  await appendExecutionProgress(executionPlan.error_log_path, `runner dispatch retry: ${dispatch.unit_id}`, [
@@ -2546,6 +2980,14 @@ async function runSingleDispatchWithRetries(args) {
2546
2980
  `retry_delay_ms: ${retryDelay}`,
2547
2981
  `error: ${error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200)}`,
2548
2982
  ]);
2983
+ resubmitApplied = (await applyResubmitErrorSpec({
2984
+ dispatch,
2985
+ error,
2986
+ attempt,
2987
+ reviewExecutionProfile,
2988
+ errorLogPath: executionPlan.error_log_path,
2989
+ executionPlan,
2990
+ })) || resubmitApplied;
2549
2991
  if (dispatch.unit_kind === "synthesize") {
2550
2992
  await appendExecutionProgress(executionPlan.error_log_path, `runner synthesize retry: ${dispatch.unit_id}`, [
2551
2993
  `attempt: ${attempt + 1}/${effectiveMaxRetries}`,
@@ -2555,7 +2997,7 @@ async function runSingleDispatchWithRetries(args) {
2555
2997
  }
2556
2998
  await sleep(retryDelay);
2557
2999
  }
2558
- if (!shouldRetryUnitFailure({ error, attempt, maxRetries: effectiveMaxRetries }))
3000
+ if (!shouldRetryUnitFailure({ error, attempt, maxRetries: effectiveMaxRetries, dispatch, reviewExecutionProfile }))
2559
3001
  break;
2560
3002
  }
2561
3003
  }
@@ -2624,6 +3066,7 @@ async function runSingleDispatchWithRetries(args) {
2624
3066
  completedAtMs: Date.now(),
2625
3067
  attemptCount: attemptsUsed + 1,
2626
3068
  recovery: "salvaged_submit",
3069
+ ...(resubmitApplied ? { resubmitApplied: true } : {}),
2627
3070
  childOutcomes: [failedOutcome],
2628
3071
  ...(executorMetadata !== undefined ? { executorMetadata } : {}),
2629
3072
  artifactGenerationRealization: executorMetadata?.artifact_generation_realization ??
@@ -2651,6 +3094,7 @@ async function runSingleDispatchWithRetries(args) {
2651
3094
  startedAtMs,
2652
3095
  completedAtMs,
2653
3096
  attemptCount: attemptsUsed,
3097
+ ...(resubmitApplied ? { resubmitApplied: true } : {}),
2654
3098
  packetBytes,
2655
3099
  outputBytes,
2656
3100
  failure,
@@ -2767,6 +3211,12 @@ export async function runNestedStageFirstAttempt(args) {
2767
3211
  * - batch fail + remaining budget → flat retries (effective - 1);
2768
3212
  * - batch fail + zero budget → finalize the failure (no extra attempt —
2769
3213
  * an explicit zero-retry policy means exactly one attempt).
3214
+ *
3215
+ * The two batch-window branches (batch ok, zero-budget fail) carry
3216
+ * `nestedBatchWindow: true` marking them as not directly observed. A breaker
3217
+ * skips a batch-window SUCCESS (completed, no streak reset) but still records a
3218
+ * batch-window FAILURE as a failure (see recordNestedUnitOutcomeToBreaker) —
3219
+ * the flat-retry branch is a real dispatch and stays untagged (§4-1).
2770
3220
  */
2771
3221
  export async function unitOutcomeWithNestedFirstAttempt(args) {
2772
3222
  const runFlat = args.runFlat ?? runSingleDispatchWithRetries;
@@ -2791,6 +3241,7 @@ export async function unitOutcomeWithNestedFirstAttempt(args) {
2791
3241
  startedAtMs: args.batch.startedAtMs,
2792
3242
  completedAtMs: args.batch.completedAtMs,
2793
3243
  attemptCount: 1,
3244
+ nestedBatchWindow: true,
2794
3245
  artifactGenerationRealization,
2795
3246
  semanticQualityEvidence,
2796
3247
  packetBytes: await fileSizeIfPresent(dispatch.packet_path),
@@ -2825,6 +3276,7 @@ export async function unitOutcomeWithNestedFirstAttempt(args) {
2825
3276
  startedAtMs: args.batch.startedAtMs,
2826
3277
  completedAtMs: args.batch.completedAtMs,
2827
3278
  attemptCount: 1,
3279
+ nestedBatchWindow: true,
2828
3280
  packetBytes,
2829
3281
  outputBytes,
2830
3282
  failure,
@@ -2880,8 +3332,14 @@ export async function executeIssueStanceUnit(args) {
2880
3332
  };
2881
3333
  await removeFileIfExists(dispatch.output_path);
2882
3334
  await appendExecutionFailure(ctx.executionPlan.error_log_path, failure, ctx.executionPlan.effective_boundary_state);
3335
+ // §4-1: an on-disk validation failure is a directly-observed unit failure,
3336
+ // not a batch-window outcome — drop the nested-batch tag so the breaker
3337
+ // records it as a failure (dead-letter), consistent with the flat path.
3338
+ // (A batch-ok→validation-fail unit would otherwise spread
3339
+ // `nestedBatchWindow: true` and be mis-recorded as skipped/completed.)
3340
+ const { nestedBatchWindow: _batchWindow, ...observed } = outcome;
2883
3341
  return {
2884
- ...outcome,
3342
+ ...observed,
2885
3343
  success: false,
2886
3344
  completedAtMs: Date.now(),
2887
3345
  outputBytes: await fileSizeIfPresent(dispatch.output_path),
@@ -3275,22 +3733,38 @@ async function runIssueStanceMatrixCollectionDispatch(args) {
3275
3733
  packetPath: dispatch.packet_path,
3276
3734
  });
3277
3735
  }));
3736
+ // Continuation per-unit gate: run-owing 유닛만 디스패치 대상이다.
3737
+ // preserved 유닛은 완료된 prior result에서 outcome을 복원한다 (아래 워커).
3738
+ const owesStanceRun = (unitId) => args.runUnitIds === undefined || args.runUnitIds.has(unitId);
3739
+ const runOwingDispatches = dispatches.filter((dispatch) => owesStanceRun(dispatch.unit_id));
3278
3740
  // nested-workers: attempt #1 for the whole stage goes through ONE outer
3279
3741
  // nesting batch worker (waves capped at the flat pool width); failed
3280
- // units fall back to the flat per-unit retry loop below.
3742
+ // units fall back to the flat per-unit retry loop below. preserved 유닛은
3743
+ // 배치에 넣지 않는다 — 디스패치를 빚지지 않은 유닛이다.
3281
3744
  const stanceNestedBatch = await runNestedStageFirstAttempt({
3282
3745
  stageLabel: "issue-stance",
3283
3746
  projectRoot: args.projectRoot,
3284
3747
  sessionRoot: args.sessionRoot,
3285
3748
  executionPlan: args.executionPlan,
3286
3749
  executorConfig: args.executorConfig,
3287
- dispatches,
3750
+ dispatches: runOwingDispatches,
3288
3751
  dispatchWidth: maxConcurrentIssueStanceResponses,
3289
3752
  unitTimeoutMs: args.unitTimeoutMs,
3290
3753
  reviewExecutionProfile: args.reviewExecutionProfile,
3291
3754
  });
3292
3755
  const outcomes = new Array(dispatches.length);
3293
3756
  let nextDispatchIndex = 0;
3757
+ // 설계 B + §4-1: stance 풀 breaker — 유닛의 최종 outcome(per-unit bounded
3758
+ // retry 소진 후)을 관찰 단위로 기록한다. nested 1차 배치가 실행돼도 생성한다:
3759
+ // 배치-창 SUCCESS는 실 디스패치가 아니므로 recordItemSkipped로 완료만 집계해
3760
+ // 과거 배치 창의 생존 증거가 현재 계통 실패 streak을 오리셋하지 못하게 하고
3761
+ // (#166 결함 클래스 재유입 차단), 배치-창 FAILURE는 flat 경로처럼 실패로 기록
3762
+ // 한다(item-local→dead-letter, 계통→회복 victim). 배치-실패 유닛이 flat 재시도
3763
+ // 예산을 쓰면 그 실 관측이 streak을 구동한다.
3764
+ const breakerState = reviewDispatchBreakerFromProfile(args.reviewExecutionProfile, {
3765
+ concurrent: Math.min(maxConcurrentIssueStanceResponses, runOwingDispatches.length) > 1,
3766
+ });
3767
+ let breakerTripOutcome = null;
3294
3768
  async function runIssueStanceWorker() {
3295
3769
  while (true) {
3296
3770
  const dispatchIndex = nextDispatchIndex;
@@ -3298,7 +3772,39 @@ async function runIssueStanceMatrixCollectionDispatch(args) {
3298
3772
  if (dispatchIndex >= dispatches.length)
3299
3773
  return;
3300
3774
  const dispatch = dispatches[dispatchIndex];
3301
- outcomes[dispatchIndex] = await executeIssueStanceUnit({
3775
+ if (!owesStanceRun(dispatch.unit_id)) {
3776
+ // preserved/continuation 유닛: 디스패치 없이 완료 증거를 복원한다.
3777
+ // breaker에는 기록하지 않는다 — planned 집합 자체가 run-owing
3778
+ // 유닛으로 계산된다 (lens 풀과 동일 규약). 트립 여부와 무관하게
3779
+ // 기록해야 완료 증거가 결과 아티팩트에서 유실되지 않는다.
3780
+ const prior = args.preservedResultsByUnitId?.get(dispatch.unit_id);
3781
+ if (!prior || prior.status !== "completed") {
3782
+ throw new Error(`Cannot preserve continuation unit without a completed prior result: ${dispatch.unit_id}`);
3783
+ }
3784
+ outcomes[dispatchIndex] = outcomeFromPreviousResult(prior);
3785
+ continue;
3786
+ }
3787
+ // 트립 이후엔 새 flat 디스패치를 빚지는 유닛만 건너뛴다 — 그런 유닛은
3788
+ // 미디스패치로 incomplete 집합에 남아 회복 재디스패치 대상이 된다(규칙 5).
3789
+ // 배치-성공(디스패치 안 빚음)과 zero-retry 배치-실패(예산 소진 → 새 flat
3790
+ // 디스패치 없음)는 트립 후에도 처리해 recordNested…로 기록·분류한다:
3791
+ // 미기록 시 incomplete로 오집계되고, item-local 배치-실패는 dead-letter
3792
+ // 여야 하는데 스킵하면 incomplete로 오분류된다(교차검증 — lens 풀과 대칭,
3793
+ // 계약의 nested 균일 규칙 준수). return이 아닌 continue: 뒤 인덱스
3794
+ // preserved 복원도 마저 소진.
3795
+ if (breakerState?.tripped()) {
3796
+ const batchOutcome = stanceNestedBatch?.byUnitId.get(dispatch.unit_id);
3797
+ const owesNewDispatch = batchOutcome?.ok !== true &&
3798
+ (batchOutcome === undefined ||
3799
+ maxRetriesForDispatch({
3800
+ profile: args.reviewExecutionProfile,
3801
+ dispatch,
3802
+ fallback: args.retryPolicy.issueArtifactMaxRetries,
3803
+ }) >= 1);
3804
+ if (owesNewDispatch)
3805
+ continue;
3806
+ }
3807
+ const outcome = await executeIssueStanceUnit({
3302
3808
  ctx: {
3303
3809
  projectRoot: args.projectRoot,
3304
3810
  sessionRoot: args.sessionRoot,
@@ -3312,22 +3818,105 @@ async function runIssueStanceMatrixCollectionDispatch(args) {
3312
3818
  participatingLensIds,
3313
3819
  nestedBatch: stanceNestedBatch,
3314
3820
  });
3821
+ outcomes[dispatchIndex] = outcome;
3822
+ if (breakerState) {
3823
+ // §4-1: 배치-창 결과는 skipped(완료만, 계통 streak 불변), 실 flat
3824
+ // 디스패치는 성공/실패로 반영한다. 첫 임계 도달이 트립 권위 — halt
3825
+ // 귀속을 그 유닛의 outcome으로 고정한다.
3826
+ const trip = recordNestedUnitOutcomeToBreaker(breakerState, outcome);
3827
+ if (trip !== null)
3828
+ breakerTripOutcome = outcome;
3829
+ }
3315
3830
  }
3316
3831
  }
3317
3832
  await Promise.all(Array.from({
3318
3833
  length: Math.min(maxConcurrentIssueStanceResponses, dispatches.length),
3319
3834
  }, async () => runIssueStanceWorker()));
3320
3835
  const completedOutcomes = outcomes.filter((outcome) => outcome !== undefined);
3321
- const failedOutcome = completedOutcomes.find((outcome) => !outcome.success);
3322
- if (failedOutcome) {
3323
- throw new ReviewIssueArtifactDispatchError(`Issue stance response failed: ${failedOutcome.failure?.message ?? "unknown error"}`, failedOutcome, failedOutcome.failure?.message ?? "unknown error");
3836
+ const failedOutcomes = completedOutcomes.filter((outcome) => !outcome.success);
3837
+ const resubmitEnabled = args.reviewExecutionProfile?.retry?.resubmit?.enabled === true;
3838
+ const stanceDispatchError = (outcome, haltReason = null, batchOutcomes = []) => {
3839
+ const message = outcome.failure?.message ?? "unknown error";
3840
+ return new ReviewIssueArtifactDispatchError(`Issue stance response failed: ${message}`, outcome, message, haltReason, batchOutcomes);
3841
+ };
3842
+ if (breakerState) {
3843
+ // 규칙 6: 트립이든 완주든 배치 end-state를 영속 — 회복 절차가 항상
3844
+ // 정확한 재디스패치 집합을 갖는다. 트립이 아니어도 아래의 기존
3845
+ // 강등/halt 규칙은 그대로 진행된다 (breaker는 구제하지 않는다).
3846
+ const incompleteArtifactPath = await persistReviewDispatchIncompleteArtifact({
3847
+ sessionRoot: args.sessionRoot,
3848
+ batchLabel: "issue-stance",
3849
+ // planned = 이번 run이 실제 디스패치를 빚진 유닛 집합 (preserved 제외).
3850
+ plannedItemIds: runOwingDispatches.map((dispatch) => dispatch.unit_id),
3851
+ state: breakerState,
3852
+ });
3853
+ const trip = breakerState.tripped();
3854
+ if (trip) {
3855
+ // 규칙 4: 계통 실패 임계 도달 — 배치 halt + 사용자 공지(halt_reason에
3856
+ // 미완료 목록 경로 포함). 설계 A의 halt 배관(4번째 인자 haltReason →
3857
+ // haltAfterIssueArtifactFailure → halted_partial)을 재사용한다.
3858
+ const haltReason = reviewDispatchBreakerHaltReason(trip, incompleteArtifactPath);
3859
+ await appendExecutionProgress(args.executionPlan.error_log_path, "runner issue stance dispatch breaker tripped", [
3860
+ `failure_class: ${trip.failure_class}`,
3861
+ `consecutive_unit_count: ${trip.consecutive_item_count}`,
3862
+ `threshold: ${trip.threshold}`,
3863
+ `dispatch_incomplete_path: ${incompleteArtifactPath}`,
3864
+ ]);
3865
+ throw stanceDispatchError(breakerTripOutcome ?? failedOutcomes[0], haltReason,
3866
+ // halt 시점까지의 배치 진실 전체 — 완료 유닛의 행이 남아야 회복
3867
+ // 재디스패치 집합이 dispatch-incomplete의 미완료 집합과 일치한다.
3868
+ completedOutcomes);
3869
+ }
3324
3870
  }
3871
+ let demotedLensIds = [];
3872
+ if (failedOutcomes.length > 0) {
3873
+ // 검증-거부 분류는 두 근거를 모두 본다: in-process 경로는 실패 메시지
3874
+ // (submit-시점·on-disk 검증기 양쪽 문구), worker 경로는 stderr가 검증
3875
+ // 문구를 보장하지 않으므로 frozen salvage input을 구조적 근거로 읽는다.
3876
+ const validationFailures = [];
3877
+ if (resubmitEnabled) {
3878
+ for (const outcome of failedOutcomes) {
3879
+ const classified = isUnsupportedEvidenceRefFailureMessage(outcome.failure?.message) ||
3880
+ (await readFrozenUnsupportedRefViolation(outcome.dispatch.output_path)) !== null;
3881
+ if (classified)
3882
+ validationFailures.push(outcome);
3883
+ }
3884
+ }
3885
+ if (resubmitEnabled &&
3886
+ correlatedValidationExceeded({
3887
+ validationFailedUnitCount: validationFailures.length,
3888
+ totalUnitCount: dispatches.length,
3889
+ })) {
3890
+ // 설계 A 상관 에스컬레이션: 같은 검증 클래스가 stance 유닛 과반에서
3891
+ // 실패하면 구조 결함(프롬프트/스키마/컨텍스트 조립)이므로 whole-run
3892
+ // halt를 보존한다.
3893
+ throw stanceDispatchError(validationFailures[0], `${CORRELATED_VALIDATION_HALT_REASON}: evidence_refs validation rejected ${validationFailures.length}/${dispatches.length} stance units`);
3894
+ }
3895
+ const demotable = resubmitEnabled && validationFailures.length === failedOutcomes.length;
3896
+ if (!demotable) {
3897
+ // 현행 승격 규칙 보존: 인프라 실패(timeout/transport/…)와 OFF 경로는
3898
+ // 지금처럼 whole-run halt.
3899
+ throw stanceDispatchError(failedOutcomes[0]);
3900
+ }
3901
+ // 설계 A 유닛 강등: resubmit cap을 소진한 검증-거부 유닛만
3902
+ // complete-with-failure로 남긴다. 실패 outcome은 집계 outcome의 failed
3903
+ // child로 유지되어 degradation-summary와 상태 강등이 자동 전파되고,
3904
+ // 리뷰는 생존 렌즈의 stance로 계속한다.
3905
+ demotedLensIds = validationFailures.map((outcome) => outcome.dispatch.unit_id.slice("issue-stance:".length));
3906
+ await appendExecutionProgress(args.executionPlan.error_log_path, "runner stance units demoted (bounded resubmit exhausted)", demotedLensIds.map((lensId) => `lens_id: ${lensId}`));
3907
+ }
3908
+ const survivorLensIds = participatingLensIds.filter((lensId) => !demotedLensIds.includes(lensId));
3909
+ const survivorResponsePaths = [
3910
+ ...new Set(survivorLensIds
3911
+ .map((lensId) => responsePathsByLensId.get(lensId))
3912
+ .filter((value) => value !== undefined)),
3913
+ ];
3325
3914
  await fs.mkdir(path.dirname(seat.packet_path), { recursive: true });
3326
3915
  await fs.writeFile(seat.packet_path, `${renderRuntimeIssueStanceMatrixPacket({
3327
3916
  projectRoot: args.projectRoot,
3328
3917
  sessionId: args.executionPlan.session_id,
3329
3918
  outputPath: seat.output_path,
3330
- responsePaths: Array.from(responsePathsByLensId.values()),
3919
+ responsePaths: survivorResponsePaths,
3331
3920
  }).trimEnd()}\n`, "utf8");
3332
3921
  await registerGeneratedPromptPacketRefForDispatch({
3333
3922
  executionPlan: args.executionPlan,
@@ -3338,7 +3927,8 @@ async function runIssueStanceMatrixCollectionDispatch(args) {
3338
3927
  executionPlan: args.executionPlan,
3339
3928
  projectRoot: args.projectRoot,
3340
3929
  responsePathsByLensId,
3341
- participatingLensIds,
3930
+ participatingLensIds: survivorLensIds,
3931
+ demotedLensIds,
3342
3932
  outputPath: seat.output_path,
3343
3933
  });
3344
3934
  return {
@@ -3371,6 +3961,8 @@ async function runIssueArtifactDispatch(args) {
3371
3961
  unitTimeoutMs: args.unitTimeoutMs,
3372
3962
  retryPolicy: args.retryPolicy,
3373
3963
  reviewExecutionProfile: args.reviewExecutionProfile,
3964
+ runUnitIds: args.runUnitIds,
3965
+ preservedResultsByUnitId: args.preservedResultsByUnitId,
3374
3966
  });
3375
3967
  }
3376
3968
  const seat = await writeIssueArtifactPromptPacket({
@@ -4533,6 +5125,23 @@ export async function executeReviewPromptExecution(params) {
4533
5125
  }
4534
5126
  const executionOutcomes = new Array(lensDispatches.length);
4535
5127
  let nextLensIndex = 0;
5128
+ // A-path 스코프 판별을 호이스트: 초기 lens 페이즈가 nested 배치로 가는
5129
+ // 조건 (continuation/repair 패스는 항상 flat per-unit 루프).
5130
+ const nestedLensWorkerExecutor = !continuationMode &&
5131
+ params.reviewExecutionProfile?.mode === "nested-workers" &&
5132
+ (params.reviewExecutionProfile.worker_executor === "codex" ||
5133
+ params.reviewExecutionProfile.worker_executor === "claude_code")
5134
+ ? params.reviewExecutionProfile.worker_executor
5135
+ : null;
5136
+ // 설계 B + §4-1: lens 풀 breaker — flat per-unit 루프(runLensWorker)와 nested
5137
+ // 1차 배치의 flat-fallback이 최종 outcome을 관찰 단위로 기록한다. nested
5138
+ // 배치-성공 유닛은 실 디스패치가 아니므로 recordItemSkipped로 완료만
5139
+ // 집계하고(미기록 시 incomplete로 오집계, 배치-창 생존이 계통 streak을
5140
+ // 오리셋하는 것 차단), 배치-실패 유닛의 flat 재시도만 streak을 구동한다.
5141
+ const lensBreakerState = reviewDispatchBreakerFromProfile(params.reviewExecutionProfile, {
5142
+ concurrent: Math.min(maxConcurrentLenses, lensDispatches.filter((dispatch) => shouldRunUnit(dispatch.unit_id))
5143
+ .length) > 1,
5144
+ });
4536
5145
  async function haltForCancellation(args) {
4537
5146
  const completedLensOutcomes = executionOutcomes.filter(isSuccessfulOutcome);
4538
5147
  const successfulLensDispatches = completedLensOutcomes.map((outcome) => outcome.dispatch);
@@ -4626,9 +5235,21 @@ export async function executeReviewPromptExecution(params) {
4626
5235
  return;
4627
5236
  }
4628
5237
  if (!shouldRunUnit(dispatch.unit_id)) {
5238
+ // preserved/continuation 유닛은 breaker에 기록하지 않는다 — planned
5239
+ // 집합 자체가 "이번 run이 실제 디스패치하는 유닛"으로 계산된다.
5240
+ // 트립 여부와 무관하게 기록한다: 디스패치가 필요 없는 완료 증거를
5241
+ // 빼먹으면 barrier/execution-result가 완료 lens를 missing으로
5242
+ // 기록해 다음 continuation이 이미 완료된 lens를 재디스패치한다.
4629
5243
  executionOutcomes[currentIndex] = preservedOutcomeForDispatch(dispatch);
4630
5244
  continue;
4631
5245
  }
5246
+ // 설계 B: 트립 이후 새 lens를 디스패치하지 않는다 — 남은 실행 유닛은
5247
+ // 미디스패치로 incomplete 집합에 남아 회복 재디스패치 대상이 된다
5248
+ // (규칙 5). return이 아닌 continue: 뒤 인덱스의 preserved 유닛 기록을
5249
+ // 마저 소진해야 한다.
5250
+ if (lensBreakerState?.tripped()) {
5251
+ continue;
5252
+ }
4632
5253
  console.log(`[review runner] starting ${dispatch.unit_kind}: ${dispatch.unit_id}`);
4633
5254
  await appendExecutionProgress(executionPlan.error_log_path, `runner dispatch started: ${dispatch.unit_id}`, [
4634
5255
  `unit_id: ${dispatch.unit_id}`,
@@ -4669,6 +5290,8 @@ export async function executeReviewPromptExecution(params) {
4669
5290
  error,
4670
5291
  attempt,
4671
5292
  maxRetries: effectiveMaxRetries,
5293
+ dispatch,
5294
+ reviewExecutionProfile: params.reviewExecutionProfile,
4672
5295
  })) {
4673
5296
  const retryDelay = effectiveRetryInitialDelayMs * (attempt + 1);
4674
5297
  console.log(`[review runner] ${dispatch.unit_id} attempt ${attempt + 1} failed, retrying in ${retryDelay}ms...`);
@@ -4683,6 +5306,8 @@ export async function executeReviewPromptExecution(params) {
4683
5306
  error,
4684
5307
  attempt,
4685
5308
  maxRetries: effectiveMaxRetries,
5309
+ dispatch,
5310
+ reviewExecutionProfile: params.reviewExecutionProfile,
4686
5311
  }))
4687
5312
  break;
4688
5313
  }
@@ -4705,6 +5330,8 @@ export async function executeReviewPromptExecution(params) {
4705
5330
  packetBytes: await fileSizeIfPresent(dispatch.packet_path),
4706
5331
  outputBytes: await fileSizeIfPresent(dispatch.output_path),
4707
5332
  };
5333
+ // 실 디스패치 성공만 프로바이더 생존 증거다 (breaker 규칙 2).
5334
+ lensBreakerState?.recordItemSuccess(dispatch.unit_id);
4708
5335
  }
4709
5336
  else {
4710
5337
  const completedAtMs = Date.now();
@@ -4720,7 +5347,7 @@ export async function executeReviewPromptExecution(params) {
4720
5347
  const outputBytes = await fileSizeIfPresent(dispatch.output_path);
4721
5348
  await removeFileIfExists(dispatch.output_path);
4722
5349
  await appendExecutionFailure(executionPlan.error_log_path, failure, executionPlan.effective_boundary_state);
4723
- executionOutcomes[currentIndex] = {
5350
+ const outcome = {
4724
5351
  dispatch,
4725
5352
  success: false,
4726
5353
  startedAtMs,
@@ -4730,6 +5357,16 @@ export async function executeReviewPromptExecution(params) {
4730
5357
  outputBytes,
4731
5358
  failure,
4732
5359
  };
5360
+ executionOutcomes[currentIndex] = outcome;
5361
+ // 유닛의 최종 실패(per-unit retry 소진)를 관찰 단위로 기록. 계통
5362
+ // 클래스가 아니면(dead-letter) streak에 닿지 않고, 트립 여부는 루프
5363
+ // 상단 체크가 소비한다 — halt는 배리어 뒤 epilogue가 수행.
5364
+ lensBreakerState?.recordItemFailure({
5365
+ item_id: dispatch.unit_id,
5366
+ failure_class: reviewSystemicFailureClassFromOutcome(outcome),
5367
+ failure_message: failure.message,
5368
+ attempt_count: attemptsUsed,
5369
+ });
4733
5370
  }
4734
5371
  }
4735
5372
  }
@@ -4737,18 +5374,15 @@ export async function executeReviewPromptExecution(params) {
4737
5374
  // scope). Continuation/repair passes re-dispatch remaining units through
4738
5375
  // the flat per-unit loop — same unit-executor invocation, same artifact
4739
5376
  // contract, so the seat truth is identical either way.
4740
- if (!continuationMode &&
4741
- params.reviewExecutionProfile?.mode === "nested-workers" &&
4742
- (params.reviewExecutionProfile.worker_executor === "codex" ||
4743
- params.reviewExecutionProfile.worker_executor === "claude_code")) {
4744
- const nestedBrand = params.reviewExecutionProfile.worker_executor === "codex"
5377
+ if (nestedLensWorkerExecutor !== null) {
5378
+ const nestedBrand = nestedLensWorkerExecutor === "codex"
4745
5379
  ? "codex"
4746
5380
  : "claude";
4747
- console.log(`[review runner] mode=nested-workers worker_executor=${params.reviewExecutionProfile.worker_executor}`);
5381
+ console.log(`[review runner] mode=nested-workers worker_executor=${nestedLensWorkerExecutor}`);
4748
5382
  await appendExecutionProgress(executionPlan.error_log_path, "runner profile dispatch: nested-workers", [
4749
- `teamlead_seat: ${params.reviewExecutionProfile.teamlead.seat}`,
4750
- `lens_seat: ${params.reviewExecutionProfile.lens.seat}`,
4751
- `worker_executor: ${params.reviewExecutionProfile.worker_executor}`,
5383
+ `teamlead_seat: ${params.reviewExecutionProfile?.teamlead.seat}`,
5384
+ `lens_seat: ${params.reviewExecutionProfile?.lens.seat}`,
5385
+ `worker_executor: ${nestedLensWorkerExecutor}`,
4752
5386
  `planned_lens_count: ${lensDispatches.length}`,
4753
5387
  ]);
4754
5388
  const nestedStartedAtMs = Date.now();
@@ -4834,15 +5468,22 @@ export async function executeReviewPromptExecution(params) {
4834
5468
  `unit_kind: ${dispatch.unit_kind}`,
4835
5469
  `output_path: ${dispatch.output_path}`,
4836
5470
  ]);
4837
- executionOutcomes[i] = {
5471
+ const okOutcome = {
4838
5472
  dispatch,
4839
5473
  success: true,
4840
5474
  startedAtMs: nestedStartedAtMs,
4841
5475
  completedAtMs: nestedCompletedAtMs,
4842
5476
  attemptCount: 1,
5477
+ nestedBatchWindow: true,
4843
5478
  packetBytes: await fileSizeIfPresent(dispatch.packet_path),
4844
5479
  outputBytes: await fileSizeIfPresent(dispatch.output_path),
4845
5480
  };
5481
+ executionOutcomes[i] = okOutcome;
5482
+ // §4-1: 배치-성공은 실 디스패치가 아니다 — 헬퍼가 skipped로 완료만
5483
+ // 집계(계통 streak 불변). 미기록 시 incomplete로 오집계된다.
5484
+ if (lensBreakerState) {
5485
+ recordNestedUnitOutcomeToBreaker(lensBreakerState, okOutcome);
5486
+ }
4846
5487
  continue;
4847
5488
  }
4848
5489
  catch (error) {
@@ -4863,6 +5504,11 @@ export async function executeReviewPromptExecution(params) {
4863
5504
  fallback: maxRetries,
4864
5505
  });
4865
5506
  if (effectiveLensMaxRetries >= 1) {
5507
+ // §4-1: 트립 이후에는 새 flat 재시도를 디스패치하지 않는다 — 이 유닛은
5508
+ // 미디스패치로 incomplete 집합에 남아 회복 재디스패치 대상이 된다
5509
+ // (규칙 5). 앞선 배치-성공 유닛은 이미 skipped로 기록됐다.
5510
+ if (lensBreakerState?.tripped())
5511
+ continue;
4866
5512
  // Batch consumed attempt #1 — clear the dead seat and spend the
4867
5513
  // remaining budget through the flat loop (same executor config
4868
5514
  // derivation as the flat lens path: invokeExecutor applies the
@@ -4872,7 +5518,7 @@ export async function executeReviewPromptExecution(params) {
4872
5518
  `batch_failure: ${batchFailureMessage}`,
4873
5519
  `remaining_max_retries: ${effectiveLensMaxRetries - 1}`,
4874
5520
  ]);
4875
- executionOutcomes[i] = await runSingleDispatchWithRetries({
5521
+ const flatOutcome = await runSingleDispatchWithRetries({
4876
5522
  projectRoot,
4877
5523
  sessionRoot,
4878
5524
  executionPlan,
@@ -4884,6 +5530,14 @@ export async function executeReviewPromptExecution(params) {
4884
5530
  reviewExecutionProfile: params.reviewExecutionProfile,
4885
5531
  maxRetriesOverride: effectiveLensMaxRetries - 1,
4886
5532
  });
5533
+ executionOutcomes[i] = flatOutcome;
5534
+ // flat 재시도는 실 디스패치 관측이다 — 성공/실패를 breaker에 반영
5535
+ // (§4-1; flatOutcome은 배치-창 태그가 없어 skipped로 빠지지 않는다).
5536
+ // lens 트립 halt는 배리어 뒤 epilogue가 tripped()로 수행하므로 반환
5537
+ // trip은 소비하지 않는다.
5538
+ if (lensBreakerState) {
5539
+ recordNestedUnitOutcomeToBreaker(lensBreakerState, flatOutcome);
5540
+ }
4887
5541
  continue;
4888
5542
  }
4889
5543
  const failure = {
@@ -4898,7 +5552,7 @@ export async function executeReviewPromptExecution(params) {
4898
5552
  const outputBytes = await fileSizeIfPresent(dispatch.output_path);
4899
5553
  await removeFileIfExists(dispatch.output_path);
4900
5554
  await appendExecutionFailure(executionPlan.error_log_path, failure, executionPlan.effective_boundary_state);
4901
- executionOutcomes[i] = {
5555
+ const failureOutcome = {
4902
5556
  dispatch,
4903
5557
  success: false,
4904
5558
  startedAtMs: nestedStartedAtMs,
@@ -4908,6 +5562,13 @@ export async function executeReviewPromptExecution(params) {
4908
5562
  outputBytes,
4909
5563
  failure,
4910
5564
  };
5565
+ executionOutcomes[i] = failureOutcome;
5566
+ // §4-1 (교차검증 F2/Finding A): zero-retry 배치 실패는 flat 경로처럼
5567
+ // 실패로 기록한다 — 검증 실패(item-local)는 dead-letter, 계통 실패는
5568
+ // 회복 victim(incomplete). skipped(완료 오집계)가 아니다.
5569
+ if (lensBreakerState) {
5570
+ recordNestedUnitOutcomeToBreaker(lensBreakerState, failureOutcome);
5571
+ }
4911
5572
  }
4912
5573
  // Capture outer teamlead halt_reason for the post-dispatch halt check
4913
5574
  // (synthesize may still run if enough lenses participated, matching the
@@ -4919,6 +5580,19 @@ export async function executeReviewPromptExecution(params) {
4919
5580
  else {
4920
5581
  await Promise.all(Array.from({ length: Math.min(maxConcurrentLenses, lensDispatches.length) }, async (_, workerIndex) => runLensWorker(workerIndex)));
4921
5582
  }
5583
+ // 설계 B 규칙 6: breaker-ON lens 배치는 트립이든 완주든 end-state를
5584
+ // 영속한다 — 회복 절차가 항상 정확한 재디스패치 집합을 갖는다.
5585
+ let lensDispatchIncompletePath = null;
5586
+ if (lensBreakerState) {
5587
+ lensDispatchIncompletePath = await persistReviewDispatchIncompleteArtifact({
5588
+ sessionRoot,
5589
+ batchLabel: "lens",
5590
+ plannedItemIds: lensDispatches
5591
+ .filter((dispatch) => shouldRunUnit(dispatch.unit_id))
5592
+ .map((dispatch) => dispatch.unit_id),
5593
+ state: lensBreakerState,
5594
+ });
5595
+ }
4922
5596
  const postLensCancelRequest = await readReviewCancelRequest(sessionRoot);
4923
5597
  if (postLensCancelRequest) {
4924
5598
  return haltForCancellation({
@@ -4941,10 +5615,21 @@ export async function executeReviewPromptExecution(params) {
4941
5615
  successfulLensDispatches,
4942
5616
  executionFailures,
4943
5617
  });
4944
- if (!lensCompletionBarrier.downstream_allowed) {
4945
- const haltReason = successfulLensDispatches.length === 0
4946
- ? "No participating lens outputs were produced."
4947
- : `Selected lens completion barrier failed: ${lensCompletionBarrier.completed_lens_ids.length}/${lensCompletionBarrier.planned_lens_ids.length} planned lenses completed.`;
5618
+ // 설계 B 규칙 4: lens 트립은 배리어 판정과 무관하게 무조건 halt한다
5619
+ // (트립 시점 이후 유닛은 미디스패치라 배리어가 우연히 downstream을 허용해도
5620
+ // 배치는 이미 불완전하다). 배리어 아티팩트는 위에서 정상 기록되어
5621
+ // continuation frontier가 미완료 lens를 재제안할 있다. lens 풀은
5622
+ // 실패를 throw하지 않고 outcome으로만 기록하므로(전파 캐치 없음), 트립
5623
+ // halt도 기존 배리어 halt와 같은 구조화 블록으로 수행한다.
5624
+ const lensBreakerTrip = lensBreakerState?.tripped() ?? null;
5625
+ if (lensBreakerTrip !== null || !lensCompletionBarrier.downstream_allowed) {
5626
+ const haltReason = lensBreakerTrip !== null
5627
+ ? reviewDispatchBreakerHaltReason(lensBreakerTrip, lensDispatchIncompletePath ??
5628
+ dispatchIncompleteArtifactPath(sessionRoot))
5629
+ : successfulLensDispatches.length === 0
5630
+ ? "No participating lens outputs were produced."
5631
+ : `Selected lens completion barrier failed: ${lensCompletionBarrier.completed_lens_ids.length}/${lensCompletionBarrier.planned_lens_ids.length} planned lenses completed.`;
5632
+ const haltPhase = lensBreakerTrip !== null ? "lens_dispatch_breaker" : "lens_completion_barrier";
4948
5633
  await appendMarkdownLogEntry(executionPlan.error_log_path, "runner halted before synthesize", `${haltReason}\n\n[effective_boundary_state]\n${renderEffectiveBoundaryStateLog(executionPlan.effective_boundary_state)}`);
4949
5634
  const degradedLensIds = executionFailures
4950
5635
  .filter((failure) => failure.unit_kind === "lens")
@@ -4976,7 +5661,7 @@ export async function executeReviewPromptExecution(params) {
4976
5661
  synthesis_executed: false,
4977
5662
  deliberation_status: "not_performed",
4978
5663
  halt_reason: haltReason,
4979
- ...haltArtifactFields("lens_completion_barrier", null),
5664
+ ...haltArtifactFields(haltPhase, null),
4980
5665
  error_log_path: executionPlan.error_log_path,
4981
5666
  lens_completion_barrier_ref: executionPlan.lens_completion_barrier_path ??
4982
5667
  path.join(sessionRoot, "lens-completion-barrier.yaml"),
@@ -4996,12 +5681,23 @@ export async function executeReviewPromptExecution(params) {
4996
5681
  synthesis_executed: false,
4997
5682
  error_log_path: executionPlan.error_log_path,
4998
5683
  halt_reason: haltReason,
4999
- ...haltArtifactFields("lens_completion_barrier", null),
5684
+ ...haltArtifactFields(haltPhase, null),
5000
5685
  };
5001
5686
  }
5002
5687
  const issueArtifactOutcomes = [];
5003
5688
  const lensOutputPaths = successfulLensDispatches.map((dispatch) => dispatch.output_path);
5004
5689
  const haltAfterIssueArtifactFailure = async (args) => {
5690
+ // 설계 B 트립: halt 시점까지의 배치 outcome 전체(완료 포함)를 결과
5691
+ // 아티팩트에 보존한다 — 완료 stance 유닛 행이 없으면 continuation
5692
+ // ledger가 missing으로 도출해 이미 지불한 완료분을 재디스패치한다.
5693
+ const batchOutcomes = args.error instanceof ReviewIssueArtifactDispatchError
5694
+ ? args.error.batchOutcomes
5695
+ : [];
5696
+ for (const outcome of batchOutcomes) {
5697
+ if (!issueArtifactOutcomes.some((existing) => existing.dispatch.unit_id === outcome.dispatch.unit_id)) {
5698
+ issueArtifactOutcomes.push(outcome);
5699
+ }
5700
+ }
5005
5701
  const failureOutcome = issueArtifactOutcomeFromError(args.error);
5006
5702
  if (failureOutcome &&
5007
5703
  !issueArtifactOutcomes.some((outcome) => outcome.dispatch.unit_id === failureOutcome.dispatch.unit_id)) {
@@ -5013,7 +5709,10 @@ export async function executeReviewPromptExecution(params) {
5013
5709
  .filter((failure) => failure.unit_kind === "lens")
5014
5710
  .map((failure) => failure.unit_id);
5015
5711
  const executionCompletedAtMs = Date.now();
5016
- const haltReason = `Issue artifact generation failed: ${failureMessage}`;
5712
+ const haltReasonOverride = args.error instanceof ReviewIssueArtifactDispatchError
5713
+ ? args.error.haltReason
5714
+ : null;
5715
+ const haltReason = haltReasonOverride ?? `Issue artifact generation failed: ${failureMessage}`;
5017
5716
  await writeExecutionResultArtifact(executionPlan, {
5018
5717
  session_id: executionPlan.session_id,
5019
5718
  session_root: sessionRoot,
@@ -5119,6 +5818,15 @@ export async function executeReviewPromptExecution(params) {
5119
5818
  unitTimeoutMs,
5120
5819
  retryPolicy,
5121
5820
  reviewExecutionProfile: params.reviewExecutionProfile,
5821
+ // Continuation: stance 수집 스테이지가 run-owing 유닛만 디스패치하고
5822
+ // 완료 유닛은 prior result에서 복원한다 (규칙 5 — 회복 재디스패치
5823
+ // 집합 == 미완료 집합).
5824
+ ...(continuationMode
5825
+ ? {
5826
+ runUnitIds: continuationRunUnitIds,
5827
+ preservedResultsByUnitId: previousResultsByUnitId,
5828
+ }
5829
+ : {}),
5122
5830
  }));
5123
5831
  return null;
5124
5832
  }
@@ -5351,7 +6059,9 @@ export async function executeReviewPromptExecution(params) {
5351
6059
  throw new Error("post-lens frontier loop did not converge (max iterations)");
5352
6060
  }
5353
6061
  const frontier = await computeReviewFrontier(sessionRoot);
5354
- if (frontier.unitLedger.units.every((unit) => isTrustedLedgerUnit(unit))) {
6062
+ // Convergence = no unit owes work: trusted output OR terminally
6063
+ // resolved (demoted complete-with-failure — 설계 A).
6064
+ if (frontier.unitLedger.units.every((unit) => isResolvedLedgerUnit(unit))) {
5355
6065
  break;
5356
6066
  }
5357
6067
  const ready = frontier.frontierUnits.filter((unit) => unit.dispatchDecision !== "skip");