mandrel 1.88.0 → 1.90.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/README.md +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +62 -27
- package/.agents/docs/configuration.md +5 -4
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +10 -6
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/coverage-capture.js +17 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/close-validation/gates.js +64 -24
- package/.agents/scripts/lib/config/ci.js +12 -1
- package/.agents/scripts/lib/config/runners.js +13 -5
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/npm-scripts.js +55 -0
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +116 -43
- package/.agents/workflows/helpers/deliver-epic.md +123 -54
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -366
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
|
@@ -66,9 +66,12 @@
|
|
|
66
66
|
|
|
67
67
|
import { spawnSync } from 'node:child_process';
|
|
68
68
|
|
|
69
|
+
import { hasSurvivingCritical } from '../../../audit-suite/findings.js';
|
|
69
70
|
import { getCiDelivery } from '../../../config/ci.js';
|
|
71
|
+
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
70
72
|
import * as epicRunStateStore from '../../epic-run-state-store.js';
|
|
71
73
|
import { findStructuredComment } from '../../ticketing.js';
|
|
74
|
+
import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
|
|
72
75
|
import { normalizeCheckState, RECOGNIZED_CHECK_STATES } from './watcher.js';
|
|
73
76
|
|
|
74
77
|
/**
|
|
@@ -223,19 +226,51 @@ export function probeRequiredChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
223
226
|
* stdout. We parse stdout first (it is populated even on the non-zero
|
|
224
227
|
* exit) and classify from the outcomes.
|
|
225
228
|
*
|
|
229
|
+
* Story #4472 — checks-less repos. In a repo with zero required checks
|
|
230
|
+
* (no branch protection, or protection that requires no status checks),
|
|
231
|
+
* `gh pr checks --required` writes NOTHING to stdout and reports
|
|
232
|
+
* `no checks reported on the <branch> branch` to stderr with a non-zero
|
|
233
|
+
* exit. That is the SAME empty-parsed-set condition the outcomes loop
|
|
234
|
+
* below already treats as green — there is simply nothing to gate on — so
|
|
235
|
+
* we must not conflate it with a genuine probe failure (auth, network, no
|
|
236
|
+
* PR). We detect the `no checks reported` stderr signature and return
|
|
237
|
+
* green, UNLESS the consumer opted into `delivery.ci.requireChecks`, in
|
|
238
|
+
* which case the absent CI gate is a deliberate hard block.
|
|
239
|
+
*
|
|
226
240
|
* @param {{ status: number, stdout: string, stderr: string }} probe
|
|
241
|
+
* @param {{ requireChecks?: boolean }} [opts] When `requireChecks` is
|
|
242
|
+
* true, a checks-less repo fails closed instead of arming.
|
|
227
243
|
* @returns {{ ok: boolean, reason: string|null, outcomes: Record<string, string> }}
|
|
228
244
|
*/
|
|
229
|
-
export function classifyRequiredChecksProbe(
|
|
245
|
+
export function classifyRequiredChecksProbe(
|
|
246
|
+
probe,
|
|
247
|
+
{ requireChecks = false } = {},
|
|
248
|
+
) {
|
|
230
249
|
const stdout = String(probe?.stdout ?? '').trim();
|
|
231
|
-
|
|
232
|
-
//
|
|
250
|
+
const stderr = String(probe?.stderr ?? '').trim();
|
|
251
|
+
// Empty stdout: either a checks-less repo (green, nothing to gate on) or
|
|
252
|
+
// a genuine probe failure. The `no checks reported` stderr signature
|
|
253
|
+
// distinguishes them.
|
|
233
254
|
if (stdout.length === 0) {
|
|
255
|
+
const noChecksReported = /no checks reported/i.test(stderr);
|
|
256
|
+
if (noChecksReported && !requireChecks) {
|
|
257
|
+
// Zero required checks configured — matches the empty-parsed-set
|
|
258
|
+
// "treated as green" branch below. Nothing to gate on.
|
|
259
|
+
return { ok: true, reason: null, outcomes: {} };
|
|
260
|
+
}
|
|
261
|
+
if (noChecksReported && requireChecks) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
reason:
|
|
265
|
+
'no required checks reported and delivery.ci.requireChecks is set — failing closed per policy',
|
|
266
|
+
outcomes: {},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
234
269
|
return {
|
|
235
270
|
ok: false,
|
|
236
271
|
reason:
|
|
237
272
|
`live required-check probe failed (status=${probe?.status ?? 'unknown'})` +
|
|
238
|
-
(
|
|
273
|
+
(stderr ? `: ${stderr.slice(0, 200)}` : ''),
|
|
239
274
|
outcomes: {},
|
|
240
275
|
};
|
|
241
276
|
}
|
|
@@ -441,7 +476,11 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
|
|
|
441
476
|
if (codeReviewUnparseable) {
|
|
442
477
|
return { codeReviewFound, codeReviewUnparseable, severity };
|
|
443
478
|
}
|
|
444
|
-
|
|
479
|
+
// Route the halt-on-critical decision through the single halting rule of
|
|
480
|
+
// the unified verification-results contract (Story #4411) rather than a
|
|
481
|
+
// re-derived `critical > 0` expression. The unparseable branch above has
|
|
482
|
+
// already returned, so `severity.critical` is a concrete number here.
|
|
483
|
+
if (hasSurvivingCritical(severity)) {
|
|
445
484
|
pushReason(
|
|
446
485
|
reasons,
|
|
447
486
|
REASON_CATEGORY.CRITICAL_REVIEW,
|
|
@@ -648,7 +687,11 @@ export async function evaluateAutoMergePredicate({
|
|
|
648
687
|
// only IO, but the rule is directory-scoped; sequencing here is a
|
|
649
688
|
// cheap concession for living inside the listener tree.
|
|
650
689
|
const state = await readRunStateFn({ provider, epicId });
|
|
651
|
-
const codeReview = await findCommentFn(
|
|
690
|
+
const codeReview = await findCommentFn(
|
|
691
|
+
provider,
|
|
692
|
+
epicId,
|
|
693
|
+
'verification-results',
|
|
694
|
+
);
|
|
652
695
|
let retro = await findCommentFn(provider, epicId, 'retro');
|
|
653
696
|
if (!retro) {
|
|
654
697
|
retro = await findCommentFn(provider, epicId, 'retro-partial');
|
|
@@ -669,8 +712,13 @@ export class AutomergePredicate {
|
|
|
669
712
|
* evaluator). Required for the read of run-state + structured
|
|
670
713
|
* comments.
|
|
671
714
|
* @param {object} [opts.config] Resolved agent config. Read for the
|
|
672
|
-
* `delivery.ci.autoMerge` policy
|
|
673
|
-
*
|
|
715
|
+
* `delivery.ci.autoMerge` policy and the `delivery.ci.requireChecks`
|
|
716
|
+
* fail-closed-without-checks policy via `getCiDelivery`. Defaults to the
|
|
717
|
+
* framework defaults (`trust-ci` / `requireChecks: false`) when omitted.
|
|
718
|
+
* @param {boolean} [opts.headless] When true (a `/deliver --yes` run), a
|
|
719
|
+
* predicate refusal escalates to an explicit `merge.unlanded` +
|
|
720
|
+
* `epic.blocked` terminal instead of silently parking on the
|
|
721
|
+
* operator-merges path (Story #4472). Defaults to `false` (attended).
|
|
674
722
|
* @param {string} [opts.cwd] Working directory for the live
|
|
675
723
|
* `gh pr checks --required` probe. Defaults to `process.cwd()`.
|
|
676
724
|
* @param {Function} [opts.evaluatePredicateFn] override of
|
|
@@ -699,13 +747,20 @@ export class AutomergePredicate {
|
|
|
699
747
|
this.epicId = opts.epicId;
|
|
700
748
|
this.provider = opts.provider;
|
|
701
749
|
this.cwd = opts.cwd ?? process.cwd();
|
|
702
|
-
// Resolve the merge posture once at construction.
|
|
703
|
-
// applies the framework
|
|
704
|
-
|
|
750
|
+
// Resolve the merge posture + fail-closed policy once at construction.
|
|
751
|
+
// `getCiDelivery` applies the framework defaults (`trust-ci` /
|
|
752
|
+
// `requireChecks: false`) for any omitted field.
|
|
753
|
+
const ci = getCiDelivery(opts.config ?? null);
|
|
754
|
+
this.policy = ci.autoMerge;
|
|
755
|
+
this.requireChecks = ci.requireChecks;
|
|
756
|
+
this.headless = opts.headless === true;
|
|
705
757
|
this.evaluatePredicateFn =
|
|
706
758
|
opts.evaluatePredicateFn ?? evaluateAutoMergePredicate;
|
|
707
759
|
this.probeRequiredChecksFn =
|
|
708
760
|
opts.probeRequiredChecksFn ?? probeRequiredChecks;
|
|
761
|
+
// Injected for tests so the headless terminal escalation can be
|
|
762
|
+
// observed without touching disk.
|
|
763
|
+
this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
|
|
709
764
|
this.logger = opts.logger ?? console;
|
|
710
765
|
/** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
|
|
711
766
|
this._seen = new Set();
|
|
@@ -777,7 +832,9 @@ export class AutomergePredicate {
|
|
|
777
832
|
let probeVerdict;
|
|
778
833
|
try {
|
|
779
834
|
const probe = this.probeRequiredChecksFn({ prUrl, cwd: this.cwd });
|
|
780
|
-
probeVerdict = classifyRequiredChecksProbe(probe
|
|
835
|
+
probeVerdict = classifyRequiredChecksProbe(probe, {
|
|
836
|
+
requireChecks: this.requireChecks,
|
|
837
|
+
});
|
|
781
838
|
} catch (err) {
|
|
782
839
|
probeVerdict = {
|
|
783
840
|
ok: false,
|
|
@@ -869,6 +926,17 @@ export class AutomergePredicate {
|
|
|
869
926
|
* Emit `epic.merge.blocked`. Helper carved out so the blocking paths
|
|
870
927
|
* (CI failure / predicate dirty / evaluator throw) share the same emit
|
|
871
928
|
* shape.
|
|
929
|
+
*
|
|
930
|
+
* Story #4472 — must-land coverage of predicate refusal. In a headless
|
|
931
|
+
* (`/deliver --yes`) run there is no operator to act on a bare
|
|
932
|
+
* `epic.merge.blocked` (nothing in the listener chain consumes it), so
|
|
933
|
+
* the run would silently park on the operator-merges path. When
|
|
934
|
+
* `this.headless`, we additionally attribute the refusal to the
|
|
935
|
+
* lifecycle ledger via `merge.unlanded` (blockClass `predicate-refused`)
|
|
936
|
+
* and drive the explicit `epic.blocked` terminal — the same
|
|
937
|
+
* escalation the MergeWatcher performs on post-arm budget exhaustion —
|
|
938
|
+
* so the Epic transitions to `agent::blocked` with an operator-visible
|
|
939
|
+
* reason instead of stalling.
|
|
872
940
|
*/
|
|
873
941
|
async _emitBlocked(prUrl, reason) {
|
|
874
942
|
try {
|
|
@@ -878,6 +946,35 @@ export class AutomergePredicate {
|
|
|
878
946
|
`[AutomergePredicate] epic.merge.blocked emit failed (swallowed): ${err?.message ?? err}`,
|
|
879
947
|
);
|
|
880
948
|
}
|
|
949
|
+
if (!this.headless) return;
|
|
950
|
+
// Ledger attribution — best-effort; a failed append must NOT mask the
|
|
951
|
+
// epic.blocked transition below.
|
|
952
|
+
try {
|
|
953
|
+
const prNumber = parsePrNumberFromUrl(prUrl);
|
|
954
|
+
if (Number.isInteger(prNumber) && prNumber > 0) {
|
|
955
|
+
this.emitMergeUnlandedFn({
|
|
956
|
+
scope: 'epic',
|
|
957
|
+
ticketId: this.epicId,
|
|
958
|
+
prNumber,
|
|
959
|
+
blockClass: 'predicate-refused',
|
|
960
|
+
reason,
|
|
961
|
+
elapsedSeconds: 0,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
} catch (err) {
|
|
965
|
+
this.logger.warn?.(
|
|
966
|
+
`[AutomergePredicate] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
try {
|
|
970
|
+
await this.bus.emit('epic.blocked', {
|
|
971
|
+
reason: `merge-predicate:refused`,
|
|
972
|
+
});
|
|
973
|
+
} catch (err) {
|
|
974
|
+
this.logger.warn?.(
|
|
975
|
+
`[AutomergePredicate] epic.blocked emit on predicate refusal failed (swallowed): ${err?.message ?? err}`,
|
|
976
|
+
);
|
|
977
|
+
}
|
|
881
978
|
}
|
|
882
979
|
|
|
883
980
|
reset() {
|
|
@@ -16,8 +16,15 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Side effects executed inside `handle()`:
|
|
18
18
|
* 1. Emit `epic.finalize.start`.
|
|
19
|
-
* 2. Auto-graduate non-blocking
|
|
20
|
-
* (best-effort; never throws).
|
|
19
|
+
* 2. Auto-graduate non-blocking findings in a SINGLE pass over the
|
|
20
|
+
* unified `verification-results` comment (best-effort; never throws).
|
|
21
|
+
* Story #4411 folded the former code-review + audit-results
|
|
22
|
+
* structured-comment contracts into one comment; the lens-aware
|
|
23
|
+
* audit-results graduator is the single canonical reader (its
|
|
24
|
+
* 🟢→`suggestion` severity vocabulary matches `findings-renderer.js`).
|
|
25
|
+
* Running a second code-review pass over the same comment would file
|
|
26
|
+
* every non-blocking finding twice, so only the audit-results
|
|
27
|
+
* graduation runs here.
|
|
21
28
|
* 3. Idempotency probe — `gh pr list --head epic/<id>` returns any
|
|
22
29
|
* existing PR URL. If one exists, short-circuit to `pr.created`
|
|
23
30
|
* + `epic.finalize.end` carrying the existing URL.
|
|
@@ -69,7 +76,6 @@ import {
|
|
|
69
76
|
graduateAuditResults as defaultGraduateAuditResults,
|
|
70
77
|
isAutoFileEnabled as isAuditResultsAutoFileEnabled,
|
|
71
78
|
} from '../../../feedback-loop/audit-results-graduator.js';
|
|
72
|
-
import { graduateFindings as defaultGraduateFindings } from '../../../feedback-loop/code-review-graduator.js';
|
|
73
79
|
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
74
80
|
import {
|
|
75
81
|
markPrReady as defaultMarkPrReady,
|
|
@@ -235,17 +241,39 @@ export function extractPrUrl(stdout) {
|
|
|
235
241
|
return match ? match[0] : null;
|
|
236
242
|
}
|
|
237
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Default bounded timeout for the finalize idempotency probe. Story #4415
|
|
246
|
+
* (Epic #4406) — a hung `gh pr list` spawn previously blocked finalize
|
|
247
|
+
* forever; the probe now SIGKILLs the child at this bound so a stalled
|
|
248
|
+
* `gh` cannot park the merge gate.
|
|
249
|
+
*/
|
|
250
|
+
export const GH_PR_LIST_TIMEOUT_MS = 30000;
|
|
251
|
+
|
|
238
252
|
/**
|
|
239
253
|
* Default `gh` spawn used by the listener's idempotency probe.
|
|
240
254
|
* Mirrors the `shell: false` contract `openOrLocatePr` and the other
|
|
241
255
|
* listener helpers use so a future Windows audit doesn't have to grep
|
|
242
|
-
* across two modules.
|
|
256
|
+
* across two modules. Bounded by `timeoutMs` (default
|
|
257
|
+
* {@link GH_PR_LIST_TIMEOUT_MS}) + `killSignal: 'SIGKILL'` so a hung `gh`
|
|
258
|
+
* spawn cannot block finalize indefinitely (Story #4415). Exported so
|
|
259
|
+
* tests can stub.
|
|
243
260
|
*/
|
|
244
|
-
export function ghPrListHead({
|
|
261
|
+
export function ghPrListHead({
|
|
262
|
+
epicBranch,
|
|
263
|
+
cwd,
|
|
264
|
+
spawnFn = spawnSync,
|
|
265
|
+
timeoutMs = GH_PR_LIST_TIMEOUT_MS,
|
|
266
|
+
}) {
|
|
245
267
|
const result = spawnFn(
|
|
246
268
|
'gh',
|
|
247
269
|
['pr', 'list', '--head', epicBranch, '--json', 'url', '--jq', '.[0].url'],
|
|
248
|
-
{
|
|
270
|
+
{
|
|
271
|
+
cwd,
|
|
272
|
+
encoding: 'utf-8',
|
|
273
|
+
shell: false,
|
|
274
|
+
timeout: timeoutMs,
|
|
275
|
+
killSignal: 'SIGKILL',
|
|
276
|
+
},
|
|
249
277
|
);
|
|
250
278
|
return {
|
|
251
279
|
status: result.status ?? 1,
|
|
@@ -276,7 +304,6 @@ export class Finalizer {
|
|
|
276
304
|
* the graduators.
|
|
277
305
|
* @param {{owner:string,repo:string}} [opts.currentRepo]
|
|
278
306
|
* @param {{owner:string,repo:string}} [opts.frameworkRepo]
|
|
279
|
-
* @param {Function} [opts.graduateFindingsFn]
|
|
280
307
|
* @param {Function} [opts.graduateAuditResultsFn]
|
|
281
308
|
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
|
|
282
309
|
*/
|
|
@@ -314,8 +341,6 @@ export class Finalizer {
|
|
|
314
341
|
this.config = opts.config ?? null;
|
|
315
342
|
this.currentRepo = opts.currentRepo ?? null;
|
|
316
343
|
this.frameworkRepo = opts.frameworkRepo ?? null;
|
|
317
|
-
this.graduateFindingsFn =
|
|
318
|
-
opts.graduateFindingsFn ?? defaultGraduateFindings;
|
|
319
344
|
this.graduateAuditResultsFn =
|
|
320
345
|
opts.graduateAuditResultsFn ?? defaultGraduateAuditResults;
|
|
321
346
|
this.logger = opts.logger ?? console;
|
|
@@ -373,10 +398,11 @@ export class Finalizer {
|
|
|
373
398
|
return;
|
|
374
399
|
}
|
|
375
400
|
|
|
376
|
-
// 1b. Auto-graduate non-blocking
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
//
|
|
401
|
+
// 1b. Auto-graduate non-blocking findings in a SINGLE pass over the
|
|
402
|
+
// unified `verification-results` comment (Story #2615; unified in
|
|
403
|
+
// Story #4411). The lens-aware audit-results graduator is the sole
|
|
404
|
+
// canonical reader — a second code-review pass over the same comment
|
|
405
|
+
// would file every non-blocking finding twice.
|
|
380
406
|
await this._runAuditResultsGraduation();
|
|
381
407
|
|
|
382
408
|
// 2. Idempotency probe — does a PR already exist on the head
|
|
@@ -498,54 +524,14 @@ export class Finalizer {
|
|
|
498
524
|
}
|
|
499
525
|
|
|
500
526
|
/**
|
|
501
|
-
* Invoke the
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
* is
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (!this.provider || !this.currentRepo) {
|
|
510
|
-
this.logger.debug?.(
|
|
511
|
-
'[Finalizer] code-review graduation skipped: provider or currentRepo not wired',
|
|
512
|
-
);
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
try {
|
|
516
|
-
const summary = await this.graduateFindingsFn({
|
|
517
|
-
epicId: this.epicId,
|
|
518
|
-
provider: this.provider,
|
|
519
|
-
config: this.config,
|
|
520
|
-
currentRepo: this.currentRepo,
|
|
521
|
-
frameworkRepo: this.frameworkRepo,
|
|
522
|
-
cwd: this.cwd,
|
|
523
|
-
logger: this.logger,
|
|
524
|
-
});
|
|
525
|
-
const filed = Array.isArray(summary?.filed) ? summary.filed.length : 0;
|
|
526
|
-
const skipped = Array.isArray(summary?.skipped)
|
|
527
|
-
? summary.skipped.length
|
|
528
|
-
: 0;
|
|
529
|
-
const errors = Array.isArray(summary?.errors) ? summary.errors.length : 0;
|
|
530
|
-
this.logger.info?.(
|
|
531
|
-
`[Finalizer] code-review graduation: filed=${filed} skipped=${skipped} errors=${errors}`,
|
|
532
|
-
);
|
|
533
|
-
if (errors > 0) {
|
|
534
|
-
this.logger.warn?.(
|
|
535
|
-
`[Finalizer] code-review graduator errors: ${summary.errors.join('; ')}`,
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
} catch (err) {
|
|
539
|
-
this.logger.warn?.(
|
|
540
|
-
`[Finalizer] code-review graduator threw (swallowed): ${err?.message ?? err}`,
|
|
541
|
-
);
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Invoke the audit-results graduator best-effort. Wired into finalize
|
|
547
|
-
* so that non-blocking audit findings (high/medium/low/suggestion) get
|
|
548
|
-
* auto-filed as routed follow-up issues — Story #2615 / Epic #2586.
|
|
527
|
+
* Invoke the audit-results graduator best-effort — the SINGLE canonical
|
|
528
|
+
* graduation pass over the unified `verification-results` comment. Wired
|
|
529
|
+
* into finalize so that non-blocking findings (high/medium/low/suggestion)
|
|
530
|
+
* get auto-filed as routed follow-up issues — Story #2615 / Epic #2586,
|
|
531
|
+
* unified into one pass in Story #4411 / Epic #4405. The lens-aware
|
|
532
|
+
* audit-results graduator is canonical because its 🟢→`suggestion`
|
|
533
|
+
* severity vocabulary matches `findings-renderer.js`; running a parallel
|
|
534
|
+
* code-review pass over the same comment would double-file every finding.
|
|
549
535
|
*/
|
|
550
536
|
async _runAuditResultsGraduation() {
|
|
551
537
|
if (!this.provider || !this.currentRepo) {
|
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
* 4. AutomergeArmer (epic.merge.ready → epic.merge.armed)
|
|
19
19
|
* 5. AutomergePredicate (epic.watch.end → epic.merge.{ready,blocked})
|
|
20
20
|
* 6. BranchCleaner (epic.cleanup.start → branch reap)
|
|
21
|
-
* 7.
|
|
22
|
-
* 8.
|
|
21
|
+
* 7. MergeWatcher (epic.merge.armed → epic.merge.confirmed)
|
|
22
|
+
* 8. Cleaner (epic.merge.confirmed → epic.cleanup.* / epic.complete)
|
|
23
|
+
* 9. LabelTransitioner (epic.complete → Epic ticket flips to agent::done)
|
|
24
|
+
* 10. CheckpointPointerWriter (every *.end → checkpoint.json)
|
|
23
25
|
*
|
|
24
26
|
* The bus contract requires LedgerWriter first: its `onEmitted` hook
|
|
25
27
|
* lands the `emitted` ledger record on disk BEFORE any listener body
|
|
@@ -60,6 +62,7 @@ import { BranchCleaner } from './branch-cleaner.js';
|
|
|
60
62
|
import { CheckpointPointerWriter } from './checkpoint-pointer-writer.js';
|
|
61
63
|
import { Cleaner } from './cleaner.js';
|
|
62
64
|
import { Finalizer } from './finalizer.js';
|
|
65
|
+
import { LabelTransitioner } from './label-transitioner.js';
|
|
63
66
|
import { MergeWatcher } from './merge-watcher.js';
|
|
64
67
|
|
|
65
68
|
/**
|
|
@@ -118,6 +121,11 @@ export function parseLedgerPath(ledgerPath) {
|
|
|
118
121
|
* @param {object} [opts.checkpointer] Epic-run-state checkpoint reader.
|
|
119
122
|
* When omitted, BranchCleaner is skipped.
|
|
120
123
|
* @param {object} [opts.logger] Logger surface (`debug`/`warn`/`error`).
|
|
124
|
+
* @param {boolean} [opts.headless] Explicit must-land signal (Story
|
|
125
|
+
* #4427), threaded straight through to `MergeWatcher`. Defaults to
|
|
126
|
+
* `false` (attended-mode behavior unchanged). `lifecycle-emit.js`
|
|
127
|
+
* resolves this from its own `--headless` runtime flag — an explicit
|
|
128
|
+
* input, never an ambient global.
|
|
121
129
|
*
|
|
122
130
|
* @returns {Promise<{
|
|
123
131
|
* ledgerWriter: object,
|
|
@@ -140,6 +148,7 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
140
148
|
config = null,
|
|
141
149
|
checkpointer = null,
|
|
142
150
|
logger = console,
|
|
151
|
+
headless = false,
|
|
143
152
|
} = opts;
|
|
144
153
|
if (
|
|
145
154
|
!bus ||
|
|
@@ -217,10 +226,15 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
217
226
|
order.push('Finalizer');
|
|
218
227
|
|
|
219
228
|
// 4. AutomergeArmer — arms `gh pr merge --auto --squash --delete-branch`
|
|
220
|
-
// on epic.merge.ready.
|
|
229
|
+
// on epic.merge.ready. Story #4472: `headless` gates the direct-merge
|
|
230
|
+
// fallback's terminal escalation (a genuine arm failure emits
|
|
231
|
+
// `merge.unlanded` + `epic.blocked` in a `--yes` run instead of
|
|
232
|
+
// returning silently); `epicId` scopes the `merge.unlanded` ledger row.
|
|
221
233
|
const automergeArmer = new AutomergeArmer({
|
|
222
234
|
bus,
|
|
235
|
+
epicId,
|
|
223
236
|
cwd: repoRoot,
|
|
237
|
+
headless,
|
|
224
238
|
logger,
|
|
225
239
|
});
|
|
226
240
|
automergeArmer.register();
|
|
@@ -241,6 +255,7 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
241
255
|
provider,
|
|
242
256
|
config,
|
|
243
257
|
cwd: repoRoot,
|
|
258
|
+
headless,
|
|
244
259
|
logger,
|
|
245
260
|
});
|
|
246
261
|
automergePredicate.register();
|
|
@@ -288,6 +303,7 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
288
303
|
cwd: repoRoot,
|
|
289
304
|
intervalSeconds: mergeWatchConfig.intervalSeconds,
|
|
290
305
|
maxBudgetSeconds: mergeWatchConfig.maxBudgetSeconds,
|
|
306
|
+
headless,
|
|
291
307
|
logger,
|
|
292
308
|
});
|
|
293
309
|
mergeWatcher.register();
|
|
@@ -306,7 +322,32 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
306
322
|
cleaner.register();
|
|
307
323
|
order.push('Cleaner');
|
|
308
324
|
|
|
309
|
-
//
|
|
325
|
+
// 9. LabelTransitioner — flips the Epic ticket to `agent::done` (and
|
|
326
|
+
// closes it as completed, idempotently) on the terminal
|
|
327
|
+
// `epic.complete` event. Requires a truthy `provider`; skip
|
|
328
|
+
// cleanly when the caller omitted one — the same guard pattern as
|
|
329
|
+
// AutomergePredicate. Restores the contract the Cleaner /
|
|
330
|
+
// BranchCleaner / MergeWatcher docstrings have referenced since
|
|
331
|
+
// the original listener was deleted with the epic-runner stratum
|
|
332
|
+
// (#3936): without this registration the flip had NO owner and
|
|
333
|
+
// cleanly-merged Epics stranded at `agent::executing`.
|
|
334
|
+
let labelTransitioner = null;
|
|
335
|
+
if (provider) {
|
|
336
|
+
labelTransitioner = new LabelTransitioner({
|
|
337
|
+
bus,
|
|
338
|
+
epicId,
|
|
339
|
+
provider,
|
|
340
|
+
logger,
|
|
341
|
+
});
|
|
342
|
+
labelTransitioner.register();
|
|
343
|
+
order.push('LabelTransitioner');
|
|
344
|
+
} else {
|
|
345
|
+
logger?.debug?.(
|
|
346
|
+
'[lifecycle] buildDefaultListenerChain: skipping LabelTransitioner (no provider)',
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 10. CheckpointPointerWriter — persists `{ lastCompletedSeqId, phase }`
|
|
310
351
|
// on every `*.end` event.
|
|
311
352
|
const checkpointPointerWriter = new CheckpointPointerWriter({
|
|
312
353
|
bus,
|
|
@@ -330,6 +371,7 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
330
371
|
branchCleaner,
|
|
331
372
|
mergeWatcher,
|
|
332
373
|
cleaner,
|
|
374
|
+
labelTransitioner,
|
|
333
375
|
checkpointPointerWriter,
|
|
334
376
|
order,
|
|
335
377
|
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// .agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js
|
|
2
|
+
/**
|
|
3
|
+
* LabelTransitioner — lifecycle listener that owns the terminal Epic
|
|
4
|
+
* ticket-state flip: on `epic.complete` it transitions the Epic to
|
|
5
|
+
* `agent::done` via the canonical `transitionTicketState` API (which
|
|
6
|
+
* also closes the issue with `state_reason: completed` and mirrors the
|
|
7
|
+
* Projects-v2 status column).
|
|
8
|
+
*
|
|
9
|
+
* Subscribes to:
|
|
10
|
+
* - `epic.complete` → and ONLY this event.
|
|
11
|
+
*
|
|
12
|
+
* Why this listener exists (regression history): the original
|
|
13
|
+
* LabelTransitioner lived in the in-process epic-runner stratum and was
|
|
14
|
+
* deleted with it (Story #3908 / #3936) — but the `lifecycle-emit`
|
|
15
|
+
* listener chain never re-registered a replacement, so the Epic
|
|
16
|
+
* `agent::done` flip silently had NO owner. Every docstring in
|
|
17
|
+
* `cleaner.js` / `branch-cleaner.js` / `merge-watcher.js` that says
|
|
18
|
+
* "LabelTransitioner flips the Epic ticket to `agent::done` on
|
|
19
|
+
* epic.complete" referenced a ghost. In practice the flip only happened
|
|
20
|
+
* when a driving session (or the operator) ran `update-ticket-state.js`
|
|
21
|
+
* by hand — observed live on 2026-07-11 when Epics #4405 / #4425 /
|
|
22
|
+
* #4429 merged cleanly (Cleaner archived, `epic.complete` on the
|
|
23
|
+
* ledger) yet stayed at `agent::executing`. This listener restores the
|
|
24
|
+
* documented contract on the SOLE production wiring path
|
|
25
|
+
* (`buildDefaultListenerChain`).
|
|
26
|
+
*
|
|
27
|
+
* Side effects executed inside `handle()`:
|
|
28
|
+
* 1. `transitionTicketState(provider, epicId, STATE_LABELS.DONE)` —
|
|
29
|
+
* adds `agent::done`, removes every other `agent::*` label, closes
|
|
30
|
+
* the issue as completed (idempotent when the GitHub Closes-#N
|
|
31
|
+
* linkage already closed it), syncs the board column, and runs the
|
|
32
|
+
* upward cascade (a no-op sweep here: story-close already flipped
|
|
33
|
+
* every child Story).
|
|
34
|
+
*
|
|
35
|
+
* Failure posture: a failed transition THROWS (per
|
|
36
|
+
* `rules/orchestration-error-handling.md` — throw, never fatal). The
|
|
37
|
+
* bus's `onFailed` hook records the failure on the ledger and
|
|
38
|
+
* `lifecycle-emit`'s `collectOutcomes` → `emitBlockedSignal` path
|
|
39
|
+
* surfaces it loudly, so a provider outage cannot silently strand the
|
|
40
|
+
* Epic at `agent::executing` again — the exact failure mode this
|
|
41
|
+
* listener exists to close.
|
|
42
|
+
*
|
|
43
|
+
* Idempotency contract: per-instance `Set<string>` of
|
|
44
|
+
* `${event}:${seqId}` keys (the standard bus-replay defence). The
|
|
45
|
+
* transition itself is also idempotent at the provider layer (label
|
|
46
|
+
* add/remove and a close on an already-closed issue are no-ops), so a
|
|
47
|
+
* cross-process replay after a crash re-runs the flip harmlessly.
|
|
48
|
+
*
|
|
49
|
+
* Side-effect firewall: exactly one provider call per handled event. No
|
|
50
|
+
* filesystem writes, no follow-up bus emits, no `gh` shell-outs.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { STATE_LABELS } from '../../ticketing/reads.js';
|
|
54
|
+
import { transitionTicketState } from '../../ticketing/transition.js';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The single lifecycle event this listener subscribes to. `epic.complete`
|
|
58
|
+
* is the terminal event of a successful Epic run, emitted by Cleaner
|
|
59
|
+
* AFTER the MergeWatcher observed a non-null mergeCommit — so the flip
|
|
60
|
+
* can never fire for an Epic whose PR did not actually merge.
|
|
61
|
+
*/
|
|
62
|
+
export const SUBSCRIBED_EVENT = 'epic.complete';
|
|
63
|
+
|
|
64
|
+
export class LabelTransitioner {
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} opts
|
|
67
|
+
* @param {object} opts.bus Lifecycle bus exposing `on()`.
|
|
68
|
+
* @param {number} opts.epicId Epic ticket id.
|
|
69
|
+
* @param {import('../../../ITicketingProvider.js').ITicketingProvider} opts.provider
|
|
70
|
+
* Ticketing provider. Required — the chain builder skips this
|
|
71
|
+
* listener entirely when no provider is wired (parity with
|
|
72
|
+
* AutomergePredicate's guard), so construction can demand one.
|
|
73
|
+
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
|
|
74
|
+
*/
|
|
75
|
+
constructor(opts = {}) {
|
|
76
|
+
if (!opts.bus || typeof opts.bus.on !== 'function') {
|
|
77
|
+
throw new TypeError('LabelTransitioner requires a bus with on()');
|
|
78
|
+
}
|
|
79
|
+
if (!Number.isInteger(opts.epicId) || opts.epicId < 1) {
|
|
80
|
+
throw new TypeError('LabelTransitioner requires a numeric epicId');
|
|
81
|
+
}
|
|
82
|
+
if (!opts.provider) {
|
|
83
|
+
// Truthiness-only, parity with AutomergePredicate: the chain
|
|
84
|
+
// builder's best-effort registration must not explode on a
|
|
85
|
+
// shape-minimal provider — a malformed one fails loudly at
|
|
86
|
+
// handle time instead, where the ledger records the outcome.
|
|
87
|
+
throw new TypeError('LabelTransitioner requires a provider');
|
|
88
|
+
}
|
|
89
|
+
this.bus = opts.bus;
|
|
90
|
+
this.epicId = opts.epicId;
|
|
91
|
+
this.provider = opts.provider;
|
|
92
|
+
this.logger = opts.logger ?? console;
|
|
93
|
+
/** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
|
|
94
|
+
this._seen = new Set();
|
|
95
|
+
// Canonical subscription-set shape: the lifecycle doc-drift gate
|
|
96
|
+
// (`check-lifecycle-doc-drift.js#extractCodeEvents`) and the
|
|
97
|
+
// event-connectivity contract test both resolve this frozen array
|
|
98
|
+
// (constant references included) to derive the subscriber table.
|
|
99
|
+
this.events = Object.freeze([SUBSCRIBED_EVENT]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Register the listener on `epic.complete`. Returns the array of
|
|
104
|
+
* unsubscribe callbacks the bus produced (parity with the sibling
|
|
105
|
+
* listeners).
|
|
106
|
+
*/
|
|
107
|
+
register() {
|
|
108
|
+
return this.events.map((event) =>
|
|
109
|
+
this.bus.on(event, async (ctx) => this.handle(ctx)),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Bus listener body. Idempotent on `(event, seqId)`; flips the Epic
|
|
115
|
+
* to `agent::done` exactly once per observed `epic.complete`.
|
|
116
|
+
*/
|
|
117
|
+
async handle({ event, seqId }) {
|
|
118
|
+
const key = `${event}:${seqId}`;
|
|
119
|
+
if (this._seen.has(key)) {
|
|
120
|
+
this.logger.debug?.(
|
|
121
|
+
`[LabelTransitioner] skip duplicate ${key} (idempotent)`,
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this._seen.add(key);
|
|
126
|
+
|
|
127
|
+
this.logger.info?.(
|
|
128
|
+
`[LabelTransitioner] epic.complete observed — transitioning Epic #${this.epicId} to ${STATE_LABELS.DONE}.`,
|
|
129
|
+
);
|
|
130
|
+
// Throws on failure by design: the ledger records the failed
|
|
131
|
+
// listener outcome and lifecycle-emit surfaces it (see the failure
|
|
132
|
+
// posture note in the module docstring). Swallowing here would
|
|
133
|
+
// recreate the silent agent::executing strand this listener fixes.
|
|
134
|
+
await transitionTicketState(this.provider, this.epicId, STATE_LABELS.DONE);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Test-only — clear the idempotency cache so a single instance can
|
|
139
|
+
* exercise replay scenarios without re-constructing the listener.
|
|
140
|
+
*/
|
|
141
|
+
resetSeen() {
|
|
142
|
+
this._seen.clear();
|
|
143
|
+
}
|
|
144
|
+
}
|