mandrel 2.16.0 → 2.18.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/agentrc-reference.json +10 -0
- package/.agents/docs/configuration.md +9 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/schemas/agentrc.schema.json +48 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +67 -8
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- 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/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/observability/terse-result.js +7 -3
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- 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 +15 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/single-story-sweep.js +11 -0
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/temp-retention.js +559 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/single-story-init.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +6 -1
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -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 {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { readBaseFromGit } from '../../../baselines/git-base.js';
|
|
13
13
|
import { getKindModule } from '../../../baselines/kernel.js';
|
|
14
14
|
import { resolveScope } from '../../../baselines/scope.js';
|
|
15
|
+
import { Logger } from '../../../Logger.js';
|
|
15
16
|
import { DEFAULT_BASELINE_PATHS } from './parse-args.js';
|
|
16
17
|
|
|
17
18
|
function baselineRelativePath(kind, gateBlock) {
|
|
@@ -67,6 +68,29 @@ export async function evaluateCompare({ kind, gateBlock, scope, cwd }) {
|
|
|
67
68
|
return { baseRef: scope.ref, baseRead: true, basePayload, kindModule };
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Is the base baseline comparable to the head baseline (Story #4775)?
|
|
73
|
+
*
|
|
74
|
+
* A kind can change its SCORING SEMANTICS — how it derives a row's metric —
|
|
75
|
+
* without moving `kernelVersion`. Across that boundary the same row can carry
|
|
76
|
+
* a different score for reasons that have nothing to do with the branch's
|
|
77
|
+
* changes, so a head-vs-base diff manufactures phantom regressions (and can
|
|
78
|
+
* hide real ones behind them).
|
|
79
|
+
*
|
|
80
|
+
* The head-side stamp is already a fail-closed gate: a stale HEAD baseline
|
|
81
|
+
* never reaches this point. What reaches here is the opposite and legitimate
|
|
82
|
+
* case — a branch that DOES carry a re-derived baseline, compared against a
|
|
83
|
+
* base that predates the change. The only honest verdict is "no comparison";
|
|
84
|
+
* floors still run, so a genuine ceiling breach is still caught, and once the
|
|
85
|
+
* refreshed baseline is the base the ratchet returns to full strength on the
|
|
86
|
+
* very next run without anything to remember to reset.
|
|
87
|
+
*/
|
|
88
|
+
function baseIsComparable(headBaseline, basePayload) {
|
|
89
|
+
const head = headBaseline?.scoringSemantics ?? null;
|
|
90
|
+
const base = basePayload?.scoringSemantics ?? null;
|
|
91
|
+
return head === base;
|
|
92
|
+
}
|
|
93
|
+
|
|
70
94
|
export function runCompareStage(headBaseline, cmp) {
|
|
71
95
|
const empty = {
|
|
72
96
|
regressions: [],
|
|
@@ -75,6 +99,17 @@ export function runCompareStage(headBaseline, cmp) {
|
|
|
75
99
|
additions: [],
|
|
76
100
|
};
|
|
77
101
|
if (!cmp.baseRead || !cmp.basePayload || !cmp.kindModule) return empty;
|
|
102
|
+
if (!baseIsComparable(headBaseline, cmp.basePayload)) {
|
|
103
|
+
Logger.warn(
|
|
104
|
+
`[${cmp.kindModule.name}] ⚠ base baseline was scored under different ` +
|
|
105
|
+
`semantics (base=${cmp.basePayload.scoringSemantics ?? '<unstamped>'} ` +
|
|
106
|
+
`head=${headBaseline?.scoringSemantics ?? '<unstamped>'}); its rows are ` +
|
|
107
|
+
'not comparable, so the head-vs-base compare is skipped for this run. ' +
|
|
108
|
+
'Floors still enforced. The ratchet resumes once the re-derived ' +
|
|
109
|
+
'baseline is the base.',
|
|
110
|
+
);
|
|
111
|
+
return empty;
|
|
112
|
+
}
|
|
78
113
|
try {
|
|
79
114
|
const baseRows = Array.isArray(cmp.basePayload.rows)
|
|
80
115
|
? cmp.basePayload.rows
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from '../../../baselines/env-overrides.js';
|
|
14
14
|
import { readRangeSubjectsTouchingFile } from '../../../baselines/git-base.js';
|
|
15
15
|
import {
|
|
16
|
+
checkBaselineSemantics,
|
|
16
17
|
checkKernelVersion,
|
|
17
18
|
getKindModule,
|
|
18
19
|
} from '../../../baselines/kernel.js';
|
|
@@ -247,6 +248,18 @@ export async function evaluateKind({
|
|
|
247
248
|
const headLoad = loadHeadBaseline(kind, cwd, configPath);
|
|
248
249
|
if (headLoad.schemaError) return { kind, schemaError: headLoad.schemaError };
|
|
249
250
|
const baseline = headLoad.baseline;
|
|
251
|
+
// Story #4775 — scoring-semantics gate. A baseline whose rows were produced
|
|
252
|
+
// by superseded scoring semantics is structurally valid but semantically
|
|
253
|
+
// incomparable, so schema validation alone would wave it through. Fail
|
|
254
|
+
// closed on the `semantics` tag rather than compare across the boundary;
|
|
255
|
+
// the message names the exact re-baseline command.
|
|
256
|
+
const semanticsError = checkBaselineSemantics(kind, baseline);
|
|
257
|
+
if (semanticsError) {
|
|
258
|
+
return {
|
|
259
|
+
kind,
|
|
260
|
+
schemaError: { tag: 'semantics', message: semanticsError },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
250
263
|
const floorRollup = rollupExcludingIgnored({
|
|
251
264
|
kind,
|
|
252
265
|
baseline,
|
|
@@ -152,9 +152,24 @@ export function removeWorktree(worktreePath, cwd) {
|
|
|
152
152
|
};
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Prune the clone's stale remote-tracking refs and report which ones went.
|
|
157
|
+
*
|
|
158
|
+
* The fetch MUST NOT be `--quiet` (Story #4772). `--quiet` still prunes, but
|
|
159
|
+
* suppresses the `- [deleted] (none) -> <remote>/<ref>` progress lines that
|
|
160
|
+
* are the *only* record of what was dropped — `parsePruneFn` then reports an
|
|
161
|
+
* empty list for work that really happened, and `computeExitCode` reads the
|
|
162
|
+
* run as "nothing to do" (exit 2). The output is captured, not shown, so
|
|
163
|
+
* `--quiet` bought nothing to begin with.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} cwd
|
|
166
|
+
* @param {string} remoteName
|
|
167
|
+
* @param {(output: string, remoteName: string) => string[]} parsePruneFn
|
|
168
|
+
* @returns {{ ok: boolean, pruned: string[], stderr?: string }}
|
|
169
|
+
*/
|
|
155
170
|
/* node:coverage ignore next */
|
|
156
171
|
export function pruneRemoteTracking(cwd, remoteName, parsePruneFn) {
|
|
157
|
-
const res = gitSpawn(cwd, 'fetch', '--prune',
|
|
172
|
+
const res = gitSpawn(cwd, 'fetch', '--prune', remoteName);
|
|
158
173
|
if (res.status !== 0) return { ok: false, pruned: [], stderr: res.stderr };
|
|
159
174
|
return { ok: true, pruned: parsePruneFn(res.stderr, remoteName) };
|
|
160
175
|
}
|
|
@@ -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
|
/**
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* single-story-close/failed-terminal.js — the `failed` terminal a close
|
|
3
|
+
* emits when a phase crashes, and the gate reconstruction it carries.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `single-story-close.js` so the CLI entry stays an entry: it
|
|
6
|
+
* parses args, dispatches the runner, and maps a terminal onto an exit code.
|
|
7
|
+
* The reasoning about which gates had run by the time a phase died belongs
|
|
8
|
+
* with the envelope it feeds, not in the file that owns process lifetime.
|
|
9
|
+
*
|
|
10
|
+
* The runner deliberately throws rather than returning a failure (a red gate
|
|
11
|
+
* must not look like a return value), so without this the most common
|
|
12
|
+
* non-happy ending — a failing close-validation gate — would emit **no
|
|
13
|
+
* envelope at all**, exiting 1 with only a stderr line while the workflow
|
|
14
|
+
* docs promise the agent a `failed` envelope naming the phase.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Logger } from '../../Logger.js';
|
|
18
|
+
import {
|
|
19
|
+
buildTerminalEnvelope,
|
|
20
|
+
NEXT_COMMANDS,
|
|
21
|
+
} from '../story-deliver-terminal.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The close pipeline's phase order, as `setPhase` walks it. Only used to
|
|
25
|
+
* decide whether a gate had already run when a later phase died.
|
|
26
|
+
*/
|
|
27
|
+
const PHASE_ORDER = Object.freeze([
|
|
28
|
+
'init',
|
|
29
|
+
'wrong-tree-guard',
|
|
30
|
+
'close-validation',
|
|
31
|
+
'base-sync',
|
|
32
|
+
'push',
|
|
33
|
+
'pull-request',
|
|
34
|
+
'code-review',
|
|
35
|
+
'auto-merge',
|
|
36
|
+
'confirm-merge',
|
|
37
|
+
'post-land',
|
|
38
|
+
'done',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Each reported gate and the pipeline phase that decides it. */
|
|
42
|
+
const GATE_PHASES = Object.freeze([
|
|
43
|
+
['validation', 'close-validation'],
|
|
44
|
+
['baseSync', 'base-sync'],
|
|
45
|
+
['codeReview', 'code-review'],
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Report every gate's outcome for a run that died at `phase`.
|
|
50
|
+
*
|
|
51
|
+
* The schema's contract: "A gate the run skipped … reports `skipped` rather
|
|
52
|
+
* than being omitted, so a missing gate is never mistaken for a passing one."
|
|
53
|
+
* The previous shape named only the gate that died and omitted the rest
|
|
54
|
+
* entirely — exactly the ambiguity the contract forbids.
|
|
55
|
+
*
|
|
56
|
+
* Reconstructed from the phase order, which is sound because the pipeline is
|
|
57
|
+
* strictly sequential: reaching phase N means every gate before it completed.
|
|
58
|
+
* A gate whose phase the run never reached is `skipped`; one the operator
|
|
59
|
+
* turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
|
|
60
|
+
* not pass — it never ran).
|
|
61
|
+
*
|
|
62
|
+
* @param {string} phase The phase the run died in.
|
|
63
|
+
* @param {{ skipValidation?: boolean, skipSync?: boolean }} args Parsed CLI args.
|
|
64
|
+
* @returns {Record<string, 'passed'|'failed'|'skipped'>}
|
|
65
|
+
*/
|
|
66
|
+
export function gatesForFailedPhase(phase, args = {}) {
|
|
67
|
+
const skipped = { validation: args.skipValidation, baseSync: args.skipSync };
|
|
68
|
+
const failedAt = PHASE_ORDER.indexOf(phase);
|
|
69
|
+
const gates = {};
|
|
70
|
+
for (const [gate, gatePhase] of GATE_PHASES) {
|
|
71
|
+
const at = PHASE_ORDER.indexOf(gatePhase);
|
|
72
|
+
if (gatePhase === phase) gates[gate] = 'failed';
|
|
73
|
+
else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
|
|
74
|
+
else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
|
|
75
|
+
}
|
|
76
|
+
return gates;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the `failed` terminal for a phase that crashed. Every close
|
|
81
|
+
* invocation emits exactly one envelope; this is the path that keeps that
|
|
82
|
+
* true when a phase dies.
|
|
83
|
+
*
|
|
84
|
+
* `err.closePhase` is tagged by the runner's phase tracker.
|
|
85
|
+
*
|
|
86
|
+
* **Never throws.** This runs on the path that already has one failure in
|
|
87
|
+
* hand, so a second failure here must not REPLACE the first: an
|
|
88
|
+
* envelope-build error surfacing as the run's cause sends the operator to
|
|
89
|
+
* diagnose the wrong thing entirely — a close whose PR had already merged
|
|
90
|
+
* once reported a schema `ENOENT` as its fatal error, because the worktree
|
|
91
|
+
* holding the script had been reaped mid-run. On failure this returns null
|
|
92
|
+
* and the caller rethrows the original.
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} err
|
|
95
|
+
* @param {{ storyId?: string|number, skipValidation?: boolean, skipSync?: boolean }} args
|
|
96
|
+
* Parsed CLI args — the story id the envelope reports on, plus the skip
|
|
97
|
+
* flags `gatesForFailedPhase` needs.
|
|
98
|
+
* @returns {object|null} A validated envelope, or null when even the story id
|
|
99
|
+
* is unknown (a usage error — there is nothing to report an envelope about)
|
|
100
|
+
* or the envelope itself could not be assembled.
|
|
101
|
+
*/
|
|
102
|
+
export function failedTerminalFor(err, args = {}) {
|
|
103
|
+
const phase = err?.closePhase ?? 'init';
|
|
104
|
+
const storyId = Number(args.storyId);
|
|
105
|
+
if (!Number.isInteger(storyId) || storyId <= 0) return null;
|
|
106
|
+
try {
|
|
107
|
+
return buildTerminalEnvelope({
|
|
108
|
+
storyId,
|
|
109
|
+
status: 'failed',
|
|
110
|
+
phase,
|
|
111
|
+
gates: gatesForFailedPhase(phase, args),
|
|
112
|
+
failure: { reason: String(err?.message ?? err) },
|
|
113
|
+
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
114
|
+
elapsedSeconds: 0,
|
|
115
|
+
});
|
|
116
|
+
} catch (buildErr) {
|
|
117
|
+
Logger.error(
|
|
118
|
+
`[single-story-close] ⚠️ Could not assemble the failed terminal envelope: ${buildErr?.message ?? buildErr}. Reporting the original failure instead.`,
|
|
119
|
+
);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -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;
|
|
@@ -33,6 +33,13 @@
|
|
|
33
33
|
* needs the evidence in front of them. `AGENT_LOG_LEVEL=verbose` restores
|
|
34
34
|
* live streaming.
|
|
35
35
|
*
|
|
36
|
+
* Projection advisories (Story #4776). `baseBranch`, `storyBranch` and the
|
|
37
|
+
* resolved `config` are forwarded to `runCloseValidation` so its projection
|
|
38
|
+
* phase can run. They surface, after the gates pass, which committed
|
|
39
|
+
* baseline rows the post-merge tree would breach and the exact
|
|
40
|
+
* `*:update` + `baseline-refresh:` remedy — advisory only, so the close
|
|
41
|
+
* verdict is unchanged.
|
|
42
|
+
*
|
|
36
43
|
* `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
|
|
37
44
|
* are accepted as injected dependencies so the parent CLI's cache-busted
|
|
38
45
|
* bindings win in tests that mock the upstream module URLs.
|
|
@@ -136,7 +143,7 @@ export async function runCloseValidationPhase({
|
|
|
136
143
|
);
|
|
137
144
|
// Story #4736 — one sink for both `log` seams (gate construction and gate
|
|
138
145
|
// execution), so nothing in the chain can route around the artifact.
|
|
139
|
-
const gateLog = createGateLogSink({ storyId,
|
|
146
|
+
const gateLog = createGateLogSink({ storyId, config });
|
|
140
147
|
let validation;
|
|
141
148
|
try {
|
|
142
149
|
validation = await runCloseValidation({
|
|
@@ -154,6 +161,13 @@ export async function runCloseValidationPhase({
|
|
|
154
161
|
// epicId; the standalone flag routes the cache to
|
|
155
162
|
// temp/standalone/stories/story-<id>/validation-evidence.json.
|
|
156
163
|
standalone: true,
|
|
164
|
+
// Story #4776 — the branch pair and resolved config the advisory
|
|
165
|
+
// projections need. Without them the runner skips the projection
|
|
166
|
+
// phase entirely, which is the correct behaviour for resume/legacy
|
|
167
|
+
// callers that have no story branch to diff.
|
|
168
|
+
baseBranch,
|
|
169
|
+
storyBranch,
|
|
170
|
+
config,
|
|
157
171
|
});
|
|
158
172
|
} finally {
|
|
159
173
|
// Story #4766 — gate lines are buffered to an async stream so the drain
|
|
@@ -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)
|