mandrel 1.89.0 → 1.91.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/configuration.md +1 -0
- package/.agents/schemas/agentrc.schema.json +4 -0
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/scripts/coverage-capture.js +17 -0
- package/.agents/scripts/epic-deliver-preflight.js +37 -1
- package/.agents/scripts/lib/close-validation/gates.js +64 -24
- package/.agents/scripts/lib/config/ci.js +12 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/npm-scripts.js +55 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +10 -5
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +179 -5
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +98 -10
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +32 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +7 -1
- package/.agents/scripts/lib/orchestration/merge-block-class.js +32 -4
- package/.agents/scripts/lib/orchestration/remote-verifier.js +165 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
- package/.agents/scripts/single-story-init.js +22 -0
- package/.agents/workflows/deliver.md +8 -0
- package/.agents/workflows/helpers/deliver-epic.md +11 -0
- package/.agents/workflows/helpers/single-story-deliver.md +8 -0
- package/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
|
@@ -54,7 +54,10 @@
|
|
|
54
54
|
|
|
55
55
|
import { spawnSync } from 'node:child_process';
|
|
56
56
|
|
|
57
|
+
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
57
58
|
import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
|
|
59
|
+
import { classifyMergeBlock } from '../../merge-block-class.js';
|
|
60
|
+
import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
63
|
* Default `gh pr view --json autoMergeRequest` probe. Pure-spawn helper
|
|
@@ -109,6 +112,64 @@ export function ghPrMergeAuto({
|
|
|
109
112
|
};
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Story #4472 — direct (non-`--auto`) squash-merge fallback.
|
|
117
|
+
*
|
|
118
|
+
* GitHub's native auto-merge (`gh pr merge --auto`) can only be QUEUED on a
|
|
119
|
+
* repository that has the "Allow auto-merge" setting enabled — which in
|
|
120
|
+
* practice requires branch protection. A repo with zero required checks and
|
|
121
|
+
* no branch protection (every mandrel-bench sandbox, many real consumer
|
|
122
|
+
* repos) rejects the `--auto` arm outright with `Auto merge is not allowed
|
|
123
|
+
* for this repository`. The AutomergePredicate has already cleared the merge
|
|
124
|
+
* (green/absent required checks + a clean structured-signal verdict) by the
|
|
125
|
+
* time the armer runs, so the safe, must-land-satisfying fallback is a
|
|
126
|
+
* direct immediate squash-merge — the epic path's observed de-facto manual
|
|
127
|
+
* fallback, made legal and kept inside the sole authorized `gh pr merge`
|
|
128
|
+
* call site.
|
|
129
|
+
*
|
|
130
|
+
* Same `--squash --delete-branch` shape and same `resolveArmCwd` re-point as
|
|
131
|
+
* `ghPrMergeAuto` (so the trailing local `--delete-branch` housekeeping runs
|
|
132
|
+
* from the primary worktree, not a head-branch worktree). Omitting `--auto`
|
|
133
|
+
* makes `gh` merge synchronously.
|
|
134
|
+
*/
|
|
135
|
+
export function ghPrMergeDirect({
|
|
136
|
+
prUrl,
|
|
137
|
+
cwd,
|
|
138
|
+
spawnFn = spawnSync,
|
|
139
|
+
resolveArmCwd = resolveAutoMergeArmCwd,
|
|
140
|
+
}) {
|
|
141
|
+
const armCwd = resolveArmCwd(cwd);
|
|
142
|
+
const result = spawnFn(
|
|
143
|
+
'gh',
|
|
144
|
+
['pr', 'merge', prUrl, '--squash', '--delete-branch'],
|
|
145
|
+
{ cwd: armCwd, encoding: 'utf-8', shell: false },
|
|
146
|
+
);
|
|
147
|
+
return {
|
|
148
|
+
status: result.status ?? 1,
|
|
149
|
+
stdout: result.stdout ?? '',
|
|
150
|
+
stderr: result.stderr ?? '',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Pure: does this `gh pr merge --auto` stderr indicate that native
|
|
156
|
+
* auto-merge is unavailable on the repository (as opposed to a genuine arm
|
|
157
|
+
* failure — auth, a merge conflict, an already-merged race)? Only this
|
|
158
|
+
* specific class of failure is safe to retry as a direct merge; everything
|
|
159
|
+
* else must surface as a real failure. Matched case-insensitively.
|
|
160
|
+
*
|
|
161
|
+
* Exported so the marker set is reviewable and testable in isolation.
|
|
162
|
+
*/
|
|
163
|
+
export function isAutoMergeUnavailable(stderr) {
|
|
164
|
+
const text = String(stderr ?? '').toLowerCase();
|
|
165
|
+
return (
|
|
166
|
+
text.includes('auto merge is not allowed') ||
|
|
167
|
+
text.includes('auto-merge is not allowed') ||
|
|
168
|
+
text.includes('enablepullrequestautomerge') ||
|
|
169
|
+
(text.includes('auto') && text.includes('not enabled'))
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
112
173
|
/**
|
|
113
174
|
* Pure: parse `gh pr view --json autoMergeRequest,mergeCommit` output.
|
|
114
175
|
* `autoMergeRequest` is `null` when auto-merge is NOT armed; a non-null
|
|
@@ -172,9 +233,17 @@ export class AutomergeArmer {
|
|
|
172
233
|
/**
|
|
173
234
|
* @param {object} opts
|
|
174
235
|
* @param {object} opts.bus
|
|
236
|
+
* @param {number} [opts.epicId] Epic id — required for the headless
|
|
237
|
+
* `merge.unlanded` attribution on a genuine arm failure (Story #4472).
|
|
238
|
+
* @param {boolean} [opts.headless] When true (a `/deliver --yes` run), a
|
|
239
|
+
* genuine (non-fallback) arm failure escalates to an explicit
|
|
240
|
+
* `merge.unlanded` + `epic.blocked` terminal instead of returning
|
|
241
|
+
* silently (Story #4472). Defaults to `false` (attended).
|
|
175
242
|
* @param {string} [opts.cwd]
|
|
176
243
|
* @param {Function} [opts.ghPrViewAutoMergeFn] override for tests.
|
|
177
244
|
* @param {Function} [opts.ghPrMergeAutoFn] override for tests.
|
|
245
|
+
* @param {Function} [opts.ghPrMergeDirectFn] override for tests.
|
|
246
|
+
* @param {Function} [opts.emitMergeUnlandedFn] override for tests.
|
|
178
247
|
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
|
|
179
248
|
*/
|
|
180
249
|
constructor(opts = {}) {
|
|
@@ -186,9 +255,13 @@ export class AutomergeArmer {
|
|
|
186
255
|
throw new TypeError('AutomergeArmer requires a bus with on() and emit()');
|
|
187
256
|
}
|
|
188
257
|
this.bus = opts.bus;
|
|
258
|
+
this.epicId = Number.isInteger(opts.epicId) ? opts.epicId : null;
|
|
259
|
+
this.headless = opts.headless === true;
|
|
189
260
|
this.cwd = opts.cwd ?? process.cwd();
|
|
190
261
|
this.ghPrViewAutoMergeFn = opts.ghPrViewAutoMergeFn ?? ghPrViewAutoMerge;
|
|
191
262
|
this.ghPrMergeAutoFn = opts.ghPrMergeAutoFn ?? ghPrMergeAuto;
|
|
263
|
+
this.ghPrMergeDirectFn = opts.ghPrMergeDirectFn ?? ghPrMergeDirect;
|
|
264
|
+
this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
|
|
192
265
|
this.logger = opts.logger ?? console;
|
|
193
266
|
/** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
|
|
194
267
|
this._seen = new Set();
|
|
@@ -293,16 +366,26 @@ export class AutomergeArmer {
|
|
|
293
366
|
await this._emitArmed(prUrl);
|
|
294
367
|
return;
|
|
295
368
|
}
|
|
296
|
-
|
|
369
|
+
|
|
370
|
+
// Story #4472 — native auto-merge is unavailable on this repository
|
|
371
|
+
// (no branch protection / "Allow auto-merge" disabled). The predicate
|
|
372
|
+
// already cleared the merge, so fall back to a direct immediate
|
|
373
|
+
// squash-merge instead of stranding a landable PR on the
|
|
374
|
+
// operator-merges path.
|
|
375
|
+
if (isAutoMergeUnavailable(arm.stderr)) {
|
|
376
|
+
const armed = await this._tryDirectMerge({ event, seqId, prUrl, arm });
|
|
377
|
+
if (armed) return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Genuine arm failure (auth, conflict, an unresolved direct-merge
|
|
381
|
+
// fallback, …). Classify + escalate.
|
|
382
|
+
await this._emitArmFailure({
|
|
297
383
|
event,
|
|
298
384
|
seqId,
|
|
299
|
-
|
|
385
|
+
prUrl,
|
|
300
386
|
reason: `arm-failed:status=${arm.status}`,
|
|
301
387
|
ghStderr: arm.stderr,
|
|
302
388
|
});
|
|
303
|
-
this.logger.warn?.(
|
|
304
|
-
`[AutomergeArmer] gh pr merge --auto failed (status=${arm.status}): ${arm.stderr}`,
|
|
305
|
-
);
|
|
306
389
|
return;
|
|
307
390
|
}
|
|
308
391
|
|
|
@@ -320,6 +403,97 @@ export class AutomergeArmer {
|
|
|
320
403
|
}
|
|
321
404
|
}
|
|
322
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Story #4472 — direct-merge fallback when native auto-merge is
|
|
408
|
+
* unavailable. Runs an immediate `gh pr merge --squash --delete-branch`
|
|
409
|
+
* (no `--auto`) then re-probes; on a confirmed merge (or the same
|
|
410
|
+
* post-merge `--delete-branch` housekeeping grumble the `--auto` path
|
|
411
|
+
* already tolerates) it emits `epic.merge.armed` so the
|
|
412
|
+
* MergeWatcher → Cleaner → LabelTransitioner chain engages and confirms
|
|
413
|
+
* the merge on its first poll.
|
|
414
|
+
*
|
|
415
|
+
* @returns {Promise<boolean>} `true` when the fallback landed the PR (an
|
|
416
|
+
* `epic.merge.armed` was emitted); `false` when the direct merge did
|
|
417
|
+
* not land, so the caller escalates the original arm failure.
|
|
418
|
+
*/
|
|
419
|
+
async _tryDirectMerge({ event, seqId, prUrl, arm }) {
|
|
420
|
+
this.logger.info?.(
|
|
421
|
+
`[AutomergeArmer] native auto-merge unavailable (${arm.stderr?.trim?.() ?? arm.stderr}); falling back to a direct squash-merge on ${prUrl}.`,
|
|
422
|
+
);
|
|
423
|
+
const direct = this.ghPrMergeDirectFn({ prUrl, cwd: this.cwd });
|
|
424
|
+
const recheck = this.ghPrViewAutoMergeFn({ prUrl, cwd: this.cwd });
|
|
425
|
+
const merged =
|
|
426
|
+
recheck.status === 0 &&
|
|
427
|
+
(parsePrMerged(recheck.stdout) || parseAutoMergeArmed(recheck.stdout));
|
|
428
|
+
if (direct.status === 0 || merged) {
|
|
429
|
+
this.classifications.push({
|
|
430
|
+
event,
|
|
431
|
+
seqId,
|
|
432
|
+
outcome: 'armed',
|
|
433
|
+
prUrl,
|
|
434
|
+
note: `direct-merge fallback (native auto-merge unavailable); direct exit ${direct.status}${direct.status !== 0 ? ` but re-probe shows merged/armed (housekeeping stderr: ${direct.stderr})` : ''}`,
|
|
435
|
+
});
|
|
436
|
+
await this._emitArmed(prUrl);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
this.logger.warn?.(
|
|
440
|
+
`[AutomergeArmer] direct-merge fallback failed (status=${direct.status}): ${direct.stderr}`,
|
|
441
|
+
);
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Classify + (in headless) escalate a genuine arm failure. The `--auto`
|
|
447
|
+
* path historically returned silently here; a `/deliver --yes` run has no
|
|
448
|
+
* operator to notice, so we mirror the MergeWatcher's terminal:
|
|
449
|
+
* `merge.unlanded` ledger attribution + an explicit `epic.blocked`
|
|
450
|
+
* transition. Attended runs keep the classify-and-return behaviour.
|
|
451
|
+
*/
|
|
452
|
+
async _emitArmFailure({ event, seqId, prUrl, reason, ghStderr }) {
|
|
453
|
+
this.classifications.push({
|
|
454
|
+
event,
|
|
455
|
+
seqId,
|
|
456
|
+
outcome: 'failed',
|
|
457
|
+
reason,
|
|
458
|
+
ghStderr,
|
|
459
|
+
});
|
|
460
|
+
this.logger.warn?.(
|
|
461
|
+
`[AutomergeArmer] gh pr merge --auto failed (${reason}): ${ghStderr}`,
|
|
462
|
+
);
|
|
463
|
+
if (!this.headless) return;
|
|
464
|
+
const classification = classifyMergeBlock({
|
|
465
|
+
armResult: { armed: false, reason: ghStderr },
|
|
466
|
+
});
|
|
467
|
+
const prNumber = parsePrNumberFromUrl(prUrl);
|
|
468
|
+
if (
|
|
469
|
+
Number.isInteger(this.epicId) &&
|
|
470
|
+
Number.isInteger(prNumber) &&
|
|
471
|
+
prNumber > 0
|
|
472
|
+
) {
|
|
473
|
+
try {
|
|
474
|
+
this.emitMergeUnlandedFn({
|
|
475
|
+
scope: 'epic',
|
|
476
|
+
ticketId: this.epicId,
|
|
477
|
+
prNumber,
|
|
478
|
+
blockClass: classification.blockClass,
|
|
479
|
+
reason: classification.reason,
|
|
480
|
+
elapsedSeconds: 0,
|
|
481
|
+
});
|
|
482
|
+
} catch (err) {
|
|
483
|
+
this.logger.warn?.(
|
|
484
|
+
`[AutomergeArmer] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
await this.bus.emit('epic.blocked', { reason: `merge-arm:failed` });
|
|
490
|
+
} catch (err) {
|
|
491
|
+
this.logger.warn?.(
|
|
492
|
+
`[AutomergeArmer] epic.blocked emit on arm failure failed (swallowed): ${err?.message ?? err}`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
323
497
|
reset() {
|
|
324
498
|
this._seen.clear();
|
|
325
499
|
this.classifications = [];
|
|
@@ -68,8 +68,10 @@ import { spawnSync } from 'node:child_process';
|
|
|
68
68
|
|
|
69
69
|
import { hasSurvivingCritical } from '../../../audit-suite/findings.js';
|
|
70
70
|
import { getCiDelivery } from '../../../config/ci.js';
|
|
71
|
+
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
71
72
|
import * as epicRunStateStore from '../../epic-run-state-store.js';
|
|
72
73
|
import { findStructuredComment } from '../../ticketing.js';
|
|
74
|
+
import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
|
|
73
75
|
import { normalizeCheckState, RECOGNIZED_CHECK_STATES } from './watcher.js';
|
|
74
76
|
|
|
75
77
|
/**
|
|
@@ -224,19 +226,51 @@ export function probeRequiredChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
224
226
|
* stdout. We parse stdout first (it is populated even on the non-zero
|
|
225
227
|
* exit) and classify from the outcomes.
|
|
226
228
|
*
|
|
229
|
+
* Story #4472 — checks-less repos. In a repo with zero required checks
|
|
230
|
+
* (no branch protection, or protection that requires no status checks),
|
|
231
|
+
* `gh pr checks --required` writes NOTHING to stdout and reports
|
|
232
|
+
* `no checks reported on the <branch> branch` to stderr with a non-zero
|
|
233
|
+
* exit. That is the SAME empty-parsed-set condition the outcomes loop
|
|
234
|
+
* below already treats as green — there is simply nothing to gate on — so
|
|
235
|
+
* we must not conflate it with a genuine probe failure (auth, network, no
|
|
236
|
+
* PR). We detect the `no checks reported` stderr signature and return
|
|
237
|
+
* green, UNLESS the consumer opted into `delivery.ci.requireChecks`, in
|
|
238
|
+
* which case the absent CI gate is a deliberate hard block.
|
|
239
|
+
*
|
|
227
240
|
* @param {{ status: number, stdout: string, stderr: string }} probe
|
|
241
|
+
* @param {{ requireChecks?: boolean }} [opts] When `requireChecks` is
|
|
242
|
+
* true, a checks-less repo fails closed instead of arming.
|
|
228
243
|
* @returns {{ ok: boolean, reason: string|null, outcomes: Record<string, string> }}
|
|
229
244
|
*/
|
|
230
|
-
export function classifyRequiredChecksProbe(
|
|
245
|
+
export function classifyRequiredChecksProbe(
|
|
246
|
+
probe,
|
|
247
|
+
{ requireChecks = false } = {},
|
|
248
|
+
) {
|
|
231
249
|
const stdout = String(probe?.stdout ?? '').trim();
|
|
232
|
-
|
|
233
|
-
//
|
|
250
|
+
const stderr = String(probe?.stderr ?? '').trim();
|
|
251
|
+
// Empty stdout: either a checks-less repo (green, nothing to gate on) or
|
|
252
|
+
// a genuine probe failure. The `no checks reported` stderr signature
|
|
253
|
+
// distinguishes them.
|
|
234
254
|
if (stdout.length === 0) {
|
|
255
|
+
const noChecksReported = /no checks reported/i.test(stderr);
|
|
256
|
+
if (noChecksReported && !requireChecks) {
|
|
257
|
+
// Zero required checks configured — matches the empty-parsed-set
|
|
258
|
+
// "treated as green" branch below. Nothing to gate on.
|
|
259
|
+
return { ok: true, reason: null, outcomes: {} };
|
|
260
|
+
}
|
|
261
|
+
if (noChecksReported && requireChecks) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
reason:
|
|
265
|
+
'no required checks reported and delivery.ci.requireChecks is set — failing closed per policy',
|
|
266
|
+
outcomes: {},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
235
269
|
return {
|
|
236
270
|
ok: false,
|
|
237
271
|
reason:
|
|
238
272
|
`live required-check probe failed (status=${probe?.status ?? 'unknown'})` +
|
|
239
|
-
(
|
|
273
|
+
(stderr ? `: ${stderr.slice(0, 200)}` : ''),
|
|
240
274
|
outcomes: {},
|
|
241
275
|
};
|
|
242
276
|
}
|
|
@@ -678,8 +712,13 @@ export class AutomergePredicate {
|
|
|
678
712
|
* evaluator). Required for the read of run-state + structured
|
|
679
713
|
* comments.
|
|
680
714
|
* @param {object} [opts.config] Resolved agent config. Read for the
|
|
681
|
-
* `delivery.ci.autoMerge` policy
|
|
682
|
-
*
|
|
715
|
+
* `delivery.ci.autoMerge` policy and the `delivery.ci.requireChecks`
|
|
716
|
+
* fail-closed-without-checks policy via `getCiDelivery`. Defaults to the
|
|
717
|
+
* framework defaults (`trust-ci` / `requireChecks: false`) when omitted.
|
|
718
|
+
* @param {boolean} [opts.headless] When true (a `/deliver --yes` run), a
|
|
719
|
+
* predicate refusal escalates to an explicit `merge.unlanded` +
|
|
720
|
+
* `epic.blocked` terminal instead of silently parking on the
|
|
721
|
+
* operator-merges path (Story #4472). Defaults to `false` (attended).
|
|
683
722
|
* @param {string} [opts.cwd] Working directory for the live
|
|
684
723
|
* `gh pr checks --required` probe. Defaults to `process.cwd()`.
|
|
685
724
|
* @param {Function} [opts.evaluatePredicateFn] override of
|
|
@@ -708,13 +747,20 @@ export class AutomergePredicate {
|
|
|
708
747
|
this.epicId = opts.epicId;
|
|
709
748
|
this.provider = opts.provider;
|
|
710
749
|
this.cwd = opts.cwd ?? process.cwd();
|
|
711
|
-
// Resolve the merge posture once at construction.
|
|
712
|
-
// applies the framework
|
|
713
|
-
|
|
750
|
+
// Resolve the merge posture + fail-closed policy once at construction.
|
|
751
|
+
// `getCiDelivery` applies the framework defaults (`trust-ci` /
|
|
752
|
+
// `requireChecks: false`) for any omitted field.
|
|
753
|
+
const ci = getCiDelivery(opts.config ?? null);
|
|
754
|
+
this.policy = ci.autoMerge;
|
|
755
|
+
this.requireChecks = ci.requireChecks;
|
|
756
|
+
this.headless = opts.headless === true;
|
|
714
757
|
this.evaluatePredicateFn =
|
|
715
758
|
opts.evaluatePredicateFn ?? evaluateAutoMergePredicate;
|
|
716
759
|
this.probeRequiredChecksFn =
|
|
717
760
|
opts.probeRequiredChecksFn ?? probeRequiredChecks;
|
|
761
|
+
// Injected for tests so the headless terminal escalation can be
|
|
762
|
+
// observed without touching disk.
|
|
763
|
+
this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
|
|
718
764
|
this.logger = opts.logger ?? console;
|
|
719
765
|
/** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
|
|
720
766
|
this._seen = new Set();
|
|
@@ -786,7 +832,9 @@ export class AutomergePredicate {
|
|
|
786
832
|
let probeVerdict;
|
|
787
833
|
try {
|
|
788
834
|
const probe = this.probeRequiredChecksFn({ prUrl, cwd: this.cwd });
|
|
789
|
-
probeVerdict = classifyRequiredChecksProbe(probe
|
|
835
|
+
probeVerdict = classifyRequiredChecksProbe(probe, {
|
|
836
|
+
requireChecks: this.requireChecks,
|
|
837
|
+
});
|
|
790
838
|
} catch (err) {
|
|
791
839
|
probeVerdict = {
|
|
792
840
|
ok: false,
|
|
@@ -878,6 +926,17 @@ export class AutomergePredicate {
|
|
|
878
926
|
* Emit `epic.merge.blocked`. Helper carved out so the blocking paths
|
|
879
927
|
* (CI failure / predicate dirty / evaluator throw) share the same emit
|
|
880
928
|
* shape.
|
|
929
|
+
*
|
|
930
|
+
* Story #4472 — must-land coverage of predicate refusal. In a headless
|
|
931
|
+
* (`/deliver --yes`) run there is no operator to act on a bare
|
|
932
|
+
* `epic.merge.blocked` (nothing in the listener chain consumes it), so
|
|
933
|
+
* the run would silently park on the operator-merges path. When
|
|
934
|
+
* `this.headless`, we additionally attribute the refusal to the
|
|
935
|
+
* lifecycle ledger via `merge.unlanded` (blockClass `predicate-refused`)
|
|
936
|
+
* and drive the explicit `epic.blocked` terminal — the same
|
|
937
|
+
* escalation the MergeWatcher performs on post-arm budget exhaustion —
|
|
938
|
+
* so the Epic transitions to `agent::blocked` with an operator-visible
|
|
939
|
+
* reason instead of stalling.
|
|
881
940
|
*/
|
|
882
941
|
async _emitBlocked(prUrl, reason) {
|
|
883
942
|
try {
|
|
@@ -887,6 +946,35 @@ export class AutomergePredicate {
|
|
|
887
946
|
`[AutomergePredicate] epic.merge.blocked emit failed (swallowed): ${err?.message ?? err}`,
|
|
888
947
|
);
|
|
889
948
|
}
|
|
949
|
+
if (!this.headless) return;
|
|
950
|
+
// Ledger attribution — best-effort; a failed append must NOT mask the
|
|
951
|
+
// epic.blocked transition below.
|
|
952
|
+
try {
|
|
953
|
+
const prNumber = parsePrNumberFromUrl(prUrl);
|
|
954
|
+
if (Number.isInteger(prNumber) && prNumber > 0) {
|
|
955
|
+
this.emitMergeUnlandedFn({
|
|
956
|
+
scope: 'epic',
|
|
957
|
+
ticketId: this.epicId,
|
|
958
|
+
prNumber,
|
|
959
|
+
blockClass: 'predicate-refused',
|
|
960
|
+
reason,
|
|
961
|
+
elapsedSeconds: 0,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
} catch (err) {
|
|
965
|
+
this.logger.warn?.(
|
|
966
|
+
`[AutomergePredicate] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
try {
|
|
970
|
+
await this.bus.emit('epic.blocked', {
|
|
971
|
+
reason: `merge-predicate:refused`,
|
|
972
|
+
});
|
|
973
|
+
} catch (err) {
|
|
974
|
+
this.logger.warn?.(
|
|
975
|
+
`[AutomergePredicate] epic.blocked emit on predicate refusal failed (swallowed): ${err?.message ?? err}`,
|
|
976
|
+
);
|
|
977
|
+
}
|
|
890
978
|
}
|
|
891
979
|
|
|
892
980
|
reset() {
|
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
openOrLocatePr as defaultOpenOrLocatePr,
|
|
83
83
|
} from '../../finalize/open-or-locate-pr.js';
|
|
84
84
|
import { postHandoffComment as defaultPostHandoffComment } from '../../finalize/post-handoff-comment.js';
|
|
85
|
+
import { probeRemoteBranch as defaultProbeRemoteBranch } from '../../remote-verifier.js';
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
88
|
* Build the production default `runFinalizeFn` that composes the
|
|
@@ -103,12 +104,22 @@ import { postHandoffComment as defaultPostHandoffComment } from '../../finalize/
|
|
|
103
104
|
* contract is identical and `markPrReady` is a no-op on an already-ready
|
|
104
105
|
* PR, so replay stays idempotent.
|
|
105
106
|
*
|
|
107
|
+
* Issue #4483 — deterministic land-or-block backstop. Before opening (or
|
|
108
|
+
* readying) the PR, finalize asserts the delivery branch `epic/<id>`
|
|
109
|
+
* actually exists on origin. A delivery that was never pushed — e.g. an
|
|
110
|
+
* agent that built the Epic inline on local `main` and skipped the
|
|
111
|
+
* orchestration — MUST surface as an explicit
|
|
112
|
+
* `delivery-branch-missing-on-origin` blocker (which keeps the Epic at
|
|
113
|
+
* `agent::blocked`), never a declared success. The probe is bounded
|
|
114
|
+
* (timeout + SIGKILL) so a hung remote degrades to a blocker too.
|
|
115
|
+
*
|
|
106
116
|
* @param {{
|
|
107
117
|
* provider?: object|null,
|
|
108
118
|
* earlyPr?: boolean,
|
|
109
119
|
* openOrLocatePrFn?: typeof defaultOpenOrLocatePr,
|
|
110
120
|
* markPrReadyFn?: typeof defaultMarkPrReady,
|
|
111
121
|
* postHandoffCommentFn?: typeof defaultPostHandoffComment,
|
|
122
|
+
* probeRemoteBranchFn?: typeof defaultProbeRemoteBranch,
|
|
112
123
|
* }} deps
|
|
113
124
|
*/
|
|
114
125
|
export function composeBusOwnedFinalize(deps = {}) {
|
|
@@ -116,6 +127,8 @@ export function composeBusOwnedFinalize(deps = {}) {
|
|
|
116
127
|
const markPrReadyFn = deps.markPrReadyFn ?? defaultMarkPrReady;
|
|
117
128
|
const postHandoffCommentFn =
|
|
118
129
|
deps.postHandoffCommentFn ?? defaultPostHandoffComment;
|
|
130
|
+
const probeRemoteBranchFn =
|
|
131
|
+
deps.probeRemoteBranchFn ?? defaultProbeRemoteBranch;
|
|
119
132
|
const provider = deps.provider ?? null;
|
|
120
133
|
const earlyPr = deps.earlyPr !== false;
|
|
121
134
|
|
|
@@ -128,6 +141,25 @@ export function composeBusOwnedFinalize(deps = {}) {
|
|
|
128
141
|
},
|
|
129
142
|
};
|
|
130
143
|
}
|
|
144
|
+
|
|
145
|
+
// Issue #4483 backstop — the delivery branch MUST be on origin before
|
|
146
|
+
// finalize declares any success. A never-pushed branch is the silent
|
|
147
|
+
// local-main failure shape; block explicitly with the probe detail.
|
|
148
|
+
let branchProbe;
|
|
149
|
+
try {
|
|
150
|
+
branchProbe = probeRemoteBranchFn({ branch: `epic/${epicId}`, cwd });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
branchProbe = { exists: false, detail: err?.message ?? String(err) };
|
|
153
|
+
}
|
|
154
|
+
if (!branchProbe.exists) {
|
|
155
|
+
return {
|
|
156
|
+
blocker: {
|
|
157
|
+
reason: 'delivery-branch-missing-on-origin',
|
|
158
|
+
detail: `epic/${epicId} is not on origin — the delivery was never pushed; refusing to finalize (issue #4483). Probe: ${branchProbe.detail}`,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
131
163
|
let openResult;
|
|
132
164
|
try {
|
|
133
165
|
openResult = await openOrLocatePrFn({
|
|
@@ -226,10 +226,15 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
226
226
|
order.push('Finalizer');
|
|
227
227
|
|
|
228
228
|
// 4. AutomergeArmer — arms `gh pr merge --auto --squash --delete-branch`
|
|
229
|
-
// on epic.merge.ready.
|
|
229
|
+
// on epic.merge.ready. Story #4472: `headless` gates the direct-merge
|
|
230
|
+
// fallback's terminal escalation (a genuine arm failure emits
|
|
231
|
+
// `merge.unlanded` + `epic.blocked` in a `--yes` run instead of
|
|
232
|
+
// returning silently); `epicId` scopes the `merge.unlanded` ledger row.
|
|
230
233
|
const automergeArmer = new AutomergeArmer({
|
|
231
234
|
bus,
|
|
235
|
+
epicId,
|
|
232
236
|
cwd: repoRoot,
|
|
237
|
+
headless,
|
|
233
238
|
logger,
|
|
234
239
|
});
|
|
235
240
|
automergeArmer.register();
|
|
@@ -250,6 +255,7 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
250
255
|
provider,
|
|
251
256
|
config,
|
|
252
257
|
cwd: repoRoot,
|
|
258
|
+
headless,
|
|
253
259
|
logger,
|
|
254
260
|
});
|
|
255
261
|
automergePredicate.register();
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* one of four classes from the SAME decision logic, instead of each path
|
|
11
11
|
* inventing its own ad hoc diagnosis.
|
|
12
12
|
*
|
|
13
|
-
* Block classes (Epic #4425 Goal):
|
|
13
|
+
* Block classes (Epic #4425 Goal; `predicate-refused` added by #4472):
|
|
14
14
|
* - `checks-pending-timeout` The watch/poll budget was
|
|
15
15
|
* exhausted while required checks
|
|
16
16
|
* were still pending/running — not
|
|
@@ -33,6 +33,18 @@
|
|
|
33
33
|
* a transient GraphQL/API error, an
|
|
34
34
|
* ambiguous probe result, or a
|
|
35
35
|
* genuinely novel condition.
|
|
36
|
+
* - `predicate-refused` The AutomergePredicate refused to
|
|
37
|
+
* arm merge BEFORE any arm attempt —
|
|
38
|
+
* a red/pending required check, an
|
|
39
|
+
* unreadable check probe, a dirty
|
|
40
|
+
* structured-signal verdict, or a
|
|
41
|
+
* `delivery.ci.requireChecks` policy
|
|
42
|
+
* block on a checks-less repo (#4472).
|
|
43
|
+
* The must-land contract previously
|
|
44
|
+
* only covered post-arm poll
|
|
45
|
+
* exhaustion, so a predicate refusal
|
|
46
|
+
* in headless mode silently parked;
|
|
47
|
+
* this class makes it attributable.
|
|
36
48
|
*
|
|
37
49
|
* Pure function, no I/O: callers pass in the already-observed
|
|
38
50
|
* arm-result / PR-probe / budget signals (from `AutomergeArmer`,
|
|
@@ -55,12 +67,28 @@ export const BLOCK_CLASSES = Object.freeze([
|
|
|
55
67
|
'api-race-other',
|
|
56
68
|
]);
|
|
57
69
|
|
|
58
|
-
|
|
70
|
+
/**
|
|
71
|
+
* The full set of block-class values a `merge.unlanded` record may carry.
|
|
72
|
+
* This is the classifier's four outputs PLUS `predicate-refused` (#4472),
|
|
73
|
+
* which is emitted DIRECTLY by the AutomergePredicate / AutomergeArmer for a
|
|
74
|
+
* headless refusal that never reached the poll-exhaustion classifier — so it
|
|
75
|
+
* is a valid attribution value even though `classifyMergeBlock` never
|
|
76
|
+
* produces it. `isValidBlockClass` (and the `merge.unlanded` schema enum)
|
|
77
|
+
* validate against this broader set; the classifier's own reachability
|
|
78
|
+
* invariant stays scoped to `BLOCK_CLASSES`.
|
|
79
|
+
*/
|
|
80
|
+
export const MERGE_UNLANDED_BLOCK_CLASSES = Object.freeze([
|
|
81
|
+
...BLOCK_CLASSES,
|
|
82
|
+
'predicate-refused',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const BLOCK_CLASS_SET = new Set(MERGE_UNLANDED_BLOCK_CLASSES);
|
|
59
86
|
|
|
60
87
|
/**
|
|
61
88
|
* @param {string} value
|
|
62
|
-
* @returns {boolean} `true` iff `value` is
|
|
63
|
-
* block
|
|
89
|
+
* @returns {boolean} `true` iff `value` is a valid `merge.unlanded`
|
|
90
|
+
* block-class attribution (the four classifier outputs plus the directly-
|
|
91
|
+
* emitted `predicate-refused`).
|
|
64
92
|
*/
|
|
65
93
|
export function isValidBlockClass(value) {
|
|
66
94
|
return BLOCK_CLASS_SET.has(value);
|