fix(standards-sync-stuck-automerge-alert): split the stuck-PR scan into a cheap page fetch and a per-candidate merge-state probe with retry - #234
Conversation
…to a cheap page fetch and a per-candidate merge-state probe with retry The single-page query selected mergeStateStatus across a 50-node page, which forces an on-demand mergeability computation per PR server-side and reliably returns an opaque GraphQL error, failing every scheduled run. Page fetches now select only the fields needed to identify armed, past-threshold bot PRs; mergeStateStatus is fetched one candidate at a time. Every GraphQL call retries with exponential backoff so a transient server error no longer aborts the scan and falsely clears the tracking issue; a persistent failure still propagates and fails the run loudly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
…e two-phase scan tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
|
Claude finished @kyle-sexton's task in 2m 41s —— View job PR Review
SummaryThis is a solid fix to a real operational problem. The two-phase split is the right architectural move: removing CorrectnessRetry logic — the off-by-one is correct: for (let attempt = 1; ; attempt += 1) {
try {
return await github.graphql(query, variables);
} catch (error) {
if (attempt >= retryAttempts) throw error; // throws on attempt 4 (with retryAttempts=4)
...
await sleep(delayMs);
}
}With False all-clear prevention is structurally sound: Candidate scoping — Phase-2 probes are sequential — One minor observation — the // .github/workflows/standards-sync-stuck-automerge-alert.yml, line 141–142
const retryAttempts = Number(process.env.GRAPHQL_RETRY_ATTEMPTS ?? 4);
const retryBaseMs = Number(process.env.GRAPHQL_RETRY_BASE_MS ?? 1000);are dead code in the Actions runtime: both vars are declared in the step's Security / CLAUDE.md alignmentTrigger surface: unchanged — still SHA pins: no Permissions: no changes to the job-level Log hygiene — the new // line 159
core.info(`GraphQL ${description} failed (attempt ${attempt}/${retryAttempts}): ${error.message}; retrying in ${delayMs}ms.`);logs GraphQL variables: queries use parameterized Test coverageThe new tests directly match the invariants the PR claims to establish:
The One note on the "queries every manifest-derived target repository" test: it maps all VerdictApprove. The fix is correct, secure, well-tested, and CLAUDE.md-compliant. The one remaining acceptance criterion from the PR description (≥2 consecutive green scheduled runs against the real fleet) is appropriately flagged as post-merge follow-through. |
|
Claude finished @kyle-sexton's task in 2m 16s —— View job Security Review
Scope: SummaryNo critical or important security issues found. The change is security-positive overall: the split-phase design reduces the attack surface of each GraphQL call, and the false-clear protection is correctly preserved — One low-confidence suggestion follows. SUGGESTION —
|
| Check | Result |
|---|---|
on: trigger surface |
Unchanged; no dangerous pull_request_target or workflow_run with secrets introduced |
permissions: blocks |
Unchanged; narrowest-needed grants preserved |
New uses: entries |
None introduced |
Script injection via ${{ }} in run: |
New env vars GRAPHQL_RETRY_ATTEMPTS/GRAPHQL_RETRY_BASE_MS are literal string constants, not expressions over user-controlled context |
| GraphQL query construction | Both listQuery and mergeStateQuery use parameterized typed variables ($repo: String!, $number: Int!); no interpolation into query text |
| False-clear invariant | core.setOutput('stuck-count', ...) is only reached after the full scan loop; a throw anywhere in the loop leaves the output unset, so the close-issue branch is unreachable on failure |
| Token scope | permission-pull-requests: read App token unchanged; issue-write token scope unchanged |
GRAPHQL_RETRY_ATTEMPTS as attack surface |
Set to the literal '4' in step env:, not derived from any workflow inputs: or context expression; callers cannot tune it to trigger unbounded retry loops |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2ed4293b87
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
AI reply (work-loop lane). Review-finding classification:
|
…om the per-PR probe to close the page-vs-probe race
Auto-merge can be disarmed or re-armed in the interval between the phase-1 page
fetch and the phase-2 merge-state probe, so trusting the page's
autoMergeRequest.enabledAt could report a disarmed PR as stuck or report a stale
armed duration for a freshly re-armed one. The probe now also selects
autoMergeRequest { enabledAt }; the stuck decision re-checks armed state and the
threshold against that fresh value and computes armedHoursAgo from it, skipping a
PR that is no longer armed or is now armed under the threshold.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
|
Claude finished @kyle-sexton's task in 1m 37s —— View job Review of TOCTOU fix commit (
|
| Test | Scenario | Assert |
|---|---|---|
disarmed between page fetch and probe |
probeOverrides: { 8: { enabledAt: null } } |
stuck-count === '0', report === null |
duration computed from probe-fresh enabledAt |
page says 20h, probe says 6h | report matches /| 6h |/, not /20h/ |
re-armed under threshold between page and probe |
page says 20h (over), probe says 1h (under) | stuck-count === '0', report === null |
The "enabledAt" in override check (not just override.enabledAt) is the right way to distinguish "explicitly set to null" from "not present" — correctly handles the disarm case where null is a meaningful value.
No regressions introduced
isArmedCandidatebehavior is unchanged — the refactor toarmedHoursSinceis equivalent to the previous inline arithmetic.BLOCKEDcheck remains first in the probe post-processing, so non-blocked PRs still skip without touchingenabledAt.armedHoursAgo: Math.floor(armedHours)is computed from the samearmedHoursvariable used for the threshold check — single source of truth.- False-all-clear prevention and retry logic are untouched.
Verdict
Approve. The fix is correct, complete, and well-tested. All three TOCTOU scenarios are covered, the helper extraction is a genuine DRY improvement (not incidental cleanup), and the probeOverrides harness mechanism is clean without being over-engineered. No issues found.
|
Claude finished @kyle-sexton's task in 3m 23s —— View job Security Review
Scope: No critical or important security findings. TOCTOU fix verification (07f706e)The codex P2 finding is correctly resolved. The phase-2 probe now fetches // https://github.com/melodic-software/ci-workflows/blob/07f706eb6e9a101a032ba310372c842c66139cfd/.github/workflows/standards-sync-stuck-automerge-alert.yml#L249-L253
const pullRequest = result.repository?.pullRequest;
if (pullRequest?.mergeStateStatus !== 'BLOCKED') continue;
const armedHours = armedHoursSince(pullRequest.autoMergeRequest?.enabledAt);
if (armedHours === null || armedHours < thresholdHours) continue;
stuck.push({ repo, number: candidate.number, url: candidate.url, armedHoursAgo: Math.floor(armedHours) });The phase-1 value ( New code introduced —
|
| Field | Type | Source | Used as |
|---|---|---|---|
candidate.number |
Int! (GraphQL typed) |
GitHub API | GraphQL $number variable in phase-2 probe |
candidate.url |
string | GitHub API | Cosmetic display link in Markdown report |
Neither field is string-interpolated into query text. candidate.number is passed as a typed GraphQL variable ($number: Int!). No injection path.
The stuck array entries contain only hardcoded strings, GitHub-API-sourced integers and URLs, and Math.floor(armedHours) (integer). The Markdown report wraps repo in backticks and uses integer values elsewhere. No injection surface in the report body.
Other checks (clean)
| Check | Result |
|---|---|
on: trigger surface |
Unchanged; workflow_call only |
permissions: |
Unchanged; contents: read on the job |
New uses: entries |
None |
${{ }} expressions in run: |
None new; MANIFEST env-var pattern (pre-existing, correct) |
| GraphQL query construction | Both queries use parameterized typed variables; only first: ${PAGE_SIZE} (numeric constant 50) is interpolated |
| False-clear invariant | core.setOutput('stuck-count', ...) is only reached after the full scan loop; a throw anywhere leaves the output unset |
| Retry-amplified false-clear | graphqlWithRetry rethrows on exhaustion — the throw propagates, scan step fails, close-issue branch is unreachable |
candidates array scope |
Declared inside for (const repo of repoNames) — resets per-repo, no cross-repo leakage |
No security issues to flag. The change is security-neutral to positive: the split-phase design reduces each query's attack surface, the TOCTOU fix is correctly scoped to probe-fresh data only, and the retry wrapper introduces no new caller-reachable surface.
…ody gate (#240) ## Summary Plugin-created PRs in this repo trip the `pr-issue-linkage` gate because the source-control plugin's portable default PR body scaffolds only `## Summary` and `## Test plan`, omitting the `## Related` section this repo requires. This declares the team-tracked `pr_body_required_sections` key in `.claude/source-control.md` — the plugin's designed per-repo seam (`reference/config-resolution.md`) — so `/source-control:pull-request create` both drafts a `## Related` section and pre-checks it before `gh pr create`. The key is a **closed list** that replaces the plugin's default wholesale, so the portable `Summary` / `Test plan` sections are re-declared alongside `Related`. The closing-keyword / `No linked issue` half of the gate is a separate, independent mechanism the plugin already satisfies natively (its create logic always emits a `Closes #N` line or a `No related issue:` opt-out marker); it is not expressible through this heading-only key, so no attempt is made to encode it here. ## Test plan - No executable behavior changes; this is a plugin-config data file. - This PR's own body is the live verification: it carries `## Summary`, `## Test plan`, and a non-empty `## Related` section plus the `No linked issue` marker, so the `pr-issue-linkage / pr-issue-linkage` required check must pass on it. ## Related No linked issue. This closes no GitHub issue; it is a convention-config change. It exists because the gate previously tripped on plugin-composed bodies: PR #234 in this repo and dotfiles #301 in a sibling repo both failed the same closing-keyword-plus-`## Related` validation on creation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0169XBydnqC5S6bkHDz1TDwL Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…never armed (#291) ## Summary `standards-sync.yml` arms squash auto-merge on sync PRs. The mutation is wrapped in a `try`/`catch` that downgrades **every** rejection to `core.warning`, so an arming failure produces a sync PR that simply sits unarmed — indistinguishable from the pre-arming status quo, and invisible to a watchdog that only hunted PRs stuck **with** auto-merge armed. Failing closed is correct; failing closed **invisibly** is the defect, because the operator's mental model says "armed" while the PR waits on a human who was never told to look. This PR closes that blind spot, and fixes the arming gate that would otherwise have made the new detection fire on PRs where arming was deliberately skipped. ### Today's failure behavior, from source `.github/workflows/standards-sync.yml` at `ac223bb`, the arming step's tail: ```js } catch (error) { // Known rejection: a PR that is already immediately mergeable // (GraphQL mergeStateStatus CLEAN) has nothing to wait for, and // GitHub's mutation errors instead of no-op succeeding. Any // other transient rejection is handled the same way: log and // let the sync continue, never fail the run over arming. core.warning( `Could not arm auto-merge on ${owner}/${repo}#${pull_number}: ${error.message}`, ); } ``` The failure is **trapped**. The step carries no `continue-on-error` and does not need one — the `catch` swallows the throw, so the step succeeds regardless, and nothing downstream reads the outcome. `standards-sync-automerge-arm.test.cjs` pinned this deliberately ("a rejected mutation ... is logged and swallowed, not thrown"). ### Can a GitHub App installation token arm auto-merge? Researched against official GitHub sources, not recall. **Yes** — established by a chain, because no single page states it: 1. `GITHUB_TOKEN` **is** an App installation access token — github/docs `content/actions/concepts/security/github_token.md`: *"The `GITHUB_TOKEN` secret is a GitHub App installation access token."* 2. GitHub documents a workflow enabling auto-merge with exactly that token — `content/code-security/tutorials/secure-your-dependencies/automate-dependabot-with-actions.md` runs `gh pr merge --auto` under `permissions: contents: write` + `pull-requests: write`. 3. `gh pr merge --auto` issues this mutation — `cli/cli` `pkg/cmd/pr/merge/http.go`: `graphql:"enablePullRequestAutoMerge(input: $input)"`. First-party **source**, not documentation; flagged as such. The App behind `GITHUB_TOKEN` is GitHub's own Actions App, and docs state no divergence for third-party Apps in either direction. Not settled by documentation, and marked as such rather than inferred past: | Question | Verdict | | --- | --- | | Minimal permission for the mutation | **UNVERIFIED** — no GraphQL per-mutation permissions table exists. `content/apps/.../choosing-permissions-for-a-github-app.md` explicitly punts: *"you should test your app to ensure that it has the required permissions."* The sync token already requests the `contents: write` + `pull-requests: write` pair from GitHub's own working example. **Would settle it:** a throwaway repo, arming with each permission alone and then both, recording `errors[].type`. | | Does the mutation error rather than no-op when the PR is already mergeable? | **UNVERIFIED** — the GA changelog says auto-merge *"can only be enabled ... when there are unsatisfied merge requirements"*, but never states the API errors. **Would settle it:** same throwaway repo, a PR with zero unsatisfied requirements. | Both experiments mutate state, so a disposable repo is the ceiling — never the live fleet. **Verified preconditions:** *"Before you use auto-merge, it must be enabled for the repository"* (`allow_auto_merge`, default `false`), and *"People with write permissions to a repository can enable auto-merge for a pull request."* ## The fix ### Part 1 — the watchdog reports never-armed sync PRs `standards-sync-stuck-automerge-alert.yml` gains a second category beside armed-but-BLOCKED: a sync PR past `threshold-hours`, in a target the manifest marks `automerge: true`, on which auto-merge was **never** armed. On detection it takes the path the existing category already takes — a marker-deduped tracking issue in the calling repo, and `exit 1` so the *scheduled* run fails and notifies. That is what makes the failure observable rather than merely logged. The discriminator is the **absence of any auto-merge *enabled* event** in the PR's timeline. That is direct positive evidence the mutation never succeeded. The first draft of this PR keyed on the absence of an `AutoMergeDisabledEvent` instead, which is only an inference and conflates two different things: GitHub disables auto-merge on its own when someone without write access pushes to the head branch or the base is switched, and the event's `reason` / `reasonCode` are free-form `String` in the published schema, not an enum, so they cannot be keyed on. An enabled event answers the question that actually matters — *did arming ever take?* **Two GraphQL semantics had to be established live, and both were wrong in an intermediate revision of this PR.** Each independently inverts the check, so in a live run they would have masked each other: - `timelineItems.totalCount` reports the **whole** timeline and ignores `itemTypes` — only `nodes` is filtered. Verified on `github-iac#234`: `totalCount: 4` alongside zero matching nodes. Reading `totalCount` makes every PR look already-armed, which would have stopped the sync arming anything at all and made the watchdog exonerate every real failure. - `mergeMethod: SQUASH` records an **`AutoSquashEnabledEvent`**, not an `AutoMergeEnabledEvent`. Verified on `medley#1619` (armed, `mergeMethod: SQUASH`): zero `AUTO_MERGE_ENABLED_EVENT` nodes, one `AutoSquashEnabledEvent`. Both workflows now read `nodes` across all three enabled-event types (`AUTO_MERGE_` / `AUTO_SQUASH_` / `AUTO_REBASE_ENABLED_EVENT`), so a future merge-method change cannot silently blind the check. `first: 1` is safe because GitHub applies the `itemTypes` filter *before* pagination (verified: an event at raw timeline index 2 is still returned by `first: 1`). Hand-written mocks are structurally unable to catch this class of defect — they encode whatever semantics the author believed. So the mocks now model the real behavior (filtered `nodes` plus a non-zero `totalCount` decoy the production code must not read), and four contract tests pin both facts directly against the shipped query text. ### Part 2 — arming is self-healing, which is the root cause Arming was gated on `steps.cpr.outputs.pull-request-operation == 'created'`. `peter-evans/create-pull-request` at the pinned SHA sets `pull-request-number` whenever the branch differs from base, but only ever reports `created` once (verified in `src/create-pull-request.ts` at `5f6978faf089d4d20b00c7766989d076bb2fc7f1`). So a sync PR opened while its target carried `automerge: false` — how a rollout window is held — **stayed unarmed forever**, and lifting the opt-out would not have repaired it. Two such PRs are open on the fleet right now: `github-iac#234` and `medley#1665`, both created 2026-07-27, both unarmed with an empty *arming* timeline (they do carry ordinary timeline items — that distinction is the subject of the next section). Under the created-only gate they would have been reported as arming failures every hour, permanently, and sent the operator to a warning line the *skipped* step never wrote. So the gate is now "this PR exists, the manifest says arm it, and it has never been armed" — the same predicate the watchdog uses. Consequences, stated up front: - Once the standards sync-engine pin carries this change, the next sync after the manifest restores `automerge: true` arms every open sync PR fleet-wide. That is the intended end state, and it repairs #234 and #1665, which today will never self-merge and which nothing is watching. **Sequencing matters:** if the manifest is restored while the engine pin still predates this change, those PRs stay unarmed and a re-pinned watchdog reports them permanently. The engine re-pin should target this PR's merge SHA, not `ac223bb`. - Given the right order, a **transient** alert is still possible if the hourly watchdog fires in the gap between the manifest flip and the next sync. Standards' `sync.yml` triggers on push to `main`, so the flip is itself the trigger and the gap is minutes. It self-resolves via the existing `Close recovered tracking issue` step — a bounded transient, not the permanent hourly alarm this removes. - The recovery text now covers both cases: an arming step that ran and was rejected leaves a warning to read; a caller pinned to an engine that predates arming on already-open PRs skips the step entirely, and the text names the re-pin as that fix rather than pointing at a log line that does not exist. - A reviewer who disarms a PR to hold it back is still never overridden. Verified live on `medley#1613`: disarming does **not** erase the enabled event. The single GraphQL read also replaces the REST `pulls.get` the step used to fetch the node id. ### Rejected alternatives **Rejected — make the arming step fail its leg.** The step's own comment records that an already-mergeable PR is a *known benign* rejection, and the docs research above leaves the mutation's error-vs-no-op semantics **UNVERIFIED**. Failing the sync leg would turn a benign, undocumented condition into a red sync, and the token already requests exactly GitHub's documented working permission pair, so a permission wall is not the likely failure mode. A post-condition check ("did the PR end up armed?") was considered and folded into Part 2 instead, where it costs nothing and repairs rather than merely reports. **Rejected — emit a distinguishable annotation.** `core.error` instead of `core.warning` colors the log, but nothing reads sync logs on a schedule — which is precisely why the stuck-PR case needed a watchdog rather than louder logging. Louder, not observable. **Rejected — a new dedicated workflow.** It would duplicate the manifest read, the App-token mint, the pagination-with-retry, the marker-deduped tracking issue, and the decoy-resistant issue adoption this workflow already has, then file a second competing issue for one incident. Both conditions answer the same question ("can this sync PR merge itself?") and belong in one report. ## Activation dependency — this ships inert The watchdog is a reusable workflow. Exactly **one** `uses:` pin exists across the default branches of every org repo the token can see (`gh search code` returns 13 mentions across 7 repos; every other hit is prose or a materialized copy of standards' governance data, not a `uses:`): `melodic-software/standards` `.github/workflows/standards-sync-stuck-automerge-alert.yml:19`, pinned at `43bc8d0`. `components/runner-policy/policy.json:438` carries the same SHA as governance data. Until that caller is re-pinned, the never-armed detection never runs. Reachability also needs the manifest's Phase 3d `automerge: false` window to close. The sync-engine pin is the second axis: standards' `sync.yml` pins `0b45b9f`, which has no arming step at all, so the self-healing arm is equally latent until that is re-pinned past this PR. This is the same second-staleness axis already recorded for the sync engine's own pin. ## Scope: the `43bc8d0` → `42329ef` re-pin is split out Not because it is unrelated — `42329ef` (#234) hardened the very scan loop this PR extends — but because: - The pin lives in `melodic-software/standards`, not this repository (`git grep 43bc8d0` finds zero hits here), and standards is outside this work's write fence. - `42329ef` is no longer the right target. Once this PR merges, the caller needs the SHA that carries **this** change, which supersedes `42329ef` entirely. It belongs in the Phase 3g re-pin sweep, aimed at this PR's merge SHA. ## Deferred, with trigger Auto-merge that was armed and later fell off by itself (a push from someone without write access, a base-branch switch) is deliberately **not** detected — that PR carries an enabled event and is exonerated by both the watchdog and the self-healing arm. It is a different failure from the arming blind spot this PR closes. **Trigger to revisit:** an armed sync PR observed silently reverting to unarmed on the live fleet. ## Test plan All counts below regenerated from the commands shown, not from memory. - `node --test .github/scripts/*.test.cjs` (CI's exact command, from `ci.yml:373`): **304 pass, 0 fail.** - `standards-sync-stuck-automerge-alert.test.cjs`: **40 pass** (27 on `origin/main`, so 13 new). - `standards-sync-automerge-arm.test.cjs`: **10 pass** (5 on `origin/main`, so 5 new). - `actionlint 1.7.12` on both changed workflows: clean, exit 0. - `npx @biomejs/biome@2.5.4 ci --config-path=fixtures/typescript/good/biome.json --error-on-warnings fixtures/typescript/good .github/scripts`: clean, exit 0. (A previous revision of this PR failed this check; the config lives at `fixtures/typescript/good/biome.json`, which an earlier note wrongly said did not exist.) - `markdownlint-cli2@0.23.1 README.md`: 0 issues. The tests execute the shipped code, not a re-implementation: `extractScanScript` / `extractArmingScript` read the actual `.yml`, slice the `script: |` block by step name, and run it through `AsyncFunction`. Only `github.graphql` and `core` are mocked. New coverage: - detection past threshold; no alarm inside threshold - a PR armed-then-disarmed exonerated, with the probe proven to have run - an opted-out target never probed at all - a PR armed, merged, or closed in the page→probe race - non-sync authors ignored - a persistent probe error failing loudly with no false all-clear - both categories reported in separate sections; all-clear requires both empty - arming skipped for a currently-armed PR and for an armed-then-disarmed PR - an unreadable PR warns instead of mutating - contract tests, in both files, pinning that the arming history is read from filtered `nodes` and never `totalCount`, and that all three merge methods' enabled events are probed ## Related - Refs #213 — the arming step and this watchdog's original half. - Refs #234 (`42329ef`) — the scan-loop split and retry this change extends. - Refs melodic-software/standards#289 — the engine re-pin that first puts the arming step into production. No linked issue. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
## Summary Pre-step (0) (merged in #301) states that re-pinning the standards sync engine to `8202e03f` also activates the never-armed watchdog, "so ONE pin satisfies both." The commit does carry both halves; **standards does not consume it that way**, so the guidance is wrong for the operator who follows it. Verified against the live repos: | Claim | Reality | |---|---| | One pin activates arming + watchdog | Two separate callers, two separate pins | | Watchdog rides `sync.yml`'s pin | Watchdog is `standards-sync-stuck-automerge-alert.yml`, a different reusable | ``` standards/.github/workflows/sync.yml:33 → standards-sync.yml@8202e03f… (standards#293, in flight) standards/.github/workflows/standards-sync-stuck-automerge-alert.yml:19 → standards-sync-stuck-automerge-alert.yml@43bc8d0f # 43bc8d0 2026-07-22 ``` Re-pinning `sync.yml` therefore activates the **arming half only**. `43bc8d0` predates **two** commits, not one — `git log --oneline 43bc8d0..8202e03 -- .github/workflows/standards-sync-stuck-automerge-alert.yml`: - `8202e03` #291 — never-armed detection - `42329ef` #234 — split the stuck-PR scan into a cheap page fetch + per-candidate merge-state probe with retry **The deadline the old text implied was already met.** The never-armed scan selects targets with `jq '[.include[] | select(.automerge) | .repo_name]'` and gates candidates on `automergeRepoNames.has(repo)`, so it is inert while the rollout window holds all 8 targets at `automerge: false` — and becomes load-bearing at the exact moment `automerge: true` is restored. The watchdog re-pin must land **before** that restore. **One nuance the correction records**, because it cuts against reading the stale pin as wholly dormant: the *armed-but-stuck* scan is **not** automerge-gated. It loops `repoNames` (all 8 targets) at `:299` and pushes `isArmedCandidate` matches at `:315` with no automerge check — only `:316`'s never-armed branch is gated. It sweeps every target on the hourly cron today, and is quiet only because no sync PR is currently armed (checked: dotfiles#361, provisioning#231, github-iac#244, medley#1676 all report `autoMergeRequest: null`). No phase tag advances. Scope is the pre-step (0) paragraph only. ## Test plan - `markdownlint-cli2 docs/topics/claude-review-lanes/PLAN.md` → `Summary: 0 error(s)` - `git diff -U0 | grep -E "^[+-].*\[(DONE|DOING|TODO)\]"` → no matches (no phase tag touched) - Every factual claim above regenerated from a command against the live repos, on a **full** clone — the local clone was shallow, which can make `git diff <sha>^ <sha>` error in a way that reads as a false positive; `git fetch --unshallow` was run and every finding re-derived. All held. - Independently re-verified by a fresh-context verifier (verdict in the PR thread). ## Related No linked issue. Corrects text merged in #301; describes work delivered by #291 and #234. The paired standards-side watchdog re-pin ships as its own PR. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #226
Summary
number/url/author/autoMergeRequest { enabledAt }(nomergeStateStatus), phase 2 probesmergeStateStatusone PR at a time viarepository.pullRequest(number:), and only for armed, past-threshold bot PRs.graphqlWithRetry— exponential backoff (GRAPHQL_RETRY_ATTEMPTS=4,GRAPHQL_RETRY_BASE_MS=1000, both step-env-tunable), rethrowing the last error on exhaustion so a persistent failure still fails the run loudly. On a throw thestuck-countoutput is never set, so the close-issue branch cannot fire a false all-clear.MAX_PAGESsoundness guard unchanged; "stuck" semantics are preserved (theBLOCKEDcheck moved from the bulk page filter to the per-candidate probe).uses:SHA pins, or permissions blocks.Test plan
node --test .github/scripts/*.test.cjs(CI's exact command): 257/257 pass, including 24 instandards-sync-stuck-automerge-alert.test.cjs— new coverage asserts the page query never selectsmergeStateStatus, exactly one phase-2 probe per candidate (no bulk fan-out), transient-error retry on both page fetch and probe, and persistent-error loud failure with no false all-clear.actionlinton the changed workflow: clean.biome check(CI config/scope): clean.Related
🤖 Generated with Claude Code