mandrel 2.9.0 → 2.10.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.
- package/.agents/agents/.markdownlint.json +4 -0
- package/.agents/agents/acceptance-critic.md +30 -5
- package/.agents/agents/auditor.md +36 -19
- package/.agents/agents/plan-critic.md +31 -5
- package/.agents/agents/story-worker.md +91 -100
- package/.agents/docs/configuration.md +16 -4
- package/.agents/docs/execution-reference.md +13 -0
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +131 -265
- package/.agents/rules/git-conventions.md +47 -83
- package/.agents/rules/orchestration-error-handling.md +28 -0
- package/.agents/schemas/agentrc.schema.json +24 -2
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +1 -1
- package/.agents/scripts/apply-quality-bootstrap.js +1 -1
- package/.agents/scripts/check-test-temp-hygiene.js +438 -0
- package/.agents/scripts/deliver-recover.js +23 -6
- package/.agents/scripts/lib/audit-suite/index.js +5 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -1
- package/.agents/scripts/lib/config/temp-paths.js +121 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
- package/.agents/scripts/lib/config-settings-schema.js +1 -1
- package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +113 -2
- package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
- package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
- package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +57 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
- package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
- package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
- package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
- package/.agents/scripts/lib/templates/decomposer-prompts.js +13 -6
- package/.agents/scripts/lib/test-env.js +65 -0
- package/.agents/scripts/plan-context.js +66 -9
- package/.agents/scripts/plan-critics.js +115 -3
- package/.agents/scripts/plan-persist.js +11 -1
- package/.agents/scripts/plan-run-epilogue.js +1 -1
- package/.agents/scripts/single-story-confirm-merge.js +65 -5
- package/.agents/scripts/stories-wave-tick.js +1 -1
- package/.agents/workflows/deliver.md +86 -230
- package/.agents/workflows/helpers/deliver-reference.md +167 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
- package/.agents/workflows/helpers/deliver-story.md +114 -432
- package/.agents/workflows/helpers/plan-reference.md +211 -0
- package/.agents/workflows/plan.md +107 -304
- package/docs/CHANGELOG.md +27 -0
- package/package.json +1 -1
|
@@ -36,6 +36,24 @@
|
|
|
36
36
|
* Backgrounding is not a workaround here and does not need to be: an
|
|
37
37
|
* interrupted poll is stateless and re-entrant by construction.
|
|
38
38
|
*
|
|
39
|
+
* ## Async mode (Story #4698 — a designed short probe window, not an accident)
|
|
40
|
+
*
|
|
41
|
+
* `maxWaitSeconds` (default 300s) still routinely EXPIRES on a slow-CI
|
|
42
|
+
* consumer: the median PR-create→merge time can be minutes, so nearly every
|
|
43
|
+
* close burns its whole foreground slot polling and then returns `pending`
|
|
44
|
+
* anyway. `delivery.mergeWatch.mode: "async"` makes that async confirm a
|
|
45
|
+
* designed mode rather than an expiry accident. It caps the per-invocation
|
|
46
|
+
* wait to a short probe window (`ASYNC_PROBE_WINDOW_SECONDS`, ~60s) — long
|
|
47
|
+
* enough for the loop's existing checks to catch an instant merge and, via the
|
|
48
|
+
* imported {@link decideMergeWaitFailFast} decision (Story #4695/#4710), an
|
|
49
|
+
* instantly-red required check — then returns the SAME resumable `pending`
|
|
50
|
+
* terminal, whose `nextCommand` the worker launches in the background. Nothing
|
|
51
|
+
* else changes: the cumulative `maxBudgetSeconds` anchor is untouched, and
|
|
52
|
+
* `sync` mode (the default) is byte-compatible. An explicit `--max-wait-seconds`
|
|
53
|
+
* override wins over the async cap so a headless caller can still land in one
|
|
54
|
+
* block. The clamp lives entirely in `resolveMergeWaitConfig`; the poll loop is
|
|
55
|
+
* mode-agnostic.
|
|
56
|
+
*
|
|
39
57
|
* ## The wait is not weaker than the watch it displaced
|
|
40
58
|
*
|
|
41
59
|
* The pre-#4543 poll read only `state` / `mergedAt`. A check that went red
|
|
@@ -72,7 +90,7 @@
|
|
|
72
90
|
* confirm.
|
|
73
91
|
*/
|
|
74
92
|
|
|
75
|
-
import {
|
|
93
|
+
import { createGh } from '../../../gh-exec.js';
|
|
76
94
|
import {
|
|
77
95
|
confirmStoryMerged as defaultConfirmStoryMerged,
|
|
78
96
|
readPrMergeState as defaultReadPrMergeState,
|
|
@@ -86,8 +104,10 @@ import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-blo
|
|
|
86
104
|
import {
|
|
87
105
|
DEFAULT_INTERVAL_SECONDS,
|
|
88
106
|
DEFAULT_MAX_BUDGET_SECONDS,
|
|
107
|
+
decideMergeWaitFailFast,
|
|
89
108
|
deriveChecksStatus,
|
|
90
|
-
|
|
109
|
+
deriveRequiredRunEvidence,
|
|
110
|
+
MERGE_WAIT_GH_TIMEOUT_MS,
|
|
91
111
|
} from '../../merge-poll.js';
|
|
92
112
|
import { NEXT_COMMANDS } from '../../story-deliver-terminal.js';
|
|
93
113
|
import {
|
|
@@ -105,6 +125,17 @@ import { runPostLandTail as defaultRunPostLandTail } from './post-land.js';
|
|
|
105
125
|
*/
|
|
106
126
|
export const DEFAULT_MAX_WAIT_SECONDS = 300;
|
|
107
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Async-mode per-invocation probe window (Story #4698). When
|
|
130
|
+
* `delivery.mergeWatch.mode` is `"async"`, `resolveMergeWaitConfig` caps the
|
|
131
|
+
* per-invocation wait to this many seconds so close returns the resumable
|
|
132
|
+
* `pending` terminal fast instead of burning the foreground host slot. Sized
|
|
133
|
+
* to catch an instant merge and — via the head-anchored required-check
|
|
134
|
+
* predicate — an instantly-red required check, while staying far inside the
|
|
135
|
+
* cumulative `maxBudgetSeconds` give-up bound.
|
|
136
|
+
*/
|
|
137
|
+
export const ASYNC_PROBE_WINDOW_SECONDS = 60;
|
|
138
|
+
|
|
108
139
|
/** Bounded `gh pr update-branch` attempts for a BEHIND PR. */
|
|
109
140
|
export const DEFAULT_UPDATE_ATTEMPTS = 3;
|
|
110
141
|
|
|
@@ -129,6 +160,49 @@ function defaultSleep(ms) {
|
|
|
129
160
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
130
161
|
}
|
|
131
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The wait's default `gh` facade, bound to a spawn-level timeout (Story
|
|
165
|
+
* #4710): every subprocess the wait launches through it carries
|
|
166
|
+
* `MERGE_WAIT_GH_TIMEOUT_MS`, so a wedged `gh` child is killed rather than
|
|
167
|
+
* stranding an unattended async-mode wait forever. Callers that inject their
|
|
168
|
+
* own `gh` (tests, the resume CLI) are bounded by {@link withGhTimeout} at
|
|
169
|
+
* the call sites instead.
|
|
170
|
+
*/
|
|
171
|
+
const defaultGh = createGh(undefined, { timeoutMs: MERGE_WAIT_GH_TIMEOUT_MS });
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Bound an arbitrary `gh` call with a wall-clock timeout (Story #4710). The
|
|
175
|
+
* spawn-level `timeoutMs` on {@link defaultGh} already kills a wedged real
|
|
176
|
+
* subprocess, but an injected `gh` implementation (a test stub, a facade
|
|
177
|
+
* built without defaults) can still return a promise that never settles —
|
|
178
|
+
* and the merge wait must never hang on any of them. Rejection maps to the
|
|
179
|
+
* caller's existing error handling: the probe degrades to its conservative
|
|
180
|
+
* pending shape, the update-branch attempt logs and continues.
|
|
181
|
+
*
|
|
182
|
+
* A late settlement of the losing promise is explicitly absorbed so a
|
|
183
|
+
* post-timeout rejection cannot surface as an unhandled rejection.
|
|
184
|
+
*
|
|
185
|
+
* The timeout timer is deliberately NOT `unref`'d: when the awaited call is a
|
|
186
|
+
* promise that never settles (a hung stub, or a real gh child whose I/O has
|
|
187
|
+
* gone quiet), the timer is the ONLY handle keeping the event loop alive, so
|
|
188
|
+
* unref'ing it would let the process/test exit before the timeout ever fires —
|
|
189
|
+
* exactly the hang this guard exists to prevent. It is short-lived and always
|
|
190
|
+
* cleared in `finally`, so keeping it referenced costs nothing.
|
|
191
|
+
*/
|
|
192
|
+
function withGhTimeout(promise, timeoutMs, label) {
|
|
193
|
+
let timer;
|
|
194
|
+
const bounded = new Promise((resolve, reject) => {
|
|
195
|
+
timer = setTimeout(() => {
|
|
196
|
+
reject(
|
|
197
|
+
new Error(`${label} did not return within ${timeoutMs}ms (timeout)`),
|
|
198
|
+
);
|
|
199
|
+
}, timeoutMs);
|
|
200
|
+
promise.then(resolve, reject);
|
|
201
|
+
}).finally(() => clearTimeout(timer));
|
|
202
|
+
promise.catch(() => {});
|
|
203
|
+
return bounded;
|
|
204
|
+
}
|
|
205
|
+
|
|
132
206
|
/**
|
|
133
207
|
* One probe per poll iteration, carrying every field the loop and the
|
|
134
208
|
* terminal classifier need: merge state, the checks rollup, the merge-state
|
|
@@ -137,20 +211,31 @@ function defaultSleep(ms) {
|
|
|
137
211
|
*
|
|
138
212
|
* Returns a degraded `{ checksStatus: 'pending', error }` probe when the read
|
|
139
213
|
* itself fails, preserving the conservative classification on probe errors —
|
|
140
|
-
* a flaky API read must not be mistaken for a definitive verdict.
|
|
214
|
+
* a flaky API read must not be mistaken for a definitive verdict. A probe
|
|
215
|
+
* that exceeds `ghTimeoutMs` (Story #4710) takes the SAME degraded path: a
|
|
216
|
+
* hung subprocess must surface as a probe error within the bound, never
|
|
217
|
+
* strand the wait.
|
|
141
218
|
*
|
|
142
219
|
* @returns {Promise<object>}
|
|
143
220
|
*/
|
|
144
|
-
export async function readPrWaitProbe({
|
|
221
|
+
export async function readPrWaitProbe({
|
|
222
|
+
prNumber,
|
|
223
|
+
gh = defaultGh,
|
|
224
|
+
ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
|
|
225
|
+
}) {
|
|
145
226
|
try {
|
|
146
|
-
const view = await
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
227
|
+
const view = await withGhTimeout(
|
|
228
|
+
gh.pr.view(prNumber, [
|
|
229
|
+
'state',
|
|
230
|
+
'mergedAt',
|
|
231
|
+
'createdAt',
|
|
232
|
+
'mergeStateStatus',
|
|
233
|
+
'reviewDecision',
|
|
234
|
+
'statusCheckRollup',
|
|
235
|
+
]),
|
|
236
|
+
ghTimeoutMs,
|
|
237
|
+
`gh pr view ${prNumber}`,
|
|
238
|
+
);
|
|
154
239
|
return {
|
|
155
240
|
state: typeof view?.state === 'string' ? view.state : null,
|
|
156
241
|
mergedAt: typeof view?.mergedAt === 'string' ? view.mergedAt : null,
|
|
@@ -164,6 +249,11 @@ export async function readPrWaitProbe({ prNumber, gh = defaultGh }) {
|
|
|
164
249
|
? view.reviewDecision
|
|
165
250
|
: undefined,
|
|
166
251
|
checksStatus: deriveChecksStatus(view?.statusCheckRollup),
|
|
252
|
+
// Head-anchored per-run evidence (Story #4695): distinguishes a
|
|
253
|
+
// genuinely red required run from the superseded / still-pending noise
|
|
254
|
+
// the aggregate `checksStatus` folds together. `null` when the rollup is
|
|
255
|
+
// absent/empty — the loop's consecutive-probe fallback owns that path.
|
|
256
|
+
requiredRunEvidence: deriveRequiredRunEvidence(view?.statusCheckRollup),
|
|
167
257
|
};
|
|
168
258
|
} catch (err) {
|
|
169
259
|
return {
|
|
@@ -185,18 +275,36 @@ export async function readPrWaitProbe({ prNumber, gh = defaultGh }) {
|
|
|
185
275
|
* raises the per-invocation bound to keep single-block semantics without
|
|
186
276
|
* editing the consumer's config.
|
|
187
277
|
*
|
|
278
|
+
* `mode` (Story #4698) selects the close-time merge posture. `async` caps the
|
|
279
|
+
* per-invocation wait to `ASYNC_PROBE_WINDOW_SECONDS` so close returns the
|
|
280
|
+
* resumable `pending` terminal fast; `sync` (the default) is unchanged. An
|
|
281
|
+
* explicit `maxWaitSecondsOverride` still wins over the async cap — a headless
|
|
282
|
+
* caller with no host ceiling opts back into single-block waiting.
|
|
283
|
+
*
|
|
188
284
|
* @param {object} [config]
|
|
189
285
|
* @param {number} [maxWaitSecondsOverride]
|
|
190
|
-
* @returns {{ intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
|
|
286
|
+
* @returns {{ mode: 'sync'|'async', intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
|
|
191
287
|
*/
|
|
192
288
|
export function resolveMergeWaitConfig(config, maxWaitSecondsOverride) {
|
|
193
289
|
const mergeWatch = config?.delivery?.mergeWatch ?? {};
|
|
194
290
|
const int = (value, fallback, min = 1) =>
|
|
195
291
|
Number.isInteger(value) && value >= min ? value : fallback;
|
|
196
|
-
const
|
|
292
|
+
const mode = mergeWatch.mode === 'async' ? 'async' : 'sync';
|
|
293
|
+
const configuredMaxWait = int(
|
|
197
294
|
maxWaitSecondsOverride,
|
|
198
295
|
int(mergeWatch.maxWaitSeconds, DEFAULT_MAX_WAIT_SECONDS),
|
|
199
296
|
);
|
|
297
|
+
// Async mode caps the per-invocation wait to a short probe window so close
|
|
298
|
+
// returns `pending` fast instead of burning the foreground host slot on a
|
|
299
|
+
// merge that lands after the wait would have expired anyway. The window is
|
|
300
|
+
// long enough for the loop's existing checks to catch an instant merge and —
|
|
301
|
+
// via the imported `decideMergeWaitFailFast` decision — an instantly
|
|
302
|
+
// red required check. An explicit `--max-wait-seconds` override still wins so
|
|
303
|
+
// a headless caller with no host ceiling opts back into single-block waiting.
|
|
304
|
+
const maxWaitSeconds =
|
|
305
|
+
mode === 'async' && maxWaitSecondsOverride == null
|
|
306
|
+
? Math.min(configuredMaxWait, ASYNC_PROBE_WINDOW_SECONDS)
|
|
307
|
+
: configuredMaxWait;
|
|
200
308
|
// A poll interval longer than the wait bound is incoherent, and silently
|
|
201
309
|
// harmful: the pending check would fire on poll 1 every time, so the wait
|
|
202
310
|
// could never sleep, `polls` could never reach
|
|
@@ -210,6 +318,7 @@ export function resolveMergeWaitConfig(config, maxWaitSecondsOverride) {
|
|
|
210
318
|
maxWaitSeconds,
|
|
211
319
|
);
|
|
212
320
|
return {
|
|
321
|
+
mode,
|
|
213
322
|
intervalSeconds,
|
|
214
323
|
maxWaitSeconds,
|
|
215
324
|
maxBudgetSeconds: int(
|
|
@@ -429,6 +538,12 @@ async function blockOnUnlanded({
|
|
|
429
538
|
budget,
|
|
430
539
|
});
|
|
431
540
|
const elapsedSeconds = budget?.elapsedSeconds ?? 0;
|
|
541
|
+
// Which evidence path produced a `checks-failed` verdict (Story #4695):
|
|
542
|
+
// `per-run` (head-anchored required-run evidence) or `consecutive-probe`
|
|
543
|
+
// (the evidence-unavailable fallback). Named on the emitted record so the
|
|
544
|
+
// `merge.unlanded` telemetry attributes the fail-fast to the path that
|
|
545
|
+
// fired it. Absent for every other block class.
|
|
546
|
+
const evidencePath = prProbe?.evidencePath;
|
|
432
547
|
|
|
433
548
|
if (Number.isInteger(prNumber) && prNumber > 0) {
|
|
434
549
|
try {
|
|
@@ -439,6 +554,7 @@ async function blockOnUnlanded({
|
|
|
439
554
|
blockClass,
|
|
440
555
|
reason,
|
|
441
556
|
elapsedSeconds,
|
|
557
|
+
...(evidencePath ? { evidencePath } : {}),
|
|
442
558
|
});
|
|
443
559
|
} catch (err) {
|
|
444
560
|
progress?.(
|
|
@@ -509,6 +625,7 @@ async function maybeUpdateBehindPr({
|
|
|
509
625
|
updatesUsed,
|
|
510
626
|
updateAttempts,
|
|
511
627
|
gh,
|
|
628
|
+
ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
|
|
512
629
|
progress,
|
|
513
630
|
}) {
|
|
514
631
|
if (probe.mergeStateStatus !== 'BEHIND') return false;
|
|
@@ -520,7 +637,11 @@ async function maybeUpdateBehindPr({
|
|
|
520
637
|
return false;
|
|
521
638
|
}
|
|
522
639
|
try {
|
|
523
|
-
await (
|
|
640
|
+
await withGhTimeout(
|
|
641
|
+
(gh ?? defaultGh).pr.updateBranch(prNumber),
|
|
642
|
+
ghTimeoutMs,
|
|
643
|
+
`gh pr update-branch ${prNumber}`,
|
|
644
|
+
);
|
|
524
645
|
progress?.(
|
|
525
646
|
'CONFIRM',
|
|
526
647
|
`⏫ PR #${prNumber} was BEHIND its base — updated (attempt ${updatesUsed + 1}/${updateAttempts}).`,
|
|
@@ -650,6 +771,10 @@ async function onMergeObserved({
|
|
|
650
771
|
* @param {(ms: number) => Promise<void>} [args.sleepFn] Test seam so the
|
|
651
772
|
* suite does not actually wait.
|
|
652
773
|
* @param {() => number} [args.nowMsFn] Test seam; returns epoch ms.
|
|
774
|
+
* @param {number} [args.ghTimeoutMs] Wall-clock bound for each `gh` call the
|
|
775
|
+
* wait makes (Story #4710). A framework constant
|
|
776
|
+
* (`MERGE_WAIT_GH_TIMEOUT_MS`), overridable only as a test seam — not
|
|
777
|
+
* config.
|
|
653
778
|
* @returns {Promise<object>}
|
|
654
779
|
*/
|
|
655
780
|
export async function runConfirmMergePhase({
|
|
@@ -676,6 +801,7 @@ export async function runConfirmMergePhase({
|
|
|
676
801
|
runPostLandTailFn = defaultRunPostLandTail,
|
|
677
802
|
sleepFn = defaultSleep,
|
|
678
803
|
nowMsFn = Date.now,
|
|
804
|
+
ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
|
|
679
805
|
}) {
|
|
680
806
|
// The arm itself never succeeded (gh failure, unparseable PR number, or a
|
|
681
807
|
// deliberate disablement) — there is no "armed but unconfirmed" PR to
|
|
@@ -699,22 +825,38 @@ export async function runConfirmMergePhase({
|
|
|
699
825
|
});
|
|
700
826
|
}
|
|
701
827
|
|
|
702
|
-
const {
|
|
703
|
-
|
|
828
|
+
const {
|
|
829
|
+
mode,
|
|
830
|
+
intervalSeconds,
|
|
831
|
+
maxWaitSeconds,
|
|
832
|
+
maxBudgetSeconds,
|
|
833
|
+
updateAttempts,
|
|
834
|
+
} = resolveMergeWaitConfig(config, maxWaitSecondsOverride);
|
|
704
835
|
const intervalMs = intervalSeconds * 1000;
|
|
705
836
|
const startedAtMs = nowMsFn();
|
|
706
837
|
let anchorMs = startedAtMs;
|
|
707
838
|
let updatesUsed = 0;
|
|
708
839
|
let polls = 0;
|
|
840
|
+
// Consecutive failing check probes observed WITHOUT per-run evidence
|
|
841
|
+
// (Story #4695). The evidence-unavailable fallback: a single failing rollup
|
|
842
|
+
// snapshot never fail-fasts — two consecutive failing probes at least one
|
|
843
|
+
// poll interval apart are required. Reset on any non-failing (or genuinely
|
|
844
|
+
// evidenced) probe.
|
|
845
|
+
let consecutiveRequiredFailSnapshots = 0;
|
|
709
846
|
|
|
710
847
|
progress?.(
|
|
711
848
|
'CONFIRM',
|
|
712
849
|
`⏳ Close-and-land: polling PR #${prNumber} for merge confirmation ` +
|
|
713
|
-
`(wait=${maxWaitSeconds}s this invocation,
|
|
850
|
+
`(mode=${mode}, wait=${maxWaitSeconds}s this invocation, ` +
|
|
851
|
+
`cumulative budget=${maxBudgetSeconds}s)...`,
|
|
714
852
|
);
|
|
715
853
|
|
|
716
854
|
while (true) {
|
|
717
|
-
const probe = await readPrWaitProbeFn({
|
|
855
|
+
const probe = await readPrWaitProbeFn({
|
|
856
|
+
prNumber,
|
|
857
|
+
gh: injectedGh,
|
|
858
|
+
ghTimeoutMs,
|
|
859
|
+
});
|
|
718
860
|
polls += 1;
|
|
719
861
|
|
|
720
862
|
// Anchor the cumulative budget at the PR's creation the first time we
|
|
@@ -755,6 +897,12 @@ export async function runConfirmMergePhase({
|
|
|
755
897
|
});
|
|
756
898
|
}
|
|
757
899
|
|
|
900
|
+
// Everything below funnels into ONE terminal exit (Story #4710): each
|
|
901
|
+
// definitive condition fills `unlanded` and the single call site at the
|
|
902
|
+
// bottom classifies, emits, and blocks — the fail-fast tree used to
|
|
903
|
+
// duplicate that block twice inline.
|
|
904
|
+
let unlanded = null;
|
|
905
|
+
|
|
758
906
|
if (probe.state === 'CLOSED') {
|
|
759
907
|
// Closed without merging — a definitive terminal, not a "still
|
|
760
908
|
// pending" condition the budget should keep waiting on. checksStatus
|
|
@@ -763,10 +911,7 @@ export async function runConfirmMergePhase({
|
|
|
763
911
|
// pending", which would misclassify this definitive case as
|
|
764
912
|
// checks-pending-timeout instead of reaching the api-race-other
|
|
765
913
|
// reason built from prProbe.error.
|
|
766
|
-
|
|
767
|
-
storyId,
|
|
768
|
-
prNumber,
|
|
769
|
-
prUrl,
|
|
914
|
+
unlanded = {
|
|
770
915
|
prProbe: {
|
|
771
916
|
checksStatus: 'closed',
|
|
772
917
|
error: 'PR closed without merging (state=CLOSED)',
|
|
@@ -775,72 +920,78 @@ export async function runConfirmMergePhase({
|
|
|
775
920
|
exhausted: true,
|
|
776
921
|
elapsedSeconds: Math.round(waitedMs / 1000),
|
|
777
922
|
},
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
923
|
+
};
|
|
924
|
+
} else {
|
|
925
|
+
// Fail fast on a GENUINELY red REQUIRED check — head-anchored (Story
|
|
926
|
+
// #4695), decided by the extracted `decideMergeWaitFailFast` (Story
|
|
927
|
+
// #4710): per-run evidence decides on a single probe; without evidence
|
|
928
|
+
// two consecutive failing probes are required. No remaining budget
|
|
929
|
+
// turns a failed check green, and waiting it out is what made the
|
|
930
|
+
// pre-#4543 wait report the operator's red test run as a
|
|
931
|
+
// branch-protection block.
|
|
932
|
+
const decision = decideMergeWaitFailFast({
|
|
933
|
+
probe,
|
|
934
|
+
consecutiveRequiredFailSnapshots,
|
|
782
935
|
});
|
|
936
|
+
consecutiveRequiredFailSnapshots =
|
|
937
|
+
decision.consecutiveRequiredFailSnapshots;
|
|
938
|
+
if (decision.failFast) {
|
|
939
|
+
progress?.(
|
|
940
|
+
'CONFIRM',
|
|
941
|
+
decision.evidencePath === 'per-run'
|
|
942
|
+
? `🛑 PR #${prNumber}: a required check concluded failure with none in flight — failing fast (evidence=per-run).`
|
|
943
|
+
: `🛑 PR #${prNumber}: two consecutive failing check probes without per-run evidence — failing fast (evidence=consecutive-probe).`,
|
|
944
|
+
);
|
|
945
|
+
unlanded = {
|
|
946
|
+
prProbe: decision.prProbe,
|
|
947
|
+
budget: {
|
|
948
|
+
exhausted: false,
|
|
949
|
+
elapsedSeconds: Math.round(waitedMs / 1000),
|
|
950
|
+
},
|
|
951
|
+
};
|
|
952
|
+
}
|
|
783
953
|
}
|
|
784
954
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
)
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
prNumber,
|
|
800
|
-
prUrl,
|
|
801
|
-
prProbe: probe,
|
|
802
|
-
budget: {
|
|
803
|
-
exhausted: false,
|
|
804
|
-
elapsedSeconds: Math.round(waitedMs / 1000),
|
|
805
|
-
},
|
|
806
|
-
provider,
|
|
807
|
-
progress,
|
|
808
|
-
classifyMergeBlockFn,
|
|
809
|
-
emitMergeUnlandedFn,
|
|
810
|
-
});
|
|
811
|
-
}
|
|
955
|
+
if (!unlanded) {
|
|
956
|
+
if (
|
|
957
|
+
await maybeUpdateBehindPr({
|
|
958
|
+
probe,
|
|
959
|
+
prNumber,
|
|
960
|
+
updatesUsed,
|
|
961
|
+
updateAttempts,
|
|
962
|
+
gh: injectedGh,
|
|
963
|
+
ghTimeoutMs,
|
|
964
|
+
progress,
|
|
965
|
+
})
|
|
966
|
+
) {
|
|
967
|
+
updatesUsed += 1;
|
|
968
|
+
}
|
|
812
969
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
970
|
+
// Cumulative budget exhausted → the genuine give-up. Classify from the
|
|
971
|
+
// probe we already hold. Gated behind the poll floor so an
|
|
972
|
+
// already-over-budget PR (anchored at a createdAt older than the budget
|
|
973
|
+
// — a resume the next day, or a long-open PR) still gets a real poll
|
|
974
|
+
// cycle instead of being blocked before this invocation waited at all.
|
|
975
|
+
if (
|
|
976
|
+
polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
|
|
977
|
+
cumulativeMs + intervalMs > maxBudgetSeconds * 1000
|
|
978
|
+
) {
|
|
979
|
+
unlanded = {
|
|
980
|
+
prProbe: probe,
|
|
981
|
+
budget: {
|
|
982
|
+
exhausted: true,
|
|
983
|
+
elapsedSeconds: Math.round(cumulativeMs / 1000),
|
|
984
|
+
},
|
|
985
|
+
};
|
|
986
|
+
}
|
|
824
987
|
}
|
|
825
988
|
|
|
826
|
-
|
|
827
|
-
// probe we already hold. Gated behind the poll floor so an
|
|
828
|
-
// already-over-budget PR (anchored at a createdAt older than the budget —
|
|
829
|
-
// a resume the next day, or a long-open PR) still gets a real poll cycle
|
|
830
|
-
// instead of being blocked before this invocation waited at all.
|
|
831
|
-
if (
|
|
832
|
-
polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
|
|
833
|
-
cumulativeMs + intervalMs > maxBudgetSeconds * 1000
|
|
834
|
-
) {
|
|
989
|
+
if (unlanded) {
|
|
835
990
|
return blockOnUnlanded({
|
|
836
991
|
storyId,
|
|
837
992
|
prNumber,
|
|
838
993
|
prUrl,
|
|
839
|
-
|
|
840
|
-
budget: {
|
|
841
|
-
exhausted: true,
|
|
842
|
-
elapsedSeconds: Math.round(cumulativeMs / 1000),
|
|
843
|
-
},
|
|
994
|
+
...unlanded,
|
|
844
995
|
provider,
|
|
845
996
|
progress,
|
|
846
997
|
classifyMergeBlockFn,
|
|
@@ -13,10 +13,17 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
+
evaluateLensDiffFloor,
|
|
17
|
+
resolveLensDiffFloor,
|
|
16
18
|
runAuditSuite,
|
|
17
19
|
selectLocalLenses,
|
|
18
20
|
} from '../../../audit-suite/index.js';
|
|
21
|
+
import { resolveConfig } from '../../../config-resolver.js';
|
|
19
22
|
import { gitSpawn } from '../../../git-utils.js';
|
|
23
|
+
import {
|
|
24
|
+
emitRuntimeFriction,
|
|
25
|
+
RUNTIME_FRICTION_CATEGORIES,
|
|
26
|
+
} from '../../../observability/runtime-friction.js';
|
|
20
27
|
import { computeChangeSet } from '../../change-set.js';
|
|
21
28
|
|
|
22
29
|
/**
|
|
@@ -180,10 +187,21 @@ function resolveLensChangeSet({
|
|
|
180
187
|
* this the default review provider dropped the materialized envelope, so the
|
|
181
188
|
* pass was a progress log line with no reader.
|
|
182
189
|
*
|
|
190
|
+
* Story #4699 — the **lens diff-floor**. When the caller supplies a known
|
|
191
|
+
* `changedLineCount` and the diff sits strictly below the configured floor
|
|
192
|
+
* (`delivery.review.lensDiffFloor`, default 40) with zero sensitive-path
|
|
193
|
+
* hits, the pass records the matched roster but skips materialization
|
|
194
|
+
* entirely (`skipped: true` with the lenses retained and a `floorSkip`
|
|
195
|
+
* verdict) — the maker-blind code-review pillar and every hard gate are
|
|
196
|
+
* untouched. An unknown line count, a disabled floor, or a sensitive-path
|
|
197
|
+
* hit all fail open to the full materialization.
|
|
198
|
+
*
|
|
183
199
|
* @param {{
|
|
184
200
|
* baseRef: string,
|
|
185
201
|
* headRef: string,
|
|
186
202
|
* changedFiles?: string[]|null,
|
|
203
|
+
* changedLineCount?: number|null,
|
|
204
|
+
* lensDiffFloor?: number,
|
|
187
205
|
* storyId?: number|string|null,
|
|
188
206
|
* artifactPrefix?: string,
|
|
189
207
|
* progress: (tag: string, msg: string) => void,
|
|
@@ -191,11 +209,15 @@ function resolveLensChangeSet({
|
|
|
191
209
|
* gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
|
|
192
210
|
* selectLocalLensesFn?: typeof selectLocalLenses,
|
|
193
211
|
* runAuditSuiteFn?: typeof runAuditSuite,
|
|
212
|
+
* resolveConfigFn?: typeof resolveConfig,
|
|
213
|
+
* evaluateLensDiffFloorFn?: typeof evaluateLensDiffFloor,
|
|
214
|
+
* emitToolDegradationFn?: typeof emitRuntimeFriction,
|
|
194
215
|
* }} args
|
|
195
216
|
* @returns {Promise<{
|
|
196
217
|
* depth: 'light',
|
|
197
218
|
* lenses: string[],
|
|
198
219
|
* skipped: boolean,
|
|
220
|
+
* floorSkip: object|null,
|
|
199
221
|
* materialized: object|null,
|
|
200
222
|
* artifactPaths: string[],
|
|
201
223
|
* }>}
|
|
@@ -204,6 +226,8 @@ export async function runLocalLensReview({
|
|
|
204
226
|
baseRef,
|
|
205
227
|
headRef,
|
|
206
228
|
changedFiles: injectedChangedFiles,
|
|
229
|
+
changedLineCount = null,
|
|
230
|
+
lensDiffFloor,
|
|
207
231
|
storyId,
|
|
208
232
|
artifactPrefix,
|
|
209
233
|
progress,
|
|
@@ -211,11 +235,15 @@ export async function runLocalLensReview({
|
|
|
211
235
|
gitSpawnFn = gitSpawn,
|
|
212
236
|
selectLocalLensesFn = selectLocalLenses,
|
|
213
237
|
runAuditSuiteFn = runAuditSuite,
|
|
238
|
+
resolveConfigFn = resolveConfig,
|
|
239
|
+
evaluateLensDiffFloorFn = evaluateLensDiffFloor,
|
|
240
|
+
emitToolDegradationFn = emitRuntimeFriction,
|
|
214
241
|
}) {
|
|
215
242
|
const empty = {
|
|
216
243
|
depth: STORY_SCOPE_LENS_DEPTH,
|
|
217
244
|
lenses: [],
|
|
218
245
|
skipped: true,
|
|
246
|
+
floorSkip: null,
|
|
219
247
|
materialized: null,
|
|
220
248
|
artifactPaths: [],
|
|
221
249
|
};
|
|
@@ -234,6 +262,34 @@ export async function runLocalLensReview({
|
|
|
234
262
|
);
|
|
235
263
|
return empty;
|
|
236
264
|
}
|
|
265
|
+
|
|
266
|
+
// Lens diff-floor (Story #4699). Deliberately evaluated AFTER lens
|
|
267
|
+
// selection so a floor-skip still records WHICH lenses it skipped —
|
|
268
|
+
// the findings-yield ledger needs the roster either way.
|
|
269
|
+
const floorVerdict = evaluateLensDiffFloorFn({
|
|
270
|
+
changedFiles,
|
|
271
|
+
changedLineCount,
|
|
272
|
+
floor:
|
|
273
|
+
typeof lensDiffFloor === 'number'
|
|
274
|
+
? lensDiffFloor
|
|
275
|
+
: resolveLensDiffFloor(safeResolveConfig(resolveConfigFn)),
|
|
276
|
+
});
|
|
277
|
+
if (floorVerdict.skip) {
|
|
278
|
+
progress(
|
|
279
|
+
progressTag,
|
|
280
|
+
`Lens diff-floor: ${floorVerdict.changedLineCount} changed line(s) < ` +
|
|
281
|
+
`floor ${floorVerdict.floor} with zero sensitive-path hits — ` +
|
|
282
|
+
`skipping materialization of ${lenses.join(', ')}.`,
|
|
283
|
+
);
|
|
284
|
+
return {
|
|
285
|
+
depth: STORY_SCOPE_LENS_DEPTH,
|
|
286
|
+
lenses,
|
|
287
|
+
skipped: true,
|
|
288
|
+
floorSkip: floorVerdict,
|
|
289
|
+
materialized: null,
|
|
290
|
+
artifactPaths: [],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
237
293
|
// Scope the artifact filenames to this Story so concurrent closes on a
|
|
238
294
|
// shared audit output dir cannot clobber each other's prompts.
|
|
239
295
|
const effectivePrefix =
|
|
@@ -256,16 +312,48 @@ export async function runLocalLensReview({
|
|
|
256
312
|
depth: STORY_SCOPE_LENS_DEPTH,
|
|
257
313
|
lenses,
|
|
258
314
|
skipped: false,
|
|
315
|
+
floorSkip: floorVerdict,
|
|
259
316
|
materialized,
|
|
260
317
|
artifactPaths,
|
|
261
318
|
};
|
|
262
319
|
} catch (err) {
|
|
263
320
|
// The lens pass is advisory: a git or materialization failure must not
|
|
264
|
-
// fail the close. Log
|
|
321
|
+
// fail the close. Log, route the tool-execution degradation to friction
|
|
322
|
+
// telemetry (Story #4699 — degradations are operational signals, not
|
|
323
|
+
// findings), and degrade to a skipped envelope.
|
|
265
324
|
progress(
|
|
266
325
|
progressTag,
|
|
267
326
|
`⚠️ local lens pass failed (continuing without it): ${err?.message ?? err}`,
|
|
268
327
|
);
|
|
328
|
+
try {
|
|
329
|
+
await emitToolDegradationFn({
|
|
330
|
+
storyId,
|
|
331
|
+
category: RUNTIME_FRICTION_CATEGORIES.TOOL_DEGRADED,
|
|
332
|
+
tool: 'local-lens-review',
|
|
333
|
+
details: {
|
|
334
|
+
surface: 'lens-materialization',
|
|
335
|
+
reason: String(err?.message ?? err).slice(0, 500),
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
} catch {
|
|
339
|
+
// Observability must never fail the close (best-effort contract).
|
|
340
|
+
}
|
|
269
341
|
return empty;
|
|
270
342
|
}
|
|
271
343
|
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Resolve config for the floor read without letting a resolver failure
|
|
347
|
+
* fail the (advisory) lens pass. Module-local: a degraded config simply
|
|
348
|
+
* yields the framework-default floor.
|
|
349
|
+
*
|
|
350
|
+
* @param {typeof resolveConfig} resolveConfigFn
|
|
351
|
+
* @returns {object|undefined}
|
|
352
|
+
*/
|
|
353
|
+
function safeResolveConfig(resolveConfigFn) {
|
|
354
|
+
try {
|
|
355
|
+
return resolveConfigFn();
|
|
356
|
+
} catch {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
}
|