Skip to content

🐛 fix(pr-review-queue): remediate merged-#35 CodeRabbit findings#36

Open
AojdevStudio wants to merge 1 commit into
mainfrom
fix/pr-review-queue-remediation
Open

🐛 fix(pr-review-queue): remediate merged-#35 CodeRabbit findings#36
AojdevStudio wants to merge 1 commit into
mainfrom
fix/pr-review-queue-remediation

Conversation

@AojdevStudio

@AojdevStudio AojdevStudio commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Post-merge follow-up to #35. Fixes the one dead-on-arrival blocker plus the run.mjs stability cluster CodeRabbit flagged on the merged PR, each with a regression test.

Blocker (was dead on arrival)

  • protocols.md — the §1 election command omitted the --authorized flag that claim.mjs hard-requires, so every literal run exited non-zero. The doc then told the worker to read a non-zero exit as a benign "head moved" race, so it would silently skip every PR forever. Now documents --authorized "$AUTHORIZED_WORKERS" and splits the exit-status semantics: a head-mismatch payload is a race (retry once), a payload-less failure is genuine (fail loud, stop).

Stability cluster (run.mjs)

  • Per-PR failure isolation in pollOnce — one bad PR (closed mid-cycle, permission hiccup) no longer aborts the whole cycle; a systemic-failure guard still throws when every PR in a non-empty queue fails, so runLoop's maxErrors abort keeps working.
  • runScript now checks the child exit status before JSON.parse, so a non-zero sibling script no longer gets parsed as if it succeeded.
  • Crash-safe state — atomic write (.tmp + rename) and corrupt-file recovery on load, so a kill mid-write can't brick every restart.
  • unref() the backoff timer so a graceful stop can't keep the process alive up to the 5-minute cap.

Other

  • review-gate.mjs — map the STARTUP_FAILURE check conclusion to fail instead of throwing unknown check-run conclusion and aborting the entire gate evaluation.
  • .gitignore — ignore local .worktrees/ (mirrors the already-ignored .claude/worktrees); it was breaking local biome with a nested-root-config error.

Tests

Added regression tests: pollOnce isolation + systemic-failure guard, corrupt-state-file recovery, and STARTUP_FAILURE classification. Full suite green (145 pass), bun run check + bun run pack:dry pass.

Deferred to a follow-up issue

The two protocol-race findings CodeRabbit rated "heavy lift": the gate-only re-check path has no claim step, and there's no in-progress claim renewal. Both are correctness-hardening on concurrency, not blockers, and want their own design pass.

Summary by CodeRabbit

  • New Features

    • Added support for authorized worker lists during review-queue claim elections.
    • Added handling for startup failures in review check results.
  • Bug Fixes

    • Review polling now continues when individual items fail.
    • Corrupt state files no longer prevent startup.
    • State updates are saved more safely.
    • Improved command failure reporting and graceful shutdown behavior.
  • Tests

    • Added coverage for partial polling failures, complete polling failures, corrupt state files, and startup-failure results.
  • Chores

    • Excluded local review worktree directories from version control.

Post-merge follow-up to #35. Fixes the one dead-on-arrival blocker plus the
run.mjs stability cluster CodeRabbit flagged, with regression tests.

- protocols.md: document the required --authorized flag on claim.mjs (the
  election command failed on every literal run without it) and correct the
  exit-status semantics so a genuine failure is no longer misread as a benign
  head-moved race.
- review-gate.mjs: map STARTUP_FAILURE conclusion to fail instead of throwing
  and aborting the whole gate evaluation.
- run.mjs: per-PR failure isolation in pollOnce with a systemic-failure guard
  so maxErrors still fires; check child exit status in runScript before
  JSON.parse; atomic state write + corrupt-file recovery on load; unref the
  backoff timer so graceful stop cannot hang up to the 5-minute cap.
- .gitignore: ignore local .worktrees/ (mirrors .claude/worktrees).

Deferred to a follow-up issue: the two protocol-race findings (gate-only
re-check claim step, in-progress claim renewal).
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The changes refine review-queue election handling, classify startup failures, isolate polling errors, harden observation persistence and runtime behavior, add regression tests, and ignore local .worktrees/ directories.

Changes

Review queue behavior

Layer / File(s) Summary
Election authorization and retry semantics
skills/pr-review-queue/protocols.md
Claim elections now require an authorized worker allowlist, while non-zero election results distinguish head-mismatch retries from fatal failures.
Startup failure classification
skills/pr-review-queue/scripts/review-gate.mjs, skills/pr-review-queue/scripts/review-gate.test.mjs
STARTUP_FAILURE check conclusions map to fail, with unit coverage for the mapping.
Polling, persistence, and runtime resilience
skills/pr-review-queue/scripts/run.mjs, skills/pr-review-queue/scripts/run.test.mjs
Polling isolates failed PRs, corrupt state resets to an empty map, observation writes are atomic, script failures surface diagnostics, timers unref, and regression tests cover the new behavior.

Local worktree ignore

Layer / File(s) Summary
Worktree ignore rule
.gitignore
Adds .worktrees/ to the ignored local review-worktree directories.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects a follow-up fix in pr-review-queue addressing CodeRabbit findings from the prior merge.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pr-review-queue-remediation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
skills/pr-review-queue/scripts/run.test.mjs (1)

90-108: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Strengthen the isolation test to cover state retention.

Since the intent of per-PR isolation is to keep the prior observation and retry the PR next cycle, consider prepopulating the observations map for the failing PR in this test and asserting that it is retained in the output observations. This will prevent regressions related to state retention.

🛠️ Proposed test enhancement
 test("pollOnce isolates a single failing PR and still processes the rest of the queue", async () => {
+  const priorObservations = new Map([
+    [35, { pr: 35, head: "old", updatedAt: "old", gateFingerprint: "old", claimState: null }]
+  ]);
   const { actionable, observations, errors } = await pollOnce({
     fetchQueue: async () => [35, 36],
     fetchPrState: async (pr) => {
       if (pr === 35) throw new Error("PR 35 was closed mid-cycle");
       return { head: "abc123", election: election(), gateEvidence: evidence() };
     },
-    observations: new Map(),
+    observations: priorObservations,
     now: "2026-07-20T00:00:00Z",
   });
   assert.deepEqual(
     actionable,
     [{ pr: 36, head: "abc123", action: "full-review" }],
     "the healthy PR must still be flagged",
   );
   assert.equal(errors.length, 1);
   assert.equal(errors[0].pr, 35);
-  assert.ok(!observations.has(35), "a PR that failed to poll must not get a fabricated observation");
+  assert.equal(observations.get(35)?.head, "old", "the failed PR must retain its previous observation");
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/pr-review-queue/scripts/run.test.mjs` around lines 90 - 108,
Strengthen the pollOnce isolation test by prepopulating the observations map
with an existing observation for PR 35 before calling pollOnce, then assert that
the returned observations retain that exact entry despite fetchPrState failing.
Keep the existing assertions that PR 36 remains actionable and PR 35 is reported
as an error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@skills/pr-review-queue/protocols.md`:
- Around line 41-51: The re-election command that assigns RECHECK_JSON must pass
the required authorization set. Update the claim.mjs invocation in the “re-read
and re-elect” section to include --authorized "$AUTHORIZED_WORKERS", preserving
the existing repository, PR, and expected-head arguments.

In `@skills/pr-review-queue/scripts/run.mjs`:
- Around line 36-63: Update the polling function’s nextObservations
initialization to use an empty Map rather than cloning observations, so PRs no
longer in queue are discarded. In the fetchPrState error path, preserve the
prior observation for the failed PR when one exists, while continuing to update
successful PRs as before.
- Around line 235-237: Update the sleepFn timer in main to remove .unref() so
the daemon remains alive through the backoff sleep and next poll cycle. At the
end of main, explicitly exit with status 1 when status.lastError is set,
otherwise exit with status 0.

---

Nitpick comments:
In `@skills/pr-review-queue/scripts/run.test.mjs`:
- Around line 90-108: Strengthen the pollOnce isolation test by prepopulating
the observations map with an existing observation for PR 35 before calling
pollOnce, then assert that the returned observations retain that exact entry
despite fetchPrState failing. Keep the existing assertions that PR 36 remains
actionable and PR 35 is reported as an error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1fa39bf9-7229-4a41-ba13-1b3d4e4f4941

📥 Commits

Reviewing files that changed from the base of the PR and between f47789c and 972737d.

📒 Files selected for processing (6)
  • .gitignore
  • skills/pr-review-queue/protocols.md
  • skills/pr-review-queue/scripts/review-gate.mjs
  • skills/pr-review-queue/scripts/review-gate.test.mjs
  • skills/pr-review-queue/scripts/run.mjs
  • skills/pr-review-queue/scripts/run.test.mjs

Comment on lines +41 to +51
`$AUTHORIZED_WORKERS` is the comma-separated set of fleet-worker logins
allowed to hold a claim (it must include this worker's own login). It comes
from the dispatcher's assignment; `claim.mjs` folds any claim or abandon
event from a login outside this set as if it never existed, and **requires**
the flag. Omitting `--authorized` makes every election exit non-zero.

```bash
HEAD_SHA="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq .headRefOid)"
set +e
ELECTION_JSON="$(bun <skill-directory>/scripts/claim.mjs --repo "$REPO" --pr "$PR" --expected-head "$HEAD_SHA")"
ELECTION_JSON="$(bun <skill-directory>/scripts/claim.mjs --repo "$REPO" --pr "$PR" \
--expected-head "$HEAD_SHA" --authorized "$AUTHORIZED_WORKERS")"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Missing --authorized flag in the re-election command.

The new requirement for the --authorized flag correctly applies to the initial claim.mjs election. However, the re-election command under the "Then re-read and re-elect to confirm you actually won:" section (around line 101) still omits this flag. Since claim.mjs throws an error if --authorized is missing, the recheck will always fail and exit non-zero.

Please update the RECHECK_JSON command to include --authorized "$AUTHORIZED_WORKERS".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/pr-review-queue/protocols.md` around lines 41 - 51, The re-election
command that assigns RECHECK_JSON must pass the required authorization set.
Update the claim.mjs invocation in the “re-read and re-elect” section to include
--authorized "$AUTHORIZED_WORKERS", preserving the existing repository, PR, and
expected-head arguments.

Comment on lines +36 to 63
const errors = [];
const nextObservations = new Map(observations);
for (const pr of queue) {
const { head, election, gateEvidence: evidence } = await fetchPrState(pr);
let state;
try {
state = await fetchPrState(pr);
} catch (error) {
// Per-PR isolation: one bad PR (closed mid-cycle, permission hiccup,
// transient gh error) must not abort polling for every other PR. Keep
// the prior observation and retry this PR next cycle.
errors.push({ pr, message: error.message });
continue;
}
const { head, election, gateEvidence: evidence } = state;
const previous = observations.get(pr) ?? initialObservation(pr);
const next = updateObservation(previous, { head, gateEvidence: evidence, election, now });
const gatesChanged = observationChanged(previous, next);
nextObservations.set(pr, next);
const action = nextAction(election, gatesChanged);
if (action !== "skip") actionable.push({ pr, head, action });
}
return { actionable, observations: nextObservations };
// Systemic-failure guard: if every PR in a non-empty queue failed, surface it
// as a real cycle error so runLoop's maxErrors abort still fires.
if (queue.length > 0 && errors.length === queue.length) {
throw new Error(`all ${queue.length} queued PRs failed to poll: ${errors[0].message}`);
}
return { actionable, observations: nextObservations, errors };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Memory leak: nextObservations accumulates stale PRs indefinitely.

By initializing nextObservations = new Map(observations) and only updating PRs that are currently in the queue, PRs that fall out of the queue (e.g., merged or closed PRs) are never removed. This will cause the .pr-review-queue-state.json state file and the worker's memory to grow endlessly over time.

To fix this, start with an empty map and only retain previous observations for PRs that throw an error during fetchPrState.

♻️ Proposed fix
-  const nextObservations = new Map(observations);
+  const nextObservations = new Map();
   for (const pr of queue) {
     let state;
     try {
       state = await fetchPrState(pr);
     } catch (error) {
       // Per-PR isolation: one bad PR (closed mid-cycle, permission hiccup,
       // transient gh error) must not abort polling for every other PR. Keep
       // the prior observation and retry this PR next cycle.
       errors.push({ pr, message: error.message });
+      if (observations.has(pr)) {
+        nextObservations.set(pr, observations.get(pr));
+      }
       continue;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const errors = [];
const nextObservations = new Map(observations);
for (const pr of queue) {
const { head, election, gateEvidence: evidence } = await fetchPrState(pr);
let state;
try {
state = await fetchPrState(pr);
} catch (error) {
// Per-PR isolation: one bad PR (closed mid-cycle, permission hiccup,
// transient gh error) must not abort polling for every other PR. Keep
// the prior observation and retry this PR next cycle.
errors.push({ pr, message: error.message });
continue;
}
const { head, election, gateEvidence: evidence } = state;
const previous = observations.get(pr) ?? initialObservation(pr);
const next = updateObservation(previous, { head, gateEvidence: evidence, election, now });
const gatesChanged = observationChanged(previous, next);
nextObservations.set(pr, next);
const action = nextAction(election, gatesChanged);
if (action !== "skip") actionable.push({ pr, head, action });
}
return { actionable, observations: nextObservations };
// Systemic-failure guard: if every PR in a non-empty queue failed, surface it
// as a real cycle error so runLoop's maxErrors abort still fires.
if (queue.length > 0 && errors.length === queue.length) {
throw new Error(`all ${queue.length} queued PRs failed to poll: ${errors[0].message}`);
}
return { actionable, observations: nextObservations, errors };
}
const errors = [];
const nextObservations = new Map();
for (const pr of queue) {
let state;
try {
state = await fetchPrState(pr);
} catch (error) {
// Per-PR isolation: one bad PR (closed mid-cycle, permission hiccup,
// transient gh error) must not abort polling for every other PR. Keep
// the prior observation and retry this PR next cycle.
errors.push({ pr, message: error.message });
if (observations.has(pr)) {
nextObservations.set(pr, observations.get(pr));
}
continue;
}
const { head, election, gateEvidence: evidence } = state;
const previous = observations.get(pr) ?? initialObservation(pr);
const next = updateObservation(previous, { head, gateEvidence: evidence, election, now });
const gatesChanged = observationChanged(previous, next);
nextObservations.set(pr, next);
const action = nextAction(election, gatesChanged);
if (action !== "skip") actionable.push({ pr, head, action });
}
// Systemic-failure guard: if every PR in a non-empty queue failed, surface it
// as a real cycle error so runLoop's maxErrors abort still fires.
if (queue.length > 0 && errors.length === queue.length) {
throw new Error(`all ${queue.length} queued PRs failed to poll: ${errors[0].message}`);
}
return { actionable, observations: nextObservations, errors };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/pr-review-queue/scripts/run.mjs` around lines 36 - 63, Update the
polling function’s nextObservations initialization to use an empty Map rather
than cloning observations, so PRs no longer in queue are discarded. In the
fetchPrState error path, preserve the prior observation for the failed PR when
one exists, while continuing to update successful PRs as before.

Comment on lines +235 to +237
// unref so a pending backoff timer never keeps the process alive after a
// graceful stop resolves the sleep-unless-stopped promise early.
sleepFn: (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Critical: .unref() will cause the daemon to exit immediately during sleep.

Awaiting a promise does not keep the Node.js/Bun event loop alive. Because gh is spawned synchronously and I/O completes before the sleep begins, there are typically no other active handles during the sleep phase. Calling .unref() on the only active timer will cause the event loop to empty out, and the process will exit immediately without waiting for the sleep to finish or the next poll cycle to start.

To ensure the process exits promptly after a graceful stop without breaking the sleep, remove .unref() and instead explicitly call process.exit(status.lastError ? 1 : 0) at the end of main.

🐛 Proposed fix
-    // unref so a pending backoff timer never keeps the process alive after a
-    // graceful stop resolves the sleep-unless-stopped promise early.
-    sleepFn: (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref()),
+    sleepFn: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),

And then at the end of main (line 242):

-  process.exitCode = status.lastError ? 1 : 0;
+  process.exit(status.lastError ? 1 : 0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// unref so a pending backoff timer never keeps the process alive after a
// graceful stop resolves the sleep-unless-stopped promise early.
sleepFn: (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref()),
sleepFn: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/pr-review-queue/scripts/run.mjs` around lines 235 - 237, Update the
sleepFn timer in main to remove .unref() so the daemon remains alive through the
backoff sleep and next poll cycle. At the end of main, explicitly exit with
status 1 when status.lastError is set, otherwise exit with status 0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant