mandrel 1.87.0 → 1.89.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 +63 -16
- package/.agents/docs/configuration.md +5 -3
- 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 +16 -4
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -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/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/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +21 -2
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- 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 +66 -20
- 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/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 +188 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
- 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 +218 -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/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/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 +193 -44
- package/.agents/workflows/helpers/deliver-epic.md +128 -39
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -283
- 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 -207
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
|
@@ -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
|
+
}
|
|
@@ -41,11 +41,47 @@
|
|
|
41
41
|
* labels, post comments, or call `notify`. Downstream listeners
|
|
42
42
|
* (Cleaner / LabelTransitioner on `epic.merge.confirmed`, Task #2912)
|
|
43
43
|
* own those side effects.
|
|
44
|
+
*
|
|
45
|
+
* Must-land terminal step (Story #4427, Epic #4425 slice 2). In
|
|
46
|
+
* headless (`--yes`) delivery runs — signalled via the explicit
|
|
47
|
+
* `headless` constructor option, threaded from `/deliver`'s `--yes`
|
|
48
|
+
* flag through `lifecycle-emit.js`'s `--headless` runtime flag and
|
|
49
|
+
* `buildDefaultListenerChain({ headless })` — budget exhaustion no
|
|
50
|
+
* longer falls straight through to `epic.blocked`. Instead the watcher
|
|
51
|
+
* classifies the block (`classifyMergeBlock`, the shared classifier
|
|
52
|
+
* from Story #4426) and, bounded by one attempt each per watch run:
|
|
53
|
+
*
|
|
54
|
+
* - `checks-pending-timeout` (required checks still progressing) →
|
|
55
|
+
* extend the watch budget once and keep polling in the SAME watch
|
|
56
|
+
* cycle.
|
|
57
|
+
* - `api-race-other` (no definitive block signal) → re-arm once by
|
|
58
|
+
* re-emitting `epic.merge.ready` on the bus. This does NOT call
|
|
59
|
+
* `gh pr merge` directly — AutomergeArmer remains the sole
|
|
60
|
+
* authorized call site (merge-lockout invariant, Story #4427 AC).
|
|
61
|
+
* AutomergeArmer's own idempotent `gh pr view` probe short-circuits
|
|
62
|
+
* to a single `epic.merge.armed` re-emit when auto-merge is
|
|
63
|
+
* already armed on the PR, which re-triggers this watcher's
|
|
64
|
+
* `handle()` for a fresh watch cycle that continues the resume
|
|
65
|
+
* ledger's attempt count.
|
|
66
|
+
* - `branch-protection-human-required`, or retries already
|
|
67
|
+
* exhausted (both bounded attempts spent) → terminal: emit
|
|
68
|
+
* `merge.unlanded` (scope `"epic"`, carrying the block class) via
|
|
69
|
+
* `emitMergeUnlanded`, THEN fall through to the existing single
|
|
70
|
+
* `epic.blocked` emit below — one blocked path, never a duplicate
|
|
71
|
+
* `agent::blocked` transition.
|
|
72
|
+
*
|
|
73
|
+
* Attended-mode (headless === false, the default) behavior is
|
|
74
|
+
* byte-for-byte unchanged: budget exhaustion emits exactly
|
|
75
|
+
* `epic.blocked` with `reason: 'merge-watch:budget-exceeded'`, no
|
|
76
|
+
* classification, no retry, no `merge.unlanded`.
|
|
44
77
|
*/
|
|
45
78
|
|
|
46
79
|
import { spawnSync } from 'node:child_process';
|
|
47
80
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
48
81
|
import path from 'node:path';
|
|
82
|
+
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
83
|
+
import { classifyMergeBlock } from '../../merge-block-class.js';
|
|
84
|
+
import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
|
|
49
85
|
|
|
50
86
|
/**
|
|
51
87
|
* Default poll interval and budget. The schema in
|
|
@@ -59,15 +95,31 @@ export const DEFAULT_INTERVAL_SECONDS = 30;
|
|
|
59
95
|
export const DEFAULT_MAX_BUDGET_SECONDS = 3600;
|
|
60
96
|
|
|
61
97
|
/**
|
|
62
|
-
*
|
|
98
|
+
* Fields requested from `gh pr view` on every poll. The merge-confirm
|
|
99
|
+
* fields (`mergeCommit`, `mergedAt`, `number`) are the original Story
|
|
100
|
+
* #2896 contract; `mergeStateStatus`, `reviewDecision`, and
|
|
101
|
+
* `statusCheckRollup` were added in Story #4427 so a headless
|
|
102
|
+
* budget-exhaustion path can classify the block (`classifyMergeBlock`)
|
|
103
|
+
* from the SAME probe already being polled, instead of issuing a
|
|
104
|
+
* second `gh` call.
|
|
105
|
+
*/
|
|
106
|
+
const PR_VIEW_JSON_FIELDS =
|
|
107
|
+
'mergeCommit,mergedAt,number,mergeStateStatus,reviewDecision,statusCheckRollup';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Default `gh pr view --json <PR_VIEW_JSON_FIELDS>` probe. Pure-spawn
|
|
63
111
|
* helper — exported so tests can stub the shell-out without touching
|
|
64
112
|
* the spawn wrapper.
|
|
65
113
|
*/
|
|
66
114
|
export function ghPrViewMerge({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
67
115
|
const result = spawnFn(
|
|
68
116
|
'gh',
|
|
69
|
-
['pr', 'view', prUrl, '--json',
|
|
70
|
-
{
|
|
117
|
+
['pr', 'view', prUrl, '--json', PR_VIEW_JSON_FIELDS],
|
|
118
|
+
{
|
|
119
|
+
cwd,
|
|
120
|
+
encoding: 'utf-8',
|
|
121
|
+
shell: false,
|
|
122
|
+
},
|
|
71
123
|
);
|
|
72
124
|
return {
|
|
73
125
|
status: result.status ?? 1,
|
|
@@ -77,24 +129,58 @@ export function ghPrViewMerge({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
77
129
|
}
|
|
78
130
|
|
|
79
131
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* `
|
|
83
|
-
*
|
|
132
|
+
* Pure: derive an aggregate `checksStatus` (`success` | `pending` |
|
|
133
|
+
* `still-running` | `failure` | `unknown`) from a
|
|
134
|
+
* `statusCheckRollup` array (`gh pr view --json statusCheckRollup`
|
|
135
|
+
* shape: `{ status, conclusion }` per check). Mirrors the values
|
|
136
|
+
* `classifyMergeBlock` expects on `prProbe.checksStatus`.
|
|
137
|
+
*/
|
|
138
|
+
export function deriveChecksStatus(statusCheckRollup) {
|
|
139
|
+
if (!Array.isArray(statusCheckRollup) || statusCheckRollup.length === 0) {
|
|
140
|
+
return 'unknown';
|
|
141
|
+
}
|
|
142
|
+
let anyPending = false;
|
|
143
|
+
for (const check of statusCheckRollup) {
|
|
144
|
+
const conclusion = String(check?.conclusion ?? '').toUpperCase();
|
|
145
|
+
const status = String(check?.status ?? '').toUpperCase();
|
|
146
|
+
if (['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ERROR'].includes(conclusion)) {
|
|
147
|
+
return 'failure';
|
|
148
|
+
}
|
|
149
|
+
if (status !== 'COMPLETED') {
|
|
150
|
+
anyPending = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return anyPending ? 'still-running' : 'success';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse `gh pr view --json <PR_VIEW_JSON_FIELDS>` output. Returns
|
|
158
|
+
* `{ mergeCommitSha, mergedAt, prNumber, mergeStateStatus,
|
|
159
|
+
* reviewDecision, checksStatus }` where `mergeCommitSha` is `null`
|
|
160
|
+
* until the PR has merged. Pure — exported for tests so the
|
|
161
|
+
* JSON-shape pin is reviewable.
|
|
84
162
|
*/
|
|
85
163
|
export function parseMergeView(stdout) {
|
|
164
|
+
const empty = {
|
|
165
|
+
mergeCommitSha: null,
|
|
166
|
+
mergedAt: null,
|
|
167
|
+
prNumber: null,
|
|
168
|
+
mergeStateStatus: null,
|
|
169
|
+
reviewDecision: null,
|
|
170
|
+
checksStatus: 'unknown',
|
|
171
|
+
};
|
|
86
172
|
const trimmed = String(stdout ?? '').trim();
|
|
87
173
|
if (trimmed.length === 0) {
|
|
88
|
-
return
|
|
174
|
+
return empty;
|
|
89
175
|
}
|
|
90
176
|
let parsed;
|
|
91
177
|
try {
|
|
92
178
|
parsed = JSON.parse(trimmed);
|
|
93
179
|
} catch {
|
|
94
|
-
return
|
|
180
|
+
return empty;
|
|
95
181
|
}
|
|
96
182
|
if (!parsed || typeof parsed !== 'object') {
|
|
97
|
-
return
|
|
183
|
+
return empty;
|
|
98
184
|
}
|
|
99
185
|
const merge = parsed.mergeCommit;
|
|
100
186
|
const sha =
|
|
@@ -103,9 +189,35 @@ export function parseMergeView(stdout) {
|
|
|
103
189
|
: null;
|
|
104
190
|
const mergedAt = typeof parsed.mergedAt === 'string' ? parsed.mergedAt : null;
|
|
105
191
|
const prNumber = Number.isInteger(parsed.number) ? parsed.number : null;
|
|
106
|
-
|
|
192
|
+
const mergeStateStatus =
|
|
193
|
+
typeof parsed.mergeStateStatus === 'string'
|
|
194
|
+
? parsed.mergeStateStatus
|
|
195
|
+
: null;
|
|
196
|
+
const reviewDecision =
|
|
197
|
+
typeof parsed.reviewDecision === 'string' ? parsed.reviewDecision : null;
|
|
198
|
+
const checksStatus = deriveChecksStatus(parsed.statusCheckRollup);
|
|
199
|
+
return {
|
|
200
|
+
mergeCommitSha: sha,
|
|
201
|
+
mergedAt,
|
|
202
|
+
prNumber,
|
|
203
|
+
mergeStateStatus,
|
|
204
|
+
reviewDecision,
|
|
205
|
+
checksStatus,
|
|
206
|
+
};
|
|
107
207
|
}
|
|
108
208
|
|
|
209
|
+
// `parsePrNumberFromUrl` (imported above from the Story #3649 canonical
|
|
210
|
+
// `lib/github-url.js` helper) is the last-resort fallback for a `gh pr
|
|
211
|
+
// view` probe that never successfully returned `number` (e.g. every poll
|
|
212
|
+
// on the final watch cycle probe-failed). `emitMergeUnlanded` requires a
|
|
213
|
+
// positive-integer `prNumber`, and `prUrl` is always present by the time
|
|
214
|
+
// a watch cycle starts (checked in `handle()`), so this is the
|
|
215
|
+
// last-resort source of truth. Re-exported here so existing imports of
|
|
216
|
+
// `parsePrNumberFromUrl` from this module (e.g.
|
|
217
|
+
// `tests/epic-must-land-terminal.test.js`) keep working without a
|
|
218
|
+
// duplicate implementation (code-review finding, Epic #4425).
|
|
219
|
+
export { parsePrNumberFromUrl };
|
|
220
|
+
|
|
109
221
|
/**
|
|
110
222
|
* Resolve the resume-ledger path for an Epic. Pure helper — exported
|
|
111
223
|
* so tests can pin the layout.
|
|
@@ -194,6 +306,16 @@ export class MergeWatcher {
|
|
|
194
306
|
* epoch ms.
|
|
195
307
|
* @param {() => string} [opts.nowIsoFn] override for tests; returns
|
|
196
308
|
* ISO-8601 wall-clock for the attempt record.
|
|
309
|
+
* @param {boolean} [opts.headless] Explicit must-land signal (Story
|
|
310
|
+
* #4427). Defaults to `false` — attended-mode behavior (immediate
|
|
311
|
+
* `epic.blocked` on budget exhaustion, no classification, no
|
|
312
|
+
* retry) is unchanged. `true` engages the bounded classify-and-
|
|
313
|
+
* retry terminal step. Threaded from `/deliver`'s `--yes` flag via
|
|
314
|
+
* `lifecycle-emit.js --headless true` → `buildDefaultListenerChain`
|
|
315
|
+
* — an explicit constructor input, never an ambient global.
|
|
316
|
+
* @param {Function} [opts.emitMergeUnlandedFn] override for tests
|
|
317
|
+
* (defaults to the real `emitMergeUnlanded`, which appends to the
|
|
318
|
+
* on-disk lifecycle ledger).
|
|
197
319
|
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
|
|
198
320
|
*/
|
|
199
321
|
constructor(opts = {}) {
|
|
@@ -229,14 +351,26 @@ export class MergeWatcher {
|
|
|
229
351
|
this.nowMsFn = opts.nowMsFn ?? Date.now;
|
|
230
352
|
this.nowIsoFn =
|
|
231
353
|
opts.nowIsoFn ?? (() => new Date(this.nowMsFn()).toISOString());
|
|
354
|
+
this.headless = opts.headless === true;
|
|
355
|
+
this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
|
|
232
356
|
this.logger = opts.logger ?? console;
|
|
233
357
|
/** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
|
|
234
358
|
this._seen = new Set();
|
|
359
|
+
/**
|
|
360
|
+
* Must-land bounded-retry state (Story #4427, headless only). Each
|
|
361
|
+
* flips to `true` on its single use across the instance's whole
|
|
362
|
+
* lifetime — NOT per watch cycle — so a re-armed or budget-extended
|
|
363
|
+
* cycle that times out again falls straight through to the
|
|
364
|
+
* `merge.unlanded` terminal rather than retrying indefinitely.
|
|
365
|
+
*/
|
|
366
|
+
this._budgetExtended = false;
|
|
367
|
+
this._reArmed = false;
|
|
235
368
|
/**
|
|
236
369
|
* Classification log — every `epic.merge.armed` observed lands
|
|
237
370
|
* here with the outcome (`confirmed`, `budget-exceeded`,
|
|
238
|
-
* `skipped-duplicate`, `failed
|
|
239
|
-
* "no silent skip"
|
|
371
|
+
* `skipped-duplicate`, `failed`, or — headless only — `extended` /
|
|
372
|
+
* `re-armed`). Mirrors the Armer / Cleaner "no silent skip"
|
|
373
|
+
* surface.
|
|
240
374
|
*/
|
|
241
375
|
this.classifications = [];
|
|
242
376
|
// Frozen tuple — MergeWatcher subscribes to EXACTLY one event.
|
|
@@ -292,7 +426,9 @@ export class MergeWatcher {
|
|
|
292
426
|
}
|
|
293
427
|
|
|
294
428
|
const intervalMs = this.intervalSeconds * 1000;
|
|
295
|
-
const
|
|
429
|
+
// `let`, not `const`: the headless must-land path extends this
|
|
430
|
+
// once on a `checks-pending-timeout` classification (Story #4427).
|
|
431
|
+
let budgetMs = this.maxBudgetSeconds * 1000;
|
|
296
432
|
const startedAtMs = this.nowMsFn();
|
|
297
433
|
let attempt = priorAttempts;
|
|
298
434
|
|
|
@@ -377,6 +513,112 @@ export class MergeWatcher {
|
|
|
377
513
|
// Not merged. Budget check before sleeping.
|
|
378
514
|
const elapsedMs = this.nowMsFn() - startedAtMs;
|
|
379
515
|
if (elapsedMs + intervalMs > budgetMs) {
|
|
516
|
+
// Headless must-land: classify the block and try the bounded
|
|
517
|
+
// per-instance retry (budget extension OR re-arm, each at most
|
|
518
|
+
// once across this watcher's whole lifetime) before giving up.
|
|
519
|
+
// Attended mode (this.headless === false) skips straight to the
|
|
520
|
+
// unchanged budget-exceeded → epic.blocked path below.
|
|
521
|
+
if (this.headless) {
|
|
522
|
+
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
|
523
|
+
const classification = classifyMergeBlock({
|
|
524
|
+
prProbe: {
|
|
525
|
+
reviewDecision: view.reviewDecision,
|
|
526
|
+
mergeStateStatus: view.mergeStateStatus,
|
|
527
|
+
checksStatus: view.checksStatus,
|
|
528
|
+
error:
|
|
529
|
+
probe.status !== 0
|
|
530
|
+
? probe.stderr || 'gh pr view failed'
|
|
531
|
+
: undefined,
|
|
532
|
+
},
|
|
533
|
+
budget: { exhausted: true, elapsedSeconds },
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
if (
|
|
537
|
+
classification.blockClass === 'checks-pending-timeout' &&
|
|
538
|
+
!this._budgetExtended
|
|
539
|
+
) {
|
|
540
|
+
this._budgetExtended = true;
|
|
541
|
+
budgetMs += this.maxBudgetSeconds * 1000;
|
|
542
|
+
this.classifications.push({
|
|
543
|
+
event,
|
|
544
|
+
seqId,
|
|
545
|
+
outcome: 'extended',
|
|
546
|
+
reason: classification.reason,
|
|
547
|
+
prUrl,
|
|
548
|
+
pollAttempts: attempt,
|
|
549
|
+
});
|
|
550
|
+
this.logger.info?.(
|
|
551
|
+
`[MergeWatcher] extending watch budget once (checks-pending-timeout): ${classification.reason}`,
|
|
552
|
+
);
|
|
553
|
+
await this.sleepFn(intervalMs);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (
|
|
558
|
+
classification.blockClass === 'api-race-other' &&
|
|
559
|
+
!this._reArmed
|
|
560
|
+
) {
|
|
561
|
+
this._reArmed = true;
|
|
562
|
+
this.classifications.push({
|
|
563
|
+
event,
|
|
564
|
+
seqId,
|
|
565
|
+
outcome: 're-armed',
|
|
566
|
+
reason: classification.reason,
|
|
567
|
+
prUrl,
|
|
568
|
+
pollAttempts: attempt,
|
|
569
|
+
});
|
|
570
|
+
this.logger.info?.(
|
|
571
|
+
`[MergeWatcher] re-arming once (api-race-other): ${classification.reason}`,
|
|
572
|
+
);
|
|
573
|
+
let reArmEmitSucceeded = false;
|
|
574
|
+
try {
|
|
575
|
+
await this.bus.emit('epic.merge.ready', {
|
|
576
|
+
prUrl,
|
|
577
|
+
reason: `must-land retry: ${classification.reason}`,
|
|
578
|
+
});
|
|
579
|
+
reArmEmitSucceeded = true;
|
|
580
|
+
} catch (err) {
|
|
581
|
+
this.logger.warn?.(
|
|
582
|
+
`[MergeWatcher] must-land re-arm epic.merge.ready emit failed: ${err?.message ?? err}`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
if (reArmEmitSucceeded) {
|
|
586
|
+
// A successful re-arm re-emits epic.merge.armed (via
|
|
587
|
+
// AutomergeArmer's idempotent-probe short-circuit or a
|
|
588
|
+
// fresh arm), which re-triggers this watcher's handle()
|
|
589
|
+
// for a new watch cycle continuing the resume ledger's
|
|
590
|
+
// attempt count. Do NOT also emit epic.blocked here.
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// The re-arm attempt itself failed to emit — the bounded
|
|
594
|
+
// retry is spent with nothing landed. Fall through to the
|
|
595
|
+
// terminal merge.unlanded + epic.blocked path below rather
|
|
596
|
+
// than returning silently (audit-quality Critical finding,
|
|
597
|
+
// Epic #4425): a swallowed re-arm failure must still
|
|
598
|
+
// surface as an explicit block, never a silent stall.
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Terminal: branch-protection-human-required, or both bounded
|
|
602
|
+
// retries already spent. Emit merge.unlanded before falling
|
|
603
|
+
// through to the existing single epic.blocked emit below —
|
|
604
|
+
// one blocked path, never a duplicate agent::blocked
|
|
605
|
+
// transition.
|
|
606
|
+
try {
|
|
607
|
+
this.emitMergeUnlandedFn({
|
|
608
|
+
scope: 'epic',
|
|
609
|
+
ticketId: this.epicId,
|
|
610
|
+
prNumber: view.prNumber ?? parsePrNumberFromUrl(prUrl),
|
|
611
|
+
blockClass: classification.blockClass,
|
|
612
|
+
reason: classification.reason,
|
|
613
|
+
elapsedSeconds,
|
|
614
|
+
});
|
|
615
|
+
} catch (err) {
|
|
616
|
+
this.logger.warn?.(
|
|
617
|
+
`[MergeWatcher] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
380
622
|
this.classifications.push({
|
|
381
623
|
event,
|
|
382
624
|
seqId,
|
|
@@ -416,6 +658,8 @@ export class MergeWatcher {
|
|
|
416
658
|
|
|
417
659
|
reset() {
|
|
418
660
|
this._seen.clear();
|
|
661
|
+
this._budgetExtended = false;
|
|
662
|
+
this._reArmed = false;
|
|
419
663
|
this.classifications = [];
|
|
420
664
|
}
|
|
421
665
|
}
|
|
@@ -147,6 +147,12 @@ export class NotifyDispatcher {
|
|
|
147
147
|
kind: 'notification.emitted',
|
|
148
148
|
seqId,
|
|
149
149
|
ts: new Date(this._now()).toISOString(),
|
|
150
|
+
// The common signal envelope requires `epicId` on the record
|
|
151
|
+
// itself — `hasCommonEnvelope` (lib/signals/schema.js) rejects
|
|
152
|
+
// rows without it and the read side silently drops them, so
|
|
153
|
+
// without this field the record was write-only (invisible to
|
|
154
|
+
// signals-view.js and every other reader).
|
|
155
|
+
epicId: this.epicId,
|
|
150
156
|
sourceEvent: event,
|
|
151
157
|
webhookEvent,
|
|
152
158
|
};
|