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.
- package/.agents/agents/acceptance-critic.md +7 -7
- package/.agents/agents/auditor.md +17 -18
- package/.agents/agents/plan-critic.md +5 -5
- package/.agents/agents/story-worker.md +5 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/execution-reference.md +27 -5
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +12 -13
- package/.agents/rules/ci-remediation.md +3 -3
- package/.agents/rules/gherkin-standards.md +3 -2
- package/.agents/rules/git-conventions-reference.md +17 -8
- package/.agents/rules/git-conventions.md +10 -8
- package/.agents/rules/testing-standards.md +8 -7
- package/.agents/runtime-deps.json +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- package/.agents/scripts/bootstrap.js +94 -89
- package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
- package/.agents/scripts/clean-temp.js +54 -0
- package/.agents/scripts/clean-worktrees.js +593 -0
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/cli/standard-args.js +60 -76
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/config/gates/shared.js +3 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
- package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
- package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
- package/.agents/scripts/lib/observability/signal-validator.js +17 -5
- package/.agents/scripts/lib/observability/source-classifier.js +3 -1
- package/.agents/scripts/lib/orchestration/code-review.js +22 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
- package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
- package/.agents/scripts/lib/signals/detectors/common.js +63 -51
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/transpile.js +28 -3
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/single-story-close.js +10 -2
- package/.agents/scripts/single-story-confirm-merge.js +267 -238
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
- package/.agents/workflows/audit-architecture.md +5 -4
- package/.agents/workflows/audit-documentation.md +5 -5
- package/.agents/workflows/audit-performance.md +10 -10
- package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
- package/.agents/workflows/clean-temp.md +67 -0
- package/.agents/workflows/clean-worktrees.md +63 -0
- package/.agents/workflows/git-deliver.md +1 -1
- package/.agents/workflows/helpers/acceptance-self-eval.md +11 -10
- package/.agents/workflows/helpers/audit-lens-core.md +30 -57
- package/.agents/workflows/helpers/deliver-digest.md +2 -2
- package/.agents/workflows/helpers/deliver-reference.md +3 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +6 -1
- package/.agents/workflows/helpers/parallel-tooling.md +16 -18
- package/.agents/workflows/mandrel-deliver.md +1 -1
- package/.agents/workflows/mandrel-plan.md +6 -5
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/guarded-sync.js +87 -0
- package/lib/cli/sync-agents.js +9 -92
- package/lib/cli/sync-commands.js +9 -101
- 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
|
|
658
|
-
*
|
|
659
|
-
*
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
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
|
-
}
|