mandrel 2.65.0 → 2.67.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.
Files changed (82) hide show
  1. package/.agents/agents/acceptance-critic.md +7 -7
  2. package/.agents/agents/auditor.md +17 -18
  3. package/.agents/agents/plan-critic.md +5 -5
  4. package/.agents/agents/story-worker.md +5 -5
  5. package/.agents/docs/agentrc-reference.json +2 -1
  6. package/.agents/docs/configuration.md +2 -1
  7. package/.agents/docs/execution-reference.md +27 -5
  8. package/.agents/docs/workflows.md +4 -2
  9. package/.agents/instructions.md +12 -13
  10. package/.agents/rules/ci-remediation.md +3 -3
  11. package/.agents/rules/gherkin-standards.md +3 -2
  12. package/.agents/rules/git-conventions-reference.md +17 -8
  13. package/.agents/rules/git-conventions.md +10 -8
  14. package/.agents/rules/testing-standards.md +8 -7
  15. package/.agents/runtime-deps.json +1 -1
  16. package/.agents/schemas/agentrc.schema.json +6 -1
  17. package/.agents/scripts/boot-sweep.js +97 -9
  18. package/.agents/scripts/bootstrap.js +94 -89
  19. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  20. package/.agents/scripts/clean-temp.js +54 -0
  21. package/.agents/scripts/clean-worktrees.js +593 -0
  22. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  23. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  24. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
  25. package/.agents/scripts/lib/clean-temp.js +440 -0
  26. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  27. package/.agents/scripts/lib/cli-args.js +26 -0
  28. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
  30. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  31. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  32. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  34. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  35. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  36. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  37. package/.agents/scripts/lib/observability/source-classifier.js +3 -1
  38. package/.agents/scripts/lib/orchestration/code-review.js +22 -0
  39. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  40. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  41. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  42. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
  43. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  48. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
  49. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  50. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  51. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  52. package/.agents/scripts/lib/temp-removal.js +110 -0
  53. package/.agents/scripts/lib/temp-retention.js +122 -73
  54. package/.agents/scripts/lib/transpile.js +28 -3
  55. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  56. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  57. package/.agents/scripts/single-story-close.js +10 -2
  58. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  59. package/.agents/scripts/single-story-init.js +120 -17
  60. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  61. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  62. package/.agents/workflows/audit-architecture.md +5 -4
  63. package/.agents/workflows/audit-documentation.md +5 -5
  64. package/.agents/workflows/audit-performance.md +10 -10
  65. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  66. package/.agents/workflows/clean-temp.md +67 -0
  67. package/.agents/workflows/clean-worktrees.md +63 -0
  68. package/.agents/workflows/git-deliver.md +1 -1
  69. package/.agents/workflows/helpers/acceptance-self-eval.md +11 -10
  70. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  71. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  72. package/.agents/workflows/helpers/deliver-reference.md +3 -1
  73. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  74. package/.agents/workflows/helpers/deliver-story.md +6 -1
  75. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  76. package/.agents/workflows/mandrel-deliver.md +1 -1
  77. package/.agents/workflows/mandrel-plan.md +6 -5
  78. package/docs/CHANGELOG.md +39 -0
  79. package/lib/cli/guarded-sync.js +87 -0
  80. package/lib/cli/sync-agents.js +9 -92
  81. package/lib/cli/sync-commands.js +9 -101
  82. package/package.json +2 -2
@@ -654,13 +654,11 @@ async function maybeRerunAdvisory({
654
654
  }
655
655
 
656
656
  /**
657
- * Advisory-gate terminal for one poll; passes a decided `unlanded` through so
658
- * the caller stays branch-free (`runMergePoll` is at the cyclomatic ceiling).
659
- * Skips runs already re-ran, enriches the rest, spends any rerun allowance,
660
- * and disarms BEFORE returning a block so the PR cannot merge out from under it.
657
+ * Advisory-gate terminal for one poll, or `null` to keep polling. Skips runs
658
+ * already re-ran, enriches the rest, spends any rerun allowance, and disarms
659
+ * BEFORE returning a block so the PR cannot merge out from under it.
661
660
  */
662
661
  async function resolveAdvisoryUnlanded({
663
- unlanded,
664
662
  probe,
665
663
  blockOnAdvisoryFailure,
666
664
  advisoryAllowlist,
@@ -672,7 +670,6 @@ async function resolveAdvisoryUnlanded({
672
670
  disarmAutoMergeFn,
673
671
  elapsedSeconds,
674
672
  }) {
675
- if (unlanded) return unlanded;
676
673
  const advisory = decideAdvisoryGateBlock({
677
674
  probe,
678
675
  blockOnAdvisoryFailure,
@@ -1001,6 +998,308 @@ async function onMergeObserved({
1001
998
  };
1002
999
  }
1003
1000
 
1001
+ /**
1002
+ * Everything one poll hands the next.
1003
+ *
1004
+ * @param {{ startedAtMs: number, intervalSeconds: number }} args
1005
+ * @returns {{ startedAtMs: number, polls: number, intervalMs: number,
1006
+ * updatesUsed: number, consecutiveRequiredFailSnapshots: number }}
1007
+ */
1008
+ export function createMergeWaitState({ startedAtMs, intervalSeconds }) {
1009
+ return {
1010
+ startedAtMs,
1011
+ polls: 0,
1012
+ intervalMs: intervalSeconds * 1000,
1013
+ updatesUsed: 0,
1014
+ consecutiveRequiredFailSnapshots: 0,
1015
+ };
1016
+ }
1017
+
1018
+ /** Budget first — it is the real give-up. */
1019
+ function provisionalVerdict(
1020
+ { polls, intervalMs, waitedMs, cumulativeMs },
1021
+ limits,
1022
+ ) {
1023
+ if (
1024
+ polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
1025
+ cumulativeMs + intervalMs > limits.maxBudgetSeconds * 1000
1026
+ ) {
1027
+ return 'budget-exhausted';
1028
+ }
1029
+ if (waitedMs + intervalMs > limits.maxWaitSeconds * 1000) return 'wait-bound';
1030
+ return 'continue';
1031
+ }
1032
+
1033
+ /**
1034
+ * Pure: state + probe + clock → next state and verdict. `merged`, `closed`
1035
+ * and `checks-failed` are definitive; `budget-exhausted`, `wait-bound` and
1036
+ * `continue` hold only if the loop's advisory check does not block first.
1037
+ * The two clock reads (wait bound, then budget) keep a stepping test clock's
1038
+ * call sequence.
1039
+ *
1040
+ * @param {{ state: object, probe: object, nowMs: number,
1041
+ * cumulativeNowMs?: number, limits: object }} args
1042
+ * @returns {{ state: object, verdict: string, waitedMs: number,
1043
+ * cumulativeMs: number, elapsedSeconds: number, waitBudget: object,
1044
+ * failFast?: object }}
1045
+ */
1046
+ export function decideMergeWaitPoll({
1047
+ state,
1048
+ probe,
1049
+ nowMs,
1050
+ cumulativeNowMs = nowMs,
1051
+ limits,
1052
+ }) {
1053
+ const anchorMs = resolveBudgetAnchorMs({
1054
+ createdAt: probe.createdAt,
1055
+ fallbackMs: state.startedAtMs,
1056
+ });
1057
+ const waitedMs = nowMs - state.startedAtMs;
1058
+ const cumulativeMs = Math.max(cumulativeNowMs - anchorMs, waitedMs);
1059
+ const polled = {
1060
+ ...state,
1061
+ polls: state.polls + 1,
1062
+ intervalMs: pollIntervalMs(probe.checksStatus, limits.intervalSeconds),
1063
+ };
1064
+ const timing = {
1065
+ waitedMs,
1066
+ cumulativeMs,
1067
+ elapsedSeconds: Math.round(waitedMs / 1000),
1068
+ waitBudget: {
1069
+ maxWaitSeconds: limits.maxWaitSeconds,
1070
+ waitedSeconds: Math.round(waitedMs / 1000),
1071
+ cumulativeSeconds: Math.round(cumulativeMs / 1000),
1072
+ maxBudgetSeconds: limits.maxBudgetSeconds,
1073
+ },
1074
+ };
1075
+ if (isPrMerged(probe)) return { state: polled, verdict: 'merged', ...timing };
1076
+ if (probe.state === 'CLOSED') {
1077
+ return { state: polled, verdict: 'closed', ...timing };
1078
+ }
1079
+ // No remaining budget turns a red required check green; fail fast.
1080
+ const failFast = decideMergeWaitFailFast({
1081
+ probe,
1082
+ consecutiveRequiredFailSnapshots: state.consecutiveRequiredFailSnapshots,
1083
+ });
1084
+ const next = {
1085
+ ...polled,
1086
+ consecutiveRequiredFailSnapshots: failFast.consecutiveRequiredFailSnapshots,
1087
+ };
1088
+ if (failFast.failFast) {
1089
+ return { state: next, verdict: 'checks-failed', failFast, ...timing };
1090
+ }
1091
+ return {
1092
+ state: next,
1093
+ verdict: provisionalVerdict({ ...next, waitedMs, cumulativeMs }, limits),
1094
+ ...timing,
1095
+ };
1096
+ }
1097
+
1098
+ function blockWith(ctx, unlanded) {
1099
+ return blockOnUnlanded({
1100
+ storyId: ctx.storyId,
1101
+ prNumber: ctx.prNumber,
1102
+ prUrl: ctx.prUrl,
1103
+ ...unlanded,
1104
+ provider: ctx.provider,
1105
+ progress: ctx.progress,
1106
+ classifyMergeBlockFn: ctx.classifyMergeBlockFn,
1107
+ emitMergeUnlandedFn: ctx.emitMergeUnlandedFn,
1108
+ });
1109
+ }
1110
+
1111
+ function settleMerged(ctx, decision, probe) {
1112
+ return onMergeObserved({
1113
+ ...ctx,
1114
+ prProbe: probe,
1115
+ elapsedSeconds: decision.elapsedSeconds,
1116
+ });
1117
+ }
1118
+
1119
+ function settleClosed(ctx, decision, probe) {
1120
+ return blockWith(ctx, {
1121
+ prProbe: probe,
1122
+ budget: { exhausted: true, elapsedSeconds: decision.elapsedSeconds },
1123
+ blockClassOverride: CLOSED_UNMERGED_BLOCK_CLASS,
1124
+ reasonOverride: CLOSED_UNMERGED_REASON,
1125
+ });
1126
+ }
1127
+
1128
+ async function settleChecksFailed(ctx, decision, probe) {
1129
+ const { failFast } = decision;
1130
+ ctx.progress?.(
1131
+ 'CONFIRM',
1132
+ failFast.evidencePath === 'per-run'
1133
+ ? `🛑 PR #${ctx.prNumber}: a required check concluded failure with none in flight — failing fast (evidence=per-run).`
1134
+ : `🛑 PR #${ctx.prNumber}: two consecutive failing check probes without per-run evidence — failing fast (evidence=consecutive-probe).`,
1135
+ );
1136
+ return blockWith(ctx, {
1137
+ prProbe: failFast.prProbe,
1138
+ budget: { exhausted: false, elapsedSeconds: decision.elapsedSeconds },
1139
+ blockClassOverride: failFast.blockClass,
1140
+ reasonOverride: failFast.reason,
1141
+ redRecord: await recordChecksFailedRed({
1142
+ storyId: ctx.storyId,
1143
+ prNumber: ctx.prNumber,
1144
+ probe,
1145
+ cwd: ctx.cwd,
1146
+ config: ctx.config,
1147
+ gh: ctx.injectedGh,
1148
+ progress: ctx.progress,
1149
+ disarmAutoMergeFn: ctx.disarmAutoMergeFn,
1150
+ recordRequiredRedFn: ctx.recordRequiredRedFn,
1151
+ }),
1152
+ });
1153
+ }
1154
+
1155
+ const DEFINITIVE_SETTLERS = Object.freeze({
1156
+ merged: settleMerged,
1157
+ closed: settleClosed,
1158
+ 'checks-failed': settleChecksFailed,
1159
+ });
1160
+
1161
+ /** Invocation bound reached: resumable `pending`, no mutation. */
1162
+ function pendingAtWaitBound(ctx, decision, probe) {
1163
+ const { waitBudget } = decision;
1164
+ const checks = probe.checksStatus ?? 'unknown';
1165
+ ctx.progress?.(
1166
+ 'CONFIRM',
1167
+ `⏸ Merge wait bound reached (${waitBudget.waitedSeconds}s of ${waitBudget.maxWaitSeconds}s this invocation; ` +
1168
+ `${waitBudget.cumulativeSeconds}s of ${waitBudget.maxBudgetSeconds}s cumulative). PR #${ctx.prNumber} still in flight ` +
1169
+ `(checks=${checks}). Story stays at agent::closing — resumable.`,
1170
+ );
1171
+ return {
1172
+ confirmed: false,
1173
+ terminal: 'pending',
1174
+ reason: `merge wait bound reached with the PR still in flight (checks=${checks})`,
1175
+ prProbe: probe,
1176
+ waitBudget,
1177
+ elapsedSeconds: waitBudget.waitedSeconds,
1178
+ };
1179
+ }
1180
+
1181
+ /**
1182
+ * Advisory gates are usually still QUEUED at arm time, so they redden here,
1183
+ * mid-wait, before auto-merge lands over them.
1184
+ *
1185
+ * @returns {Promise<{ state: object, outcome: object|null }>}
1186
+ */
1187
+ async function settleProvisional(ctx, decision, probe) {
1188
+ const advisory = await resolveAdvisoryUnlanded({
1189
+ probe,
1190
+ blockOnAdvisoryFailure: ctx.blockOnAdvisoryFailure,
1191
+ advisoryAllowlist: ctx.advisoryAllowlist,
1192
+ rerunState: ctx.rerunState,
1193
+ prNumber: ctx.prNumber,
1194
+ gh: ctx.injectedGh,
1195
+ ghTimeoutMs: ctx.ghTimeoutMs,
1196
+ progress: ctx.progress,
1197
+ disarmAutoMergeFn: ctx.disarmAutoMergeFn,
1198
+ elapsedSeconds: decision.elapsedSeconds,
1199
+ });
1200
+ if (advisory) {
1201
+ return { state: decision.state, outcome: await blockWith(ctx, advisory) };
1202
+ }
1203
+ const updated = await maybeUpdateBehindPr({
1204
+ probe,
1205
+ prNumber: ctx.prNumber,
1206
+ updatesUsed: decision.state.updatesUsed,
1207
+ updateAttempts: ctx.limits.updateAttempts,
1208
+ gh: ctx.injectedGh,
1209
+ ghTimeoutMs: ctx.ghTimeoutMs,
1210
+ progress: ctx.progress,
1211
+ });
1212
+ const state = updated
1213
+ ? { ...decision.state, updatesUsed: decision.state.updatesUsed + 1 }
1214
+ : decision.state;
1215
+ if (decision.verdict === 'budget-exhausted') {
1216
+ const outcome = await blockWith(ctx, {
1217
+ prProbe: probe,
1218
+ budget: {
1219
+ exhausted: true,
1220
+ elapsedSeconds: Math.round(decision.cumulativeMs / 1000),
1221
+ },
1222
+ ...queuedExhaustionVerdict(probe, decision.cumulativeMs),
1223
+ });
1224
+ return { state, outcome };
1225
+ }
1226
+ if (decision.verdict === 'wait-bound') {
1227
+ return { state, outcome: pendingAtWaitBound(ctx, decision, probe) };
1228
+ }
1229
+ return { state, outcome: null };
1230
+ }
1231
+
1232
+ /** @returns {Promise<{ state: object, done: boolean, outcome?: object }>} */
1233
+ async function runMergePoll(ctx, state) {
1234
+ const probe = await ctx.readPrWaitProbeFn({
1235
+ prNumber: ctx.prNumber,
1236
+ gh: ctx.injectedGh,
1237
+ ghTimeoutMs: ctx.ghTimeoutMs,
1238
+ });
1239
+ const decision = decideMergeWaitPoll({
1240
+ state,
1241
+ probe,
1242
+ nowMs: ctx.nowMsFn(),
1243
+ cumulativeNowMs: ctx.nowMsFn(),
1244
+ limits: ctx.limits,
1245
+ });
1246
+ // Heartbeat: a backgrounded close's output-file growth is its liveness signal.
1247
+ ctx.progress?.(
1248
+ 'CONFIRM',
1249
+ pollHeartbeat({
1250
+ polls: decision.state.polls,
1251
+ prNumber: ctx.prNumber,
1252
+ probe,
1253
+ waitBudget: decision.waitBudget,
1254
+ }),
1255
+ );
1256
+ const definitive = DEFINITIVE_SETTLERS[decision.verdict];
1257
+ const settled = definitive
1258
+ ? {
1259
+ state: decision.state,
1260
+ outcome: await definitive(ctx, decision, probe),
1261
+ }
1262
+ : await settleProvisional(ctx, decision, probe);
1263
+ return settled.outcome
1264
+ ? { state: settled.state, done: true, outcome: settled.outcome }
1265
+ : { state: settled.state, done: false };
1266
+ }
1267
+
1268
+ /** Carries the arm phase's advisory class rather than a generic `arm-failure`. */
1269
+ function blockNeverArmed({
1270
+ storyId,
1271
+ prNumber,
1272
+ prUrl,
1273
+ autoMergeReason,
1274
+ advisoryGate,
1275
+ provider,
1276
+ progress,
1277
+ classifyMergeBlockFn,
1278
+ emitMergeUnlandedFn,
1279
+ }) {
1280
+ progress?.(
1281
+ 'CONFIRM',
1282
+ `⚠️ Auto-merge not enabled (${autoMergeReason ?? 'unknown'}) — cannot wait for a merge that was never armed.`,
1283
+ );
1284
+ return blockOnUnlanded({
1285
+ storyId,
1286
+ prNumber,
1287
+ prUrl,
1288
+ armResult: { armed: false, reason: autoMergeReason },
1289
+ budget: { elapsedSeconds: 0 },
1290
+ provider,
1291
+ progress,
1292
+ classifyMergeBlockFn,
1293
+ emitMergeUnlandedFn,
1294
+ ...(autoMergeReason === 'advisory-gate-red'
1295
+ ? {
1296
+ blockClassOverride: advisoryGate?.blockClass ?? 'advisory-gate-red',
1297
+ reasonOverride: advisoryGate?.reason,
1298
+ }
1299
+ : {}),
1300
+ });
1301
+ }
1302
+
1004
1303
  /**
1005
1304
  * Poll an armed Story PR to merge confirmation, a resumable `pending`
1006
1305
  * expiry, or a classified `agent::blocked` terminal.
@@ -1035,23 +1334,9 @@ async function onMergeObserved({
1035
1334
  * @returns {Promise<object>}
1036
1335
  */
1037
1336
  export async function runConfirmMergePhase({
1038
- cwd,
1039
- storyId,
1040
- storyBranch,
1041
- baseBranch,
1042
- prNumber,
1043
- prUrl,
1044
- autoMergeEnabled,
1045
- autoMergeReason,
1046
- advisoryGate,
1047
- provider,
1048
- config,
1049
1337
  maxWaitSeconds: maxWaitSecondsOverride,
1050
1338
  mergeWatchMode: mergeWatchModeOverride,
1051
1339
  rerunAdvisory: rerunAdvisoryOverride,
1052
- progress,
1053
- injectedGh,
1054
- injectedNotify,
1055
1340
  confirmStoryMergedFn = defaultConfirmStoryMerged,
1056
1341
  readPrWaitProbeFn = readPrWaitProbe,
1057
1342
  readPrMergeStateFn = defaultReadPrMergeState,
@@ -1064,273 +1349,78 @@ export async function runConfirmMergePhase({
1064
1349
  sleepFn = defaultSleep,
1065
1350
  nowMsFn = Date.now,
1066
1351
  ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
1352
+ ...args
1067
1353
  }) {
1068
- // Never armed: nothing to poll, but a terminal is still required.
1069
- if (!autoMergeEnabled) {
1070
- progress?.(
1071
- 'CONFIRM',
1072
- `⚠️ Auto-merge not enabled (${autoMergeReason ?? 'unknown'}) — cannot wait for a merge that was never armed.`,
1073
- );
1074
- return blockOnUnlanded({
1075
- storyId,
1076
- prNumber,
1077
- prUrl,
1078
- armResult: { armed: false, reason: autoMergeReason },
1079
- budget: { elapsedSeconds: 0 },
1080
- provider,
1081
- progress,
1354
+ if (!args.autoMergeEnabled) {
1355
+ return blockNeverArmed({
1356
+ ...args,
1082
1357
  classifyMergeBlockFn,
1083
1358
  emitMergeUnlandedFn,
1084
- // Carry the arm phase's advisory verdict and class (which may be
1085
- // `inconclusive`) rather than a generic `arm-failure`.
1086
- ...(autoMergeReason === 'advisory-gate-red'
1087
- ? {
1088
- blockClassOverride: advisoryGate?.blockClass ?? 'advisory-gate-red',
1089
- reasonOverride: advisoryGate?.reason,
1090
- }
1091
- : {}),
1092
1359
  });
1093
1360
  }
1094
1361
 
1095
- const {
1096
- mode,
1097
- intervalSeconds,
1098
- maxWaitSeconds,
1099
- maxBudgetSeconds,
1100
- updateAttempts,
1101
- } = resolveMergeWaitConfig(
1102
- config,
1362
+ const limits = resolveMergeWaitConfig(
1363
+ args.config,
1103
1364
  maxWaitSecondsOverride,
1104
1365
  mergeWatchModeOverride,
1105
1366
  );
1106
- const { blockOnAdvisoryFailure, advisoryAllowlist } = getCiDelivery(config);
1367
+ const { blockOnAdvisoryFailure, advisoryAllowlist } = getCiDelivery(
1368
+ args.config,
1369
+ );
1107
1370
  // Spent across the whole wait, not per poll, so `n` bounds CI minutes.
1108
1371
  const rerunAllowance = resolveAdvisoryRerunAllowance(
1109
- config,
1372
+ args.config,
1110
1373
  rerunAdvisoryOverride,
1111
1374
  );
1112
- const rerunState = {
1113
- allowance: rerunAllowance,
1114
- remaining: rerunAllowance,
1115
- issued: new Set(),
1375
+ const ctx = {
1376
+ ...args,
1377
+ limits,
1378
+ blockOnAdvisoryFailure,
1379
+ advisoryAllowlist,
1380
+ rerunState: {
1381
+ allowance: rerunAllowance,
1382
+ remaining: rerunAllowance,
1383
+ issued: new Set(),
1384
+ },
1385
+ confirmStoryMergedFn,
1386
+ readPrWaitProbeFn,
1387
+ readPrMergeStateFn,
1388
+ classifyMergeBlockFn,
1389
+ emitMergeUnlandedFn,
1390
+ emitMergeFlipFailedFn,
1391
+ runPostLandTailFn,
1392
+ disarmAutoMergeFn,
1393
+ recordRequiredRedFn,
1394
+ nowMsFn,
1395
+ ghTimeoutMs,
1116
1396
  };
1117
- let intervalMs = intervalSeconds * 1000;
1118
- const startedAtMs = nowMsFn();
1119
- let anchorMs = startedAtMs;
1120
- let updatesUsed = 0;
1121
- let polls = 0;
1122
- // Without per-run evidence, fail fast only after two consecutive failing
1123
- // probes an interval apart; reset on any other probe.
1124
- let consecutiveRequiredFailSnapshots = 0;
1397
+ let state = createMergeWaitState({
1398
+ startedAtMs: nowMsFn(),
1399
+ intervalSeconds: limits.intervalSeconds,
1400
+ });
1125
1401
 
1126
- progress?.(
1402
+ args.progress?.(
1127
1403
  'CONFIRM',
1128
- `⏳ Close-and-land: polling PR #${prNumber} for merge confirmation ` +
1129
- `(mode=${mode}, wait=${maxWaitSeconds}s this invocation, ` +
1130
- `cumulative budget=${maxBudgetSeconds}s)...`,
1404
+ `⏳ Close-and-land: polling PR #${args.prNumber} for merge confirmation ` +
1405
+ `(mode=${limits.mode}, wait=${limits.maxWaitSeconds}s this invocation, ` +
1406
+ `cumulative budget=${limits.maxBudgetSeconds}s)...`,
1131
1407
  );
1132
1408
 
1133
- /** One iteration: `{ done: false }` or `{ done: true, outcome }`. */
1134
- async function runMergePoll() {
1135
- const probe = await readPrWaitProbeFn({
1136
- prNumber,
1137
- gh: injectedGh,
1138
- ghTimeoutMs,
1139
- });
1140
- polls += 1;
1141
- intervalMs = pollIntervalMs(probe.checksStatus, intervalSeconds);
1142
-
1143
- anchorMs = resolveBudgetAnchorMs({
1144
- createdAt: probe.createdAt,
1145
- fallbackMs: startedAtMs,
1146
- });
1147
-
1148
- const waitedMs = nowMsFn() - startedAtMs;
1149
- const cumulativeMs = Math.max(nowMsFn() - anchorMs, waitedMs);
1150
- const waitBudget = {
1151
- maxWaitSeconds,
1152
- waitedSeconds: Math.round(waitedMs / 1000),
1153
- cumulativeSeconds: Math.round(cumulativeMs / 1000),
1154
- maxBudgetSeconds,
1155
- };
1156
-
1157
- // Heartbeat: a backgrounded close's output-file growth is its liveness signal.
1158
- progress?.(
1159
- 'CONFIRM',
1160
- pollHeartbeat({ polls, prNumber, probe, waitBudget }),
1161
- );
1162
-
1163
- if (isPrMerged(probe)) {
1164
- return doneWith(
1165
- await onMergeObserved({
1166
- storyId,
1167
- storyBranch,
1168
- baseBranch,
1169
- prNumber,
1170
- prUrl,
1171
- cwd,
1172
- config,
1173
- provider,
1174
- progress,
1175
- injectedGh,
1176
- injectedNotify,
1177
- readPrMergeStateFn,
1178
- confirmStoryMergedFn,
1179
- runPostLandTailFn,
1180
- emitMergeFlipFailedFn,
1181
- prProbe: probe,
1182
- elapsedSeconds: Math.round(waitedMs / 1000),
1183
- }),
1184
- );
1185
- }
1186
-
1187
- // Each definitive condition fills `unlanded`; one call site below blocks.
1188
- let unlanded = null;
1189
-
1190
- if (probe.state === 'CLOSED') {
1191
- unlanded = {
1192
- prProbe: probe,
1193
- budget: {
1194
- exhausted: true,
1195
- elapsedSeconds: Math.round(waitedMs / 1000),
1196
- },
1197
- blockClassOverride: CLOSED_UNMERGED_BLOCK_CLASS,
1198
- reasonOverride: CLOSED_UNMERGED_REASON,
1199
- };
1200
- } else {
1201
- // No remaining budget turns a red required check green; fail fast.
1202
- const decision = decideMergeWaitFailFast({
1203
- probe,
1204
- consecutiveRequiredFailSnapshots,
1205
- });
1206
- consecutiveRequiredFailSnapshots =
1207
- decision.consecutiveRequiredFailSnapshots;
1208
- if (decision.failFast) {
1209
- progress?.(
1210
- 'CONFIRM',
1211
- decision.evidencePath === 'per-run'
1212
- ? `🛑 PR #${prNumber}: a required check concluded failure with none in flight — failing fast (evidence=per-run).`
1213
- : `🛑 PR #${prNumber}: two consecutive failing check probes without per-run evidence — failing fast (evidence=consecutive-probe).`,
1214
- );
1215
- unlanded = {
1216
- prProbe: decision.prProbe,
1217
- budget: {
1218
- exhausted: false,
1219
- elapsedSeconds: Math.round(waitedMs / 1000),
1220
- },
1221
- blockClassOverride: decision.blockClass,
1222
- reasonOverride: decision.reason,
1223
- redRecord: await recordChecksFailedRed({
1224
- storyId,
1225
- prNumber,
1226
- probe,
1227
- cwd,
1228
- config,
1229
- gh: injectedGh,
1230
- progress,
1231
- disarmAutoMergeFn,
1232
- recordRequiredRedFn,
1233
- }),
1234
- };
1235
- }
1236
-
1237
- // Advisory gates are usually still QUEUED at arm time, so they redden
1238
- // here, mid-wait, before auto-merge lands over them.
1239
- unlanded = await resolveAdvisoryUnlanded({
1240
- unlanded,
1241
- probe,
1242
- blockOnAdvisoryFailure,
1243
- advisoryAllowlist,
1244
- rerunState,
1245
- prNumber,
1246
- gh: injectedGh,
1247
- ghTimeoutMs,
1248
- progress,
1249
- disarmAutoMergeFn,
1250
- elapsedSeconds: Math.round(waitedMs / 1000),
1251
- });
1252
- }
1253
-
1254
- if (!unlanded) {
1255
- if (
1256
- await maybeUpdateBehindPr({
1257
- probe,
1258
- prNumber,
1259
- updatesUsed,
1260
- updateAttempts,
1261
- gh: injectedGh,
1262
- ghTimeoutMs,
1263
- progress,
1264
- })
1265
- ) {
1266
- updatesUsed += 1;
1267
- }
1268
-
1269
- // Cumulative budget exhausted, behind the poll floor.
1270
- if (
1271
- polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
1272
- cumulativeMs + intervalMs > maxBudgetSeconds * 1000
1273
- ) {
1274
- unlanded = {
1275
- prProbe: probe,
1276
- budget: {
1277
- exhausted: true,
1278
- elapsedSeconds: Math.round(cumulativeMs / 1000),
1279
- },
1280
- ...queuedExhaustionVerdict(probe, cumulativeMs),
1281
- };
1282
- }
1283
- }
1284
-
1285
- if (unlanded) {
1286
- return doneWith(
1287
- await blockOnUnlanded({
1288
- storyId,
1289
- prNumber,
1290
- prUrl,
1291
- ...unlanded,
1292
- provider,
1293
- progress,
1294
- classifyMergeBlockFn,
1295
- emitMergeUnlandedFn,
1296
- }),
1297
- );
1298
- }
1299
-
1300
- // Invocation bound reached: resumable `pending`, no mutation.
1301
- if (waitedMs + intervalMs > maxWaitSeconds * 1000) {
1302
- progress?.(
1303
- 'CONFIRM',
1304
- `⏸ Merge wait bound reached (${waitBudget.waitedSeconds}s of ${maxWaitSeconds}s this invocation; ` +
1305
- `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative). PR #${prNumber} still in flight ` +
1306
- `(checks=${probe.checksStatus ?? 'unknown'}). Story stays at agent::closing — resumable.`,
1307
- );
1308
- return doneWith({
1309
- confirmed: false,
1310
- terminal: 'pending',
1311
- reason: `merge wait bound reached with the PR still in flight (checks=${probe.checksStatus ?? 'unknown'})`,
1312
- prProbe: probe,
1313
- waitBudget,
1314
- elapsedSeconds: waitBudget.waitedSeconds,
1315
- });
1316
- }
1317
-
1318
- return { done: false };
1319
- }
1320
-
1321
1409
  const tick = await pollUntil({
1322
1410
  fn: async () => {
1323
1411
  try {
1324
- return await runMergePoll();
1412
+ const step = await runMergePoll(ctx, state);
1413
+ state = step.state;
1414
+ return step;
1325
1415
  } catch (err) {
1326
1416
  // pollUntil treats a throw as a non-match and would spin; carry it out.
1327
1417
  return { done: true, thrown: err };
1328
1418
  }
1329
1419
  },
1330
1420
  predicate: (result) => result?.done === true,
1331
- intervalMs,
1421
+ intervalMs: state.intervalMs,
1332
1422
  // No pollUntil timeout: the tick owns both bounds and the cadence.
1333
- sleepFn: () => sleepFn(intervalMs),
1423
+ sleepFn: () => sleepFn(state.intervalMs),
1334
1424
  });
1335
1425
  if (tick.thrown) throw tick.thrown;
1336
1426
  return tick.outcome;
@@ -1346,7 +1436,3 @@ function pollHeartbeat({ polls, prNumber, probe, waitBudget }) {
1346
1436
  (probe.error ? ` — probe error: ${probe.error}` : '')
1347
1437
  );
1348
1438
  }
1349
-
1350
- function doneWith(outcome) {
1351
- return { done: true, outcome };
1352
- }