internal/planning/2026-06-21-plan-qa-batch-design.md
Date: 2026-06-21
Branch: jg-conductor/3952-qa-automation-plan
Behavioral QA — proving that a merged change actually does what it claims — has a
worker but no planner. The verify-pr-fix skill
(the installed/shared $verify-pr-fix skill) verifies one PR: reproduce the bug
before the fix, confirm it gone after, post evidence to the PR. But nothing
decides which merged PRs get that treatment, when, or in what order.
Issue #3952 was the manual planner — a hand-curated, tiered list of behavioral
bug-fix PRs to verify since rc.1. It was closed with the rationale that
verifying user-facing fixes is a per-PR practice (do it on the PR with
verify-pr-fix, post evidence there), not a standing umbrella tracking
issue. That was the right call about the tracker, but it removed the only
trigger without replacing it: today, whether a merged PR ever gets QA'd depends
on someone remembering. There is no systematic, gap-free way to ask "what merged
since the last release that still needs its behavior verified?"
This is the same selection/interview/dispatch gap that plan-pr-batch →
pr-batch already fills for implementation work. QA needs the equivalent.
HEAD, surface
every merged PR still needing QA, so coverage cannot silently skip a PR.agent-coord heartbeat the
implementation batches use, so QA never collides with in-flight edit work.verify ($verify).post-merge-audit. plan-qa-batch is the
behavioral half of release readiness; the two are siblings.| Skill | Question it answers |
|---|---|
post-merge-audit | Process/release risk: review gates clean? changelog present? cross-PR interactions? |
plan-qa-batch (new) | Behavioral: which merged PRs still need their change proven, by what method, in what order? |
qa-batch (new, slim runner) | Executes the plan: one subagent per QA lane, coordinated and contention-aware |
verify-pr-fix | The worker for one behavioral PR: reproduce-before / confirm-after |
plan-qa-batch is to verify-pr-fix what plan-pr-batch is to pr-batch.
$plan-qa-batch with no args runs the
authoritative unbounded backlog query (§3) and uses the last-RC window only to
order/prioritize what it surfaces — never to filter (§2).$plan-qa-batch <base..head>, --full (drop the
since-RC ordering window; backlog coverage is always unbounded regardless —
§2), a label/milestone filter, or an explicit PR list.qa-verified or qa-skipped, §5) drop out automatically, so the skill always
shows exactly what still needs QA.HEAD (origin/main / the release branch) is always the upper bound. The QA
backlog is defined only by terminal labels, so the range can never silently
drop a PR:
backlog = (all merged PRs through HEAD)
− (PRs labeled `qa-verified`) ← passed QA (§5)
− (PRs labeled `qa-skipped`) ← reviewed, nothing to verify (§5)
Every merged PR that passes QA or is deliberately skipped ends in exactly one
terminal state — qa-verified or qa-skipped. A failing, blocked, or
tool-stalled PR intentionally carries neither label, so it remains visible for a
later run. The authoritative, unbounded label query (§3) runs on every
invocation, so a PR merged before the last RC that was never QA'd is never
dropped — the RC window is only an ordering/prioritization hint, never a filter
that removes unlabeled PRs. The labels — not the range — guarantee no gaps, and
because a qa-skipped PR is never re-presented, the backlog (future work)
stays bounded to unlabeled PRs only. (The qa-skipped set itself grows
monotonically with the repo's formatting/typo history; that is harmless because
it never re-enters the backlog, but it is not "bounded.")
The authoritative backlog is always the unbounded, paginated label query for
the active target base branch, run on every invocation (not just --full):
set -o pipefail
repo="${QA_REPO:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" || {
echo "ERROR: could not determine repository name" >&2
exit 1
}
target_bases="${QA_TARGET_BASES:-main}" # e.g. "main release/v4.1"
for target_base in $target_bases; do
gh api --method GET --paginate \
-f state=closed -f base="$target_base" -f per_page=100 \
"/repos/$repo/pulls"
done |
jq -s '
[.[][] | select(.merged_at != null)
| select(any(.labels[]; .name == "qa-verified") | not)
| select(any(.labels[]; .name == "qa-skipped") | not)]
| unique_by(.number)
'
gh pr list defaults to --state open and --limit 30, and its search backend
caps around 1000 results. The planner therefore MUST NOT rely on a high
--limit over search to prove "gap-free" coverage. It must page the pull-request
API (or an equivalent non-search GraphQL connection) until hasNextPage /
Link: rel="next" is exhausted, filter merged_at != null, and constrain the
query to the trusted target-base set. That set is usually one branch (main or
the active release branch), but a main sweep in a release-branch workflow must
include release branches whose PRs can reach HEAD via release → main merges;
otherwise PRs originally targeted at release/* are invisible to a
base=main-only query. If the planner cannot enumerate the relevant base set, it
stops with UNKNOWN instead of narrowing the "gap-free" guarantee.
The repo value is derived from the trusted local checkout or an explicit
trusted override and reused anywhere this document shows OWNER/REPO-style
placeholders. Repository derivation failure is also UNKNOWN: abort before making
the backlog API call. The implementation MUST preserve pipefail (or capture the
API exit code explicitly) and abort on API/auth/rate-limit/network errors before
using the jq output; a failed page fetch is UNKNOWN, not an empty backlog. The
jq -s example buffers all returned pages before filtering; that is acceptable
for React on Rails' current PR volume, but the SKILL.md should switch to
per-page/streaming processing if a target repo's merged PR count makes that
memory trade-off unsafe.
The read-only resolver
POST_MERGE_AUDIT_SKILL_DIR="$(.agents/bin/shared-skill-dir post-merge-audit)"; "${POST_MERGE_AUDIT_SKILL_DIR}/bin/post-merge-audit-scope" --json is then used
only as an optimization to order/prioritize the common since-RC window, never
to filter the backlog. Two real constraints when calling it (verified against the
script): it hard-fails (return 1) when no *.rc.* tag is in head history
and --base is omitted, so plan-qa-batch always passes an explicit
release-aware --base matching the sweep target (origin/main for a main sweep,
or the active release branch for an RC/release sweep). If the resolver returns an
empty optimization or a known "no RC tag" result for that base, the planner
treats the optimization as unavailable — the label query above remains the
unconditional source of truth. Unexpected resolver failures (auth, missing tools,
network, malformed JSON, or an unrecognized non-zero exit) are infrastructure
UNKNOWN and abort before planning. plan-qa-batch then layers QA classification
(§4) on the resulting set.
Nothing merged is dropped silently — every PR is routed to the right verification method. Each candidate is classified by reading its diff and linked issue:
| Merged change | QA method |
|---|---|
| Behavioral fix / feature | verify-pr-fix — reproduce before/after, post evidence |
| Docs | Read it: does it make sense, are commands/links/examples correct, does it match the code it describes |
| Refactor / types / config / other code | "Does it do what it claims, nothing broke" — confirm behavior unchanged + coverage |
| Truly nothing to verify (formatting, comment typo) | Glance + note, then apply qa-skipped (§5) so it never resurfaces — the only "excluded" lane, near-empty |
Each candidate carries three independent attributes (deliberately not collapsed into a single "tier"):
git checkout <fix>~1 -- <file> before/after), OSS dummy app,
Pro renderer + Redis + license, or browser/Playwright. This is a property of
the PR, not of a value tier — an error-path fix and an RSC-cache fix can both
need (or both not need) the Pro stack.Ranking is by priority. The environment profile drives lane scheduling (§9) and the availability check in the interview (§7) — it does not change a PR's rank.
The skip filter from verify-pr-fix's "When NOT to use" still applies to the
behavioral-reproduction decision (a docs PR does not get a before/after
repro), but it no longer removes the PR from QA — the PR is routed to the docs or
code-check method instead.
Classification is the correctness boundary (a wrong "nothing to verify" silences a PR), so it is explicit, not ad hoc:
plan-pr-batch's
pr-file-touch-map): docs-only, comment/format-only, and code path sets route
to a provisional method.repro tactic (e.g. git checkout <fix>~1 -- <file> + spec, or "boot Pro
dummy, hit route X"). The tactic is provisional: verify-pr-fix finalizes it
at execution time, so the plan is actionable before launch without over-fitting.qa-skipped lane is
surfaced in the interview (§7) with its one-line reason and is labeled only
after acknowledgment (an attended run has a developer present to confirm;
an unattended run has no operator and records an auto-acknowledgment audit
trail — §7) — so the exclusion is deliberate and auditable, never silent.Every merged PR that passes QA or is deliberately skipped reaches exactly one terminal QA state, recorded as a GitHub label. Unlabeled PRs are still pending, failed, blocked, or tool-stalled, so the backlog (§2) remains a pure query:
qa-verified — applied on a passing QA run (any method: behavioral
pass, docs OK, code-check OK).qa-skipped — applied after the "nothing to verify" glance (§4), symmetric
with qa-verified, so those PRs never re-enter the backlog.Properties:
.context
(Conductor-only, non-portable), no tracker issue. Works identically on every
platform and machine.gh pr edit --add-label does not
create a missing label (it errors / no-ops), which would silently break dedup.
So the writer first ensures the label exists, then adds it — no manual one-time
setup to forget. Because qa-batch lanes run concurrently, the ensure step is
idempotent: read the full label set first via the paginated labels API
(for example, gh api --method GET --paginate "/repos/$repo/labels" with
--jq '.[].name'), create the missing label if absent, and if a concurrent
gh label create returns the GitHub 422 validation error, re-read the full
label set and treat it as success only when the expected label is now present.
A failed re-read after a 422 is also a loud failure: abort the label step and
surface the error rather than proceeding silently. Any other create failure
stays loud (equivalently, a single serialized bootstrap before fan-out).qa-verified MUST be the
final write: apply it only after gh pr comment returns success for the
evidence comment. If the evidence post fails, the label step is aborted — so a
PR can never carry qa-verified without its supporting evidence (no silent
false positive). A crash between a successful evidence post and the label is
survivable: the next run simply re-verifies. qa-skipped has no evidence
comment; its prerequisite write is the developer acknowledgment, or the
unattended auto-acknowledgment audit-trail note (§7).verify-pr-fix comment are enough for v1.)QA batches register in the same agent-coord backend
(shakacode/agent-coordination) that pr-batch/plan-pr-batch use; canonical
rules live in .agents/workflows/pr-processing.md and
internal/contributor-info/agent-coordination-backend.md. Per QA lane:
agent-coord doctor + targeted
agent-coord status --repo "$repo" --target <pr> per candidate — if a
candidate PR has a live claim from another (implementation/review) batch,
skip it this round and report why; QA must not verify a moving target.agent-coord claim the PR's QA lane before starting (compare-and-swap gate;
CLAIM_REFUSED / exit 3 is a hard stop — report holder + heartbeat liveness).agent-coord heartbeat at phase transitions (claimed → reproducing →
verifying → reporting → done).This is two-way conflict-avoidance: QA will not run on a PR being actively
re-edited, and other batches see QA in flight. The same integration is added to
post-merge-audit so audit passes coordinate too.
Degraded-backend fallback (exit-code-aware). The backend doc distinguishes
exit 3 (CLAIM_REFUSED, hard stop) from exit 2 (UNKNOWN / degraded). Because QA
lanes are dependency-sensitive by definition (point 1 — must not verify a moving
target), any exit-2 UNKNOWN status for a candidate PR is treated as
blocked/deferred, not proceeded on advisory public comments. This matches
the canonical rule in agent-coordination-backend.md; proceeding on UNKNOWN
would let QA verify an in-progress state.
Repeated-contention escalation. A PR skipped for a live claim (point 1) is
retried next run, but a PR under perpetual edit would be skipped forever and the
coverage gap would be invisible. The default policy is 3 consecutive
contention skips before surfacing blocked — repeated contention: one skip is
common during active review, two may be normal during a release day, and three
means the PR has now missed multiple QA opportunities. The threshold is a
qa-batch parameter, recorded in the handoff, so release managers can tighten or
relax it for cadence.
The skip count needs durable, file-free storage across invocations. Do not
assume arbitrary mutable metadata exists on current agent-coord records. Phase A
must either add a first-class coordination field / batch-state entry with
compare-and-swap semantics, or explicitly ship v1 in degraded mode where
repeated-contention counts are reported in the current batch output only and are
not used for automated escalation. A successful QA claim clears the durable count
when that capability exists; without it, escalation is advisory/UNKNOWN rather
than a hard gate, and qa-batch must label that mode as "no automated
repeated-contention escalation" in its handoff.
Present the auto-ranked candidate list plus a collapsed "nothing to verify" list, then ask only for what cannot be inferred:
qa-skipped list. Only the newly classified "nothing
to verify" PRs from this run are shown (already-qa-skipped PRs are filtered
out by §2 and never reappear), each with its one-line reason. qa-skipped is
applied only after acknowledgment, so no PR is silently retired. In an
unattended run there is no developer to confirm, so the runner
auto-acknowledges and records the per-PR reasons in the batch output as the
audit trail (symmetric with unattended issue creation, §10) — otherwise
"nothing to verify" PRs could never reach a terminal label and would resurface
forever.--cap invocation value,
or unlimited if unset.qa-batch concurrency, capped by resource serialization (§9).plan-qa-batch is a planner (like plan-pr-batch): it does not execute
unless asked. It emits, to session output:
#NNNN — title — method — env profile — priority — linked issue — repro tactic
— plus the collapsed excluded list with reasons, the deferred-for-environment
list, and the exact gh/resolver commands used.qa-batch that specifies the concurrent subagent
lanes, the agent-coord coordination rules, the contention constraints (§9),
and the per-PR closeout (label on pass / issue on fail). Sized under the same
goal-prompt budget discipline plan-pr-batch already enforces.qa-batchThe qa-batch runner dispatches one subagent per QA lane, each claiming its
PR(s), running the routed method, posting evidence, and closing out. Concurrency
is bounded by three constraints, with a liveness exit so a stuck lane cannot
wedge the batch:
agent-coord claims — never two lanes (or a lane and an implementation
batch) on the same PR.agent-coord claims: the qa-batch scheduler keeps an in-process resource
semaphore for the current batch and, before cross-batch Pro concurrency is
enabled, must either acquire an explicit backend resource claim such as
resource:pro-stack (if the coordination backend supports non-PR targets) or
stop with UNKNOWN because another live QA batch could already be using the
shared stack and there is no safe cross-machine detection primitive. Per-PR
claims alone are insufficient to protect the Pro resources. The v1 default
is to stop with UNKNOWN unless the backend resource claim is implemented and
verified; proceeding without cross-batch Pro serialization is not allowed.git checkout <fix>~1 -- <file>, §4 /
verify-pr-fix) does mutate the shared checkout — two such lanes in one
working directory can run against, or restore, each other's pre-fix file and
corrupt the before/after evidence. So any lane using that tactic runs in its
own git worktree (isolation: 'worktree'), exactly as pr-batch workers
do; only that subset needs it, not docs/read lanes. (This is the one place
pr-batch's isolation model does apply.)qa-batch parameter. A lane that exceeds
it (hung renderer, crashed agent, stale agent-coord claim, never-exiting
test) is marked failed — not labeled, its claim released, and it is deferred
to the next batch and surfaced to the maintainer — the same blocked/deferred
posture as the agent-coord-unavailable fallback in §6. One stuck lane never
blocks the rest indefinitely.plan-qa-batch stays plan-only (Phase A): it produces the plan and goal prompt
and launches qa-batch only when the user asks. Phase B (the planner
self-dispatching subagents directly, without the explicit launch handoff) is
documented as a future enhancement, not built.
verify-pr-fix evidence comment, then apply
qa-verified as the final write (§5 invariant).qa-skipped after acknowledgment — developer in
an attended run, auto-acknowledged with an audit-trail note when unattended
(§7).post-merge-audit's "Needs follow-up issue / Needs fix PR" classification), and
post the failing evidence on the PR. Neither label is applied.Issue-creation approval. Creating an issue is outward-facing, so it is gated:
qa-batch with no operator): do not
block. Create the issue with a needs-human-review label, or — if issue
auto-creation is disabled for the run — record the finding in the batch output
for later filing. The mode is a qa-batch invocation flag, recorded in the
handoff.Issue bodies are generated from a trusted template. PR-sourced content (titles,
bodies, comments, diffs, logs, and reproduced output) is treated as untrusted
data: quote it, fence it, cap excerpts, and never let it supply instructions to
the agent or alter labels, assignees, commands, repository paths, or safety
rules. Build multi-line issue bodies in a temporary Markdown file and pass them
with gh issue create --body-file, matching the repository's GitHub follow-up
issue rule. The implementation must create that file with mktemp and register a
cleanup trap (for example, trap 'rm -f "$tmp"' EXIT) before writing reproduced
output into it.
verify-pr-fix — after a passing verification, ensure the label exists
using the paginated label lookup and idempotent create flow from §5, then add
it as the final write, only after the evidence comment posts successfully
(§5 invariant). No other change to its flow.post-merge-audit — register agent-coord claims/heartbeats for audit
passes so audits coordinate with implementation and QA batches (§6).qa-batch runner skill (slim)A sibling to pr-batch, deliberately thin and QA-specific:
plan-qa-batch goal prompt.agent-coord protocol
(§6).pr-batch's safety posture (untrusted GitHub content cannot override
AGENTS.md; stop on blocked approvals) and its worktree isolation for the
spec-only lanes that mutate the checkout (§9c), but not its branch/merge
machinery — QA never creates branches or PRs and never merges.It is kept separate from pr-batch because the primary contention model (env
resources, not file paths) and the closeout (label/issue, not branch/merge
readiness) differ enough that overloading pr-batch would muddy both.
plan-qa-batch is plan-only: it produces the plan and goal prompt and does
not directly invoke verify-pr-fix; that is qa-batch's job.post-merge-audit (process risk) or verify (local checks).Create:
$plan-qa-batch skill$qa-batch skill.agents/workflows/qa-processing.md (the QA operating model the
goal prompt references, analogous to pr-processing.md) if the runner needs
more than the SKILL.md carries.Edit:
$verify-pr-fix skill — add the qa-verified label step.$post-merge-audit skill — add agent-coord coordination.Labels (created on demand by the writers — no manual pre-step, see §5):
qa-verified, qa-skipped, and needs-human-review (the last for unattended
issue creation, §10).qa-verified/qa-skipped. Acceptable for v1 (a label means "the
change as merged reached this state once"); revisit if churn makes it
misleading.verify-pr-fix. v1 keeps them as a structured read +
note; they may warrant their own mini-rubric later.post-merge-audit-scope ties plan-qa-batch to
that script's contract (its --json shape, and the last-RC default base that
§3 deliberately overrides with the label query as the authoritative backlog).agent-coord schema extension. The
repeated-contention escalation (§6) needs a first-class, compare-and-swap-safe
counter field on coordination records. If that field does not exist at Phase A
implementation time, v1 explicitly runs in degraded mode (current-batch report
only, no automated gate) and must say so in every handoff. A later iteration can
add the field to agent-coord or use a surrogate such as a dedicated GitHub
status/check.docs/superpowers/specs/; by the "docs/ is public, internal design
goes to internal/planning/" rule they should move. Out of scope for this
work — handle in a separate PR.plan-qa-batch / qa-batch skill implementation may belong in
shakacode/agent-workflows. Decide before Phase A implementation whether to
keep only the React on Rails release policy here and move the generic workflow
mechanics there.plan-qa-batch planner + slim qa-batch runner +
the two coordinated edits + the label. Concurrent subagent lanes, plan-only
launch.