Skip to content

feat(pr): queue and drain verbs (#473 Task 4) - #518

Merged
cameronsjo merged 4 commits into
mainfrom
feat/pr-drain
Sep 12, 2026
Merged

cameronsjo merged 4 commits into
mainfrom
feat/pr-drain

Conversation

@cameronsjo

Copy link
Copy Markdown
Owner

Closes #473

Task 4 of the review-autonomy spine (docs/plans/2026-09-11-review-autonomy-spine.md), the last of four. With this the plan's contract is delivered: lock and atomic records (#497), phases and pr repair (#502), the cap on every launch path (#516), and now the queue and its drainer.

What lands

  • pr queue lists queued records oldest first, --json, with no queued reviews and [] when empty.
  • pr drain runs one pass by default (--once), or loops with --watch --interval. A pass: take the lock, count occupied slots, refuse the pass if any record is unreadable, refuse a local queue entry at claim, claim the oldest queued records to preparing under the same hold, release, then prepare and launch each through the same path pr <ref> uses, with every gate re-run at launch time rather than trusted from the record. One line per pass reaches stdout even with logging off; --dry-run names what would launch and creates nothing; --interval without --watch refuses; exit codes follow the design.
  • Retry rule. A failure with no window and no park requeues the record with attempts and lastError, tearing the failed attempt's workspace down after the lock is released. A failure that already parked the record, or that left a window tmux can resolve, or that happened while tmux was unreadable, stays parked with a reason naming pr repair --adopt-window; the workspace and the window pointer are kept. The drainer therefore never launches a second agent over a live one. At three attempts a record parks with drain: 3 attempts, last: ….
  • scripts/dogfood-drain.sh queues two refs inside a scratch home (with XDG_CONFIG_HOME and XDG_STATE_HOME pinned, so the isolation holds on Linux), drains, and asserts the report. Dry-run is the default; --launch is the opt-in for the live pass.

Dogfood

The dry-run path was run against two real open PRs under a live tmux server: both named as would-launch, no workspace created, no window dispatched. The live --launch pass is still owed.

Review

Code review at 8c04073: clean apart from the script defaulting to a live launch, fixed in dd49816f. Security review at 8c04073, probed: launch-time gates hold; four Important in the retry path (teardown under a possibly-live agent and requeue; local entries not refused at claim; lock held across the removal; script isolation on Linux), all fixed in 4e73dfac with tests seen red against reverted controls. Re-check at 4e73dfac is in the marker comment. Recorded, not actioned: a dispatch failure now parks rather than retries, so the three-attempt budget applies to gh and clone failures; no test drives --watch's own loop timing, since the codebase has no clock seam yet.

Verification

From the worktree: go build ./..., go vet ./..., go test ./... -timeout 5m, golangci-lint run, gofmt -l ., bash -n scripts/dogfood-drain.sh, all clean, re-run by the orchestrator at each head.

Session-Id: c2d13fd6-30bc-409a-989c-cd5ad22073fd
Model: claude-sonnet-5
Harness: claude-code 2.1.269
Machine: cf6e768835c7

cameronsjo and others added 4 commits September 12, 2026 10:30
Adds `forgectl pr queue` (list queued reviews, FIFO by createdAt) and
`forgectl pr drain` (launch queued reviews as concurrency-cap slots free
up, once or on a --watch interval). One drain pass takes the lifecycle
lock, counts occupancy, refuses on any unreadable record, claims the
oldest free-slot's-worth of queued records to `preparing` under that
same hold, then prepares and launches each through the identical
Prepare -> Launch path `pr <ref>` uses. A launch failure records
attempts/lastError/lastAttemptAt and returns the record to `queued` for
a later pass; at 3 attempts it parks in `needs-repair` instead.

`Client.Drain` performs exactly one pass per call, matching the
one-report-per-call shape every other composite verb in this package
follows; the --watch loop, its per-pass stdout line, and its
three-consecutive-refusals exit rule live in the CLI. Dry-run tested
against two real open PRs on cameronsjo/forgectl via
scripts/dogfood-drain.sh; the live (non-dry-run) launch is left for the
orchestrator.

Session-Id: c2d13fd6-30bc-409a-989c-cd5ad22073fd
Model: claude-sonnet-5
Harness: claude-code 2.1.269
Machine: cf6e768835c7
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…opt-in (#473)

The script launched real reviewer sessions unless --dry-run was remembered.
Muscle memory now runs the safe path; the destructive one has to be named.
Verified: the default path passes under a live tmux server and dispatches
no window; without a server it fails closed on the unreadable cap.

Session-Id: c2d13fd6-30bc-409a-989c-cd5ad22073fd
Model: claude-fable-5-1
Harness: claude-code 2.1.269
Machine: cf6e768835c7
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…and release the lock before teardown (#473)

1. A retry no longer tears down a workspace whose review agent may be live.
Launch parks the record in needs-repair with a reason naming the window on
both branches where the window already exists, and settleDrainFailure was
erasing that reason, removing the clean room under the running agent, and
returning the ref to queued — so a later pass would launch a second agent for
the same ref. The settlement now re-reads the record and asks tmux first: an
already-parked record, a window resolvable by the derived name, or an
unreadable tmux (fail-closed) all mean leave it parked, keep the workspace,
stop retrying. Only a failure with neither signal — a gh or clone failure
before dispatch — requeues.

2. Drain mirrors Queue's local-ref refusal at the reader. A queued record
marked local is refused at claim time: never claimed to preparing, never
cloned, reported as a refused item. Dispatch was previously stopped only by
Launch's own refusal, which its comment calls an incidental second barrier.

3. The lifecycle lock is no longer held across os.RemoveAll of a clone. The
transition mutator is side-effect-free and idempotent (transitionLocked may
run it twice); it records the workspace it cleared, and the teardown runs
after the lock is released, through the same bounded sandboxTeardown path.

4. scripts/dogfood-drain.sh sets XDG_CONFIG_HOME and XDG_STATE_HOME alongside
HOME on every forgectl invocation. os.UserConfigDir reads XDG_CONFIG_HOME
first on Linux, so a HOME-only scratch isolated nothing there. Dry-run stays
the default.

5. Nits: CheckDispatchCapability runs once at the top of a pass, so an
undispatchable host refuses the pass instead of parking N records;
drainItem resolves provenance through provenanceFromRecord, so the
forged-authorship warning fires on the unattended path; a requeue clears
WindowID; the refusal and watch-refusal strings in internal/cli/pr_drain.go
go through safeTerm like the pass line already does.

Session-Id: c2d13fd6-30bc-409a-989c-cd5ad22073fd
Model: claude-opus-5
Harness: claude-code 2.1.269
Machine: cf6e768835c7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… owes (#473)

Session-Id: c2d13fd6-30bc-409a-989c-cd5ad22073fd
Model: claude-fable-5-1
Harness: claude-code 2.1.269
Machine: cf6e768835c7
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 28 days. After that, they cost $0.25 per reviewed file.

Or wait 9 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available. Your 55 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: ccffb460-6680-4f92-aa07-bea590612161

📥 Commits

Reviewing files that changed from the base of the PR and between 1c47683 and 5791ff7.

📒 Files selected for processing (11)
  • README.md
  • docs/commands/pr.md
  • docs/configuration.md
  • docs/plans/2026-09-11-review-autonomy-spine.md
  • internal/cli/pr.go
  • internal/cli/pr_drain.go
  • internal/cli/pr_drain_test.go
  • internal/cli/pr_queue.go
  • internal/pr/drain.go
  • internal/pr/drain_test.go
  • scripts/dogfood-drain.sh

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

@cameronsjo cameronsjo left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Code review (crit=0 imp=1) and an independent security review (crit=0 imp=4, probed) ran against the diff at 8c04073. The code finding, the dogfood script launching by default, is fixed in dd49816f (dry-run default, --launch opt-in, verified under a throwaway tmux server with nothing dispatched). The four security findings in the retry path (teardown under a possibly-live agent and requeue; local queue entries not refused at claim; the lifecycle lock held across the workspace removal; script isolation on Linux) are fixed in 4e73dfac, each with a test seen red against a reverted control. A second security pass at 4e73dfac verified all four closed by its own reads and found nothing new at Important; the delta to this head is plan bookkeeping only.

Verified by the security passes: every launch-time gate re-runs through the ordinary Prepare and Launch rather than trusting the stored record; the claim is one transition under one hold; a drainer killed after the claim leaks a slot rather than double-launching; the retry parks on a resolvable window or an unreadable tmux, keeping the workspace and the window pointer; the post-lock teardown is bounded by the sandbox prefix and symlink checks and cannot run twice on a mutator re-run.

Recorded, not actioned: a mass park after a mid-pass tmux outage prints N identical launch errors with no pointer to the recovery verb (operability, not security); a crash between the requeue write and the teardown leaves an unreferenced quarantined clone in the temp dir; no test drives the watch loop timing, since the codebase has no clock seam. Gates re-run at this head: build, vet, go test ./... -timeout 5m, golangci-lint, gofmt, bash -n on the script, all clean.

@cameronsjo
cameronsjo merged commit f12019e into main Sep 12, 2026
5 checks passed
cameronsjo added a commit that referenced this pull request Sep 12, 2026
All four task PRs are merged: #497, #502, #516, #518.

Session-Id: c2d13fd6-30bc-409a-989c-cd5ad22073fd
Model: claude-fable-5-1
Harness: claude-code 2.1.269
Machine: cf6e768835c7

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@cameronsjo
cameronsjo deleted the feat/pr-drain branch September 12, 2026 16:48
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.

pr: the review drainer — empty the backlog without a human starting each one

1 participant