mandrel 1.84.0 → 1.85.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/schemas/agentrc.schema.json +29 -6
- package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
- 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/finalize/open-or-locate-pr.js +65 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
- 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/ticket-validator-sizing.js +17 -16
- 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/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
- package/.agents/skills/core/scope-triage/SKILL.md +5 -4
- package/.agents/workflows/helpers/deliver-epic-reference.md +22 -8
- package/.agents/workflows/helpers/deliver-epic.md +123 -28
- package/.agents/workflows/helpers/deliver-stories.md +2 -2
- package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
- package/.agents/workflows/helpers/single-story-deliver.md +56 -19
- package/docs/CHANGELOG.md +9 -0
- package/package.json +1 -1
|
@@ -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
|
});
|
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
* `.agents/scripts/lib/config-settings-schema.js`.
|
|
18
18
|
*
|
|
19
19
|
* Sizing model (Story #3760 — profile-matrix collapse; Story #3874 — one
|
|
20
|
-
* uniform relaxed profile
|
|
21
|
-
*
|
|
20
|
+
* uniform relaxed profile; the hard acceptance ceiling was removed after the
|
|
21
|
+
* Epic #4355 decomposition experiment showed it forced fragmentation):
|
|
22
|
+
* - Flat knobs: `softFiles` (~15), `hardFiles` (~30),
|
|
22
23
|
* `softAcceptanceCount` (~10). No per-profile ceiling map, no parallel
|
|
23
|
-
* `testSurface` axis, no selector and no second profile.
|
|
24
|
+
* `testSurface` axis, no selector and no second profile. Acceptance
|
|
25
|
+
* mass is advisory-only.
|
|
24
26
|
* - The four-profile `sizingProfile` enum is replaced by a single optional
|
|
25
27
|
* `wide` declaration carrying a one-line human-readable reason. Declaring
|
|
26
28
|
* `wide` with a reason lifts the `hardFiles` rejection; no Story is
|
|
@@ -119,9 +121,12 @@ export const DEFAULT_TASK_SIZING = Object.freeze({
|
|
|
119
121
|
// The hard `hardFiles` rejection (30) is unchanged.
|
|
120
122
|
softFiles: 15,
|
|
121
123
|
softAcceptanceCount: 10,
|
|
122
|
-
// Hard
|
|
124
|
+
// Hard ceiling (rejection unless lifted via `wide`). Acceptance mass has
|
|
125
|
+
// no hard ceiling: the former `maxAcceptance` rejection forced careful,
|
|
126
|
+
// fine-grained specs to fragment one coherent capability into dependent
|
|
127
|
+
// slices (observed on Epic #4355), so it was removed — the delivery-
|
|
128
|
+
// schedule simulation in the decomposer prompt owns that judgment now.
|
|
123
129
|
hardFiles: 30,
|
|
124
|
-
maxAcceptance: 14,
|
|
125
130
|
// Under-size (merge-candidate) thresholds (Story #4312). A Story with a
|
|
126
131
|
// footprint at or below BOTH ceilings that also carries at least one
|
|
127
132
|
// `depends_on` edge to a sibling looks like a dependent fragment rather than
|
|
@@ -528,17 +533,13 @@ function computeStorySizingFindings(story, sizing) {
|
|
|
528
533
|
),
|
|
529
534
|
);
|
|
530
535
|
|
|
531
|
-
// Acceptance
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
sizing.maxAcceptance,
|
|
539
|
-
),
|
|
540
|
-
);
|
|
541
|
-
} else if (acceptance.length > sizing.softAcceptanceCount) {
|
|
536
|
+
// Acceptance mass is advisory-only (Story #4312's under-size heuristic is
|
|
537
|
+
// the merge signal; the former hard `maxAcceptance` rejection is removed).
|
|
538
|
+
// A long binding contract is a re-check-cohesion nudge, never by itself a
|
|
539
|
+
// decomposition error — cohesion and the delivery envelope govern Story
|
|
540
|
+
// size, and the delivery-schedule simulation in the decomposer prompt owns
|
|
541
|
+
// the fragmentation/consolidation judgment.
|
|
542
|
+
if (acceptance.length > sizing.softAcceptanceCount) {
|
|
542
543
|
out.push(
|
|
543
544
|
makeSoftWidth(
|
|
544
545
|
story.slug,
|
|
@@ -50,8 +50,7 @@ export function renderDecomposerSystemPrompt({
|
|
|
50
50
|
function render2TierPrompt({ maxTickets, maxTokenBudget, epicId = null }) {
|
|
51
51
|
// Sizing thresholds are sourced from the single DEFAULT_TASK_SIZING constant
|
|
52
52
|
// (ticket-validator-sizing.js) so the prompt and the validator cannot drift.
|
|
53
|
-
const { softFiles, hardFiles,
|
|
54
|
-
DEFAULT_TASK_SIZING;
|
|
53
|
+
const { softFiles, hardFiles, softAcceptanceCount } = DEFAULT_TASK_SIZING;
|
|
55
54
|
// Deliverable-granularity definition + single-consumer merge rule + the
|
|
56
55
|
// soft envelope-floor heuristic are sourced from the single
|
|
57
56
|
// DELIVERABLE_GRANULARITY_GUIDANCE constant (ticket-validator-sizing.js) so
|
|
@@ -177,7 +176,22 @@ ${envelopeFloor}
|
|
|
177
176
|
|
|
178
177
|
- A Story touching more than **${softFiles} files** (\`softFiles\`) emits an advisory width finding — a nudge to check cohesion or declare \`wide\`.
|
|
179
178
|
- A Story touching more than **${hardFiles} files** (\`hardFiles\`) is **rejected** unless it declares \`wide\` with a reason.
|
|
180
|
-
-
|
|
179
|
+
- Acceptance mass is **advisory only**: more than **${softAcceptanceCount} acceptance items** (\`softAcceptanceCount\`) emits an advisory warning. There is NO hard acceptance ceiling — a long binding contract is a signal to re-check cohesion, never a reason to fragment one coherent capability into dependent slices.
|
|
180
|
+
|
|
181
|
+
#### DELIVERY-SCHEDULE SIMULATION — the story count must earn itself:
|
|
182
|
+
|
|
183
|
+
Before emitting, simulate the delivery schedule your plan implies, and judge the plan by its schedule — not by how tidy the taxonomy looks:
|
|
184
|
+
|
|
185
|
+
1. **Build the wave schedule.** A Story runs only after every \`depends_on\` completes, and two Stories that name the same file in \`changes[]\` cannot run in the same wave (the scheduler serializes file-overlapping Stories even when no \`depends_on\` edge links them).
|
|
186
|
+
2. **Compute the parallelism yield**: story count ÷ critical-path length in waves. A yield near 1.0 means the plan is a serial chain — N Stories that deliver no faster than one Story while paying N delivery sessions (hydration, branch, PR, review, CI).
|
|
187
|
+
3. **Every Story must earn its slot** by at least one of:
|
|
188
|
+
- **(a) parallelism** — it actually runs concurrently with a sibling in the schedule you just built ("logically independent" does not count; *schedule*-independent does);
|
|
189
|
+
- **(b) risk isolation** — it isolates a consumer-facing behavior change or high-risk cutover into its own reviewable, revertable unit;
|
|
190
|
+
- **(c) envelope pressure** — merged into its neighbor it would exceed the one-pass delivery envelope (\`maxTokenBudget\`).
|
|
191
|
+
4. **A dependent link with none of those justifications merges into its consumer.** This generalizes the single-consumer merge rule from pairs to chains.
|
|
192
|
+
5. **Hot-file rule.** When one file appears in the \`changes[]\` of more than a third of your Stories, the slicing axis cuts across a shared seam — merge the Stories that co-edit it, or re-slice along the seam so each Story owns its files.
|
|
193
|
+
|
|
194
|
+
End each Story's \`reason_to_exist\` with its justification letter and one clause, e.g. "… (a: runs in wave 1 alongside <slug>)" or "(b: isolates the auto-merge default change)". A reason that names only a topic ("config work", "docs") with no justification is a merge signal.
|
|
181
195
|
|
|
182
196
|
#### \`wide\` DECLARATION (optional — for legitimately broad changes):
|
|
183
197
|
|