mandrel 2.66.0 → 2.68.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 +2 -2
- package/.agents/agents/story-worker.md +15 -11
- package/.agents/docs/agentrc-reference.json +5 -2
- package/.agents/docs/configuration.md +38 -2
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +2 -1
- package/.agents/rules/git-conventions-reference.md +5 -5
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/schemas/agentrc.schema.json +20 -2
- package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- 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/coverage-capture.js +65 -9
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/evidence-gate.js +106 -8
- package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
- package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
- package/.agents/scripts/lib/child-exec.js +39 -1
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/close-validation/gates.js +59 -19
- package/.agents/scripts/lib/close-validation/process.js +23 -24
- package/.agents/scripts/lib/close-validation/runner.js +71 -40
- package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
- package/.agents/scripts/lib/config/quality.js +7 -1
- package/.agents/scripts/lib/config/temp-paths.js +15 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
- package/.agents/scripts/lib/coverage-baseline.js +78 -5
- package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
- package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
- package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
- package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
- package/.agents/scripts/lib/coverage-capture.js +121 -81
- package/.agents/scripts/lib/full-suite-lock.js +49 -46
- package/.agents/scripts/lib/full-suite-queue.js +83 -8
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +4 -1
- package/.agents/scripts/lib/orchestration/code-review.js +15 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
- package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
- package/.agents/scripts/lib/process-group.js +1 -1
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/supervised-suite.js +247 -0
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/quality-preview.js +112 -14
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/scripts/stories-wave-tick.js +47 -0
- package/.agents/scripts/story-review-compute.js +207 -0
- package/.agents/scripts/update-coverage-baseline.js +15 -10
- package/.agents/scripts/update-crap-baseline.js +12 -2
- package/.agents/scripts/update-maintainability-baseline.js +12 -2
- 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 +3 -2
- package/.agents/workflows/helpers/code-review.md +7 -5
- package/.agents/workflows/helpers/deliver-digest.md +39 -36
- package/.agents/workflows/helpers/deliver-reference.md +115 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +2 -1
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/registry.js +125 -18
- package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
- package/package.json +1 -1
|
@@ -12,12 +12,14 @@ import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-va
|
|
|
12
12
|
import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
|
|
13
13
|
import { LOCK_WAIT_EXPIRED_EXIT_CODE } from '../../../full-suite-lock.js';
|
|
14
14
|
import { parseLockWaitOutcome } from '../../../full-suite-queue.js';
|
|
15
|
+
import { parseSuiteTimings } from '../../../supervised-suite.js';
|
|
15
16
|
import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
|
|
16
17
|
import { runPreGateSteps as defaultRunPreGateSteps } from './pre-gate-steps.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Pre-gate self-heal steps, then the gates (throws on first failure). The
|
|
20
21
|
* steps commit in the worktree before scoring, so the gates see their output.
|
|
22
|
+
* `onPreGateStepsDone` (must not throw) runs once the tree is final.
|
|
21
23
|
*
|
|
22
24
|
* @param {{
|
|
23
25
|
* cwd: string,
|
|
@@ -34,10 +36,12 @@ import { runPreGateSteps as defaultRunPreGateSteps } from './pre-gate-steps.js';
|
|
|
34
36
|
* runBaselineUpwardWriteback?: Function,
|
|
35
37
|
* runContextBudgetWriteback?: Function,
|
|
36
38
|
* createGateLogSink?: typeof defaultCreateGateLogSink,
|
|
39
|
+
* onPreGateStepsDone?: () => void,
|
|
37
40
|
* }} args
|
|
38
41
|
* @returns {Promise<{
|
|
39
42
|
* gates: Record<string, 'passed'|'skipped'>|null,
|
|
40
|
-
* lockWait: { waitedSeconds: number, expired: boolean }|null,
|
|
43
|
+
* lockWait: { waitedSeconds: number, expired: boolean, holder?: object }|null,
|
|
44
|
+
* suiteTimings: { lockWaitMs: number, hostWaitMs: number|null, testRunMs: number }|null,
|
|
41
45
|
* pending: boolean,
|
|
42
46
|
* }>} `pending` (with `gates: null`) when a lock wait expired; any other
|
|
43
47
|
* failure throws with `err.closeGate` naming the gate.
|
|
@@ -57,6 +61,7 @@ export async function runCloseValidationPhase({
|
|
|
57
61
|
runBaselineUpwardWriteback,
|
|
58
62
|
runContextBudgetWriteback,
|
|
59
63
|
createGateLogSink = defaultCreateGateLogSink,
|
|
64
|
+
onPreGateStepsDone,
|
|
60
65
|
}) {
|
|
61
66
|
await runPreGateSteps({
|
|
62
67
|
cwd,
|
|
@@ -70,6 +75,7 @@ export async function runCloseValidationPhase({
|
|
|
70
75
|
runBaselineUpwardWriteback,
|
|
71
76
|
runContextBudgetWriteback,
|
|
72
77
|
});
|
|
78
|
+
onPreGateStepsDone?.();
|
|
73
79
|
|
|
74
80
|
progress(
|
|
75
81
|
'VALIDATE',
|
|
@@ -101,7 +107,6 @@ export async function runCloseValidationPhase({
|
|
|
101
107
|
baseBranch,
|
|
102
108
|
storyBranch,
|
|
103
109
|
config,
|
|
104
|
-
deferOnLockExpiry: true,
|
|
105
110
|
});
|
|
106
111
|
} finally {
|
|
107
112
|
// The sink is async-buffered; settle it before anything reads it, on
|
|
@@ -116,6 +121,7 @@ export async function runCloseValidationPhase({
|
|
|
116
121
|
return {
|
|
117
122
|
gates: gateOutcomes(gateList, validation),
|
|
118
123
|
lockWait,
|
|
124
|
+
suiteTimings: lockWaits.suiteTimings(),
|
|
119
125
|
pending: false,
|
|
120
126
|
};
|
|
121
127
|
}
|
|
@@ -129,7 +135,7 @@ export async function runCloseValidationPhase({
|
|
|
129
135
|
* gateLog: { replay: () => void },
|
|
130
136
|
* progress: (tag: string, msg: string) => void,
|
|
131
137
|
* }} args
|
|
132
|
-
* @returns {{ gates: null, lockWait: object, pending: true }}
|
|
138
|
+
* @returns {{ gates: null, lockWait: object, suiteTimings: null, pending: true }}
|
|
133
139
|
*/
|
|
134
140
|
function settleFailedValidation({ validation, lockWait, gateLog, progress }) {
|
|
135
141
|
const [first] = validation.failed;
|
|
@@ -139,7 +145,7 @@ function settleFailedValidation({ validation, lockWait, gateLog, progress }) {
|
|
|
139
145
|
'VALIDATE',
|
|
140
146
|
`⏸ ${gate.name} deferred: the full-suite lock wait expired after ${lockWait.waitedSeconds}s. Nothing was spawned; close will report pending.`,
|
|
141
147
|
);
|
|
142
|
-
return { gates: null, lockWait, pending: true };
|
|
148
|
+
return { gates: null, lockWait, suiteTimings: null, pending: true };
|
|
143
149
|
}
|
|
144
150
|
gateLog.replay();
|
|
145
151
|
const err = new Error(
|
|
@@ -151,27 +157,40 @@ function settleFailedValidation({ validation, lockWait, gateLog, progress }) {
|
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
/**
|
|
154
|
-
* Parsed from the gate log
|
|
160
|
+
* Parsed from the gate log: the one place that sees waits and timings both
|
|
155
161
|
* in-process and in gate children.
|
|
156
162
|
*
|
|
157
163
|
* @param {{ sink: (m: string) => void, progress: (tag: string, msg: string) => void }} args
|
|
158
|
-
* @returns {{ log: (m: string) => void, summary: () => { waitedSeconds: number, expired: boolean }|null }}
|
|
164
|
+
* @returns {{ log: (m: string) => void, summary: () => { waitedSeconds: number, expired: boolean, holder?: object }|null, suiteTimings: () => object|null }}
|
|
159
165
|
*/
|
|
160
166
|
function trackLockWaits({ sink, progress }) {
|
|
161
167
|
let tally = null;
|
|
168
|
+
let timings = null;
|
|
162
169
|
return {
|
|
163
170
|
log(line) {
|
|
164
171
|
sink(line);
|
|
172
|
+
timings = parseSuiteTimings(line) ?? timings;
|
|
165
173
|
if (!String(line).includes('[full-suite-lock]')) return;
|
|
166
174
|
progress('LOCK', line);
|
|
167
175
|
const outcome = parseLockWaitOutcome(line);
|
|
168
176
|
if (!outcome) return;
|
|
169
|
-
tally =
|
|
170
|
-
waitedSeconds: (tally?.waitedSeconds ?? 0) + outcome.waitedSeconds,
|
|
171
|
-
expired: Boolean(tally?.expired) || outcome.expired,
|
|
172
|
-
};
|
|
177
|
+
tally = mergeLockWait(tally, outcome);
|
|
173
178
|
},
|
|
174
179
|
summary: () => tally,
|
|
180
|
+
suiteTimings: () => timings,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {{ waitedSeconds: number, expired: boolean, holder?: object }|null} tally
|
|
186
|
+
* @param {{ waitedSeconds: number, expired: boolean, holder?: object }} outcome
|
|
187
|
+
*/
|
|
188
|
+
function mergeLockWait(tally, outcome) {
|
|
189
|
+
const holder = outcome.holder ?? tally?.holder;
|
|
190
|
+
return {
|
|
191
|
+
waitedSeconds: (tally?.waitedSeconds ?? 0) + outcome.waitedSeconds,
|
|
192
|
+
expired: Boolean(tally?.expired) || outcome.expired,
|
|
193
|
+
...(holder ? { holder } : {}),
|
|
175
194
|
};
|
|
176
195
|
}
|
|
177
196
|
|
|
@@ -49,29 +49,6 @@ export function buildStoryReviewCrossRefBody({
|
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
async function invokeStoryReviewCore({
|
|
53
|
-
storyId,
|
|
54
|
-
storyBranch,
|
|
55
|
-
baseRef,
|
|
56
|
-
prNumber,
|
|
57
|
-
provider,
|
|
58
|
-
runCodeReviewFn,
|
|
59
|
-
gitSpawnFn,
|
|
60
|
-
progress,
|
|
61
|
-
}) {
|
|
62
|
-
return runStoryReviewCore({
|
|
63
|
-
storyId,
|
|
64
|
-
baseRef,
|
|
65
|
-
headRef: storyBranch,
|
|
66
|
-
commentTargetId: prNumber,
|
|
67
|
-
provider,
|
|
68
|
-
progress,
|
|
69
|
-
progressTag: 'REVIEW',
|
|
70
|
-
runCodeReviewFn,
|
|
71
|
-
gitSpawnFn,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
52
|
async function postStoryReviewCrossRef({
|
|
76
53
|
provider,
|
|
77
54
|
storyId,
|
|
@@ -115,7 +92,122 @@ async function postStoryReviewCrossRef({
|
|
|
115
92
|
}
|
|
116
93
|
|
|
117
94
|
/**
|
|
118
|
-
*
|
|
95
|
+
* Run the review against `headRef`; `deferPost` holds the report unposted.
|
|
96
|
+
*
|
|
97
|
+
* @returns {Promise<{ outcome: object }|{ result: object }>} a final
|
|
98
|
+
* `outcome` (unresolvable base), or the `result` to settle.
|
|
99
|
+
*/
|
|
100
|
+
export async function computeStoryScopeReview({
|
|
101
|
+
cwd,
|
|
102
|
+
storyId,
|
|
103
|
+
headRef,
|
|
104
|
+
baseBranch,
|
|
105
|
+
commentTargetId = null,
|
|
106
|
+
deferPost = false,
|
|
107
|
+
provider,
|
|
108
|
+
runCodeReviewFn,
|
|
109
|
+
gitSpawnFn,
|
|
110
|
+
progress,
|
|
111
|
+
}) {
|
|
112
|
+
const base = resolveSharedBaseRef({ baseBranch, cwd, gitSpawnFn });
|
|
113
|
+
if (!base.resolved) {
|
|
114
|
+
return {
|
|
115
|
+
outcome: unresolvedBaseReviewOutcome({
|
|
116
|
+
storyId,
|
|
117
|
+
baseBranch,
|
|
118
|
+
remoteRef: base.remoteRef,
|
|
119
|
+
progress,
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const target = deferPost
|
|
124
|
+
? 'held until the PR exists'
|
|
125
|
+
: `→ PR #${commentTargetId}`;
|
|
126
|
+
progress(
|
|
127
|
+
'REVIEW',
|
|
128
|
+
`Running Story-scope code review for Story #${storyId} (${base.ref}...${headRef}) ${target}...`,
|
|
129
|
+
);
|
|
130
|
+
const result = await runStoryReviewCore({
|
|
131
|
+
storyId,
|
|
132
|
+
baseRef: base.ref,
|
|
133
|
+
headRef,
|
|
134
|
+
commentTargetId,
|
|
135
|
+
provider,
|
|
136
|
+
progress,
|
|
137
|
+
progressTag: 'REVIEW',
|
|
138
|
+
runCodeReviewFn: deferPost
|
|
139
|
+
? (opts) => runCodeReviewFn({ ...opts, deferPost: true })
|
|
140
|
+
: runCodeReviewFn,
|
|
141
|
+
gitSpawnFn,
|
|
142
|
+
});
|
|
143
|
+
return { result };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Settle a computed review on the PR: post a held report via `postReportFn`,
|
|
148
|
+
* report the tally, cross-reference the Story.
|
|
149
|
+
*
|
|
150
|
+
* @returns {Promise<object>} the review outcome.
|
|
151
|
+
*/
|
|
152
|
+
export async function settleStoryScopeReview({
|
|
153
|
+
computed,
|
|
154
|
+
storyId,
|
|
155
|
+
prUrl,
|
|
156
|
+
prNumber,
|
|
157
|
+
provider,
|
|
158
|
+
progress,
|
|
159
|
+
postReportFn = null,
|
|
160
|
+
}) {
|
|
161
|
+
if (computed.outcome) return computed.outcome;
|
|
162
|
+
const posting = postReportFn
|
|
163
|
+
? await postReportFn({
|
|
164
|
+
provider,
|
|
165
|
+
commentTargetId: prNumber,
|
|
166
|
+
report: computed.result.report,
|
|
167
|
+
logger: {
|
|
168
|
+
info: (m) => progress('REVIEW', m),
|
|
169
|
+
warn: (m) => progress('REVIEW', `⚠️ ${m}`),
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
: {};
|
|
173
|
+
const result = { ...computed.result, ...posting };
|
|
174
|
+
const sev = result.severity ?? {
|
|
175
|
+
critical: 0,
|
|
176
|
+
high: 0,
|
|
177
|
+
medium: 0,
|
|
178
|
+
suggestion: 0,
|
|
179
|
+
};
|
|
180
|
+
const outcome = formatReviewOutcomeLines({
|
|
181
|
+
severity: sev,
|
|
182
|
+
degradations: result.degradations,
|
|
183
|
+
prNumber,
|
|
184
|
+
posted: result.posted,
|
|
185
|
+
});
|
|
186
|
+
for (const line of outcome) progress('REVIEW', line);
|
|
187
|
+
|
|
188
|
+
const crossRefPosted = await postStoryReviewCrossRef({
|
|
189
|
+
provider,
|
|
190
|
+
storyId,
|
|
191
|
+
prUrl,
|
|
192
|
+
prNumber,
|
|
193
|
+
result,
|
|
194
|
+
severity: sev,
|
|
195
|
+
progress,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
halted: !!result.halted,
|
|
200
|
+
severity: sev,
|
|
201
|
+
criticalByProvider: result.criticalByProvider,
|
|
202
|
+
posted: result.posted,
|
|
203
|
+
postedCommentId: result.postedCommentId ?? null,
|
|
204
|
+
...degradationEnvelope(result.degradations),
|
|
205
|
+
crossRefPosted,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The serial review, posting to the PR. Skips on an unparseable PR number or an unresolvable base (recording a
|
|
119
211
|
* degradation); a runner throw propagates and fails the close.
|
|
120
212
|
*
|
|
121
213
|
* @param {{
|
|
@@ -161,64 +253,23 @@ export async function runStoryScopeReview({
|
|
|
161
253
|
);
|
|
162
254
|
return { halted: false, skipped: true };
|
|
163
255
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (!base.resolved) {
|
|
167
|
-
return unresolvedBaseReviewOutcome({
|
|
168
|
-
storyId,
|
|
169
|
-
baseBranch,
|
|
170
|
-
remoteRef: base.remoteRef,
|
|
171
|
-
progress,
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
progress(
|
|
176
|
-
'REVIEW',
|
|
177
|
-
`Running Story-scope code review for Story #${storyId} (${base.ref}...${storyBranch}) → PR #${prNumber}...`,
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
const result = await invokeStoryReviewCore({
|
|
256
|
+
const computed = await computeStoryScopeReview({
|
|
257
|
+
cwd,
|
|
181
258
|
storyId,
|
|
182
|
-
storyBranch,
|
|
183
|
-
|
|
184
|
-
prNumber,
|
|
259
|
+
headRef: storyBranch,
|
|
260
|
+
baseBranch,
|
|
261
|
+
commentTargetId: prNumber,
|
|
185
262
|
provider,
|
|
186
263
|
runCodeReviewFn,
|
|
187
264
|
gitSpawnFn,
|
|
188
265
|
progress,
|
|
189
266
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
critical: 0,
|
|
193
|
-
high: 0,
|
|
194
|
-
medium: 0,
|
|
195
|
-
suggestion: 0,
|
|
196
|
-
};
|
|
197
|
-
const outcome = formatReviewOutcomeLines({
|
|
198
|
-
severity: sev,
|
|
199
|
-
degradations: result.degradations,
|
|
200
|
-
prNumber,
|
|
201
|
-
posted: result.posted,
|
|
202
|
-
});
|
|
203
|
-
for (const line of outcome) progress('REVIEW', line);
|
|
204
|
-
|
|
205
|
-
const crossRefPosted = await postStoryReviewCrossRef({
|
|
206
|
-
provider,
|
|
267
|
+
return settleStoryScopeReview({
|
|
268
|
+
computed,
|
|
207
269
|
storyId,
|
|
208
270
|
prUrl,
|
|
209
271
|
prNumber,
|
|
210
|
-
|
|
211
|
-
severity: sev,
|
|
272
|
+
provider,
|
|
212
273
|
progress,
|
|
213
274
|
});
|
|
214
|
-
|
|
215
|
-
return {
|
|
216
|
-
halted: !!result.halted,
|
|
217
|
-
severity: sev,
|
|
218
|
-
criticalByProvider: result.criticalByProvider,
|
|
219
|
-
posted: result.posted,
|
|
220
|
-
postedCommentId: result.postedCommentId ?? null,
|
|
221
|
-
...degradationEnvelope(result.degradations),
|
|
222
|
-
crossRefPosted,
|
|
223
|
-
};
|
|
224
275
|
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* resumable `pending` with no mutation; `maxBudgetSeconds` bounds the
|
|
8
8
|
* cumulative wait, anchored at the PR's `createdAt` so resumes don't restart
|
|
9
9
|
* it, and exhausting it is the real give-up (classify, emit, block). The
|
|
10
|
-
* poll is stateless and re-entrant. Async mode
|
|
11
|
-
*
|
|
10
|
+
* poll is stateless and re-entrant. Async mode probes once (see
|
|
11
|
+
* ASYNC_PROBE_WINDOW_SECONDS). Checks are probed every iteration so a red required
|
|
12
12
|
* check fails fast instead of burning the budget.
|
|
13
13
|
*
|
|
14
14
|
* Terminals: `landed` (confirmed, post-land tail ran); `pending` (nothing
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
MERGE_WAIT_GH_TIMEOUT_MS,
|
|
49
49
|
parseWorkflowRunId,
|
|
50
50
|
pollIntervalMs,
|
|
51
|
+
probeSettlesAsyncWait,
|
|
51
52
|
readRunSummary,
|
|
52
53
|
resolveAdvisoryGateVerdict,
|
|
53
54
|
} from '../../merge-poll.js';
|
|
@@ -65,7 +66,7 @@ import { runPostLandTail as defaultRunPostLandTail } from './post-land.js';
|
|
|
65
66
|
/** Per-invocation bound; fits the host's ~10-min tool ceiling after the gates. */
|
|
66
67
|
export const DEFAULT_MAX_WAIT_SECONDS = 300;
|
|
67
68
|
|
|
68
|
-
/** Async
|
|
69
|
+
/** Async cap; only a green or red first probe polls on inside it. */
|
|
69
70
|
export const ASYNC_PROBE_WINDOW_SECONDS = 60;
|
|
70
71
|
|
|
71
72
|
export const DEFAULT_UPDATE_ATTEMPTS = 3;
|
|
@@ -223,7 +224,7 @@ export async function readPrWaitProbe({
|
|
|
223
224
|
* @param {object} [config]
|
|
224
225
|
* @param {number} [maxWaitSecondsOverride]
|
|
225
226
|
* @param {'sync'|'async'} [modeOverride]
|
|
226
|
-
* @returns {{ mode: 'sync'|'async', intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
|
|
227
|
+
* @returns {{ mode: 'sync'|'async', singleProbe: boolean, intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
|
|
227
228
|
*/
|
|
228
229
|
export function resolveMergeWaitConfig(
|
|
229
230
|
config,
|
|
@@ -239,15 +240,16 @@ export function resolveMergeWaitConfig(
|
|
|
239
240
|
maxWaitSecondsOverride,
|
|
240
241
|
int(mergeWatch.maxWaitSeconds, DEFAULT_MAX_WAIT_SECONDS),
|
|
241
242
|
);
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
const singleProbe = mode === 'async' && maxWaitSecondsOverride == null;
|
|
244
|
+
const maxWaitSeconds = singleProbe
|
|
245
|
+
? Math.min(configuredMaxWait, ASYNC_PROBE_WINDOW_SECONDS)
|
|
246
|
+
: configuredMaxWait;
|
|
246
247
|
// An interval longer than the bound would expire every invocation on poll
|
|
247
248
|
// 1, so MIN_POLLS_BEFORE_BUDGET_BLOCK and the budget could never be reached.
|
|
248
249
|
const intervalSeconds = Math.min(DEFAULT_INTERVAL_SECONDS, maxWaitSeconds);
|
|
249
250
|
return {
|
|
250
251
|
mode,
|
|
252
|
+
singleProbe,
|
|
251
253
|
intervalSeconds,
|
|
252
254
|
maxWaitSeconds,
|
|
253
255
|
maxBudgetSeconds: int(
|
|
@@ -1015,25 +1017,30 @@ export function createMergeWaitState({ startedAtMs, intervalSeconds }) {
|
|
|
1015
1017
|
};
|
|
1016
1018
|
}
|
|
1017
1019
|
|
|
1018
|
-
/** Budget first — it is the real give-up. */
|
|
1020
|
+
/** Budget first — it is the real give-up; the single probe yields to it. */
|
|
1019
1021
|
function provisionalVerdict(
|
|
1020
1022
|
{ polls, intervalMs, waitedMs, cumulativeMs },
|
|
1021
1023
|
limits,
|
|
1024
|
+
checksStatus,
|
|
1022
1025
|
) {
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
cumulativeMs + intervalMs > limits.maxBudgetSeconds * 1000
|
|
1026
|
-
) {
|
|
1026
|
+
const overBudget = cumulativeMs + intervalMs > limits.maxBudgetSeconds * 1000;
|
|
1027
|
+
if (polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK && overBudget) {
|
|
1027
1028
|
return 'budget-exhausted';
|
|
1028
1029
|
}
|
|
1029
1030
|
if (waitedMs + intervalMs > limits.maxWaitSeconds * 1000) return 'wait-bound';
|
|
1030
|
-
return
|
|
1031
|
+
return singleProbeOrContinue(limits, overBudget, checksStatus);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function singleProbeOrContinue(limits, overBudget, checksStatus) {
|
|
1035
|
+
const settles =
|
|
1036
|
+
limits.singleProbe && !overBudget && probeSettlesAsyncWait(checksStatus);
|
|
1037
|
+
return settles ? 'single-probe' : 'continue';
|
|
1031
1038
|
}
|
|
1032
1039
|
|
|
1033
1040
|
/**
|
|
1034
1041
|
* Pure: state + probe + clock → next state and verdict. `merged`, `closed`
|
|
1035
|
-
* and `checks-failed` are definitive; `budget-exhausted`, `wait-bound
|
|
1036
|
-
* `continue` hold only if the loop's advisory check does not block first.
|
|
1042
|
+
* and `checks-failed` are definitive; `budget-exhausted`, `wait-bound`,
|
|
1043
|
+
* `single-probe` and `continue` hold only if the loop's advisory check does not block first.
|
|
1037
1044
|
* The two clock reads (wait bound, then budget) keep a stepping test clock's
|
|
1038
1045
|
* call sequence.
|
|
1039
1046
|
*
|
|
@@ -1090,7 +1097,11 @@ export function decideMergeWaitPoll({
|
|
|
1090
1097
|
}
|
|
1091
1098
|
return {
|
|
1092
1099
|
state: next,
|
|
1093
|
-
verdict: provisionalVerdict(
|
|
1100
|
+
verdict: provisionalVerdict(
|
|
1101
|
+
{ ...next, waitedMs, cumulativeMs },
|
|
1102
|
+
limits,
|
|
1103
|
+
probe.checksStatus,
|
|
1104
|
+
),
|
|
1094
1105
|
...timing,
|
|
1095
1106
|
};
|
|
1096
1107
|
}
|
|
@@ -1158,13 +1169,20 @@ const DEFINITIVE_SETTLERS = Object.freeze({
|
|
|
1158
1169
|
'checks-failed': settleChecksFailed,
|
|
1159
1170
|
});
|
|
1160
1171
|
|
|
1161
|
-
/**
|
|
1172
|
+
/** The verdicts that settle a resumable `pending`, keyed to their headline. */
|
|
1173
|
+
const PENDING_HEADLINES = Object.freeze({
|
|
1174
|
+
'wait-bound': 'Merge wait bound reached',
|
|
1175
|
+
'single-probe': 'Async single probe settled',
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
/** Invocation bound or single probe: resumable `pending`, no mutation. */
|
|
1162
1179
|
function pendingAtWaitBound(ctx, decision, probe) {
|
|
1163
1180
|
const { waitBudget } = decision;
|
|
1164
1181
|
const checks = probe.checksStatus ?? 'unknown';
|
|
1182
|
+
const why = PENDING_HEADLINES[decision.verdict];
|
|
1165
1183
|
ctx.progress?.(
|
|
1166
1184
|
'CONFIRM',
|
|
1167
|
-
`⏸
|
|
1185
|
+
`⏸ ${why} (${waitBudget.waitedSeconds}s of ${waitBudget.maxWaitSeconds}s this invocation; ` +
|
|
1168
1186
|
`${waitBudget.cumulativeSeconds}s of ${waitBudget.maxBudgetSeconds}s cumulative). PR #${ctx.prNumber} still in flight ` +
|
|
1169
1187
|
`(checks=${checks}). Story stays at agent::closing — resumable.`,
|
|
1170
1188
|
);
|
|
@@ -1223,7 +1241,7 @@ async function settleProvisional(ctx, decision, probe) {
|
|
|
1223
1241
|
});
|
|
1224
1242
|
return { state, outcome };
|
|
1225
1243
|
}
|
|
1226
|
-
if (decision.verdict
|
|
1244
|
+
if (Object.hasOwn(PENDING_HEADLINES, decision.verdict)) {
|
|
1227
1245
|
return { state, outcome: pendingAtWaitBound(ctx, decision, probe) };
|
|
1228
1246
|
}
|
|
1229
1247
|
return { state, outcome: null };
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
* storyId: number,
|
|
14
14
|
* storyBranch: string,
|
|
15
15
|
* baseBranch: string,
|
|
16
|
-
* lockWait: { waitedSeconds: number, expired: boolean }|null,
|
|
16
|
+
* lockWait: { waitedSeconds: number, expired: boolean, holder?: object }|null,
|
|
17
17
|
* elapsedSeconds: number,
|
|
18
18
|
* }} args
|
|
19
19
|
* @returns {{ result: object, terminal: object, note: string }} The close
|
|
@@ -44,6 +44,12 @@ export function lockWaitPending({
|
|
|
44
44
|
nextCommand,
|
|
45
45
|
elapsedSeconds,
|
|
46
46
|
}),
|
|
47
|
-
note: `⏸ Story #${storyId}: another full suite held the host lock past the wait budget — resume with: ${nextCommand}`,
|
|
47
|
+
note: `⏸ Story #${storyId}: another full suite${describeHolder(lockWait?.holder)} held the host lock past the wait budget — resume with: ${nextCommand}`,
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
/** @param {{ ownerId: string|null, pid: number|null, ageSeconds: number|null }} [holder] */
|
|
52
|
+
function describeHolder(holder) {
|
|
53
|
+
if (!holder) return '';
|
|
54
|
+
return ` (${holder.ownerId ?? 'unknown owner'}, pid ${holder.pid ?? 'unknown'}, lock age ${holder.ageSeconds ?? 'unknown'}s)`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Story-scope review, overlapped with the close-validation gates: started
|
|
3
|
+
* once the pre-gate self-heal commits land and held (computed, never posted)
|
|
4
|
+
* until the PR exists. A worker deposit whose diff digest matches the diff at
|
|
5
|
+
* that point **is** the held result, so nothing is computed. After PR-open a
|
|
6
|
+
* pushed diff with a different digest discards it for a serial review — a
|
|
7
|
+
* clean base-sync merge moves HEAD without changing the digest. The held
|
|
8
|
+
* promise never rejects, so an abandoned review leaves no unhandled rejection.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { postReviewComment } from '../code-review.js';
|
|
12
|
+
import {
|
|
13
|
+
computeReviewDiffDigest,
|
|
14
|
+
depositAsComputedReview,
|
|
15
|
+
probeHeldReviewDiff,
|
|
16
|
+
resolveRefSha,
|
|
17
|
+
} from '../review-deposit.js';
|
|
18
|
+
import { upsertStructuredComment } from '../ticketing.js';
|
|
19
|
+
import {
|
|
20
|
+
computeStoryScopeReview,
|
|
21
|
+
runStoryScopeReview,
|
|
22
|
+
settleStoryScopeReview,
|
|
23
|
+
} from './phases/code-review.js';
|
|
24
|
+
|
|
25
|
+
/** @typedef {{ sha: string, baseRef: string|null, diffDigest: string|null, adopted: boolean, startedAtMs: number, settled: Promise<object> }} HeldReview */
|
|
26
|
+
|
|
27
|
+
/** @returns {string|null} the commit `storyBranch` points at. */
|
|
28
|
+
function resolveBranchSha({ cwd, storyBranch, gitSpawnFn }) {
|
|
29
|
+
return resolveRefSha({ cwd, ref: storyBranch, gitSpawnFn });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A held result already in hand: the worker deposit. */
|
|
33
|
+
function adoptedSettlement(deposit, atMs) {
|
|
34
|
+
const value = depositAsComputedReview(deposit);
|
|
35
|
+
return Promise.resolve({ ok: true, value, endedAtMs: atMs });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Compute the review of `sha`, held unposted; never rejects. */
|
|
39
|
+
function computeHeldReview({ sha, nowMs, ...args }) {
|
|
40
|
+
return computeStoryScopeReview({
|
|
41
|
+
...args,
|
|
42
|
+
headRef: sha,
|
|
43
|
+
deferPost: true,
|
|
44
|
+
}).then(
|
|
45
|
+
(value) => ({ ok: true, value, endedAtMs: nowMs() }),
|
|
46
|
+
(error) => ({ ok: false, error, endedAtMs: nowMs() }),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{ cwd: string, storyId: number, storyBranch: string,
|
|
52
|
+
* baseBranch: string, provider: object, runCodeReviewFn: Function,
|
|
53
|
+
* gitSpawnFn: Function, progress: Function, config?: object,
|
|
54
|
+
* readDepositFn?: Function, nowMs?: () => number }} args
|
|
55
|
+
* @returns {HeldReview|null} null when the SHA is unresolvable.
|
|
56
|
+
*/
|
|
57
|
+
export function startHeldReview(args) {
|
|
58
|
+
const { cwd, storyBranch, gitSpawnFn, progress, nowMs = Date.now } = args;
|
|
59
|
+
const sha = resolveBranchSha({ cwd, storyBranch, gitSpawnFn });
|
|
60
|
+
if (!sha) {
|
|
61
|
+
progress(
|
|
62
|
+
'REVIEW',
|
|
63
|
+
`⏭ Could not resolve ${storyBranch}; the Story-scope review runs after PR-open.`,
|
|
64
|
+
);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const { baseRef, diffDigest, deposit } = probeHeldReviewDiff({
|
|
68
|
+
...args,
|
|
69
|
+
sha,
|
|
70
|
+
});
|
|
71
|
+
const startedAtMs = nowMs();
|
|
72
|
+
const held = { sha, baseRef, diffDigest, adopted: !!deposit, startedAtMs };
|
|
73
|
+
if (deposit) {
|
|
74
|
+
progress('REVIEW', `♻️ Adopting the worker's held review; none computed.`);
|
|
75
|
+
return { ...held, settled: adoptedSettlement(deposit, startedAtMs) };
|
|
76
|
+
}
|
|
77
|
+
const settled = computeHeldReview({ ...args, sha, nowMs });
|
|
78
|
+
return { ...held, settled };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Drop a held review unposted. */
|
|
82
|
+
export function discardHeldReview(held, reason, progress) {
|
|
83
|
+
if (!held) return;
|
|
84
|
+
progress('REVIEW', `🗑 Held Story-scope review discarded (${reason}).`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Post the held review; records compute + post time, never the idle gap. */
|
|
88
|
+
async function postHeldReview(held, args) {
|
|
89
|
+
const { recordDuration, nowMs = Date.now, postReportFn } = args;
|
|
90
|
+
const settledReview = await held.settled;
|
|
91
|
+
const computeMs = settledReview.endedAtMs - held.startedAtMs;
|
|
92
|
+
if (!settledReview.ok) {
|
|
93
|
+
recordDuration('code-review', computeMs);
|
|
94
|
+
throw settledReview.error;
|
|
95
|
+
}
|
|
96
|
+
const postStartedAtMs = nowMs();
|
|
97
|
+
try {
|
|
98
|
+
return await settleStoryScopeReview({
|
|
99
|
+
computed: settledReview.value,
|
|
100
|
+
storyId: args.storyId,
|
|
101
|
+
prUrl: args.prUrl,
|
|
102
|
+
prNumber: args.prNumber,
|
|
103
|
+
provider: args.provider,
|
|
104
|
+
progress: args.progress,
|
|
105
|
+
postReportFn:
|
|
106
|
+
postReportFn ??
|
|
107
|
+
((post) =>
|
|
108
|
+
postReviewComment({
|
|
109
|
+
...post,
|
|
110
|
+
upsertCommentFn: upsertStructuredComment,
|
|
111
|
+
})),
|
|
112
|
+
});
|
|
113
|
+
} finally {
|
|
114
|
+
recordDuration('code-review', computeMs + (nowMs() - postStartedAtMs));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Post a held review whose diff digest matches the pushed diff (phase
|
|
120
|
+
* untimed: it records its own wall time), else run the serial review.
|
|
121
|
+
*
|
|
122
|
+
* @returns {Promise<object>} the review outcome.
|
|
123
|
+
*/
|
|
124
|
+
export async function reviewAfterPrOpen(args) {
|
|
125
|
+
const { held, setPhase, progress } = args;
|
|
126
|
+
if (held && args.prNumber != null) {
|
|
127
|
+
const pushedDigest = computeReviewDiffDigest({
|
|
128
|
+
cwd: args.cwd,
|
|
129
|
+
baseRef: held.baseRef,
|
|
130
|
+
headRef: resolveBranchSha(args),
|
|
131
|
+
gitSpawnFn: args.gitSpawnFn,
|
|
132
|
+
});
|
|
133
|
+
if (pushedDigest !== null && pushedDigest === held.diffDigest) {
|
|
134
|
+
setPhase('code-review');
|
|
135
|
+
args.pauseTimer();
|
|
136
|
+
progress(
|
|
137
|
+
'REVIEW',
|
|
138
|
+
`Posting the held Story-scope review → PR #${args.prNumber}...`,
|
|
139
|
+
);
|
|
140
|
+
return await postHeldReview(held, args);
|
|
141
|
+
}
|
|
142
|
+
discardHeldReview(
|
|
143
|
+
held,
|
|
144
|
+
'the pushed diff is not the reviewed diff; re-running serially',
|
|
145
|
+
progress,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
setPhase('code-review');
|
|
149
|
+
return await runStoryScopeReview({
|
|
150
|
+
cwd: args.cwd,
|
|
151
|
+
storyId: args.storyId,
|
|
152
|
+
storyBranch: args.storyBranch,
|
|
153
|
+
baseBranch: args.baseBranch,
|
|
154
|
+
prUrl: args.prUrl,
|
|
155
|
+
prNumber: args.prNumber,
|
|
156
|
+
provider: args.provider,
|
|
157
|
+
runCodeReviewFn: args.runCodeReviewFn,
|
|
158
|
+
gitSpawnFn: args.gitSpawnFn,
|
|
159
|
+
progress,
|
|
160
|
+
});
|
|
161
|
+
}
|