mandrel 2.0.0 → 2.2.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 +59 -28
- package/.agents/agents/acceptance-critic.md +20 -9
- package/.agents/agents/story-worker.md +45 -48
- package/.agents/audit-checklists/performance.md +1 -1
- package/.agents/docs/SDLC.md +60 -46
- package/.agents/docs/agentrc-reference.json +8 -13
- package/.agents/docs/configuration.md +33 -57
- package/.agents/docs/execution-reference.md +39 -10
- package/.agents/docs/quality-gates.md +17 -19
- package/.agents/docs/workflows.md +6 -6
- package/.agents/instructions.md +64 -79
- package/.agents/rules/ci-remediation.md +3 -3
- package/.agents/rules/gherkin-standards.md +10 -0
- package/.agents/rules/git-conventions-reference.md +42 -51
- package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
- package/.agents/schemas/agentrc.schema.json +35 -46
- package/.agents/schemas/audit-rules.json +59 -1
- package/.agents/schemas/audit-rules.schema.json +33 -1
- package/.agents/schemas/lifecycle/README.md +1 -2
- package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.flip-failed.schema.json +33 -0
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +1 -0
- package/.agents/schemas/lifecycle/story.merged.schema.json +1 -1
- package/.agents/schemas/signal-event.schema.json +3 -3
- package/.agents/schemas/story-deliver-terminal.schema.json +152 -0
- package/.agents/schemas/validation-evidence.schema.json +1 -1
- package/.agents/scripts/acceptance-eval.js +24 -68
- package/.agents/scripts/agents-bootstrap-github.js +1 -1
- package/.agents/scripts/bootstrap.js +3 -3
- package/.agents/scripts/check-dead-exports.js +43 -104
- package/.agents/scripts/check-doc-links.js +2 -2
- package/.agents/scripts/check-lifecycle-lint.js +1 -1
- package/.agents/scripts/check-workflow-cli-lint.js +91 -0
- package/.agents/scripts/deliver-recover.js +122 -0
- package/.agents/scripts/drain-pending-cleanup.js +1 -1
- package/.agents/scripts/evidence-gate.js +20 -50
- package/.agents/scripts/generate-skills-index.js +17 -1
- package/.agents/scripts/generate-workflows-doc.js +4 -4
- package/.agents/scripts/lib/ITicketingProvider.js +1 -19
- package/.agents/scripts/lib/audit-suite/selector.js +323 -23
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +0 -11
- package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +28 -33
- package/.agents/scripts/lib/bootstrap/manifest.js +8 -11
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +30 -53
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -2
- package/.agents/scripts/lib/checks/core-bare-clean.js +4 -1
- package/.agents/scripts/lib/checks/index.js +1 -1
- package/.agents/scripts/lib/checks/loop-health.js +12 -11
- package/.agents/scripts/lib/checks/state.js +17 -248
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +3 -3
- package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +3 -4
- package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +2 -2
- package/.agents/scripts/lib/checks/worktree-residue-biome.js +3 -3
- package/.agents/scripts/lib/cli-args.js +23 -2
- package/.agents/scripts/lib/close-validation/gates.js +13 -13
- package/.agents/scripts/lib/close-validation/projections/inputs.js +7 -7
- package/.agents/scripts/lib/close-validation/projections/maintainability.js +12 -12
- package/.agents/scripts/lib/close-validation/runner.js +13 -21
- package/.agents/scripts/lib/close-validation/telemetry.js +17 -8
- package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
- package/.agents/scripts/lib/config/delivery-routing.js +7 -6
- package/.agents/scripts/lib/config/explain.js +10 -16
- package/.agents/scripts/lib/config/github.js +7 -5
- package/.agents/scripts/lib/config/limits.js +15 -25
- package/.agents/scripts/lib/config/quality.js +11 -14
- package/.agents/scripts/lib/config/runners.js +8 -21
- package/.agents/scripts/lib/config/temp-paths.js +18 -56
- package/.agents/scripts/lib/config-settings-schema-delivery.js +34 -16
- package/.agents/scripts/lib/config-settings-schema-quality.js +9 -2
- package/.agents/scripts/lib/config-settings-schema.js +48 -22
- package/.agents/scripts/lib/dead-exports-knip.js +105 -0
- package/.agents/scripts/lib/dead-exports-mode.js +51 -0
- package/.agents/scripts/lib/duplicate-search.js +38 -7
- package/.agents/scripts/lib/findings/promote-finding.js +23 -14
- package/.agents/scripts/lib/format-generated-json.js +97 -0
- package/.agents/scripts/lib/framework-version.js +19 -189
- package/.agents/scripts/lib/gh-exec.js +8 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +0 -158
- package/.agents/scripts/lib/git-utils.js +0 -14
- package/.agents/scripts/lib/json-utils.js +1 -2
- package/.agents/scripts/lib/label-constants.js +0 -15
- package/.agents/scripts/lib/label-taxonomy.js +1 -12
- package/.agents/scripts/lib/observability/active-story-env.js +42 -163
- package/.agents/scripts/lib/observability/runtime-friction.js +243 -0
- package/.agents/scripts/lib/observability/signal-validator.js +4 -4
- package/.agents/scripts/lib/observability/signals-writer.js +6 -82
- package/.agents/scripts/lib/observability/source-classifier.js +2 -2
- package/.agents/scripts/lib/observability/tool-trace-hook.js +2 -12
- package/.agents/scripts/lib/orchestration/acceptance-clusters.js +1 -1
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +2 -2
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +43 -45
- package/.agents/scripts/lib/orchestration/change-set.js +103 -0
- package/.agents/scripts/lib/orchestration/code-review.js +70 -191
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +3 -3
- package/.agents/scripts/lib/orchestration/deliver-recover.js +328 -0
- package/.agents/scripts/lib/orchestration/detectors-phase.js +12 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/fast-forward.js +34 -0
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +3 -2
- package/.agents/scripts/lib/orchestration/lifecycle/emit-ledger-event.js +142 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +9 -11
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-flip-failed.js +86 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +37 -103
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +7 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +50 -85
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +3 -14
- package/.agents/scripts/lib/orchestration/merge-block-class.js +76 -20
- package/.agents/scripts/lib/orchestration/merge-poll.js +104 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +116 -33
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +26 -36
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +31 -22
- package/.agents/scripts/lib/orchestration/plan-metrics.js +38 -6
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +16 -6
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +173 -25
- package/.agents/scripts/lib/orchestration/plan-persist/plan-context-source.js +116 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +280 -100
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +472 -55
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +21 -16
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +509 -0
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +41 -40
- package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
- package/.agents/scripts/lib/orchestration/planning/spec-authoring-grounding.js +1 -1
- package/.agents/scripts/lib/orchestration/resolve-stories.js +344 -0
- package/.agents/scripts/lib/orchestration/retro-proposals.js +7 -7
- package/.agents/scripts/lib/orchestration/review-depth.js +105 -40
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +3 -13
- package/.agents/scripts/lib/orchestration/review-providers/native.js +1 -154
- package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +3 -2
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +1 -1
- package/.agents/scripts/lib/orchestration/review-providers/types.js +5 -4
- package/.agents/scripts/lib/orchestration/review-providers/ultrareview.js +1 -1
- package/.agents/scripts/lib/orchestration/run-epilogue.js +374 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +24 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +11 -9
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +4 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +4 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +608 -152
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +72 -30
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +305 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +12 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +37 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +2 -2
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +264 -43
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +10 -10
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +104 -279
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +360 -0
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +75 -14
- package/.agents/scripts/lib/orchestration/story-init-remote.js +12 -8
- package/.agents/scripts/lib/orchestration/story-plan-state.js +14 -29
- package/.agents/scripts/lib/orchestration/task-body-validator.js +52 -7
- package/.agents/scripts/lib/orchestration/ticket-lease.js +27 -74
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +119 -14
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +3 -4
- package/.agents/scripts/lib/orchestration/ticket-validator.js +121 -18
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +14 -47
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +19 -32
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +61 -1
- package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
- package/.agents/scripts/lib/plan-phase-cleanup.js +12 -14
- package/.agents/scripts/lib/planning-corpus.js +12 -286
- package/.agents/scripts/lib/preflight-runner.js +2 -2
- package/.agents/scripts/lib/qa/qa-context-hydrator.js +5 -5
- package/.agents/scripts/lib/signals/index.js +4 -17
- package/.agents/scripts/lib/signals/read.js +35 -35
- package/.agents/scripts/lib/signals/schema.js +8 -11
- package/.agents/scripts/lib/signals/span-tree.js +7 -7
- package/.agents/scripts/lib/signals/write.js +0 -1
- package/.agents/scripts/lib/single-story/story-merged-notify.js +13 -2
- package/.agents/scripts/lib/skills/parse-skill.js +16 -3
- package/.agents/scripts/lib/story-adjacency.js +8 -7
- package/.agents/scripts/lib/story-body/story-body.js +81 -13
- package/.agents/scripts/lib/templates/decomposer-prompts.js +15 -16
- package/.agents/scripts/lib/test-env.js +14 -1
- package/.agents/scripts/lib/test-tiers.js +0 -3
- package/.agents/scripts/lib/ticket-body-sections.js +0 -14
- package/.agents/scripts/lib/validation-evidence.js +31 -59
- package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
- package/.agents/scripts/lib/wave-runner/ready-set.js +32 -6
- package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +1 -1
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +68 -19
- package/.agents/scripts/lib/worktree/lifecycle-manager.js +1 -2
- package/.agents/scripts/plan-context.js +38 -7
- package/.agents/scripts/plan-critics.js +203 -0
- package/.agents/scripts/plan-persist.js +145 -35
- package/.agents/scripts/plan-run-epilogue.js +83 -38
- package/.agents/scripts/post-structured-comment.js +0 -38
- package/.agents/scripts/pr-watch-with-update.js +43 -22
- package/.agents/scripts/providers/github/compose.js +0 -1
- package/.agents/scripts/providers/github/errors.js +0 -19
- package/.agents/scripts/providers/github/issues.js +1 -11
- package/.agents/scripts/providers/github/mappers.js +5 -0
- package/.agents/scripts/providers/github/sub-issues.js +0 -47
- package/.agents/scripts/providers/github/tickets.js +33 -153
- package/.agents/scripts/providers/github.js +17 -6
- package/.agents/scripts/quality-preview.js +13 -6
- package/.agents/scripts/resolve-stories.js +236 -0
- package/.agents/scripts/run-coverage.js +4 -1
- package/.agents/scripts/run-lint.js +2 -2
- package/.agents/scripts/run-verify.js +31 -2
- package/.agents/scripts/signals-view.js +9 -10
- package/.agents/scripts/single-story-close.js +173 -18
- package/.agents/scripts/single-story-confirm-merge.js +288 -15
- package/.agents/scripts/single-story-init.js +6 -10
- package/.agents/scripts/stories-wave-tick.js +380 -53
- package/.agents/scripts/story-plan.js +3 -3
- package/.agents/scripts/update-ticket-state.js +8 -50
- package/.agents/skills/core/code-review-and-quality/SKILL.md +28 -450
- package/.agents/skills/core/code-review-and-quality/reference.md +458 -0
- package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +22 -315
- package/.agents/skills/core/debugging-and-error-recovery/reference.md +323 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +14 -18
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +25 -397
- package/.agents/skills/core/documentation-and-adrs/reference.md +403 -0
- package/.agents/skills/core/gates-and-baselines/SKILL.md +12 -12
- package/.agents/skills/core/idea-refinement/SKILL.md +3 -3
- package/.agents/skills/core/scope-triage/SKILL.md +3 -0
- package/.agents/skills/core/security-and-hardening/SKILL.md +22 -367
- package/.agents/skills/core/security-and-hardening/reference.md +375 -0
- package/.agents/skills/skills.index.json +2 -12
- package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +2 -4
- package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +1 -1
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -3
- package/.agents/workflows/audit-architecture.md +3 -4
- package/.agents/workflows/audit-clean-code.md +4 -4
- package/.agents/workflows/audit-documentation.md +4 -5
- package/.agents/workflows/audit-lighthouse.md +8 -0
- package/.agents/workflows/audit-navigability.md +10 -0
- package/.agents/workflows/audit-performance.md +2 -3
- package/.agents/workflows/audit-quality.md +8 -9
- package/.agents/workflows/audit-security.md +1 -2
- package/.agents/workflows/audit-seo.md +10 -0
- package/.agents/workflows/audit-ux-ui.md +7 -0
- package/.agents/workflows/deliver.md +133 -45
- package/.agents/workflows/git-cleanup.md +2 -2
- package/.agents/workflows/git-deliver.md +1 -1
- package/.agents/workflows/helpers/acceptance-self-eval.md +34 -17
- package/.agents/workflows/helpers/code-quality-guardrails.md +15 -12
- package/.agents/workflows/helpers/code-review.md +14 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +73 -32
- package/.agents/workflows/helpers/deliver-story.md +209 -118
- package/.agents/workflows/helpers/parallel-tooling.md +2 -2
- package/.agents/workflows/helpers/worktree-lifecycle.md +28 -32
- package/.agents/workflows/plan.md +239 -19
- package/.agents/workflows/qa-assist.md +6 -6
- package/.agents/workflows/qa-explore.md +3 -3
- package/.agents/workflows/qa-run.md +1 -5
- package/bin/mandrel.js +12 -1
- package/docs/CHANGELOG.md +62 -0
- package/lib/cli/registry.js +262 -19
- package/lib/cli/sync-agents.js +157 -0
- package/lib/cli/sync-commands.js +115 -6
- package/lib/cli/sync.js +168 -6
- package/lib/cli/update.js +105 -8
- package/lib/cli/version-helpers.js +131 -0
- package/lib/migrations/README.md +7 -5
- package/lib/migrations/index.js +17 -9
- package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +100 -0
- package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +101 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
- package/package.json +2 -2
- package/.agents/schemas/epic-perf-report.schema.json +0 -89
- package/.agents/schemas/lifecycle/acceptance.reconcile.failed.schema.json +0 -13
- package/.agents/schemas/lifecycle/acceptance.reconcile.ok.schema.json +0 -13
- package/.agents/schemas/lifecycle/acceptance.reconcile.skipped.schema.json +0 -13
- package/.agents/schemas/lifecycle/acceptance.reconcile.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/acceptance.reconcile.waived.schema.json +0 -13
- package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +0 -15
- package/.agents/schemas/lifecycle/epic.automerge.start.schema.json +0 -13
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +0 -13
- package/.agents/schemas/lifecycle/epic.cleanup.end.schema.json +0 -12
- package/.agents/schemas/lifecycle/epic.cleanup.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/epic.close.end.schema.json +0 -12
- package/.agents/schemas/lifecycle/epic.complete.schema.json +0 -13
- package/.agents/schemas/lifecycle/epic.finalize.end.schema.json +0 -13
- package/.agents/schemas/lifecycle/epic.finalize.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/epic.merge.armed.schema.json +0 -13
- package/.agents/schemas/lifecycle/epic.merge.blocked.schema.json +0 -14
- package/.agents/schemas/lifecycle/epic.merge.confirmed.schema.json +0 -17
- package/.agents/schemas/lifecycle/epic.merge.ready.schema.json +0 -15
- package/.agents/schemas/lifecycle/epic.plan.end.schema.json +0 -18
- package/.agents/schemas/lifecycle/epic.plan.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/epic.snapshot.end.schema.json +0 -16
- package/.agents/schemas/lifecycle/epic.snapshot.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/epic.watch.end.schema.json +0 -29
- package/.agents/schemas/lifecycle/epic.watch.start.schema.json +0 -16
- package/.agents/schemas/lifecycle/story.heartbeat.schema.json +0 -20
- package/.agents/schemas/risk-verdict.schema.json +0 -53
- package/.agents/schemas/story-perf-summary.schema.json +0 -73
- package/.agents/scripts/analyze-execution.js +0 -444
- package/.agents/scripts/check-prepush-recovery.js +0 -90
- package/.agents/scripts/lib/git-merge-orchestrator.js +0 -261
- package/.agents/scripts/lib/observability/baseline-refresh-rate.js +0 -221
- package/.agents/scripts/lib/observability/hook-heartbeat.js +0 -187
- package/.agents/scripts/lib/observability/perf-aggregator.js +0 -813
- package/.agents/scripts/lib/observability/perf-report-readers.js +0 -328
- package/.agents/scripts/lib/observability/perf-report-render.js +0 -182
- package/.agents/scripts/lib/orchestration/audit-lens-routing.js +0 -128
- package/.agents/scripts/lib/orchestration/bookkeeping-outbox.js +0 -273
- package/.agents/scripts/lib/orchestration/error-journal.js +0 -139
- package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +0 -155
- package/.agents/scripts/lib/orchestration/lifecycle/ledger-diff.js +0 -140
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +0 -665
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +0 -63
- package/.agents/scripts/lib/orchestration/planning/risk-verdict.js +0 -104
- package/.agents/scripts/lib/orchestration/planning-context-budget.js +0 -213
- package/.agents/scripts/lib/orchestration/planning-risk.js +0 -194
- package/.agents/scripts/lib/orchestration/post-merge/phases/branch-cleanup.js +0 -56
- package/.agents/scripts/lib/orchestration/post-merge/phases/dashboard-refresh.js +0 -21
- package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +0 -78
- package/.agents/scripts/lib/orchestration/post-merge/phases/temp-cleanup.js +0 -68
- package/.agents/scripts/lib/orchestration/post-merge/phases/ticket-closure.js +0 -118
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +0 -397
- package/.agents/scripts/lib/orchestration/preflight-cache.js +0 -187
- package/.agents/scripts/lib/orchestration/resolve-plan-run.js +0 -155
- package/.agents/scripts/lib/orchestration/retro-perf-heuristics.js +0 -275
- package/.agents/scripts/lib/orchestration/story-progress/story-run-progress-writer.js +0 -400
- package/.agents/scripts/lib/single-story/confirm-merge-follow-ups.js +0 -36
- package/.agents/scripts/resolve-plan-run.js +0 -117
- package/.agents/skills/core/analyze-execution/SKILL.md +0 -101
|
@@ -1,665 +0,0 @@
|
|
|
1
|
-
// .agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js
|
|
2
|
-
/**
|
|
3
|
-
* MergeWatcher — lifecycle listener that polls GitHub for merge
|
|
4
|
-
* confirmation of an Epic PR and emits `epic.merge.confirmed`. Story
|
|
5
|
-
* #2896 / Task #2907 (Epic #2880).
|
|
6
|
-
*
|
|
7
|
-
* Subscribes to:
|
|
8
|
-
* - `epic.merge.armed` → and ONLY this event.
|
|
9
|
-
*
|
|
10
|
-
* Side effects executed inside `handle()`:
|
|
11
|
-
* 1. Read the per-watcher resume ledger
|
|
12
|
-
* (`<tempRoot>/epic-<id>/merge-watcher.ndjson`) to determine
|
|
13
|
-
* the starting attempt number (resume contract — see below).
|
|
14
|
-
* 2. Poll `gh pr view <prUrl> --json mergeCommit,mergedAt` on a
|
|
15
|
-
* cadence governed by `intervalSeconds`/`maxBudgetSeconds`.
|
|
16
|
-
* Each attempt is appended to the resume ledger as
|
|
17
|
-
* `{ attempt, observedAt, status }`.
|
|
18
|
-
* 3. On the first poll where `mergeCommit` is non-null, emit
|
|
19
|
-
* `epic.merge.confirmed` carrying
|
|
20
|
-
* `{ epicId, prNumber, mergeCommitSha, mergedAt, pollAttempts }`.
|
|
21
|
-
* 4. If the budget is exceeded without observing a merge, return
|
|
22
|
-
* a `failed` classification with reason `budget-exceeded` and
|
|
23
|
-
* do NOT emit `epic.merge.confirmed`. The /deliver
|
|
24
|
-
* blocker-handler flow surfaces this via `agent::blocked`.
|
|
25
|
-
*
|
|
26
|
-
* Resume contract (AC of Task #2907): the ledger is the source of
|
|
27
|
-
* truth for the next attempt number. If a prior process recorded
|
|
28
|
-
* attempts 1..3 and then crashed, this process starts at attempt 4 —
|
|
29
|
-
* not attempt 1 — so the operator's poll budget is honoured across
|
|
30
|
-
* reruns rather than reset.
|
|
31
|
-
*
|
|
32
|
-
* Idempotency contract (mirrors Cleaner/Armer AC-10): per-instance
|
|
33
|
-
* `Set<string>` of `${event}:${seqId}` keys short-circuits replays
|
|
34
|
-
* within the same process. Cross-process replay protection comes from
|
|
35
|
-
* the on-disk ledger plus the natural idempotency of `gh pr view`
|
|
36
|
-
* (polling an already-merged PR returns `mergeCommit` again, which
|
|
37
|
-
* causes a single `epic.merge.confirmed` emit per `(event, seqId)`).
|
|
38
|
-
*
|
|
39
|
-
* Side-effect firewall: the listener emits on the bus, shells out to
|
|
40
|
-
* `gh`, and appends to its own ledger file. It does NOT mutate ticket
|
|
41
|
-
* labels, post comments, or call `notify`. Downstream listeners
|
|
42
|
-
* (Cleaner / LabelTransitioner on `epic.merge.confirmed`, Task #2912)
|
|
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`.
|
|
77
|
-
*/
|
|
78
|
-
|
|
79
|
-
import { spawnSync } from 'node:child_process';
|
|
80
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
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';
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Default poll interval and budget. The schema in
|
|
88
|
-
* `.agents/schemas/agentrc.schema.json` exposes these as
|
|
89
|
-
* `delivery.mergeWatch.intervalSeconds` (default 30) and
|
|
90
|
-
* `delivery.mergeWatch.maxBudgetSeconds` (default 3600). Hard-coding
|
|
91
|
-
* the same numbers here makes the listener self-contained when no
|
|
92
|
-
* config is wired in (e.g. unit tests).
|
|
93
|
-
*/
|
|
94
|
-
export const DEFAULT_INTERVAL_SECONDS = 30;
|
|
95
|
-
export const DEFAULT_MAX_BUDGET_SECONDS = 3600;
|
|
96
|
-
|
|
97
|
-
/**
|
|
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
|
|
111
|
-
* helper — exported so tests can stub the shell-out without touching
|
|
112
|
-
* the spawn wrapper.
|
|
113
|
-
*/
|
|
114
|
-
export function ghPrViewMerge({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
115
|
-
const result = spawnFn(
|
|
116
|
-
'gh',
|
|
117
|
-
['pr', 'view', prUrl, '--json', PR_VIEW_JSON_FIELDS],
|
|
118
|
-
{
|
|
119
|
-
cwd,
|
|
120
|
-
encoding: 'utf-8',
|
|
121
|
-
shell: false,
|
|
122
|
-
},
|
|
123
|
-
);
|
|
124
|
-
return {
|
|
125
|
-
status: result.status ?? 1,
|
|
126
|
-
stdout: result.stdout ?? '',
|
|
127
|
-
stderr: result.stderr ?? '',
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
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.
|
|
162
|
-
*/
|
|
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
|
-
};
|
|
172
|
-
const trimmed = String(stdout ?? '').trim();
|
|
173
|
-
if (trimmed.length === 0) {
|
|
174
|
-
return empty;
|
|
175
|
-
}
|
|
176
|
-
let parsed;
|
|
177
|
-
try {
|
|
178
|
-
parsed = JSON.parse(trimmed);
|
|
179
|
-
} catch {
|
|
180
|
-
return empty;
|
|
181
|
-
}
|
|
182
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
183
|
-
return empty;
|
|
184
|
-
}
|
|
185
|
-
const merge = parsed.mergeCommit;
|
|
186
|
-
const sha =
|
|
187
|
-
merge && typeof merge === 'object' && typeof merge.oid === 'string'
|
|
188
|
-
? merge.oid
|
|
189
|
-
: null;
|
|
190
|
-
const mergedAt = typeof parsed.mergedAt === 'string' ? parsed.mergedAt : null;
|
|
191
|
-
const prNumber = Number.isInteger(parsed.number) ? parsed.number : null;
|
|
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
|
-
};
|
|
207
|
-
}
|
|
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
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Resolve the resume-ledger path for an Epic. Pure helper — exported
|
|
223
|
-
* so tests can pin the layout.
|
|
224
|
-
*/
|
|
225
|
-
export function resolveLedgerPath({ tempRoot, epicId }) {
|
|
226
|
-
return path.join(tempRoot, `epic-${epicId}`, 'merge-watcher.ndjson');
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Read the resume ledger and return the count of already-recorded
|
|
231
|
-
* attempts. A missing file is the typical first-run case → 0. Lines
|
|
232
|
-
* that fail to parse are skipped (defense against truncated writes).
|
|
233
|
-
*/
|
|
234
|
-
export function readPriorAttempts({
|
|
235
|
-
tempRoot,
|
|
236
|
-
epicId,
|
|
237
|
-
readFileFn = readFileSync,
|
|
238
|
-
existsFn = existsSync,
|
|
239
|
-
}) {
|
|
240
|
-
const file = resolveLedgerPath({ tempRoot, epicId });
|
|
241
|
-
if (!existsFn(file)) return 0;
|
|
242
|
-
let raw;
|
|
243
|
-
try {
|
|
244
|
-
raw = readFileFn(file, 'utf-8');
|
|
245
|
-
} catch (err) {
|
|
246
|
-
if (err && err.code === 'ENOENT') return 0;
|
|
247
|
-
throw err;
|
|
248
|
-
}
|
|
249
|
-
if (!raw) return 0;
|
|
250
|
-
let count = 0;
|
|
251
|
-
for (const line of raw.split('\n')) {
|
|
252
|
-
const trimmed = line.trim();
|
|
253
|
-
if (!trimmed) continue;
|
|
254
|
-
try {
|
|
255
|
-
const record = JSON.parse(trimmed);
|
|
256
|
-
if (record && Number.isInteger(record.attempt)) count += 1;
|
|
257
|
-
} catch {
|
|
258
|
-
// Skip malformed lines silently — the count is advisory.
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
return count;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Append a single attempt record to the resume ledger. Creates the
|
|
266
|
-
* directory lazily.
|
|
267
|
-
*/
|
|
268
|
-
export function appendAttempt({
|
|
269
|
-
tempRoot,
|
|
270
|
-
epicId,
|
|
271
|
-
record,
|
|
272
|
-
appendFn = appendFileSync,
|
|
273
|
-
mkdirFn = mkdirSync,
|
|
274
|
-
}) {
|
|
275
|
-
const file = resolveLedgerPath({ tempRoot, epicId });
|
|
276
|
-
mkdirFn(path.dirname(file), { recursive: true });
|
|
277
|
-
appendFn(file, `${JSON.stringify(record)}\n`, 'utf-8');
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Sleep helper — exported so tests can stub timing without
|
|
282
|
-
* mocking the global.
|
|
283
|
-
*/
|
|
284
|
-
export function defaultSleep(ms) {
|
|
285
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* MergeWatcher listener.
|
|
290
|
-
*/
|
|
291
|
-
export class MergeWatcher {
|
|
292
|
-
/**
|
|
293
|
-
* @param {object} opts
|
|
294
|
-
* @param {object} opts.bus
|
|
295
|
-
* @param {number} opts.epicId
|
|
296
|
-
* @param {string} opts.tempRoot
|
|
297
|
-
* @param {string} [opts.cwd]
|
|
298
|
-
* @param {number} [opts.intervalSeconds] Defaults to 30.
|
|
299
|
-
* @param {number} [opts.maxBudgetSeconds] Defaults to 3600.
|
|
300
|
-
* @param {Function} [opts.ghPrViewMergeFn] override for tests.
|
|
301
|
-
* @param {Function} [opts.readPriorAttemptsFn] override for tests.
|
|
302
|
-
* @param {Function} [opts.appendAttemptFn] override for tests.
|
|
303
|
-
* @param {(ms: number) => Promise<void>} [opts.sleepFn] override
|
|
304
|
-
* for tests so the suite does not actually wait.
|
|
305
|
-
* @param {() => number} [opts.nowMsFn] override for tests; returns
|
|
306
|
-
* epoch ms.
|
|
307
|
-
* @param {() => string} [opts.nowIsoFn] override for tests; returns
|
|
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).
|
|
319
|
-
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
|
|
320
|
-
*/
|
|
321
|
-
constructor(opts = {}) {
|
|
322
|
-
if (
|
|
323
|
-
!opts.bus ||
|
|
324
|
-
typeof opts.bus.on !== 'function' ||
|
|
325
|
-
typeof opts.bus.emit !== 'function'
|
|
326
|
-
) {
|
|
327
|
-
throw new TypeError('MergeWatcher requires a bus with on() and emit()');
|
|
328
|
-
}
|
|
329
|
-
if (!Number.isInteger(opts.epicId) || opts.epicId < 1) {
|
|
330
|
-
throw new TypeError('MergeWatcher requires a numeric epicId');
|
|
331
|
-
}
|
|
332
|
-
if (typeof opts.tempRoot !== 'string' || opts.tempRoot.length === 0) {
|
|
333
|
-
throw new TypeError('MergeWatcher requires a non-empty tempRoot string');
|
|
334
|
-
}
|
|
335
|
-
this.bus = opts.bus;
|
|
336
|
-
this.epicId = opts.epicId;
|
|
337
|
-
this.tempRoot = opts.tempRoot;
|
|
338
|
-
this.cwd = opts.cwd ?? process.cwd();
|
|
339
|
-
this.intervalSeconds =
|
|
340
|
-
Number.isInteger(opts.intervalSeconds) && opts.intervalSeconds >= 1
|
|
341
|
-
? opts.intervalSeconds
|
|
342
|
-
: DEFAULT_INTERVAL_SECONDS;
|
|
343
|
-
this.maxBudgetSeconds =
|
|
344
|
-
Number.isInteger(opts.maxBudgetSeconds) && opts.maxBudgetSeconds >= 1
|
|
345
|
-
? opts.maxBudgetSeconds
|
|
346
|
-
: DEFAULT_MAX_BUDGET_SECONDS;
|
|
347
|
-
this.ghPrViewMergeFn = opts.ghPrViewMergeFn ?? ghPrViewMerge;
|
|
348
|
-
this.readPriorAttemptsFn = opts.readPriorAttemptsFn ?? readPriorAttempts;
|
|
349
|
-
this.appendAttemptFn = opts.appendAttemptFn ?? appendAttempt;
|
|
350
|
-
this.sleepFn = opts.sleepFn ?? defaultSleep;
|
|
351
|
-
this.nowMsFn = opts.nowMsFn ?? Date.now;
|
|
352
|
-
this.nowIsoFn =
|
|
353
|
-
opts.nowIsoFn ?? (() => new Date(this.nowMsFn()).toISOString());
|
|
354
|
-
this.headless = opts.headless === true;
|
|
355
|
-
this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
|
|
356
|
-
this.logger = opts.logger ?? console;
|
|
357
|
-
/** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
|
|
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;
|
|
368
|
-
/**
|
|
369
|
-
* Classification log — every `epic.merge.armed` observed lands
|
|
370
|
-
* here with the outcome (`confirmed`, `budget-exceeded`,
|
|
371
|
-
* `skipped-duplicate`, `failed`, or — headless only — `extended` /
|
|
372
|
-
* `re-armed`). Mirrors the Armer / Cleaner "no silent skip"
|
|
373
|
-
* surface.
|
|
374
|
-
*/
|
|
375
|
-
this.classifications = [];
|
|
376
|
-
// Frozen tuple — MergeWatcher subscribes to EXACTLY one event.
|
|
377
|
-
// Mirrors the AutomergeArmer single-event contract; the
|
|
378
|
-
// lifecycle-doc-drift check (F5) walks this array.
|
|
379
|
-
this.events = Object.freeze(['epic.merge.armed']);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
register() {
|
|
383
|
-
return this.events.map((event) =>
|
|
384
|
-
this.bus.on(event, async (ctx) => this.handle(ctx)),
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async handle({ event, seqId, payload }) {
|
|
389
|
-
const key = `${event}:${seqId}`;
|
|
390
|
-
if (this._seen.has(key)) {
|
|
391
|
-
this.classifications.push({
|
|
392
|
-
event,
|
|
393
|
-
seqId,
|
|
394
|
-
outcome: 'skipped',
|
|
395
|
-
reason: 'duplicate-seqId',
|
|
396
|
-
});
|
|
397
|
-
this.logger.debug?.(`[MergeWatcher] skip duplicate ${key} (idempotent)`);
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
this._seen.add(key);
|
|
401
|
-
|
|
402
|
-
const prUrl = payload?.prUrl;
|
|
403
|
-
if (typeof prUrl !== 'string' || prUrl.length === 0) {
|
|
404
|
-
this.classifications.push({
|
|
405
|
-
event,
|
|
406
|
-
seqId,
|
|
407
|
-
outcome: 'failed',
|
|
408
|
-
reason: 'no-pr-url',
|
|
409
|
-
});
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Resume contract: read prior attempts from the ledger so this
|
|
414
|
-
// run continues counting from N+1 rather than restarting at 1.
|
|
415
|
-
let priorAttempts;
|
|
416
|
-
try {
|
|
417
|
-
priorAttempts = this.readPriorAttemptsFn({
|
|
418
|
-
tempRoot: this.tempRoot,
|
|
419
|
-
epicId: this.epicId,
|
|
420
|
-
});
|
|
421
|
-
} catch (err) {
|
|
422
|
-
this.logger.warn?.(
|
|
423
|
-
`[MergeWatcher] failed to read resume ledger (degrading to attempt=1): ${err?.message ?? err}`,
|
|
424
|
-
);
|
|
425
|
-
priorAttempts = 0;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const intervalMs = this.intervalSeconds * 1000;
|
|
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;
|
|
432
|
-
const startedAtMs = this.nowMsFn();
|
|
433
|
-
let attempt = priorAttempts;
|
|
434
|
-
|
|
435
|
-
// Poll loop. Each iteration: increment attempt, call gh,
|
|
436
|
-
// append to ledger, decide. Cadence: a fixed
|
|
437
|
-
// `intervalSeconds` sleep BETWEEN attempts; the first poll
|
|
438
|
-
// fires immediately so a freshly-armed-then-instantly-merged
|
|
439
|
-
// PR confirms without an interval delay.
|
|
440
|
-
//
|
|
441
|
-
// Budget accounting: a poll is allowed iff
|
|
442
|
-
// `now() - startedAtMs <= budgetMs`. Once the budget is
|
|
443
|
-
// exhausted the loop bails with `budget-exceeded`.
|
|
444
|
-
while (true) {
|
|
445
|
-
attempt += 1;
|
|
446
|
-
let probe;
|
|
447
|
-
try {
|
|
448
|
-
probe = this.ghPrViewMergeFn({ prUrl, cwd: this.cwd });
|
|
449
|
-
} catch (err) {
|
|
450
|
-
probe = {
|
|
451
|
-
status: 1,
|
|
452
|
-
stdout: '',
|
|
453
|
-
stderr: err?.message ?? String(err),
|
|
454
|
-
};
|
|
455
|
-
}
|
|
456
|
-
const observedAt = this.nowIsoFn();
|
|
457
|
-
const view = parseMergeView(probe.stdout);
|
|
458
|
-
const status =
|
|
459
|
-
probe.status === 0
|
|
460
|
-
? view.mergeCommitSha
|
|
461
|
-
? 'merged'
|
|
462
|
-
: 'pending'
|
|
463
|
-
: 'probe-failed';
|
|
464
|
-
|
|
465
|
-
// Persist the attempt BEFORE deciding next steps so a crash
|
|
466
|
-
// here leaves a recoverable trail.
|
|
467
|
-
try {
|
|
468
|
-
this.appendAttemptFn({
|
|
469
|
-
tempRoot: this.tempRoot,
|
|
470
|
-
epicId: this.epicId,
|
|
471
|
-
record: { attempt, observedAt, status },
|
|
472
|
-
});
|
|
473
|
-
} catch (err) {
|
|
474
|
-
this.logger.warn?.(
|
|
475
|
-
`[MergeWatcher] failed to append attempt ${attempt} ledger record (continuing): ${err?.message ?? err}`,
|
|
476
|
-
);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
if (status === 'merged') {
|
|
480
|
-
const confirmPayload = {
|
|
481
|
-
epicId: this.epicId,
|
|
482
|
-
prUrl,
|
|
483
|
-
prNumber: view.prNumber,
|
|
484
|
-
mergeCommitSha: view.mergeCommitSha,
|
|
485
|
-
mergedAt: view.mergedAt,
|
|
486
|
-
pollAttempts: attempt,
|
|
487
|
-
};
|
|
488
|
-
try {
|
|
489
|
-
await this.bus.emit('epic.merge.confirmed', confirmPayload);
|
|
490
|
-
} catch (err) {
|
|
491
|
-
this.classifications.push({
|
|
492
|
-
event,
|
|
493
|
-
seqId,
|
|
494
|
-
outcome: 'failed',
|
|
495
|
-
reason: `confirm-emit-failed:${err?.message ?? err}`,
|
|
496
|
-
});
|
|
497
|
-
this.logger.warn?.(
|
|
498
|
-
`[MergeWatcher] epic.merge.confirmed emit failed: ${err?.message ?? err}`,
|
|
499
|
-
);
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
this.classifications.push({
|
|
503
|
-
event,
|
|
504
|
-
seqId,
|
|
505
|
-
outcome: 'confirmed',
|
|
506
|
-
prUrl,
|
|
507
|
-
pollAttempts: attempt,
|
|
508
|
-
mergeCommitSha: view.mergeCommitSha,
|
|
509
|
-
});
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// Not merged. Budget check before sleeping.
|
|
514
|
-
const elapsedMs = this.nowMsFn() - startedAtMs;
|
|
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
|
-
|
|
622
|
-
this.classifications.push({
|
|
623
|
-
event,
|
|
624
|
-
seqId,
|
|
625
|
-
outcome: 'failed',
|
|
626
|
-
reason: 'budget-exceeded',
|
|
627
|
-
prUrl,
|
|
628
|
-
pollAttempts: attempt,
|
|
629
|
-
elapsedMs,
|
|
630
|
-
});
|
|
631
|
-
this.logger.warn?.(
|
|
632
|
-
`[MergeWatcher] poll budget exceeded after ${attempt} attempts (${elapsedMs}ms elapsed, budget=${budgetMs}ms).`,
|
|
633
|
-
);
|
|
634
|
-
// ultrareview bug_010: surface the abandonment via the bus so
|
|
635
|
-
// BlockerHandler / LabelTransitioner / StructuredCommentPoster /
|
|
636
|
-
// NotifyDispatcher escalate the Epic to `agent::blocked` with
|
|
637
|
-
// an operator-visible reason. Without this emit the Epic stalls
|
|
638
|
-
// silently — Cleaner never fires, agent::done never flips, and
|
|
639
|
-
// no operator notification is produced. The epic.blocked payload
|
|
640
|
-
// schema is strict (`additionalProperties: false`), so the
|
|
641
|
-
// elapsed/attempt detail stays in the classification log and
|
|
642
|
-
// warn line above rather than in the bus payload.
|
|
643
|
-
try {
|
|
644
|
-
await this.bus.emit('epic.blocked', {
|
|
645
|
-
reason: `merge-watch:budget-exceeded`,
|
|
646
|
-
});
|
|
647
|
-
} catch (err) {
|
|
648
|
-
this.logger.warn?.(
|
|
649
|
-
`[MergeWatcher] epic.blocked emit on budget exhaustion failed (swallowed): ${err?.message ?? err}`,
|
|
650
|
-
);
|
|
651
|
-
}
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
await this.sleepFn(intervalMs);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
reset() {
|
|
660
|
-
this._seen.clear();
|
|
661
|
-
this._budgetExtended = false;
|
|
662
|
-
this._reArmed = false;
|
|
663
|
-
this.classifications = [];
|
|
664
|
-
}
|
|
665
|
-
}
|