mandrel 1.84.0 → 1.86.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/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +7 -2
- package/.agents/instructions.md +4 -0
- package/.agents/rules/ci-remediation.md +131 -0
- package/.agents/rules/git-conventions.md +33 -0
- package/.agents/schemas/agentrc.schema.json +29 -6
- package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
- package/.agents/scripts/boot-sweep.js +183 -0
- package/.agents/scripts/epic-deliver-prepare.js +55 -0
- package/.agents/scripts/git-pr-quality-gate.js +7 -5
- package/.agents/scripts/lib/config/ci.js +24 -3
- package/.agents/scripts/lib/config/explain.js +11 -3
- package/.agents/scripts/lib/config/github.js +11 -7
- package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
- package/.agents/scripts/lib/config-settings-schema.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
- package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
- package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
- package/.agents/scripts/lib/single-story-sweep.js +181 -54
- package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
- package/.agents/scripts/pr-watch-with-update.js +324 -37
- package/.agents/scripts/run-verify.js +18 -3
- package/.agents/scripts/single-story-confirm-merge.js +1 -1
- package/.agents/scripts/single-story-init.js +7 -51
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
- package/.agents/skills/core/scope-triage/SKILL.md +5 -4
- package/.agents/workflows/git-deliver.md +36 -0
- package/.agents/workflows/helpers/deliver-epic-reference.md +41 -21
- package/.agents/workflows/helpers/deliver-epic.md +148 -28
- package/.agents/workflows/helpers/deliver-stories.md +18 -5
- package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
- package/.agents/workflows/helpers/single-story-deliver.md +56 -19
- package/.agents/workflows/plan.md +32 -4
- package/docs/CHANGELOG.md +21 -0
- package/package.json +1 -1
|
@@ -40,7 +40,11 @@
|
|
|
40
40
|
* - runs `git remote prune` to drop stale `<remote>/...` tracking
|
|
41
41
|
* refs left behind by `gh pr merge --delete-branch`;
|
|
42
42
|
* - deletes the `wt-branch` scratch ref left by `story-close.js`'s
|
|
43
|
-
* internal merge worktree when it is no longer checked out
|
|
43
|
+
* internal merge worktree when it is no longer checked out;
|
|
44
|
+
* - fast-forwards the base branch to `<remote>/<baseBranch>` after the
|
|
45
|
+
* confirmed merge so the local checkout converges to origin with no
|
|
46
|
+
* manual `git pull` (skipped when the epic branch is kept for an
|
|
47
|
+
* open PR).
|
|
44
48
|
* 3. Record one classification entry per invocation (`reaped`,
|
|
45
49
|
* `no-state`, `failed`, or `skipped-duplicate`) so failures surface
|
|
46
50
|
* in the lifecycle ledger alongside Cleaner's archival outcome.
|
|
@@ -243,8 +247,8 @@ export class BranchCleaner {
|
|
|
243
247
|
* Pure: condense a `reapEpicBranches()` result into the counts that the
|
|
244
248
|
* classification log carries. Exported for tests.
|
|
245
249
|
*
|
|
246
|
-
* @param {{ reaped: Array<object>, pruned: { pruned: string[] }|null, wtBranch: { deleted: boolean }|null }} result
|
|
247
|
-
* @returns {{ branchesDeleted: number, worktreesRemoved: number, tracksPruned: number, wtBranchDeleted: boolean }}
|
|
250
|
+
* @param {{ reaped: Array<object>, pruned: { pruned: string[] }|null, wtBranch: { deleted: boolean }|null, fastForward: { applied: boolean }|null }} result
|
|
251
|
+
* @returns {{ branchesDeleted: number, worktreesRemoved: number, tracksPruned: number, wtBranchDeleted: boolean, fastForwarded: boolean }}
|
|
248
252
|
*/
|
|
249
253
|
export function summarizeReap(result) {
|
|
250
254
|
const reaped = Array.isArray(result?.reaped) ? result.reaped : [];
|
|
@@ -255,5 +259,6 @@ export function summarizeReap(result) {
|
|
|
255
259
|
).length,
|
|
256
260
|
tracksPruned: result?.pruned?.pruned?.length ?? 0,
|
|
257
261
|
wtBranchDeleted: result?.wtBranch?.deleted === true,
|
|
262
|
+
fastForwarded: result?.fastForward?.applied === true,
|
|
258
263
|
};
|
|
259
264
|
}
|
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
* 4. Otherwise, invoke `runFinalizeFn`. The production default
|
|
25
25
|
* (`composeBusOwnedFinalize`) chains
|
|
26
26
|
* a. `openOrLocatePr({ epicId, headBranch, baseBranch })`
|
|
27
|
-
* b. `
|
|
27
|
+
* b. `markPrReady({ pr })` when `delivery.ci.earlyPr` is on
|
|
28
|
+
* (Story #4359) — the wave-1 draft is flipped ready-for-review;
|
|
29
|
+
* skipped when `earlyPr` is off (the PR was opened here, never a
|
|
30
|
+
* draft).
|
|
31
|
+
* c. `postHandoffComment({ epicId, prNumber, prUrl, provider })`
|
|
28
32
|
* and returns `{ prNumber, prUrl, handoff }`. (Story #4324 retired
|
|
29
33
|
* the `closePlanningTickets` sweep with the context-ticket classes —
|
|
30
34
|
* there are no planning tickets to close.)
|
|
@@ -60,13 +64,17 @@
|
|
|
60
64
|
*/
|
|
61
65
|
|
|
62
66
|
import { spawnSync } from 'node:child_process';
|
|
67
|
+
import { getCiDelivery } from '../../../config/ci.js';
|
|
63
68
|
import {
|
|
64
69
|
graduateAuditResults as defaultGraduateAuditResults,
|
|
65
70
|
isAutoFileEnabled as isAuditResultsAutoFileEnabled,
|
|
66
71
|
} from '../../../feedback-loop/audit-results-graduator.js';
|
|
67
72
|
import { graduateFindings as defaultGraduateFindings } from '../../../feedback-loop/code-review-graduator.js';
|
|
68
73
|
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
69
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
markPrReady as defaultMarkPrReady,
|
|
76
|
+
openOrLocatePr as defaultOpenOrLocatePr,
|
|
77
|
+
} from '../../finalize/open-or-locate-pr.js';
|
|
70
78
|
import { postHandoffComment as defaultPostHandoffComment } from '../../finalize/post-handoff-comment.js';
|
|
71
79
|
|
|
72
80
|
/**
|
|
@@ -79,17 +87,31 @@ import { postHandoffComment as defaultPostHandoffComment } from '../../finalize/
|
|
|
79
87
|
* `{ blocker: { reason, detail } }` when a step fails with an
|
|
80
88
|
* unrecoverable error that should keep the Epic at `agent::blocked`.
|
|
81
89
|
*
|
|
90
|
+
* Story #4359 (Epic #4355) — early-PR draft mode. When `earlyPr` is on
|
|
91
|
+
* (the default resolved via `getCiDelivery`), the Epic PR already exists
|
|
92
|
+
* as a draft (opened at wave 1). Finalize then **locates** it (the
|
|
93
|
+
* `openOrLocatePr` probe short-circuits to `created: false`) and flips it
|
|
94
|
+
* ready-for-review via `markPrReady` rather than opening the draft as a
|
|
95
|
+
* `--draft`. When `earlyPr` is off, finalize opens the PR here with no
|
|
96
|
+
* draft — the pre-Story close-time timing. In both modes the title/body
|
|
97
|
+
* contract is identical and `markPrReady` is a no-op on an already-ready
|
|
98
|
+
* PR, so replay stays idempotent.
|
|
99
|
+
*
|
|
82
100
|
* @param {{
|
|
83
101
|
* provider?: object|null,
|
|
102
|
+
* earlyPr?: boolean,
|
|
84
103
|
* openOrLocatePrFn?: typeof defaultOpenOrLocatePr,
|
|
104
|
+
* markPrReadyFn?: typeof defaultMarkPrReady,
|
|
85
105
|
* postHandoffCommentFn?: typeof defaultPostHandoffComment,
|
|
86
106
|
* }} deps
|
|
87
107
|
*/
|
|
88
108
|
export function composeBusOwnedFinalize(deps = {}) {
|
|
89
109
|
const openOrLocatePrFn = deps.openOrLocatePrFn ?? defaultOpenOrLocatePr;
|
|
110
|
+
const markPrReadyFn = deps.markPrReadyFn ?? defaultMarkPrReady;
|
|
90
111
|
const postHandoffCommentFn =
|
|
91
112
|
deps.postHandoffCommentFn ?? defaultPostHandoffComment;
|
|
92
113
|
const provider = deps.provider ?? null;
|
|
114
|
+
const earlyPr = deps.earlyPr !== false;
|
|
93
115
|
|
|
94
116
|
return async function runBusOwnedFinalize({ epicId, cwd } = {}) {
|
|
95
117
|
if (!Number.isInteger(epicId) || epicId < 1) {
|
|
@@ -129,6 +151,24 @@ export function composeBusOwnedFinalize(deps = {}) {
|
|
|
129
151
|
};
|
|
130
152
|
}
|
|
131
153
|
|
|
154
|
+
// Story #4359: when earlyPr is on, the located PR is the wave-1 draft —
|
|
155
|
+
// flip it ready-for-review. `gh pr ready` on an already-ready PR is a
|
|
156
|
+
// no-op, so a replay (or an earlyPr-off PR that was never a draft) is
|
|
157
|
+
// safe. A failure here is a hard blocker: leaving the PR a draft would
|
|
158
|
+
// silently park the merge gate.
|
|
159
|
+
if (earlyPr) {
|
|
160
|
+
try {
|
|
161
|
+
await markPrReadyFn({ pr: openResult.url, cwd });
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return {
|
|
164
|
+
blocker: {
|
|
165
|
+
reason: 'mark-pr-ready-failed',
|
|
166
|
+
detail: err?.message ?? String(err),
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
132
172
|
// The handoff comment requires a provider. The lifecycle-emit CLI
|
|
133
173
|
// may construct the Finalizer without one; in that case the step
|
|
134
174
|
// short-circuits (the run itself still succeeds — the PR is open and
|
|
@@ -256,9 +296,14 @@ export class Finalizer {
|
|
|
256
296
|
this.cwd = opts.cwd ?? process.cwd();
|
|
257
297
|
this.fullScope = opts.fullScope === true;
|
|
258
298
|
this.provider = opts.provider ?? null;
|
|
299
|
+
// Story #4359: gate the finalize PR-open/ready branch on
|
|
300
|
+
// `delivery.ci.earlyPr` (default true) resolved via getCiDelivery.
|
|
301
|
+
// On → the wave-1 draft is located and marked ready; off → the PR is
|
|
302
|
+
// opened here at close time (pre-Story timing).
|
|
303
|
+
const earlyPr = getCiDelivery(opts.config ?? null).earlyPr;
|
|
259
304
|
this.runFinalizeFn =
|
|
260
305
|
opts.runFinalizeFn ??
|
|
261
|
-
composeBusOwnedFinalize({ provider: this.provider });
|
|
306
|
+
composeBusOwnedFinalize({ provider: this.provider, earlyPr });
|
|
262
307
|
this.ghPrListHeadFn = opts.ghPrListHeadFn ?? ghPrListHead;
|
|
263
308
|
// ultrareview bug_007: the existing-PR short-circuit must run the
|
|
264
309
|
// handoff-comment upsert (idempotent) so crash-recovery replays
|
|
@@ -229,13 +229,18 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
229
229
|
// 5. AutomergePredicate — emits epic.merge.{ready,blocked} based on
|
|
230
230
|
// the runtime predicate evaluation. Requires a truthy `provider`;
|
|
231
231
|
// skip cleanly when the caller omitted one (lifecycle-emit CLI
|
|
232
|
-
// has no provider wired in by default).
|
|
232
|
+
// has no provider wired in by default). `config` selects the
|
|
233
|
+
// `delivery.ci.autoMerge` posture (trust-ci default vs strict) and
|
|
234
|
+
// `cwd` (repoRoot) is where the live `gh pr checks --required`
|
|
235
|
+
// probe shells out (Story #4361).
|
|
233
236
|
let automergePredicate = null;
|
|
234
237
|
if (provider) {
|
|
235
238
|
automergePredicate = new AutomergePredicate({
|
|
236
239
|
bus,
|
|
237
240
|
epicId,
|
|
238
241
|
provider,
|
|
242
|
+
config,
|
|
243
|
+
cwd: repoRoot,
|
|
239
244
|
logger,
|
|
240
245
|
});
|
|
241
246
|
automergePredicate.register();
|
|
@@ -55,6 +55,35 @@ import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
|
55
55
|
* Unknown / non-pending unrecognized values collapse to `'skipped'`
|
|
56
56
|
* so any future GitHub state we haven't enumerated still validates.
|
|
57
57
|
*/
|
|
58
|
+
/**
|
|
59
|
+
* The raw check-state tokens `normalizeCheckState` recognizes (lowercased).
|
|
60
|
+
* A token absent from this set is one we have NOT enumerated — the watch
|
|
61
|
+
* path collapses it to `'skipped'` (validate-anything), but a fail-closed
|
|
62
|
+
* consumer (the auto-merge arming probe) must treat it as unknown-therefore-
|
|
63
|
+
* blocking rather than trust the `'skipped'` collapse. Exported so that
|
|
64
|
+
* stricter consumer lives here as the single vocabulary owner.
|
|
65
|
+
*/
|
|
66
|
+
export const RECOGNIZED_CHECK_STATES = Object.freeze(
|
|
67
|
+
new Set([
|
|
68
|
+
'',
|
|
69
|
+
'pending',
|
|
70
|
+
'queued',
|
|
71
|
+
'in_progress',
|
|
72
|
+
'requested',
|
|
73
|
+
'waiting',
|
|
74
|
+
'success',
|
|
75
|
+
'completed',
|
|
76
|
+
'failure',
|
|
77
|
+
'startup_failure',
|
|
78
|
+
'neutral',
|
|
79
|
+
'cancelled',
|
|
80
|
+
'timed_out',
|
|
81
|
+
'action_required',
|
|
82
|
+
'stale',
|
|
83
|
+
'skipped',
|
|
84
|
+
]),
|
|
85
|
+
);
|
|
86
|
+
|
|
58
87
|
export function normalizeCheckState(raw) {
|
|
59
88
|
const v = String(raw ?? '')
|
|
60
89
|
.trim()
|
|
@@ -266,19 +295,45 @@ export function allTerminal(outcomes) {
|
|
|
266
295
|
}
|
|
267
296
|
|
|
268
297
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
298
|
+
* Sentinel outcome for a required check that never went terminal within
|
|
299
|
+
* the poll cap AND the resume budget — the CI job is genuinely slow, not
|
|
300
|
+
* red. Story #4358 made this a first-class outcome distinct from
|
|
301
|
+
* `'timed_out'` (a GitHub-reported terminal timeout) and `'failure'` (a
|
|
302
|
+
* red check): a `'still-running'` map means "re-arm the watch / hand off
|
|
303
|
+
* to `/loop`," never "the change is broken."
|
|
273
304
|
*/
|
|
274
|
-
|
|
305
|
+
export const STILL_RUNNING = 'still-running';
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Promote any leftover `'pending'` outcomes to the schema-valid
|
|
309
|
+
* `'still-running'` sentinel before emit. Pure — exported for tests so
|
|
310
|
+
* the cap-fire behaviour is reviewable. Called only when the poll loop
|
|
311
|
+
* (and its resume budget) exits with checks still pending and none
|
|
312
|
+
* failed — the slow-but-not-red terminal state.
|
|
313
|
+
*/
|
|
314
|
+
export function promotePendingToStillRunning(outcomes) {
|
|
275
315
|
const out = {};
|
|
276
316
|
for (const [k, v] of Object.entries(outcomes)) {
|
|
277
|
-
out[k] = v === 'pending' ?
|
|
317
|
+
out[k] = v === 'pending' ? STILL_RUNNING : v;
|
|
278
318
|
}
|
|
279
319
|
return out;
|
|
280
320
|
}
|
|
281
321
|
|
|
322
|
+
/**
|
|
323
|
+
* True when at least one required check has genuinely failed — the hard
|
|
324
|
+
* stop that consumes NO resume budget and exits 1 immediately. A
|
|
325
|
+
* `'pending'` check is not a failure (it is still running); anything
|
|
326
|
+
* outside the non-failing set AND outside `'pending'` is a red block.
|
|
327
|
+
* Pure — exported for tests.
|
|
328
|
+
*/
|
|
329
|
+
export function hasFailingCheck(outcomes) {
|
|
330
|
+
for (const v of Object.values(outcomes)) {
|
|
331
|
+
if (v === 'pending') continue;
|
|
332
|
+
if (!GREEN_CHECK_OUTCOMES.has(v)) return true;
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
282
337
|
/**
|
|
283
338
|
* Default sleeper. Hoisted so tests can stub without faking timers.
|
|
284
339
|
*/
|
|
@@ -355,8 +410,13 @@ export async function pollUntilTerminal({
|
|
|
355
410
|
* @param {object} opts
|
|
356
411
|
* @param {string} opts.prUrl PR URL or number (passed to `gh` verbatim).
|
|
357
412
|
* @param {string} opts.cwd
|
|
358
|
-
* @param {number} opts.maxPolls Hard cap on total poll iterations.
|
|
413
|
+
* @param {number} opts.maxPolls Hard cap on total poll iterations per arm.
|
|
359
414
|
* @param {number} opts.maxUpdates Cap on `gh pr update-branch` recovery calls.
|
|
415
|
+
* @param {number} [opts.maxResumes] Story #4358: after the poll cap fires with
|
|
416
|
+
* one or more required checks still pending (and NONE failed), re-arm the poll
|
|
417
|
+
* loop up to this many times before giving up with a `still-running` verdict.
|
|
418
|
+
* A genuinely red check short-circuits immediately and consumes no resume
|
|
419
|
+
* budget. Defaults to 0 (no resume) so existing callers are unchanged.
|
|
360
420
|
* @param {number} opts.pollIntervalMs Delay between poll ticks.
|
|
361
421
|
* @param {Function} [opts.ghPrChecksFn] `gh pr checks` invoker. Defaults
|
|
362
422
|
* to the real `gh pr checks` spawn so the CLI path (which injects no
|
|
@@ -379,19 +439,24 @@ export async function pollUntilTerminal({
|
|
|
379
439
|
* requiredChecks: string[],
|
|
380
440
|
* polls: number,
|
|
381
441
|
* updatesApplied: number,
|
|
442
|
+
* resumesApplied: number,
|
|
382
443
|
* terminal: boolean,
|
|
383
444
|
* green: boolean,
|
|
445
|
+
* stillRunning: boolean,
|
|
384
446
|
* error?: string,
|
|
385
447
|
* }>}
|
|
386
448
|
* `outcomes` is schema-valid (no `'pending'` — leftover pending is
|
|
387
|
-
* promoted to `'
|
|
388
|
-
*
|
|
449
|
+
* promoted to `'still-running'` when the cap and resume budget are both
|
|
450
|
+
* exhausted with no failed check). `stillRunning` is true in exactly
|
|
451
|
+
* that case (slow CI, not red). `error` is set only when the first
|
|
452
|
+
* probe could not resolve the required-check set.
|
|
389
453
|
*/
|
|
390
454
|
export async function watchPrToTerminal({
|
|
391
455
|
prUrl,
|
|
392
456
|
cwd,
|
|
393
457
|
maxPolls,
|
|
394
458
|
maxUpdates,
|
|
459
|
+
maxResumes = 0,
|
|
395
460
|
pollIntervalMs,
|
|
396
461
|
ghPrChecksFn = ghPrChecks,
|
|
397
462
|
ghPrViewFn = ghPrView,
|
|
@@ -417,8 +482,10 @@ export async function watchPrToTerminal({
|
|
|
417
482
|
requiredChecks: [],
|
|
418
483
|
polls: 0,
|
|
419
484
|
updatesApplied: 0,
|
|
485
|
+
resumesApplied: 0,
|
|
420
486
|
terminal: false,
|
|
421
487
|
green: false,
|
|
488
|
+
stillRunning: false,
|
|
422
489
|
error: `gh-checks-failed:status=${first.status}`,
|
|
423
490
|
};
|
|
424
491
|
}
|
|
@@ -433,68 +500,95 @@ export async function watchPrToTerminal({
|
|
|
433
500
|
let outcomes = reduceOutcomes(firstEntries);
|
|
434
501
|
let polls = 0;
|
|
435
502
|
let updatesApplied = 0;
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
break;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
503
|
+
let resumesApplied = 0;
|
|
504
|
+
// Outer resume loop (Story #4358). Each iteration runs one full
|
|
505
|
+
// poll-to-cap + BEHIND-recovery arm. When the arm ends with checks
|
|
506
|
+
// still pending but NONE failed, we re-arm (reset the poll counter)
|
|
507
|
+
// up to `maxResumes` times before declaring `still-running`. A red
|
|
508
|
+
// check breaks out immediately without consuming resume budget.
|
|
509
|
+
for (;;) {
|
|
510
|
+
while (polls < maxPolls) {
|
|
511
|
+
({ outcomes, polls } = await pollUntilTerminal({
|
|
512
|
+
prUrl,
|
|
513
|
+
cwd,
|
|
514
|
+
outcomes,
|
|
515
|
+
polls,
|
|
516
|
+
maxPolls,
|
|
517
|
+
ghPrChecksFn,
|
|
518
|
+
pollIntervalMs,
|
|
519
|
+
sleepFn,
|
|
520
|
+
logger,
|
|
521
|
+
}));
|
|
522
|
+
// Checks have either all gone terminal or we hit the iteration cap.
|
|
523
|
+
// BEHIND-recovery (Story #2327): when every required check is green
|
|
524
|
+
// AND the PR is BEHIND its base, issue ONE `gh pr update-branch`
|
|
525
|
+
// call and re-poll the checks against the freshly-rebased commit. A
|
|
526
|
+
// red check is a hard block — stop here regardless of merge state.
|
|
527
|
+
// Bounded by `maxUpdates` so a racing base branch can't ping-pong
|
|
528
|
+
// indefinitely.
|
|
529
|
+
if (!allTerminal(outcomes) || !allGreen(outcomes)) break;
|
|
530
|
+
if (updatesApplied >= maxUpdates) break;
|
|
531
|
+
const view = ghPrViewFn({ prUrl, cwd });
|
|
532
|
+
if (view.status !== 0) {
|
|
533
|
+
logger.warn?.(
|
|
534
|
+
`[Watcher] gh pr view failed (status=${view.status}): ${view.stderr}`,
|
|
535
|
+
);
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
const mergeStateStatus = parseMergeStateStatus(view.stdout);
|
|
539
|
+
if (mergeStateStatus !== 'BEHIND') break;
|
|
540
|
+
const update = ghPrUpdateBranchFn({ prUrl, cwd });
|
|
541
|
+
if (update.status !== 0) {
|
|
542
|
+
logger.warn?.(
|
|
543
|
+
`[Watcher] gh pr update-branch failed (status=${update.status}): ${update.stderr}`,
|
|
544
|
+
);
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
updatesApplied += 1;
|
|
548
|
+
logger.info?.(
|
|
549
|
+
`[Watcher] PR BEHIND base — issued gh pr update-branch (#${updatesApplied}/${maxUpdates}); re-polling required checks.`,
|
|
470
550
|
);
|
|
471
|
-
|
|
551
|
+
await sleepFn(pollIntervalMs);
|
|
552
|
+
// After update-branch, the freshly-rebased commit invalidates the
|
|
553
|
+
// previous terminal outcomes. Reset to force the inner poll loop to
|
|
554
|
+
// re-evaluate the new CI cycle.
|
|
555
|
+
outcomes = {};
|
|
556
|
+
for (const name of requiredChecks) outcomes[name] = 'pending';
|
|
472
557
|
}
|
|
473
|
-
|
|
558
|
+
|
|
559
|
+
// Arm complete. Decide whether to re-arm. A genuinely red check is a
|
|
560
|
+
// hard stop that consumes NO resume budget — the change is broken,
|
|
561
|
+
// resuming would only burn wall-clock. Only re-arm when the arm timed
|
|
562
|
+
// out with pending-but-not-failed checks and resume budget remains.
|
|
563
|
+
if (allTerminal(outcomes) || hasFailingCheck(outcomes)) break;
|
|
564
|
+
if (resumesApplied >= maxResumes) break;
|
|
565
|
+
resumesApplied += 1;
|
|
566
|
+
polls = 0;
|
|
474
567
|
logger.info?.(
|
|
475
|
-
`[Watcher]
|
|
568
|
+
`[Watcher] poll cap reached with checks still pending; re-arming watch (resume #${resumesApplied}/${maxResumes}).`,
|
|
476
569
|
);
|
|
477
|
-
await sleepFn(pollIntervalMs);
|
|
478
|
-
// After update-branch, the freshly-rebased commit invalidates the
|
|
479
|
-
// previous terminal outcomes. Reset to force the inner poll loop to
|
|
480
|
-
// re-evaluate the new CI cycle.
|
|
481
|
-
outcomes = {};
|
|
482
|
-
for (const name of requiredChecks) outcomes[name] = 'pending';
|
|
483
570
|
}
|
|
484
571
|
|
|
485
572
|
const terminal = allTerminal(outcomes);
|
|
486
|
-
|
|
487
|
-
//
|
|
573
|
+
const failing = hasFailingCheck(outcomes);
|
|
574
|
+
// Slow-but-not-red: the poll cap AND resume budget are exhausted with
|
|
575
|
+
// one or more checks still pending and NONE failed. The schema enum
|
|
576
|
+
// forbids `'pending'`; promote leftover pending entries to the
|
|
577
|
+
// `'still-running'` sentinel (never `'timed_out'` — that would read as
|
|
578
|
+
// a genuine terminal failure to the auto-merge predicate).
|
|
579
|
+
const stillRunning = !terminal && !failing;
|
|
488
580
|
const finalOutcomes = terminal
|
|
489
581
|
? outcomes
|
|
490
|
-
:
|
|
582
|
+
: promotePendingToStillRunning(outcomes);
|
|
491
583
|
return {
|
|
492
584
|
outcomes: finalOutcomes,
|
|
493
585
|
requiredChecks,
|
|
494
586
|
polls,
|
|
495
587
|
updatesApplied,
|
|
588
|
+
resumesApplied,
|
|
496
589
|
terminal,
|
|
497
590
|
green: terminal && allGreen(finalOutcomes),
|
|
591
|
+
stillRunning,
|
|
498
592
|
};
|
|
499
593
|
}
|
|
500
594
|
|
|
@@ -513,6 +607,9 @@ export class Watcher {
|
|
|
513
607
|
* recovery calls per `pr.created` event; default 3. Mirrors the
|
|
514
608
|
* legacy `pr-watch-with-update` cap so a racing base branch
|
|
515
609
|
* can't induce an infinite update-branch ping-pong.
|
|
610
|
+
* @param {number} [opts.maxResumes] Story #4358: how many times to
|
|
611
|
+
* re-arm the poll loop after the cap fires with checks still pending
|
|
612
|
+
* (and none failed) before declaring `still-running`; default 0.
|
|
516
613
|
* @param {Function} [opts.ghPrChecksFn] override for tests.
|
|
517
614
|
* @param {Function} [opts.ghPrViewFn] override for tests; resolves
|
|
518
615
|
* `mergeStateStatus` for the BEHIND-recovery gate.
|
|
@@ -539,6 +636,10 @@ export class Watcher {
|
|
|
539
636
|
Number.isInteger(opts.maxUpdates) && opts.maxUpdates >= 0
|
|
540
637
|
? opts.maxUpdates
|
|
541
638
|
: 3;
|
|
639
|
+
this.maxResumes =
|
|
640
|
+
Number.isInteger(opts.maxResumes) && opts.maxResumes >= 0
|
|
641
|
+
? opts.maxResumes
|
|
642
|
+
: 0;
|
|
542
643
|
this.ghPrChecksFn = opts.ghPrChecksFn ?? ghPrChecks;
|
|
543
644
|
this.ghPrViewFn = opts.ghPrViewFn ?? ghPrView;
|
|
544
645
|
this.ghPrUpdateBranchFn = opts.ghPrUpdateBranchFn ?? ghPrUpdateBranch;
|
|
@@ -549,8 +650,8 @@ export class Watcher {
|
|
|
549
650
|
/**
|
|
550
651
|
* Classification log — every `pr.created` we observe lands here
|
|
551
652
|
* with the outcome (`watched`, `failed`, `skipped-duplicate`,
|
|
552
|
-
* `timed-out`). Mirrors the Finalizer / Reconciler
|
|
553
|
-
* surface.
|
|
653
|
+
* `still-running`, `timed-out`). Mirrors the Finalizer / Reconciler
|
|
654
|
+
* "no silent skip" surface.
|
|
554
655
|
*/
|
|
555
656
|
this.classifications = [];
|
|
556
657
|
this.events = Object.freeze(['pr.created']);
|
|
@@ -631,12 +732,15 @@ export class Watcher {
|
|
|
631
732
|
outcomes: emitOutcomes,
|
|
632
733
|
polls,
|
|
633
734
|
updatesApplied,
|
|
735
|
+
resumesApplied,
|
|
634
736
|
terminal,
|
|
737
|
+
stillRunning,
|
|
635
738
|
} = await watchPrToTerminal({
|
|
636
739
|
prUrl,
|
|
637
740
|
cwd: this.cwd,
|
|
638
741
|
maxPolls: this.maxPolls,
|
|
639
742
|
maxUpdates: this.maxUpdates,
|
|
743
|
+
maxResumes: this.maxResumes,
|
|
640
744
|
pollIntervalMs: this.pollIntervalMs,
|
|
641
745
|
ghPrChecksFn: this.ghPrChecksFn,
|
|
642
746
|
ghPrViewFn: this.ghPrViewFn,
|
|
@@ -646,12 +750,22 @@ export class Watcher {
|
|
|
646
750
|
firstProbe: first,
|
|
647
751
|
});
|
|
648
752
|
|
|
753
|
+
// `still-running` (slow CI, not red) is a distinct classification from
|
|
754
|
+
// a genuine `timed-out` — reserved for a check that never went
|
|
755
|
+
// terminal within the poll cap AND the resume budget while none
|
|
756
|
+
// failed. `watched` covers every terminal arm (green or red).
|
|
757
|
+
const outcome = terminal
|
|
758
|
+
? 'watched'
|
|
759
|
+
: stillRunning
|
|
760
|
+
? 'still-running'
|
|
761
|
+
: 'timed-out';
|
|
649
762
|
this.classifications.push({
|
|
650
763
|
event,
|
|
651
764
|
seqId,
|
|
652
|
-
outcome
|
|
765
|
+
outcome,
|
|
653
766
|
polls,
|
|
654
767
|
updatesApplied,
|
|
768
|
+
resumesApplied,
|
|
655
769
|
requiredChecks: requiredChecks.length,
|
|
656
770
|
});
|
|
657
771
|
try {
|
|
@@ -139,6 +139,7 @@ function makeDefaultGhAutoMergeRunner(gh) {
|
|
|
139
139
|
* prNumber: number|null,
|
|
140
140
|
* prUrl: string,
|
|
141
141
|
* noAutoMerge: boolean,
|
|
142
|
+
* autoMergePolicy?: 'trust-ci'|'strict',
|
|
142
143
|
* gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
|
|
143
144
|
* progress: (tag: string, msg: string) => void,
|
|
144
145
|
* }} args
|
|
@@ -149,6 +150,7 @@ export async function runAutoMergePhase({
|
|
|
149
150
|
prNumber,
|
|
150
151
|
prUrl,
|
|
151
152
|
noAutoMerge,
|
|
153
|
+
autoMergePolicy = 'trust-ci',
|
|
152
154
|
gh,
|
|
153
155
|
progress,
|
|
154
156
|
}) {
|
|
@@ -156,6 +158,23 @@ export async function runAutoMergePhase({
|
|
|
156
158
|
progress('PR', '⏭ Auto-merge disabled (--no-auto-merge).');
|
|
157
159
|
return { autoMergeEnabled: false, autoMergeReason: 'disabled-by-flag' };
|
|
158
160
|
}
|
|
161
|
+
// `delivery.ci.autoMerge: "strict"` opts standalone Stories out of
|
|
162
|
+
// auto-merge (parallel to the Epic path's strict predicate): the PR opens
|
|
163
|
+
// and waits for an operator merge instead of arming native auto-merge.
|
|
164
|
+
// The default `"trust-ci"` keeps arming on green required CI — GitHub's
|
|
165
|
+
// native `--auto` is the required-check gate, so no client-side predicate
|
|
166
|
+
// is needed here (unlike the Epic path, which gates on local
|
|
167
|
+
// audit/review/retro signals a standalone Story does not produce).
|
|
168
|
+
if (autoMergePolicy === 'strict') {
|
|
169
|
+
progress(
|
|
170
|
+
'PR',
|
|
171
|
+
'⏭ Auto-merge skipped (delivery.ci.autoMerge="strict") — operator merges.',
|
|
172
|
+
);
|
|
173
|
+
return {
|
|
174
|
+
autoMergeEnabled: false,
|
|
175
|
+
autoMergeReason: 'disabled-by-policy-strict',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
159
178
|
if (prNumber == null) {
|
|
160
179
|
progress(
|
|
161
180
|
'PR',
|
|
@@ -2,6 +2,7 @@ import nodeFs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { buildDefaultGates } from '../../close-validation/gates.js';
|
|
4
4
|
import { runCloseValidation } from '../../close-validation/runner.js';
|
|
5
|
+
import { getCiDelivery } from '../../config/ci.js';
|
|
5
6
|
import { resolveConfig } from '../../config-resolver.js';
|
|
6
7
|
import { getStoryBranch, gitSync } from '../../git-utils.js';
|
|
7
8
|
import { Logger } from '../../Logger.js';
|
|
@@ -316,6 +317,7 @@ export async function runSingleStoryClose({
|
|
|
316
317
|
prNumber,
|
|
317
318
|
prUrl,
|
|
318
319
|
noAutoMerge: options.noAutoMerge,
|
|
320
|
+
autoMergePolicy: getCiDelivery(config).autoMerge,
|
|
319
321
|
gh: injectedGh,
|
|
320
322
|
progress,
|
|
321
323
|
});
|
package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js
CHANGED
|
@@ -47,6 +47,51 @@ export const DEFAULT_GATE_REGISTRY = {
|
|
|
47
47
|
},
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
+
/** Gate names that fan out to per-kind refreshers via `_gateKind` tags. */
|
|
51
|
+
const COMPOSITE_GATE_KINDS = new Set(['check-baselines']);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the refresh metadata + the regression subset a single failure
|
|
55
|
+
* cycle should act on.
|
|
56
|
+
*
|
|
57
|
+
* A direct per-kind gate (`check-maintainability` / `check-crap`) maps
|
|
58
|
+
* straight through. The unified `check-baselines` gate is a **composite**:
|
|
59
|
+
* its projected regressions are tagged with `_gateKind`, and each attribution
|
|
60
|
+
* cycle picks the first regressed kind not already refreshed this cycle
|
|
61
|
+
* (`cycleState.refreshedKinds`), scoping classify + refresh to that one kind.
|
|
62
|
+
* The retry loop re-drives for any remaining kinds — so a close that regresses
|
|
63
|
+
* both maintainability and CRAP converges in two cycles instead of dead-ending
|
|
64
|
+
* on an unrecognised gate name (framework-gap #4377).
|
|
65
|
+
*
|
|
66
|
+
* @returns {{ meta: object, regressions: Array } | null}
|
|
67
|
+
*/
|
|
68
|
+
export function resolveGateMeta({
|
|
69
|
+
gateName,
|
|
70
|
+
regressions,
|
|
71
|
+
cycleState = null,
|
|
72
|
+
gateRegistry = DEFAULT_GATE_REGISTRY,
|
|
73
|
+
}) {
|
|
74
|
+
if (!Array.isArray(regressions) || regressions.length === 0) return null;
|
|
75
|
+
|
|
76
|
+
const direct = gateRegistry[gateName];
|
|
77
|
+
if (direct) return { meta: direct, regressions };
|
|
78
|
+
|
|
79
|
+
if (COMPOSITE_GATE_KINDS.has(gateName)) {
|
|
80
|
+
const refreshed = cycleState?.refreshedKinds ?? new Set();
|
|
81
|
+
for (const row of regressions) {
|
|
82
|
+
const kind = row?._gateKind;
|
|
83
|
+
const subMeta = kind ? gateRegistry[`check-${kind}`] : null;
|
|
84
|
+
if (subMeta && !refreshed.has(subMeta.kind)) {
|
|
85
|
+
return {
|
|
86
|
+
meta: subMeta,
|
|
87
|
+
regressions: regressions.filter((r) => r?._gateKind === kind),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
50
95
|
/**
|
|
51
96
|
* Top-level: handle a baseline gate failure by classifying drift and
|
|
52
97
|
* either auto-refreshing (attributable-only) or posting friction (any
|
|
@@ -88,11 +133,14 @@ export async function handleBaselineGateFailure({
|
|
|
88
133
|
gateRegistry = DEFAULT_GATE_REGISTRY,
|
|
89
134
|
deps = {},
|
|
90
135
|
} = {}) {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
136
|
+
const resolved = resolveGateMeta({
|
|
137
|
+
gateName,
|
|
138
|
+
regressions,
|
|
139
|
+
cycleState,
|
|
140
|
+
gateRegistry,
|
|
141
|
+
});
|
|
142
|
+
if (!resolved) return { action: 'rethrow' };
|
|
143
|
+
const { meta, regressions: scopedRegressions } = resolved;
|
|
96
144
|
|
|
97
145
|
const classify = deps.classifyBaselineDrift ?? defaultClassifyBaselineDrift;
|
|
98
146
|
const renderBody =
|
|
@@ -114,7 +162,7 @@ export async function handleBaselineGateFailure({
|
|
|
114
162
|
|
|
115
163
|
const epicRef = `origin/${epicBranch}`;
|
|
116
164
|
const { attributable, nonAttributable } = classify({
|
|
117
|
-
regressions,
|
|
165
|
+
regressions: scopedRegressions,
|
|
118
166
|
storyDiffPaths,
|
|
119
167
|
epicRef,
|
|
120
168
|
cwd,
|