mandrel 2.24.0 → 2.25.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 +13 -19
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +5 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/scripts/acceptance-eval.js +35 -9
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-test-temp-hygiene.js +83 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/writer.js +7 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +97 -19
- package/.agents/scripts/lib/crap-utils.js +73 -13
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/crap-worker.js +6 -3
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/quality-preview.js +25 -0
- package/.agents/scripts/run-tests.js +10 -0
- package/.agents/scripts/single-story-close.js +6 -0
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +95 -10
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +81 -95
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/deliver-reference.md +18 -3
- package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +34 -0
- package/.agents/workflows/plan.md +60 -76
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-scoped-config.js — pin the config values a delivery run was seeded
|
|
3
|
+
* from, and fail closed when the file they came from changed underneath it.
|
|
4
|
+
*
|
|
5
|
+
* The problem this exists for: `single-story-init.js` resolves config and
|
|
6
|
+
* seeds `story-<id>` from `project.baseBranch`; `single-story-close` used to
|
|
7
|
+
* re-resolve the SAME key and run close-validation, format-autofix, the gate
|
|
8
|
+
* baseline and base-sync against its own second answer. Nothing pinned the
|
|
9
|
+
* first value and nothing checked the two agreed — and the window between
|
|
10
|
+
* them is however long implementation takes. A concurrent session editing
|
|
11
|
+
* `.agentrc.json` / `.agentrc.local.json` mid-run therefore base-synced a
|
|
12
|
+
* Story against a base it was never seeded from, which surfaced only as an
|
|
13
|
+
* ordinary content conflict with nothing pointing at config.
|
|
14
|
+
*
|
|
15
|
+
* The base a Story was seeded from is a property of **that run**, not of
|
|
16
|
+
* whatever the config file says later. So init pins it and close reads the
|
|
17
|
+
* pin back.
|
|
18
|
+
*
|
|
19
|
+
* ## Where the pin lives
|
|
20
|
+
*
|
|
21
|
+
* The `story-init` structured comment, not a temp log: the post-land tail
|
|
22
|
+
* purges the temp tree, while the ticket comment survives every close re-run
|
|
23
|
+
* and every recovery path. `single-story-init.js` already upserts that
|
|
24
|
+
* comment; `pinRunScopedConfig` supplies the block it records, and
|
|
25
|
+
* `resolveRunScopedConfig` reads it back through the existing
|
|
26
|
+
* `findStructuredComment` seam.
|
|
27
|
+
*
|
|
28
|
+
* ## Adding another run-scoped key
|
|
29
|
+
*
|
|
30
|
+
* Add one row to `RUN_SCOPED_CONFIG_KEYS`. Both halves — the pin init writes
|
|
31
|
+
* and the comparison close makes — enumerate that registry, so a new key
|
|
32
|
+
* needs no second mechanism, no reader change and no writer change. Only
|
|
33
|
+
* `baseBranch` is enforced today because only its corruption is destructive
|
|
34
|
+
* (a wrong base merged into the Story branch permanently contaminates the
|
|
35
|
+
* branch and its PR diff); ceremony profile, quality floors and the
|
|
36
|
+
* concurrency cap are deliberately NOT pinned here.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { parseFencedJsonComment } from './structured-comment-parser.js';
|
|
40
|
+
import { findStructuredComment } from './ticketing.js';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The run-scoped config registry. One row per key whose value belongs to the
|
|
44
|
+
* run that seeded the Story rather than to the current contents of the config
|
|
45
|
+
* file. `read` resolves the key from a resolved config (including its
|
|
46
|
+
* default); `label` is the dotted config path the operator has to go fix.
|
|
47
|
+
*
|
|
48
|
+
* @type {Record<string, { read: (config: object) => unknown, label: string }>}
|
|
49
|
+
*/
|
|
50
|
+
const RUN_SCOPED_CONFIG_KEYS = {
|
|
51
|
+
baseBranch: {
|
|
52
|
+
read: (config) => config?.project?.baseBranch ?? 'main',
|
|
53
|
+
label: 'project.baseBranch',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Snapshot the run-scoped config values from a resolved config. This is the
|
|
59
|
+
* write half of the pin: `single-story-init.js` records the returned object
|
|
60
|
+
* in the `story-init` receipt so close can compare against it later.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} config Resolved config (`resolveConfig` output).
|
|
63
|
+
* @param {typeof RUN_SCOPED_CONFIG_KEYS} [keys] Registry override — the
|
|
64
|
+
* default is the module registry; supplying one is how a caller (or a test)
|
|
65
|
+
* exercises the registry-driven property without a second mechanism.
|
|
66
|
+
* @returns {Record<string, unknown>} the pinned values, keyed by config key.
|
|
67
|
+
*/
|
|
68
|
+
export function pinRunScopedConfig(config, keys = RUN_SCOPED_CONFIG_KEYS) {
|
|
69
|
+
const pinned = {};
|
|
70
|
+
for (const [key, spec] of Object.entries(keys)) {
|
|
71
|
+
pinned[key] = spec.read(config);
|
|
72
|
+
}
|
|
73
|
+
return pinned;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read the pinned block out of the run's `story-init` receipt.
|
|
78
|
+
*
|
|
79
|
+
* Three distinct non-success states, all reported rather than collapsed —
|
|
80
|
+
* the whole point of this module is that a fallback is never silent:
|
|
81
|
+
* - `absent`: no `story-init` comment on the ticket. Real and expected —
|
|
82
|
+
* the upsert is best-effort (init logs and continues on failure), and a
|
|
83
|
+
* recovery path may close a Story whose init predates this receipt.
|
|
84
|
+
* - `unreadable`: the comment exists but carries no parseable JSON payload.
|
|
85
|
+
* - `provider-error`: the comment read itself failed.
|
|
86
|
+
*
|
|
87
|
+
* @param {{
|
|
88
|
+
* provider: object,
|
|
89
|
+
* storyId: number,
|
|
90
|
+
* findCommentFn?: typeof findStructuredComment,
|
|
91
|
+
* }} args
|
|
92
|
+
* @returns {Promise<{ status: string, values: Record<string, unknown>|null, detail: string|null }>}
|
|
93
|
+
*/
|
|
94
|
+
async function readRunScopedConfigReceipt({
|
|
95
|
+
provider,
|
|
96
|
+
storyId,
|
|
97
|
+
findCommentFn = findStructuredComment,
|
|
98
|
+
}) {
|
|
99
|
+
let comment;
|
|
100
|
+
try {
|
|
101
|
+
comment = await findCommentFn(provider, Number(storyId), 'story-init');
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return {
|
|
104
|
+
status: 'provider-error',
|
|
105
|
+
values: null,
|
|
106
|
+
detail: `story-init comment could not be read (${err?.message ?? err})`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (!comment) {
|
|
110
|
+
return {
|
|
111
|
+
status: 'absent',
|
|
112
|
+
values: null,
|
|
113
|
+
detail: 'no story-init comment on the ticket',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const payload = parseFencedJsonComment(comment);
|
|
117
|
+
if (!payload || typeof payload !== 'object') {
|
|
118
|
+
return {
|
|
119
|
+
status: 'unreadable',
|
|
120
|
+
values: null,
|
|
121
|
+
detail: 'story-init comment carries no parseable JSON payload',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Receipts written before the `runScopedConfig` block existed carry the
|
|
125
|
+
// pinned values as top-level payload fields (`baseBranch` has been recorded
|
|
126
|
+
// there since Story #831). Reading the payload itself as the fallback block
|
|
127
|
+
// is what lets a Story initialized by an older init still close against its
|
|
128
|
+
// own pinned base instead of degrading to the fallback warning.
|
|
129
|
+
const block =
|
|
130
|
+
payload.runScopedConfig && typeof payload.runScopedConfig === 'object'
|
|
131
|
+
? payload.runScopedConfig
|
|
132
|
+
: payload;
|
|
133
|
+
return { status: 'found', values: block, detail: null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the refusal message for a mid-run config change. Names BOTH values
|
|
138
|
+
* for every conflicting key — the pinned one and the one config resolves to
|
|
139
|
+
* now — because "which base was this branch actually seeded from" is the
|
|
140
|
+
* question the operator could not previously answer.
|
|
141
|
+
*
|
|
142
|
+
* @param {{ storyId: number, conflicts: Array<{ label: string, pinned: unknown, current: unknown }> }} args
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
function formatRunScopedConflict({ storyId, conflicts }) {
|
|
146
|
+
const rows = conflicts.map(
|
|
147
|
+
({ label, pinned, current }) =>
|
|
148
|
+
`${label}: pinned at init = \`${String(pinned)}\`, currently resolves to \`${String(current)}\``,
|
|
149
|
+
);
|
|
150
|
+
return (
|
|
151
|
+
`[single-story-close] Refusing to close Story #${storyId}: run-scoped config changed mid-run. ` +
|
|
152
|
+
`${rows.join('; ')}. ` +
|
|
153
|
+
'The Story branch was seeded from the pinned value, so closing against the current one would ' +
|
|
154
|
+
'base-sync it onto a base it was never seeded from. No base-sync, format-autofix or gate run was ' +
|
|
155
|
+
'performed. A concurrent session most likely edited `.agentrc.json` / `.agentrc.local.json` during ' +
|
|
156
|
+
'the implementation window: restore the pinned value and re-run close, or re-init the Story against ' +
|
|
157
|
+
'the new value deliberately.'
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve the run-scoped config for a close, preferring the run's pin over a
|
|
163
|
+
* fresh resolution of the config file.
|
|
164
|
+
*
|
|
165
|
+
* Throws on disagreement — fail closed, before any gate, format-autofix or
|
|
166
|
+
* base-sync has run, with both values named. Never silently prefers either
|
|
167
|
+
* side: preferring the pin would base-sync correctly but hide a config change
|
|
168
|
+
* the operator needs to know about, and preferring current config is the bug
|
|
169
|
+
* this module exists to close.
|
|
170
|
+
*
|
|
171
|
+
* @param {{
|
|
172
|
+
* provider: object,
|
|
173
|
+
* storyId: number,
|
|
174
|
+
* config: object,
|
|
175
|
+
* keys?: typeof RUN_SCOPED_CONFIG_KEYS,
|
|
176
|
+
* findCommentFn?: typeof findStructuredComment,
|
|
177
|
+
* progress?: (tag: string, msg: string) => void,
|
|
178
|
+
* }} args
|
|
179
|
+
* @returns {Promise<{
|
|
180
|
+
* values: Record<string, unknown>,
|
|
181
|
+
* confirmed: boolean,
|
|
182
|
+
* receiptStatus: string,
|
|
183
|
+
* warning: string|null,
|
|
184
|
+
* }>} `confirmed` is true only when every key came from the receipt and
|
|
185
|
+
* agreed with current config — i.e. when downstream remediation may safely
|
|
186
|
+
* assume the pinned value is the one in play.
|
|
187
|
+
*/
|
|
188
|
+
export async function resolveRunScopedConfig({
|
|
189
|
+
provider,
|
|
190
|
+
storyId,
|
|
191
|
+
config,
|
|
192
|
+
keys = RUN_SCOPED_CONFIG_KEYS,
|
|
193
|
+
findCommentFn,
|
|
194
|
+
progress,
|
|
195
|
+
}) {
|
|
196
|
+
const current = pinRunScopedConfig(config, keys);
|
|
197
|
+
const receipt = await readRunScopedConfigReceipt({
|
|
198
|
+
provider,
|
|
199
|
+
storyId,
|
|
200
|
+
findCommentFn,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (receipt.status !== 'found') {
|
|
204
|
+
// A missing receipt is a real state, not an error — but the fallback is
|
|
205
|
+
// announced, because a silent one reintroduces exactly the bug above.
|
|
206
|
+
const warning =
|
|
207
|
+
`Run-scoped config could not be read from the run's init receipt ` +
|
|
208
|
+
`(${receipt.detail}); falling back to the currently-resolved config ` +
|
|
209
|
+
`(${describeValues(current, keys)}). This close cannot confirm the Story ` +
|
|
210
|
+
'was seeded from these values.';
|
|
211
|
+
progress?.('PIN', `⚠️ ${warning}`);
|
|
212
|
+
return {
|
|
213
|
+
values: current,
|
|
214
|
+
confirmed: false,
|
|
215
|
+
receiptStatus: receipt.status,
|
|
216
|
+
warning,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const values = {};
|
|
221
|
+
const conflicts = [];
|
|
222
|
+
const unpinned = [];
|
|
223
|
+
for (const [key, spec] of Object.entries(keys)) {
|
|
224
|
+
const pinned = receipt.values?.[key];
|
|
225
|
+
if (pinned === undefined || pinned === null) {
|
|
226
|
+
unpinned.push(spec.label);
|
|
227
|
+
values[key] = current[key];
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
values[key] = pinned;
|
|
231
|
+
if (pinned !== current[key]) {
|
|
232
|
+
conflicts.push({ label: spec.label, pinned, current: current[key] });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (conflicts.length > 0) {
|
|
237
|
+
throw new Error(formatRunScopedConflict({ storyId, conflicts }));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (unpinned.length > 0) {
|
|
241
|
+
const warning =
|
|
242
|
+
`The run's init receipt pins no value for ${unpinned.join(', ')}; ` +
|
|
243
|
+
`falling back to the currently-resolved config (${describeValues(values, keys)}).`;
|
|
244
|
+
progress?.('PIN', `⚠️ ${warning}`);
|
|
245
|
+
return {
|
|
246
|
+
values,
|
|
247
|
+
confirmed: false,
|
|
248
|
+
receiptStatus: receipt.status,
|
|
249
|
+
warning,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
progress?.(
|
|
254
|
+
'PIN',
|
|
255
|
+
`📌 Run-scoped config pinned by the story-init receipt (${describeValues(values, keys)}).`,
|
|
256
|
+
);
|
|
257
|
+
return {
|
|
258
|
+
values,
|
|
259
|
+
confirmed: true,
|
|
260
|
+
receiptStatus: receipt.status,
|
|
261
|
+
warning: null,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Render `label=value` pairs for the operator-facing lines above.
|
|
267
|
+
*
|
|
268
|
+
* @param {Record<string, unknown>} values
|
|
269
|
+
* @param {typeof RUN_SCOPED_CONFIG_KEYS} keys
|
|
270
|
+
* @returns {string}
|
|
271
|
+
*/
|
|
272
|
+
function describeValues(values, keys) {
|
|
273
|
+
return Object.entries(keys)
|
|
274
|
+
.map(([key, spec]) => `${spec.label}=\`${String(values[key])}\``)
|
|
275
|
+
.join(', ');
|
|
276
|
+
}
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
* cwd: string,
|
|
36
36
|
* worktreePath: string|null,
|
|
37
37
|
* baseBranch: string,
|
|
38
|
+
* baseConfirmed?: boolean,
|
|
38
39
|
* storyBranch: string,
|
|
39
40
|
* storyId: number,
|
|
40
41
|
* provider: object,
|
|
@@ -46,6 +47,7 @@ export async function runBaseSyncPhase({
|
|
|
46
47
|
cwd,
|
|
47
48
|
worktreePath,
|
|
48
49
|
baseBranch,
|
|
50
|
+
baseConfirmed = false,
|
|
49
51
|
storyBranch,
|
|
50
52
|
storyId,
|
|
51
53
|
provider,
|
|
@@ -69,6 +71,7 @@ export async function runBaseSyncPhase({
|
|
|
69
71
|
storyId,
|
|
70
72
|
syncCwd,
|
|
71
73
|
baseBranch,
|
|
74
|
+
baseConfirmed,
|
|
72
75
|
storyBranch,
|
|
73
76
|
result: syncResult,
|
|
74
77
|
progress,
|
|
@@ -95,6 +98,7 @@ export async function runBaseSyncPhase({
|
|
|
95
98
|
* storyId: number,
|
|
96
99
|
* syncCwd: string,
|
|
97
100
|
* baseBranch: string,
|
|
101
|
+
* baseConfirmed?: boolean,
|
|
98
102
|
* storyBranch: string,
|
|
99
103
|
* result: { kind: string, conflictFiles?: string[], stderr?: string },
|
|
100
104
|
* progress: (tag: string, msg: string) => void,
|
|
@@ -105,6 +109,7 @@ export async function handleSyncFailure({
|
|
|
105
109
|
storyId,
|
|
106
110
|
syncCwd,
|
|
107
111
|
baseBranch,
|
|
112
|
+
baseConfirmed = false,
|
|
108
113
|
storyBranch,
|
|
109
114
|
result,
|
|
110
115
|
progress,
|
|
@@ -113,6 +118,7 @@ export async function handleSyncFailure({
|
|
|
113
118
|
storyId,
|
|
114
119
|
storyBranch,
|
|
115
120
|
baseBranch,
|
|
121
|
+
baseConfirmed,
|
|
116
122
|
syncCwd,
|
|
117
123
|
result,
|
|
118
124
|
});
|
|
@@ -147,13 +153,23 @@ export async function handleSyncFailure({
|
|
|
147
153
|
* Build the markdown body posted on a base-sync failure. Pure; exported
|
|
148
154
|
* for tests so the operator-recoverable surface stays reviewable.
|
|
149
155
|
*
|
|
150
|
-
*
|
|
156
|
+
* Story #4891 — `baseConfirmed` gates the merge-the-base recovery block, and
|
|
157
|
+
* defaults to `false` so it fails closed. Telling the operator to run
|
|
158
|
+
* `git merge origin/<baseBranch>` is actively harmful when `<baseBranch>` is
|
|
159
|
+
* not the base the Story was seeded from: it permanently contaminates the
|
|
160
|
+
* branch and its PR diff with an unrelated base. Close confirms the base
|
|
161
|
+
* against the run's `story-init` receipt before that advice is emitted; when
|
|
162
|
+
* it could not (receipt absent, unreadable, or unpinned), the operator is
|
|
163
|
+
* told to establish the real base first instead.
|
|
164
|
+
*
|
|
165
|
+
* @param {{ storyId: number, storyBranch: string, baseBranch: string, baseConfirmed?: boolean, syncCwd: string, result: { kind: string, conflictFiles?: string[], stderr?: string } }} args
|
|
151
166
|
* @returns {string}
|
|
152
167
|
*/
|
|
153
168
|
export function buildSyncFailureCommentBody({
|
|
154
169
|
storyId,
|
|
155
170
|
storyBranch,
|
|
156
171
|
baseBranch,
|
|
172
|
+
baseConfirmed = false,
|
|
157
173
|
syncCwd,
|
|
158
174
|
result,
|
|
159
175
|
}) {
|
|
@@ -170,15 +186,30 @@ export function buildSyncFailureCommentBody({
|
|
|
170
186
|
`sync against \`origin/${baseBranch}\` could not complete. The Story has`,
|
|
171
187
|
`been transitioned to \`agent::blocked\`. To resume:`,
|
|
172
188
|
'',
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
189
|
+
...(baseConfirmed
|
|
190
|
+
? [
|
|
191
|
+
'```bash',
|
|
192
|
+
`cd ${syncCwd}`,
|
|
193
|
+
`git fetch origin ${baseBranch}`,
|
|
194
|
+
`git merge --no-edit origin/${baseBranch}`,
|
|
195
|
+
'# resolve any conflicts, then:',
|
|
196
|
+
`git add -A ; git commit --no-edit`,
|
|
197
|
+
'# re-run close:',
|
|
198
|
+
`node .agents/scripts/single-story-close.js --story ${storyId}`,
|
|
199
|
+
'```',
|
|
200
|
+
]
|
|
201
|
+
: [
|
|
202
|
+
`⚠️ **No merge advice: \`${baseBranch}\` is unconfirmed.** This close could not`,
|
|
203
|
+
`read the base branch \`${storyBranch}\` was seeded from off the run's`,
|
|
204
|
+
'`story-init` receipt, so merging that base in could contaminate the branch',
|
|
205
|
+
'and its PR diff with an unrelated base. Establish the real base first —',
|
|
206
|
+
`check the \`story-init\` comment on this issue and \`project.baseBranch\` in`,
|
|
207
|
+
'`.agentrc.json` / `.agentrc.local.json` — then merge that base and re-run:',
|
|
208
|
+
'',
|
|
209
|
+
'```bash',
|
|
210
|
+
`node .agents/scripts/single-story-close.js --story ${storyId}`,
|
|
211
|
+
'```',
|
|
212
|
+
]),
|
|
182
213
|
];
|
|
183
214
|
if (kind === 'conflict' && fileList.length > 0) {
|
|
184
215
|
lines.push('', '**Conflicting files:**', '', ...fileList);
|
|
@@ -95,6 +95,7 @@ import {
|
|
|
95
95
|
confirmStoryMerged as defaultConfirmStoryMerged,
|
|
96
96
|
readPrMergeState as defaultReadPrMergeState,
|
|
97
97
|
} from '../../../single-story/confirm-merge.js';
|
|
98
|
+
import { pollUntil } from '../../../util/poll-loop.js';
|
|
98
99
|
import {
|
|
99
100
|
emitMergeFlipFailed as defaultEmitMergeFlipFailed,
|
|
100
101
|
MERGED_FLIP_FAILED_BLOCK_CLASS,
|
|
@@ -363,7 +364,8 @@ function formatUnlandedFriction({
|
|
|
363
364
|
const remedy =
|
|
364
365
|
blockClass === 'checks-failed'
|
|
365
366
|
? `A required check is **red**. Fix the failure and push a new commit on \`story-${storyId}\`; ` +
|
|
366
|
-
`auto-merge
|
|
367
|
+
`the red disarms auto-merge, and only a green on a new head SHA re-arms it — ` +
|
|
368
|
+
`re-running the failed job is forbidden. Watch the checks with:\n\n` +
|
|
367
369
|
`\`\`\`bash\n${NEXT_COMMANDS.watchCi(storyId, prNumber)}\n\`\`\``
|
|
368
370
|
: `Resolve the underlying condition (branch protection, required checks, ` +
|
|
369
371
|
`or a manual merge), then resume the land:\n\n` +
|
|
@@ -851,7 +853,20 @@ export async function runConfirmMergePhase({
|
|
|
851
853
|
`cumulative budget=${maxBudgetSeconds}s)...`,
|
|
852
854
|
);
|
|
853
855
|
|
|
854
|
-
|
|
856
|
+
/**
|
|
857
|
+
* One poll iteration. Returns `{ done: false }` to keep polling, or
|
|
858
|
+
* `{ done: true, outcome }` with the phase's terminal. Story #4873 lifted
|
|
859
|
+
* this body out of a bespoke unbounded loop so the cadence is owned by the
|
|
860
|
+
* shared {@link pollUntil} primitive — the loop below sleeps, aborts, and
|
|
861
|
+
* counts ticks in exactly one place for every wait in the codebase. Every
|
|
862
|
+
* budget, floor, and classification decision is unchanged; only who owns the
|
|
863
|
+
* `await sleep(...)` moved.
|
|
864
|
+
*
|
|
865
|
+
* A throw from any of the terminal handlers is captured rather than allowed
|
|
866
|
+
* to escape into `pollUntil` (which treats a throwing `fn` as a non-match
|
|
867
|
+
* and would spin forever on it); the caller re-throws it after the loop.
|
|
868
|
+
*/
|
|
869
|
+
async function runMergePoll() {
|
|
855
870
|
const probe = await readPrWaitProbeFn({
|
|
856
871
|
prNumber,
|
|
857
872
|
gh: injectedGh,
|
|
@@ -875,26 +890,44 @@ export async function runConfirmMergePhase({
|
|
|
875
890
|
maxBudgetSeconds,
|
|
876
891
|
};
|
|
877
892
|
|
|
893
|
+
// Heartbeat (Story #4873). A backgrounded close writes this phase's
|
|
894
|
+
// progress to its own output file, and between the opening banner and the
|
|
895
|
+
// terminal there used to be NOTHING for minutes at a time — so an
|
|
896
|
+
// orchestrator watching that file could not tell a healthy in-flight wait
|
|
897
|
+
// from a wedged process without going back to GitHub itself. One line per
|
|
898
|
+
// poll makes the file's own growth the liveness signal.
|
|
899
|
+
progress?.(
|
|
900
|
+
'CONFIRM',
|
|
901
|
+
`⏱ poll ${polls}: PR #${prNumber} state=${probe.state ?? 'unknown'} ` +
|
|
902
|
+
`checks=${probe.checksStatus ?? 'unknown'} ` +
|
|
903
|
+
`mergeState=${probe.mergeStateStatus ?? 'unknown'} ` +
|
|
904
|
+
`(${waitBudget.waitedSeconds}s of ${maxWaitSeconds}s this invocation; ` +
|
|
905
|
+
`${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative)` +
|
|
906
|
+
(probe.error ? ` — probe error: ${probe.error}` : ''),
|
|
907
|
+
);
|
|
908
|
+
|
|
878
909
|
if (probe.state === 'MERGED' || probe.mergedAt) {
|
|
879
|
-
return
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
910
|
+
return doneWith(
|
|
911
|
+
await onMergeObserved({
|
|
912
|
+
storyId,
|
|
913
|
+
storyBranch,
|
|
914
|
+
baseBranch,
|
|
915
|
+
prNumber,
|
|
916
|
+
prUrl,
|
|
917
|
+
cwd,
|
|
918
|
+
config,
|
|
919
|
+
provider,
|
|
920
|
+
progress,
|
|
921
|
+
injectedGh,
|
|
922
|
+
injectedNotify,
|
|
923
|
+
readPrMergeStateFn,
|
|
924
|
+
confirmStoryMergedFn,
|
|
925
|
+
runPostLandTailFn,
|
|
926
|
+
emitMergeFlipFailedFn,
|
|
927
|
+
prProbe: probe,
|
|
928
|
+
elapsedSeconds: Math.round(waitedMs / 1000),
|
|
929
|
+
}),
|
|
930
|
+
);
|
|
898
931
|
}
|
|
899
932
|
|
|
900
933
|
// Everything below funnels into ONE terminal exit (Story #4710): each
|
|
@@ -987,16 +1020,18 @@ export async function runConfirmMergePhase({
|
|
|
987
1020
|
}
|
|
988
1021
|
|
|
989
1022
|
if (unlanded) {
|
|
990
|
-
return
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1023
|
+
return doneWith(
|
|
1024
|
+
await blockOnUnlanded({
|
|
1025
|
+
storyId,
|
|
1026
|
+
prNumber,
|
|
1027
|
+
prUrl,
|
|
1028
|
+
...unlanded,
|
|
1029
|
+
provider,
|
|
1030
|
+
progress,
|
|
1031
|
+
classifyMergeBlockFn,
|
|
1032
|
+
emitMergeUnlandedFn,
|
|
1033
|
+
}),
|
|
1034
|
+
);
|
|
1000
1035
|
}
|
|
1001
1036
|
|
|
1002
1037
|
// This invocation's bound expired → PENDING. Deliberately NOT a block:
|
|
@@ -1011,16 +1046,43 @@ export async function runConfirmMergePhase({
|
|
|
1011
1046
|
`${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative). PR #${prNumber} still in flight ` +
|
|
1012
1047
|
`(checks=${probe.checksStatus ?? 'unknown'}). Story stays at agent::closing — resumable.`,
|
|
1013
1048
|
);
|
|
1014
|
-
return {
|
|
1049
|
+
return doneWith({
|
|
1015
1050
|
confirmed: false,
|
|
1016
1051
|
terminal: 'pending',
|
|
1017
1052
|
reason: `merge wait bound reached with the PR still in flight (checks=${probe.checksStatus ?? 'unknown'})`,
|
|
1018
1053
|
prProbe: probe,
|
|
1019
1054
|
waitBudget,
|
|
1020
1055
|
elapsedSeconds: waitBudget.waitedSeconds,
|
|
1021
|
-
};
|
|
1056
|
+
});
|
|
1022
1057
|
}
|
|
1023
1058
|
|
|
1024
|
-
|
|
1059
|
+
return { done: false };
|
|
1025
1060
|
}
|
|
1061
|
+
|
|
1062
|
+
const tick = await pollUntil({
|
|
1063
|
+
fn: async () => {
|
|
1064
|
+
try {
|
|
1065
|
+
return await runMergePoll();
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
// A terminal handler threw. `pollUntil` treats a throwing `fn` as a
|
|
1068
|
+
// non-match and would poll forever on it, so the throw is carried out
|
|
1069
|
+
// as a match and re-raised below.
|
|
1070
|
+
return { done: true, thrown: err };
|
|
1071
|
+
}
|
|
1072
|
+
},
|
|
1073
|
+
predicate: (result) => result?.done === true,
|
|
1074
|
+
intervalMs,
|
|
1075
|
+
// The wait owns its own bounds (`maxWaitSeconds` → `pending`,
|
|
1076
|
+
// `maxBudgetSeconds` → blocked), and both are decided from the probe
|
|
1077
|
+
// inside the tick. A second, cruder wall-clock timeout here would throw
|
|
1078
|
+
// past those classifications.
|
|
1079
|
+
sleepFn: (ms) => sleepFn(ms),
|
|
1080
|
+
});
|
|
1081
|
+
if (tick.thrown) throw tick.thrown;
|
|
1082
|
+
return tick.outcome;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Wrap a phase terminal as the poll loop's match. */
|
|
1086
|
+
function doneWith(outcome) {
|
|
1087
|
+
return { done: true, outcome };
|
|
1026
1088
|
}
|