mandrel 2.9.0 → 2.11.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 +39 -25
- 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 +36 -9
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +10 -6
- 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/baselines/env-overrides.js +33 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
- package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
- package/.agents/scripts/lib/config/quality.js +13 -0
- 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 +12 -16
- 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/ceremony-routing.js +45 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
- package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +533 -93
- 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 +119 -3
- 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 +153 -2
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +221 -14
- package/.agents/scripts/lib/orchestration/resolve-stories.js +12 -1
- package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
- 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/spec-budget.js +78 -0
- package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
- 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/orchestration/ticket-validator-conflicts.js +6 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
- 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 +84 -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/resolve-stories.js +2 -0
- package/.agents/scripts/single-story-confirm-merge.js +65 -5
- package/.agents/scripts/stories-wave-tick.js +1 -1
- package/.agents/workflows/deliver.md +88 -230
- package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
- package/.agents/workflows/helpers/deliver-reference.md +171 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +223 -0
- package/.agents/workflows/helpers/deliver-story.md +115 -432
- package/.agents/workflows/helpers/plan-reference.md +246 -0
- package/.agents/workflows/plan.md +108 -304
- package/docs/CHANGELOG.md +46 -0
- package/lib/cli/registry.js +31 -14
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -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,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/spec-budget.js — the soft `## Spec` word-budget pass
|
|
3
|
+
* (Story #4723), extracted from `ticket-validator.js` so the advisory
|
|
4
|
+
* length nudge lives beside neither the hard validators nor their error
|
|
5
|
+
* channel: everything here is `'soft'` by construction and can never fail
|
|
6
|
+
* a persist.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Soft advisory word budget for a Story's inline `## Spec` (Story #4723).
|
|
13
|
+
* ~250 words is the #4707 contract-level-prose target: interfaces,
|
|
14
|
+
* invariants, and load-bearing constraints — not route-by-route behavior
|
|
15
|
+
* narration. Distinct from the hard ~1500-token fail-closed ceiling in
|
|
16
|
+
* `spec-spill.js`: this budget only warns; it never fails the persist.
|
|
17
|
+
*/
|
|
18
|
+
export const SPEC_SOFT_WORD_BUDGET = 250;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a Story's Spec prose across both authoring shapes: the canonical
|
|
22
|
+
* serialized string body (parsed; `## Spec` text block) and the
|
|
23
|
+
* pre-serialize structured object body (`body.spec`). Returns `''` when
|
|
24
|
+
* absent — or when a string body does not parse: this pass is advisory, so
|
|
25
|
+
* an unreadable body contributes no finding here and is left to the hard
|
|
26
|
+
* parse gate (`assertStoryBodiesParse`) to reject.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} story
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function resolveSpecText(story) {
|
|
32
|
+
const body = story?.body;
|
|
33
|
+
if (typeof body === 'string' && body.trim().length > 0) {
|
|
34
|
+
let spec;
|
|
35
|
+
try {
|
|
36
|
+
spec = parseStoryBody(body).body.spec;
|
|
37
|
+
} catch {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
return typeof spec === 'string' ? spec : '';
|
|
41
|
+
}
|
|
42
|
+
if (body !== null && typeof body === 'object') {
|
|
43
|
+
return typeof body?.spec === 'string' ? body.spec : '';
|
|
44
|
+
}
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Advisory `## Spec` length pass (Story #4723). Emits one `'soft'` finding
|
|
50
|
+
* per Story whose Spec prose exceeds {@link SPEC_SOFT_WORD_BUDGET} words,
|
|
51
|
+
* nudging the author toward contract-level prose (#4707). Soft only — the
|
|
52
|
+
* findings never reach the validator's `errors[]` channel, so an
|
|
53
|
+
* over-budget Spec never fails the persist.
|
|
54
|
+
*
|
|
55
|
+
* @param {{ stories: object[] }} opts
|
|
56
|
+
* @returns {object[]} Zero or more `spec-word-budget` findings.
|
|
57
|
+
*/
|
|
58
|
+
export function computeSpecBudgetFindings({ stories }) {
|
|
59
|
+
const findings = [];
|
|
60
|
+
for (const story of stories ?? []) {
|
|
61
|
+
const words = resolveSpecText(story).split(/\s+/).filter(Boolean).length;
|
|
62
|
+
if (words <= SPEC_SOFT_WORD_BUDGET) continue;
|
|
63
|
+
findings.push({
|
|
64
|
+
kind: 'spec-word-budget',
|
|
65
|
+
severity: 'soft',
|
|
66
|
+
ticketSlug: story.slug ?? '<unknown>',
|
|
67
|
+
words,
|
|
68
|
+
budget: SPEC_SOFT_WORD_BUDGET,
|
|
69
|
+
message:
|
|
70
|
+
`Story "${story.slug ?? '<unknown>'}" ## Spec is ~${words} words ` +
|
|
71
|
+
`(soft budget ${SPEC_SOFT_WORD_BUDGET}). Prefer contract-level prose ` +
|
|
72
|
+
'(interfaces, invariants, load-bearing constraints with their why) ' +
|
|
73
|
+
'over per-file behavior narration — advisory only; the persist ' +
|
|
74
|
+
'proceeds.',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/story-body-gate.js — the Story-body parse gate
|
|
3
|
+
* (Story #4541), extracted from `ticket-validator.js`: the one place a
|
|
4
|
+
* serialized Story body is parsed with parse failures translated into the
|
|
5
|
+
* validator's operator-legible `ValidationError` shape. Both the gate that
|
|
6
|
+
* refuses a plan up front (`assertStoryBodiesParse`) and the per-call
|
|
7
|
+
* translating parser (`parseStoryBodyOrThrow`) the downstream validators
|
|
8
|
+
* lean on live here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ValidationError } from '../errors/index.js';
|
|
12
|
+
import {
|
|
13
|
+
parse as parseStoryBody,
|
|
14
|
+
StoryBodyParseError,
|
|
15
|
+
} from '../story-body/story-body.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a Story's serialized markdown body, translating a
|
|
19
|
+
* `StoryBodyParseError` into a `ValidationError` that names the offending
|
|
20
|
+
* **section** and **entry** (Story #4541).
|
|
21
|
+
*
|
|
22
|
+
* `StoryBodyParseError` already carries `field` (the section the parser was
|
|
23
|
+
* reading) and `raw` (the entry text that failed); this lifts both into an
|
|
24
|
+
* operator-legible message and a structured `violation` payload so an
|
|
25
|
+
* authoring loop can point at the exact bullet instead of re-deriving it
|
|
26
|
+
* from a downstream freshness miss.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} story Story whose `body` is a non-empty markdown string.
|
|
29
|
+
* @returns {object} The structured body.
|
|
30
|
+
* @throws {ValidationError} `code: 'story-body-unparseable'`.
|
|
31
|
+
*/
|
|
32
|
+
export function parseStoryBodyOrThrow(story) {
|
|
33
|
+
try {
|
|
34
|
+
return parseStoryBody(story.body).body;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (!(err instanceof StoryBodyParseError)) throw err;
|
|
37
|
+
const slug = story.slug ?? '<unknown>';
|
|
38
|
+
const section = err.field ?? 'body';
|
|
39
|
+
const entry = err.raw ?? null;
|
|
40
|
+
const entryLine = entry === null ? '' : `\n entry: ${entry}`;
|
|
41
|
+
const violation = { slug, section, entry, reason: err.message };
|
|
42
|
+
const error = new ValidationError(
|
|
43
|
+
`Cross-Validation Failed: Story "${slug}" has an unparseable body — ` +
|
|
44
|
+
`the ## ${section} section could not be read: ${err.message}` +
|
|
45
|
+
`${entryLine}\n\nFix the offending entry; this is a malformed body, ` +
|
|
46
|
+
'not a stale path reference.',
|
|
47
|
+
{ violations: [violation] },
|
|
48
|
+
);
|
|
49
|
+
error.code = 'story-body-unparseable';
|
|
50
|
+
error.violations = [violation];
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Refuse the plan when any Story's serialized body cannot be parsed, before
|
|
57
|
+
* either git-probe gate runs (Story #4541). Ordering matters: the freshness
|
|
58
|
+
* gate consults `body.changes` for its net-new whitelist, so an unparseable
|
|
59
|
+
* body used to reach the operator as a freshness miss naming declared paths.
|
|
60
|
+
*
|
|
61
|
+
* @param {{ tickets: object[] }} opts
|
|
62
|
+
* @throws {ValidationError} `code: 'story-body-unparseable'` on the first
|
|
63
|
+
* offending Story.
|
|
64
|
+
*/
|
|
65
|
+
export function assertStoryBodiesParse({ tickets }) {
|
|
66
|
+
for (const story of (tickets ?? []).filter((t) => t.type === 'story')) {
|
|
67
|
+
if (typeof story.body !== 'string' || story.body.trim().length === 0) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
parseStoryBodyOrThrow(story);
|
|
71
|
+
}
|
|
72
|
+
}
|