mandrel 2.17.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/docs/SDLC.md +1 -1
- package/.agents/docs/agentrc-reference.json +10 -0
- package/.agents/docs/configuration.md +8 -0
- package/.agents/schemas/agentrc.schema.json +42 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/boot-sweep.js +39 -2
- package/.agents/scripts/check-doc-links.js +141 -9
- package/.agents/scripts/lib/baselines/env-overrides.js +40 -48
- package/.agents/scripts/lib/config/temp-paths.js +27 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
- package/.agents/scripts/lib/observability/terse-result.js +7 -3
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +51 -77
- package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +20 -12
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +2 -1
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
- package/.agents/scripts/lib/single-story-sweep.js +11 -0
- package/.agents/scripts/lib/temp-retention.js +559 -0
- package/.agents/scripts/single-story-init.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +6 -1
- package/.agents/workflows/audit-performance.md +2 -2
- package/.agents/workflows/helpers/diagnose.md +1 -1
- package/.agents/workflows/helpers/signals.md +2 -2
- package/.agents/workflows/mandrel-update.md +4 -4
- package/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import nodeFs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
import { orchestrationLogDir } from '../config/temp-paths.js';
|
|
4
5
|
import { Logger } from '../Logger.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -69,8 +70,10 @@ function detailBlock(label, result) {
|
|
|
69
70
|
* on; serialized compactly onto the single summary line.
|
|
70
71
|
* @param {string|number} [args.scope] Disambiguating suffix for the log name
|
|
71
72
|
* (typically the Story id) so concurrent deliveries don't clobber one file.
|
|
72
|
-
* @param {string} [args.logDir] Directory for the detail log. Defaults to
|
|
73
|
-
* `<
|
|
73
|
+
* @param {string} [args.logDir] Directory for the detail log. Defaults to the
|
|
74
|
+
* configured `<tempRoot>/orchestration` (Story #4794 — was a hardcoded
|
|
75
|
+
* `<cwd>/temp/orchestration`, which ignored `project.paths.tempRoot`).
|
|
76
|
+
* @param {object} [args.config] Resolved config bag, for the default `logDir`.
|
|
74
77
|
* @param {typeof nodeFs} [args.fs] Filesystem seam (tests).
|
|
75
78
|
* @param {{ info: (m: string) => void }} [args.log] Logger seam (tests).
|
|
76
79
|
* @param {NodeJS.ProcessEnv} [args.env] Environment seam (tests).
|
|
@@ -82,6 +85,7 @@ export function emitTerseResult({
|
|
|
82
85
|
summary = {},
|
|
83
86
|
scope,
|
|
84
87
|
logDir,
|
|
88
|
+
config,
|
|
85
89
|
fs = nodeFs,
|
|
86
90
|
log = Logger,
|
|
87
91
|
env = process.env,
|
|
@@ -94,7 +98,7 @@ export function emitTerseResult({
|
|
|
94
98
|
return { logPath: null, inline: true };
|
|
95
99
|
}
|
|
96
100
|
|
|
97
|
-
const dir = logDir ??
|
|
101
|
+
const dir = logDir ?? orchestrationLogDir(config);
|
|
98
102
|
const name = `${slugify(label)}${scope ? `-${scope}` : ''}.log`;
|
|
99
103
|
|
|
100
104
|
try {
|
|
@@ -7,10 +7,7 @@
|
|
|
7
7
|
* @module lib/orchestration/check-baselines/phases/evaluate
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
resolveBundleSizeEnvOverrides,
|
|
12
|
-
resolveMaintainabilityRefreshOverrides,
|
|
13
|
-
} from '../../../baselines/env-overrides.js';
|
|
10
|
+
import { resolveKindRefreshOverrides } from '../../../baselines/env-overrides.js';
|
|
14
11
|
import { readRangeSubjectsTouchingFile } from '../../../baselines/git-base.js';
|
|
15
12
|
import {
|
|
16
13
|
checkBaselineSemantics,
|
|
@@ -91,57 +88,34 @@ function loadHeadBaseline(kind, cwd, configPath) {
|
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* never persisted, so the ratchet returns to full strength automatically on
|
|
99
|
-
* the very next run (no lingering loosened tolerance to remember to reset).
|
|
100
|
-
*
|
|
101
|
-
* No-op for every other kind.
|
|
102
|
-
*/
|
|
103
|
-
function applyBundleSizeAcknowledgment(kind, compareOutput, env) {
|
|
104
|
-
if (kind !== 'bundle-size') return { compareOutput, acknowledged: false };
|
|
105
|
-
const { acknowledged, overrides } = resolveBundleSizeEnvOverrides(env);
|
|
106
|
-
if (!acknowledged || compareOutput.regressions.length === 0) {
|
|
107
|
-
return { compareOutput, acknowledged: false };
|
|
108
|
-
}
|
|
109
|
-
Logger.warn(
|
|
110
|
-
`[bundle-size] ⚠ ${overrides.join(', ')} — ` +
|
|
111
|
-
`${compareOutput.regressions.length} regression(s) acknowledged for this run only; ` +
|
|
112
|
-
'floors still enforced. This does not persist: the next run without ' +
|
|
113
|
-
'BUNDLE_SIZE_REFRESH re-enforces the ratchet at full strength.',
|
|
114
|
-
);
|
|
115
|
-
return {
|
|
116
|
-
acknowledged: true,
|
|
117
|
-
compareOutput: {
|
|
118
|
-
...compareOutput,
|
|
119
|
-
regressions: [],
|
|
120
|
-
unchanged: [...compareOutput.unchanged, ...compareOutput.regressions],
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Resolve the maintainability refresh trigger (Story #4731). Two paths, either
|
|
127
|
-
* of which acknowledges — mirroring the bundle-size acknowledge but adding the
|
|
128
|
-
* commit-tagged trigger the breach message already documents:
|
|
91
|
+
* Resolve the one-shot refresh trigger for any ratcheted kind (Story #4802,
|
|
92
|
+
* generalizing Story #151's bundle-size env flag and Story #4731's
|
|
93
|
+
* maintainability env-or-commit-tag pair). Two paths, either of which
|
|
94
|
+
* acknowledges:
|
|
129
95
|
*
|
|
130
|
-
* 1. Env parity:
|
|
96
|
+
* 1. Env parity: `<KIND>_REFRESH=1` (the manual override) — upper-snaked,
|
|
97
|
+
* so the two pre-existing names (`BUNDLE_SIZE_REFRESH`,
|
|
98
|
+
* `MAINTAINABILITY_REFRESH`) keep working unchanged.
|
|
131
99
|
* 2. Commit tag: a commit in the compared range `<baseRef>..HEAD` whose
|
|
132
|
-
* subject contains the configured `refreshTag` AND whose diff touches
|
|
133
|
-
*
|
|
134
|
-
*
|
|
100
|
+
* subject contains the configured `refreshTag` AND whose diff touches
|
|
101
|
+
* that kind's baseline file. One-shot by construction — once merged, the
|
|
102
|
+
* refreshed baseline becomes the base and the tag leaves the range.
|
|
135
103
|
*
|
|
136
104
|
* The tag is matched as a plain substring of a conventional commit subject, so
|
|
137
105
|
* commitlint stays satisfied (e.g. `chore(baselines): baseline-refresh: …`).
|
|
138
106
|
*
|
|
107
|
+
* Fails closed: a kind whose baseline path is neither configured nor present
|
|
108
|
+
* in `DEFAULT_BASELINE_PATHS` simply skips the commit-tag path rather than
|
|
109
|
+
* throwing, leaving the run un-acknowledged.
|
|
110
|
+
*
|
|
139
111
|
* @returns {{ triggered: boolean, reasons: string[] }}
|
|
140
112
|
*/
|
|
141
|
-
function
|
|
113
|
+
function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
|
|
142
114
|
const reasons = [];
|
|
143
|
-
const { acknowledged: envAck, overrides } =
|
|
144
|
-
|
|
115
|
+
const { acknowledged: envAck, overrides } = resolveKindRefreshOverrides(
|
|
116
|
+
kind,
|
|
117
|
+
env,
|
|
118
|
+
);
|
|
145
119
|
if (envAck) reasons.push(...overrides);
|
|
146
120
|
|
|
147
121
|
const baseRef = cmp?.baseRef ?? null;
|
|
@@ -154,15 +128,17 @@ function resolveMaintainabilityRefreshTrigger({ gateBlock, cmp, cwd, env }) {
|
|
|
154
128
|
typeof gateBlock?.baselinePath === 'string' &&
|
|
155
129
|
gateBlock.baselinePath.length
|
|
156
130
|
? gateBlock.baselinePath
|
|
157
|
-
: DEFAULT_BASELINE_PATHS
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
131
|
+
: DEFAULT_BASELINE_PATHS[kind];
|
|
132
|
+
if (typeof baselinePath === 'string' && baselinePath.length) {
|
|
133
|
+
const subjects = readRangeSubjectsTouchingFile(baseRef, baselinePath, {
|
|
134
|
+
cwd,
|
|
135
|
+
});
|
|
136
|
+
const match = subjects.find((s) => s.includes(refreshTag));
|
|
137
|
+
if (match) {
|
|
138
|
+
reasons.push(
|
|
139
|
+
`refresh commit "${match}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
166
142
|
}
|
|
167
143
|
}
|
|
168
144
|
|
|
@@ -170,26 +146,24 @@ function resolveMaintainabilityRefreshTrigger({ gateBlock, cmp, cwd, env }) {
|
|
|
170
146
|
}
|
|
171
147
|
|
|
172
148
|
/**
|
|
173
|
-
* One-shot
|
|
174
|
-
* (env flag OR a `baseline-refresh:`-tagged range commit
|
|
175
|
-
* demote every
|
|
176
|
-
* run only — floors still apply, so a row below its
|
|
177
|
-
* The trigger is read fresh every run and never persisted:
|
|
178
|
-
* refreshed baseline is the new base and the tag leaves the
|
|
179
|
-
* ratchet returns to full strength automatically.
|
|
149
|
+
* One-shot baseline refresh/acknowledge for any ratcheted kind (Story #4802).
|
|
150
|
+
* When triggered (env flag OR a `baseline-refresh:`-tagged range commit
|
|
151
|
+
* touching that kind's baseline), demote every head-vs-base regression to
|
|
152
|
+
* `unchanged` for this run only — floors still apply, so a row below its floor
|
|
153
|
+
* still breaches. The trigger is read fresh every run and never persisted:
|
|
154
|
+
* post-merge the refreshed baseline is the new base and the tag leaves the
|
|
155
|
+
* range, so the ratchet returns to full strength automatically.
|
|
180
156
|
*
|
|
181
|
-
*
|
|
157
|
+
* A no-op absent a trigger, so an unacknowledged run of any kind reports its
|
|
158
|
+
* regressions exactly as before.
|
|
182
159
|
*/
|
|
183
|
-
function
|
|
184
|
-
|
|
185
|
-
return { compareOutput, acknowledged: false };
|
|
186
|
-
}
|
|
187
|
-
const { triggered, reasons } = resolveMaintainabilityRefreshTrigger(ctx);
|
|
160
|
+
function applyRefreshAcknowledgment(kind, compareOutput, ctx) {
|
|
161
|
+
const { triggered, reasons } = resolveRefreshTrigger({ ...ctx, kind });
|
|
188
162
|
if (!triggered || compareOutput.regressions.length === 0) {
|
|
189
163
|
return { compareOutput, acknowledged: false };
|
|
190
164
|
}
|
|
191
165
|
Logger.warn(
|
|
192
|
-
`[
|
|
166
|
+
`[${kind}] ⚠ ${reasons.join('; ')} — ` +
|
|
193
167
|
`${compareOutput.regressions.length} regression(s) acknowledged for this run only; ` +
|
|
194
168
|
'floors still enforced. This does not persist: once the refresh is the ' +
|
|
195
169
|
'new base the ratchet re-enforces at full strength.',
|
|
@@ -274,14 +248,14 @@ export async function evaluateKind({
|
|
|
274
248
|
rawCompare,
|
|
275
249
|
gateBlock.tolerance ?? null,
|
|
276
250
|
);
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
);
|
|
283
|
-
const compareOutput =
|
|
284
|
-
const acknowledged =
|
|
251
|
+
const ack = applyRefreshAcknowledgment(kind, toleratedCompare, {
|
|
252
|
+
gateBlock,
|
|
253
|
+
cmp,
|
|
254
|
+
cwd,
|
|
255
|
+
env,
|
|
256
|
+
});
|
|
257
|
+
const compareOutput = ack.compareOutput;
|
|
258
|
+
const acknowledged = ack.acknowledged;
|
|
285
259
|
return buildGateReport({
|
|
286
260
|
kind,
|
|
287
261
|
gateBlock,
|
|
@@ -44,18 +44,26 @@ compare) over every configured gate, with centralised friction emission and
|
|
|
44
44
|
aggregated exit codes.
|
|
45
45
|
|
|
46
46
|
Env vars:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"unchanged" for this run only
|
|
50
|
-
enforced
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
47
|
+
<KIND>_REFRESH=1 One-shot acknowledge for a deliberate baseline
|
|
48
|
+
refresh of that kind: demotes its head-vs-base
|
|
49
|
+
regressions to "unchanged" for this run only. Floors
|
|
50
|
+
are STILL enforced, so a genuine breach is still
|
|
51
|
+
caught. The kind name is upper-snaked, e.g.
|
|
52
|
+
COVERAGE_REFRESH, CRAP_REFRESH, DUPLICATION_REFRESH,
|
|
53
|
+
MAINTAINABILITY_REFRESH, BUNDLE_SIZE_REFRESH.
|
|
54
|
+
Never persisted — the next run without the flag
|
|
55
|
+
re-enforces the ratchet at full strength.
|
|
56
|
+
|
|
57
|
+
Equivalent commit-tagged trigger: a commit in the
|
|
58
|
+
compared range whose subject contains the gate's
|
|
59
|
+
'refreshTag' (default 'baseline-refresh:') AND whose
|
|
60
|
+
diff touches that kind's baseline file. One-shot by
|
|
61
|
+
construction — once merged the refreshed baseline is
|
|
62
|
+
the new base and the tag leaves the range.
|
|
63
|
+
|
|
64
|
+
Use these when replacing a diff-scope baseline with a
|
|
65
|
+
full-scope measurement: the resulting row deltas are
|
|
66
|
+
arithmetic, not behavioural.
|
|
59
67
|
|
|
60
68
|
Exit codes:
|
|
61
69
|
0 every enabled gate passes
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Each listener in this directory subscribes to one or more lifecycle bus
|
|
4
4
|
events and performs a single side effect. The full close-tail roster and
|
|
5
|
-
event taxonomy live in
|
|
5
|
+
event taxonomy live in
|
|
6
|
+
[`docs/LIFECYCLE.md`](https://github.com/dsj1984/mandrel/blob/main/docs/LIFECYCLE.md)
|
|
6
7
|
— that document is the SSOT. This README only indexes the **files that still
|
|
7
8
|
live in this folder**.
|
|
8
9
|
|
|
@@ -37,13 +37,12 @@
|
|
|
37
37
|
* @module lib/orchestration/plan-persist/run-plan-persist
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
-
import {
|
|
40
|
+
import { rm } from 'node:fs/promises';
|
|
41
41
|
import path from 'node:path';
|
|
42
|
-
|
|
43
|
-
import { anchorTempRoot, tempRootFrom } from '../../config/temp-paths.js';
|
|
44
42
|
import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
|
|
45
43
|
import { gitSpawn } from '../../git-utils.js';
|
|
46
44
|
import { Logger } from '../../Logger.js';
|
|
45
|
+
import { sweepTempRetention } from '../../temp-retention.js';
|
|
47
46
|
import {
|
|
48
47
|
deriveStoryShape,
|
|
49
48
|
LITE_ROUTE_LABEL,
|
|
@@ -374,13 +373,6 @@ function resolveEffectiveRoute({
|
|
|
374
373
|
};
|
|
375
374
|
}
|
|
376
375
|
|
|
377
|
-
/**
|
|
378
|
-
* Age after which an abandoned `temp/plan-*` directory is reaped. A plan run
|
|
379
|
-
* that is still being authored is minutes-to-hours old; a week is far past
|
|
380
|
-
* any live run and comfortably past an operator returning to a paused one.
|
|
381
|
-
*/
|
|
382
|
-
const STALE_PLAN_DIR_MS = 7 * 24 * 60 * 60 * 1000;
|
|
383
|
-
|
|
384
376
|
/**
|
|
385
377
|
* Reap abandoned `plan-*` directories under the temp root (Story #4541).
|
|
386
378
|
*
|
|
@@ -389,9 +381,16 @@ const STALE_PLAN_DIR_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
389
381
|
* `--dry-run` left its directory behind forever. This sweeps the stragglers
|
|
390
382
|
* on each persist.
|
|
391
383
|
*
|
|
384
|
+
* Story #4794 folded the age-floored reap into the shared temp-retention
|
|
385
|
+
* engine — `planDirs` is one of its declared classes, so the plan path and
|
|
386
|
+
* the delivery path now converge on one classifier and one staleness floor
|
|
387
|
+
* (`delivery.tempRetention.staleDays`, still 7 days by default) instead of
|
|
388
|
+
* this module owning a private constant. Behaviour is unchanged: only
|
|
389
|
+
* `plan-*` directories are considered, the age test is the directory's own
|
|
390
|
+
* mtime, and the current run's `planDir` is excluded.
|
|
391
|
+
*
|
|
392
392
|
* Best-effort throughout: this is hygiene, never a reason to fail a run that
|
|
393
|
-
* has already created Stories.
|
|
394
|
-
* excluded — its cleanup is the caller's decision.
|
|
393
|
+
* has already created Stories.
|
|
395
394
|
*
|
|
396
395
|
* @param {{ config?: object, keepDir?: string|null, now?: number }} args
|
|
397
396
|
* @returns {Promise<{ reaped: string[] }>}
|
|
@@ -401,35 +400,14 @@ export async function reapStalePlanDirs({
|
|
|
401
400
|
keepDir = null,
|
|
402
401
|
now = Date.now(),
|
|
403
402
|
} = {}) {
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
const keep = keepDir ? path.resolve(keepDir) : null;
|
|
413
|
-
for (const entry of entries) {
|
|
414
|
-
if (!entry.isDirectory() || !entry.name.startsWith('plan-')) continue;
|
|
415
|
-
const dir = path.resolve(tempRoot, entry.name);
|
|
416
|
-
if (keep !== null && dir === keep) continue;
|
|
417
|
-
try {
|
|
418
|
-
const { mtimeMs } = await stat(dir);
|
|
419
|
-
if (now - mtimeMs < STALE_PLAN_DIR_MS) continue;
|
|
420
|
-
await rm(dir, { recursive: true, force: true });
|
|
421
|
-
reaped.push(dir);
|
|
422
|
-
} catch {
|
|
423
|
-
// A racing writer or a permission error: leave it for the next run.
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
if (reaped.length > 0) {
|
|
427
|
-
Logger.info(
|
|
428
|
-
`[plan-persist] reaped ${reaped.length} abandoned plan director(ies) ` +
|
|
429
|
-
`older than 7d under ${tempRoot}.`,
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
return { reaped };
|
|
403
|
+
const result = await sweepTempRetention({
|
|
404
|
+
config,
|
|
405
|
+
only: ['planDirs'],
|
|
406
|
+
excludePaths: keepDir ? [keepDir] : [],
|
|
407
|
+
now,
|
|
408
|
+
label: 'plan-persist',
|
|
409
|
+
});
|
|
410
|
+
return { reaped: result.purged.map((entry) => entry.path) };
|
|
433
411
|
}
|
|
434
412
|
|
|
435
413
|
/**
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
import nodeFs from 'node:fs';
|
|
60
60
|
import path from 'node:path';
|
|
61
61
|
|
|
62
|
+
import { orchestrationLogDir } from '../../config/temp-paths.js';
|
|
62
63
|
import { Logger, resolveLevel } from '../../Logger.js';
|
|
63
64
|
|
|
64
65
|
/**
|
|
@@ -210,26 +211,29 @@ function createArtifactWriter(fs, logPath, handle) {
|
|
|
210
211
|
*
|
|
211
212
|
* @param {{
|
|
212
213
|
* storyId: number|null,
|
|
213
|
-
* cwd?: string,
|
|
214
214
|
* logDir?: string,
|
|
215
215
|
* fs?: typeof nodeFs,
|
|
216
216
|
* logger?: { info: (m: string) => void },
|
|
217
217
|
* level?: string,
|
|
218
|
-
*
|
|
219
|
-
*
|
|
218
|
+
* config?: object,
|
|
219
|
+
* }} [args] `logDir` defaults to the configured `<tempRoot>/orchestration`
|
|
220
|
+
* (Story #4794 — was a hardcoded `<cwd>/temp/orchestration`, which ignored
|
|
221
|
+
* `project.paths.tempRoot` and hid the artifact from the retention purge);
|
|
222
|
+
* `level` defaults to the live Logger level so `AGENT_LOG_LEVEL=verbose`
|
|
223
|
+
* restores streaming.
|
|
220
224
|
* @returns {GateLogSink}
|
|
221
225
|
*/
|
|
222
226
|
export function createGateLogSink({
|
|
223
227
|
storyId = null,
|
|
224
|
-
cwd = process.cwd(),
|
|
225
228
|
logDir,
|
|
226
229
|
fs = nodeFs,
|
|
227
230
|
logger = Logger,
|
|
228
231
|
level,
|
|
232
|
+
config,
|
|
229
233
|
} = {}) {
|
|
230
234
|
const emit = (line) => logger.info?.(line);
|
|
231
235
|
const verbose = (level ?? resolveLevel()) === 'verbose';
|
|
232
|
-
const dir = logDir ??
|
|
236
|
+
const dir = logDir ?? orchestrationLogDir(config);
|
|
233
237
|
|
|
234
238
|
let writer = null;
|
|
235
239
|
let logPath = null;
|
|
@@ -143,7 +143,7 @@ export async function runCloseValidationPhase({
|
|
|
143
143
|
);
|
|
144
144
|
// Story #4736 — one sink for both `log` seams (gate construction and gate
|
|
145
145
|
// execution), so nothing in the chain can route around the artifact.
|
|
146
|
-
const gateLog = createGateLogSink({ storyId,
|
|
146
|
+
const gateLog = createGateLogSink({ storyId, config });
|
|
147
147
|
let validation;
|
|
148
148
|
try {
|
|
149
149
|
validation = await runCloseValidation({
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
RUNTIME_FRICTION_CATEGORIES,
|
|
40
40
|
} from '../../../observability/runtime-friction.js';
|
|
41
41
|
import { acquireLockWithWait as defaultAcquireLockWithWait } from '../../../single-story-sweep/sweep-lock.js';
|
|
42
|
+
import { purgeStoryTempArtifacts as defaultPurgeStoryTempArtifacts } from '../../../temp-retention.js';
|
|
42
43
|
import {
|
|
43
44
|
executeFastForward as defaultExecuteFastForward,
|
|
44
45
|
planFastForward as defaultPlanFastForward,
|
|
@@ -224,6 +225,22 @@ async function stepBaseFastForward({
|
|
|
224
225
|
};
|
|
225
226
|
}
|
|
226
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Purge this Story's spent temp artifacts now that its merge is confirmed
|
|
230
|
+
* (Story #4794).
|
|
231
|
+
*
|
|
232
|
+
* The engine already emits its own one-line summary and returns a disabled
|
|
233
|
+
* policy as `skipped` with no errors, so this step needs no branching of its
|
|
234
|
+
* own: errors degrade it, everything else — including a deliberate
|
|
235
|
+
* config-disabled no-op — is a success. Reporting a disabled purge as a failed
|
|
236
|
+
* step would train readers to ignore the field.
|
|
237
|
+
*/
|
|
238
|
+
async function stepTempPurge({ storyId, config, purgeStoryTempArtifactsFn }) {
|
|
239
|
+
const result = await purgeStoryTempArtifactsFn({ storyId, config });
|
|
240
|
+
const errors = result?.errors ?? [];
|
|
241
|
+
return { ok: errors.length === 0, detail: errors.join('; ') || null };
|
|
242
|
+
}
|
|
243
|
+
|
|
227
244
|
/**
|
|
228
245
|
* Run the whole post-land tail. Never throws.
|
|
229
246
|
*
|
|
@@ -262,7 +279,8 @@ async function stepBaseFastForward({
|
|
|
262
279
|
* @param {Function} [args.planFastForwardFn] Test seam.
|
|
263
280
|
* @param {Function} [args.executeFastForwardFn] Test seam.
|
|
264
281
|
* @param {Function} [args.acquireLockWithWaitFn] Test seam.
|
|
265
|
-
* @
|
|
282
|
+
* @param {Function} [args.purgeStoryTempArtifactsFn] Test seam.
|
|
283
|
+
* @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, tempPurge: boolean, details: Record<string, string|null> }>}
|
|
266
284
|
*/
|
|
267
285
|
export async function runPostLandTail({
|
|
268
286
|
storyId,
|
|
@@ -280,6 +298,7 @@ export async function runPostLandTail({
|
|
|
280
298
|
planFastForwardFn = defaultPlanFastForward,
|
|
281
299
|
executeFastForwardFn = defaultExecuteFastForward,
|
|
282
300
|
acquireLockWithWaitFn = defaultAcquireLockWithWait,
|
|
301
|
+
purgeStoryTempArtifactsFn = defaultPurgeStoryTempArtifacts,
|
|
283
302
|
}) {
|
|
284
303
|
progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
|
|
285
304
|
|
|
@@ -373,16 +392,27 @@ export async function runPostLandTail({
|
|
|
373
392
|
if (lock.acquired) lock.release();
|
|
374
393
|
}
|
|
375
394
|
|
|
395
|
+
// Story #4794 — the merge is confirmed, so this Story's gate transcripts and
|
|
396
|
+
// validation evidence are spent. Runs LAST so a purge can never race a step
|
|
397
|
+
// that still reads them, and outside the checkout lock because it touches
|
|
398
|
+
// only the temp tree. Its `signals.ndjson` survives by construction.
|
|
399
|
+
const tempPurge = await step(
|
|
400
|
+
() => stepTempPurge({ storyId, config, purgeStoryTempArtifactsFn }),
|
|
401
|
+
{ name: 'temp purge', progress },
|
|
402
|
+
);
|
|
403
|
+
|
|
376
404
|
const tail = {
|
|
377
405
|
followUps: followUps.ok,
|
|
378
406
|
statusResync: statusResync.ok,
|
|
379
407
|
refCleanup: refCleanup.ok,
|
|
380
408
|
baseFastForward: baseFastForward.ok,
|
|
409
|
+
tempPurge: tempPurge.ok,
|
|
381
410
|
details: {
|
|
382
411
|
followUps: followUps.detail,
|
|
383
412
|
statusResync: statusResync.detail,
|
|
384
413
|
refCleanup: refCleanup.detail,
|
|
385
414
|
baseFastForward: baseFastForward.detail,
|
|
415
|
+
tempPurge: tempPurge.detail,
|
|
386
416
|
},
|
|
387
417
|
};
|
|
388
418
|
const degraded = Object.entries(tail)
|
|
@@ -90,6 +90,7 @@ const STORY_BRANCH_INCLUDE = 'story-*';
|
|
|
90
90
|
* candidates: number,
|
|
91
91
|
* localDeleted: number,
|
|
92
92
|
* remoteDeleted: number,
|
|
93
|
+
* reaped: string[],
|
|
93
94
|
* protected: Array<{ branch: string, reason: string, worktreePath?: string|null }>,
|
|
94
95
|
* contentMerged: Array<{ branch: string, worktreePath: string|null }>,
|
|
95
96
|
* failures: Array<{ branch: string|null, scope: string, stderr?: string }>,
|
|
@@ -147,6 +148,7 @@ export async function sweepMergedBranches({
|
|
|
147
148
|
candidates: 0,
|
|
148
149
|
localDeleted: 0,
|
|
149
150
|
remoteDeleted: 0,
|
|
151
|
+
reaped: [],
|
|
150
152
|
protected: [],
|
|
151
153
|
contentMerged: [],
|
|
152
154
|
failures: [],
|
|
@@ -296,6 +298,7 @@ async function runSweepUnderLock({
|
|
|
296
298
|
candidates: 0,
|
|
297
299
|
localDeleted: 0,
|
|
298
300
|
remoteDeleted: 0,
|
|
301
|
+
reaped: [],
|
|
299
302
|
protected: [],
|
|
300
303
|
contentMerged,
|
|
301
304
|
failures: [],
|
|
@@ -320,6 +323,7 @@ async function runSweepUnderLock({
|
|
|
320
323
|
candidates: reapCandidates.length,
|
|
321
324
|
localDeleted: 0,
|
|
322
325
|
remoteDeleted: 0,
|
|
326
|
+
reaped: [],
|
|
323
327
|
protected: protectedList,
|
|
324
328
|
contentMerged,
|
|
325
329
|
failures: [],
|
|
@@ -365,6 +369,7 @@ function executeReap({
|
|
|
365
369
|
candidates: candidateCount,
|
|
366
370
|
localDeleted: 0,
|
|
367
371
|
remoteDeleted: 0,
|
|
372
|
+
reaped: [],
|
|
368
373
|
protected: protectedList,
|
|
369
374
|
contentMerged,
|
|
370
375
|
failures: [{ branch: null, scope: 'execute', stderr: msg }],
|
|
@@ -398,6 +403,11 @@ function executeReap({
|
|
|
398
403
|
candidates: candidateCount,
|
|
399
404
|
localDeleted,
|
|
400
405
|
remoteDeleted,
|
|
406
|
+
// Story #4794 — the branch names, not just the count. Each one is a merge
|
|
407
|
+
// this sweep CONFIRMED (merged PR + matching headRefOid), which is exactly
|
|
408
|
+
// the evidence the temp-retention catch-up needs to purge that Story's
|
|
409
|
+
// spent artifacts. Previously these existed only inside a log string.
|
|
410
|
+
reaped: reapable.map((c) => c.branch),
|
|
401
411
|
protected: protectedList,
|
|
402
412
|
contentMerged,
|
|
403
413
|
failures: result.failures,
|
|
@@ -503,6 +513,7 @@ function zeroResult({ error }) {
|
|
|
503
513
|
candidates: 0,
|
|
504
514
|
localDeleted: 0,
|
|
505
515
|
remoteDeleted: 0,
|
|
516
|
+
reaped: [],
|
|
506
517
|
protected: [],
|
|
507
518
|
contentMerged: [],
|
|
508
519
|
failures: [],
|