Skip to content

review: stage the review threads deterministically (orchestrator slice 1) - #308

Merged
jwbron merged 4 commits into
mainfrom
jwies/review-stage-threads
Jul 31, 2026
Merged

review: stage the review threads deterministically (orchestrator slice 1)#308
jwbron merged 4 commits into
mainfrom
jwies/review-stage-threads

Conversation

@jwbron

@jwbron jwbron commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary:

threads.json / human-threads.json move out of the prompt and into
lib/stage-pr.ts, completing deterministic-orchestrator slice 1. That module
deliberately deferred them ("Phase 2; a later slice"), so review.md Step 3 asked
the ORCHESTRATOR to fetch the unresolved threads and write both files in a
particular shape, and everything downstream depended on a model-produced file:
dispatch.ts reads it for hasThreads (which gates the thread-reconciler
dispatch, so it changes the roster) and for open-thread suppression, and
lib/rereview.ts reads it for the accountability section.

That seam is the failure class #302 patched a symptom of. There, the prompt
selected bot threads by one spelling of the bot's login while
openThreadsFromStaged admitted another, so a conforming staging produced
zero usable threads and suppression silently never ran for a whole release. The
worse direction was never hit but was always available: human-threads.json was
specified as "any author other than the bot", and a bot thread misfiled there
lands in skipLines, which makes the submission DROP a fresh finding on that
line rather than merely duplicate one.

What the staging does now. One GraphQL fetch of every unresolved review
thread, partitioned by opener: threads this bot opened (full reply chain, bodies
byte-for-byte, resolved: false, the opener's html_url) into threads.json,
everyone else's {path, line} into human-threads.json, deduped and skipping
threads with no RIGHT-side line to skip. A thread is in exactly one file and
neither list is assembled by hand. GraphQL rather than REST because REST exposes
neither a thread's resolution state nor the PRRT_... node id the
resolve-pull-request-review-thread safe output takes; the workflow's existing
pull-requests: read GITHUB_TOKEN covers it, as autofix already demonstrates.

One identity, shared by both layers. Producer and consumer now go through
lib/threads.ts's isReviewBotAuthor, which compares suffix-stripped so REST's
github-actions[bot] and GraphQL's bare github-actions are one account.
dedup.ts calls it instead of its own BOT_AUTHORS set, so the two layers can
no longer spell the identity differently. That is the actual #302 defect rather
than its symptom, and it is why this is more than moving prose into code.

One fetch, not two. The GraphQL query, its paging and its fail-closed guards
live once and are shared with autofix's staging, which had the only copy and
whose comments carry both prior postmortems (Khan/webapp#41140 staged
threadCount: 0 on a PR carrying five threads; GitHub answers a rate limit with
HTTP 200 plus an errors array). Autofix's collectThreads is now that shared
fetch plus its by-opener filter: same signature, same output, its 30 tests
unmodified. A second hand-written copy in the reviewer would have had to
re-derive both bugs.

Fail-closed, deliberately not []. A failed thread fetch fails the staging
step. Staging [] is not the conservative direction here: it drops the flip
gate's keptBlockingCount to zero, and a reduced-depth re-review may then flip a
prior REQUEST_CHANGES to APPROVE past still-open blocking threads nobody read
(submission.ts's keptBlockingFloor). The step runs before any AI spend,
GraphQL's HTTP-200 RATE_LIMITED is retried (the status-based retry cannot see
it), and the review re-runs on the next push. dedup.ts's guards (bot-authored
opener, explicit resolved: false) stay rather than trusting the new producer,
so a producer bug degrades to a duplicate comment, never to a dropped finding.

stagedThreadShapeFailure is kept, not deleted: a conforming code staging can no
longer trip it, which is the point, since a fire now means either that producer
and consumer have drifted inside one repo (a code bug, still the #302 class) or
that the staging came from the eval's own producer or a hand-built reproduction.

One fail-open closed while here: a thread with no opener at all is staged in
NEITHER file. Unreachable on a real PR, but staging it as human would put a
skipLines entry on a line that may be the bot's own.

review.md Step 3 keeps exactly one thread job: reading the reply chains for an
author's factual dispute, which is a judgment. .github/workflows/review.md is
untouched; the installed copy is pinned at review-v1.7.0 and gets bumped in its
own PR.

Test plan:

  • pnpm exec vitest run workflows: 1383 pass, 0 fail, including autofix's 30
    thread tests unmodified against the shared fetch. Repo-wide, the only red files
    are actions/filter-files and actions/fix-workflows, which fail at import on
    missing picomatch / yaml in this environment; both are untouched here and
    fail the same way on main.
  • New lib/stage-threads.test.ts (10 cases, fixtures in the API's own shape: a
    PRRT_... node id, GraphQL's bare github-actions). These pin the producer
    against the consumers it feeds rather than against the shape a reader would
    expect, because "each layer looked right on its own" is how [🔥AUDIT🔥] review: fix open-thread suppression, unreachable on every conforming run #302 shipped:
    • the staged bytes go straight into openThreadsFromStaged, which must return
      a usable thread, with stagedThreadShapeFailure silent;
    • hasThreads still gates the reconciler, asserted both directions through the
      real computeRoster;
    • the partition (the OPENER decides, not "the bot appears in the chain"), both
      bot spellings accepted, opener bodies verbatim including CRLF and trailing
      whitespace, resolved threads dropped, absent url omitted, human threads
      without a line skipped and duplicates collapsed, paging followed;
    • both hard-failure paths: an HTTP-200 errors array and a malformed body each
      fail the staging with nothing staged, routing.json never written.
  • [🔥AUDIT🔥] review: fix open-thread suppression, unreachable on every conforming run #302's verbatim get_review_comments fixture in dedup.test.ts still passes:
    it now covers the tolerance path for stagings that do not come from
    stage-pr.ts (the eval's producer, hand-built reproductions).
  • Lint, reproducing CI inside a worktree under .claude:
    git ls-files ... | xargs pnpm exec eslint --no-ignore --resolve-plugins-relative-to .
    over actions utils workflows config, minus the ignorePatterns corpus trees,
    reports 0 errors. Every touched file stays under the 1000-line cap; the new
    tests are their own file for that reason (stage-pr.test.ts would have hit 930).
  • pnpm run typecheck is clean, though note it does not include workflows/;
    I also ran tsc directly over the touched review/autofix modules and it reports
    no new errors (the pre-existing ones under the repo's strict flags are unchanged).

Not run: a live trial. This changes who produces a staged file, not the review's
judgment, and the producer/consumer bind is asserted in unit tests; a re-review
lifecycle run is still the honest confirmation that suppression and the
accountability recap see the threads on a real PR.

…nistically (orchestrator slice 1)

`threads.json` / `human-threads.json` move out of the prompt and into
`lib/stage-pr.ts`, which is the last load-bearing staging review.md Step 3
still asked the orchestrator to perform. Everything downstream depended on a
model-produced file: `hasThreads` (which gates the thread-reconciler dispatch,
so it changes the roster), open-thread suppression, and the accountability
recap.

This is the seam #302 patched a symptom of. There, the prompt selected bot
threads by one spelling of the bot's login while `openThreadsFromStaged`
admitted another, so a *conforming* staging produced zero usable threads and
suppression silently never ran for a whole release. The worse direction was
never hit but was always available: `human-threads.json` was specified as "any
author other than the bot", and a bot thread misfiled there lands in
`skipLines`, which makes the submission DROP a fresh finding on that line
rather than merely duplicate one.

The staging step now does one GraphQL fetch of every unresolved review thread
and partitions it by opener. GraphQL rather than REST because REST exposes
neither a thread's resolution state nor the `PRRT_...` node id the resolve safe
output takes. Producer and consumer share one bot-identity predicate
(`lib/threads.ts`'s `isReviewBotAuthor`, comparing suffix-stripped so REST's
`github-actions[bot]` and GraphQL's bare `github-actions` are one account), so
the two layers can no longer spell the identity differently, which is the
actual defect rather than its symptom.

The fetch, its paging and its fail-closed guards live once and are shared with
autofix's staging, which had the only copy and whose comments carry both prior
postmortems (Khan/webapp#41140's `threadCount: 0`, and GitHub answering a rate
limit with HTTP 200 plus an `errors` array). Autofix's `collectThreads` is now
that shared fetch plus its by-opener filter, with no behavior change.

A failed thread fetch fails the staging step rather than degrading to `[]`: an
empty staging drops the flip gate's `keptBlockingCount` to zero, and a
reduced-depth re-review may then flip a prior REQUEST_CHANGES to APPROVE past
still-open blocking threads nobody read. The step runs before any AI spend and
GraphQL's HTTP-200 rate limit is retried. `dedup.ts`'s fail-closed guards stay
rather than trusting the new producer, as does the `stagedThreadShapeFailure`
tripwire: a conforming code staging can no longer trip it, which is the point,
since a fire now means either producer/consumer drift inside one repo or a
staging that came from the eval's own producer.

One fail-open closed while here: a thread with no opener is staged in neither
file, since staging it as human would put a `skipLines` entry on a line that
may be the bot's own.
@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 64e0082

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
review Patch
autofix Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@khan-actions-bot
khan-actions-bot requested review from a team, jeresig and kevinb-khan and removed request for a team July 31, 2026 17:17
@github-actions

This comment has been minimized.

@khan-actions-bot
khan-actions-bot requested a review from a team July 31, 2026 17:37
Comment thread workflows/review/lib/stage-pr.ts Outdated
Comment thread workflows/review/lib/stage-pr.ts
Comment thread workflows/review/lib/threads.ts Outdated
Comment thread workflows/review/lib/threads.ts Outdated
Comment thread workflows/review/lib/threads.ts Outdated
Comment thread workflows/review/lib/stage-pr.ts Outdated
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review live A/B

Baseline: origin/main (review.md 3aa25ebe9719); candidate: working tree (review.md 1798a1300cd4).

Ruler: matcher deterministic+arbiter; corpus cc11988c0918 (9 cases).

Metric Baseline Candidate Delta
Must-catch recall 100% 100% +0%
Verdict agreement 89% 89% +0%
Noise (unmatched posted) 59% 57% -2%
Clean false flags 0 0
Judge mean quality 0.90 0.90 +0.01
Cost $8.73 $9.06
Wall clock 1163s 1079s
Cases run / skipped 9 / 0 9 / 0
Misses found-but-dropped 0 0
Findings anchor-snapped 1 0

Adversarial hard gate: PASSED on the candidate arm.

Single-run-stable rows: recall, verdict agreement, regressions, adversarial gate. Judge quality and noise are not: they jitter run-to-run at this corpus size, and a regressed reviewer can score HIGHER on judge quality (fewer, surer comments each read better). Recall against the labeled specs is the load-bearing metric.

Measured noise floor (identical arms, run 29069228968, 2026-07-10, 6 arm-samples, full corpus x3, pre-arbiter; budget skips left the samples on unequal case sets, so these v1 bands also carry case-mix variance): must-catch recall 54%-86% (sd 10%), verdict agreement 75%-100% (sd 9%), noise (unmatched posted) 50%-60% (sd 3%), judge mean quality 82%-86% (sd 2%). A single-run delta whose arms both sit inside a band is indistinguishable from run-to-run wobble; use --repeats to resolve smaller effects.

jwbron added 3 commits July 31, 2026 11:40
…taging review found

All six notes on #308 were non-blocking; these are the five worth code, plus
the test the sixth asked for.

- A thread whose opening comment has `author: null` (a deleted account) maps
  to `""`, which is a login rather than an absence, so it matched no bot and
  took the HUMAN path. That is the misfiling direction that matters: a
  `skipLines` entry on a line that may be the bot's own drops a fresh finding
  there. The adjacent comment already claimed such a thread is staged in
  neither file; now it is.
- `REVIEW_BOT_LOGIN` makes the identity deployment config, matching
  `AUTOFIX_BOT_LOGIN` and `REVIEW_SWEEP_BOT_LOGIN` (same default). A consumer
  posting under its own App could not change a compiled-in constant, so every
  one of its bot threads would have misfiled as human.
- `baseLogin` case-folds BEFORE stripping `[bot]`, so a `…[BOT]` spelling
  cannot read as a different account. Theoretical today; free to close.
- A page claiming `hasNextPage` with no cursor returned the threads collected
  so far, the partial staging every other guard in the module refuses. It now
  throws. Refusing still terminates, which is all the infinite-loop guard
  wanted, so autofix's test for it changes from "stops" to "refuses".
- The HTTP-200 `RATE_LIMITED` retry moves into `threads.ts` beside the fetch.
  It was built inline under `require.main === module`, so no test could reach
  it and autofix's port had none at all, meaning autofix died on its first
  throttle. Now both workflows inherit it and four tests cover it: retry then
  succeed, give up, propagate a non-healing error without spending a retry,
  and back off.

Left alone deliberately: code-enforced per-lens comment caps, which the
reviewer raised on #307 and that PR's body already prices as the follow-up.

Tests 1445 pass (up from 1383, no test removed), typecheck clean, lint clean.
The README named the variable but not the block that reaches both readers
(the staging step and the dispatcher share the workflow-level `env:`).
… from the gate, where it annotates

`stagedThreadShapeFailure` writes `threadSuppressionUnavailable` to
dispatch-result.json and prints a `::warning`, but the dispatcher runs inside
the agent's Bash tool, where a workflow command is only text.

Measured on webapp#41204 run 30654454047, a deliberately mis-staged
threads.json: the field carried `unusableThreads: 9`, the warning text reached
the run log and the step summary, and zero annotations across the run's six jobs
mentioned suppression. In the same run the pre-agent staging step's own
`::warning` did annotate, which is the contrast that locates the cause.

The dispatch-conformance gate re-emits it. The gate is compiled into
`post-steps`, runs `if: always()`, already reads every out/ file, and its own
`::error`/`::warning` lines annotate today. The line is rebuilt from the numeric
`unusableThreads`, never forwarded as stored text: the gate step is trusted
while out/ is a directory the agent can write, so a stored string could carry
newlines and inject `::error` or `::add-mask` into it. Absent, non-numeric, or
non-positive forwards nothing, keeping this as quiet as the tripwire itself.

The formatter is shared with dedup.ts so the two texts cannot drift, and the
forwarding lives in lib/forwarded-warnings.ts because dispatch-gate.ts is at
its 1000-line ceiling (it lands at exactly 1000 with the wiring).

This matters more after this PR, not less: a conforming code staging can no
longer trip the tripwire, so a fire now means the producer and consumer drifted
inside one repo, which is the failure class #302 was.
@github-actions

Copy link
Copy Markdown
Contributor

Review Guidance

github-actions (7 files)
File Reason
review.md Shared reviewer prompt (permission/secret surfaces); this hunk deletes model-executed staging in favor of a pre-agent code step using the token it already had.
stage-pr.ts Now the producer of threads.json/human-threads.json that gate suppression, the reconciler roster, and skipLines on every consumer PR; the partition is fail-closed and pinned against real consumers in tests.
threads.ts New module owning both the shared GraphQL thread fetch and the single bot-identity predicate; centralizes the exact identity seam that shipped #302.
dedup.ts Open-thread suppression decides which findings never post; this hunk swaps the hardcoded BOT_AUTHORS set for the shared env-configurable predicate, keeping the fail-closed guards.
dispatch-gate.ts Trusted post-step that now echoes content derived from the agent-writable out/ directory into a stream that interprets workflow commands; confined by rebuilding the warning from a numeric field.
forwarded-warnings.ts New module parsing an agent-writable file for re-emission as workflow commands from a trusted step; handled by the rebuild-from-number rule and tested for injection.
stage.ts Runs in consuming repos' CI with their credentials; behavior-preserving refactor onto the shared threads module plus two fail-closed additions (HTTP-200 RATE_LIMITED retry, refusing a partial page).

Common patterns

2 files: Empty-threads GraphQL stub inserted as the new third argument to every pre-existing runStagePrCli call site (signature adaptation) — stage-pr.test.ts, disciplines.test.ts.

2 files: Inline setTimeout promise replaced with a named sleep helper in both CLI entry points — autofix/lib/stage.ts, review/lib/stage-pr.ts.

2 files: Thread-fetch utilities extracted from autofix/lib/stage.ts into the new shared review/lib/threads.ts.

Excluded from review (5 files)

Not individually reviewed — generated, formatting-only, or fully explained by a common pattern above:

  • .changeset/review-stage-threads-deterministically.md — generated
  • workflows/review/README.md — formatting-only
  • workflows/review/lib/disciplines.test.ts — pattern-only
  • workflows/review/lib/dispatch.ts — formatting-only
  • workflows/review/lib/stage-pr.test.ts — pattern-only

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All 6 prior review threads are resolved.

Comment on lines +60 to +63
if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) {
return [];
}
return [threadSuppressionUnavailableWarning(Math.floor(count))];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nitpick (non-blocking): The count <= 0 guard runs before the floor, so a fractional unusableThreads (e.g. 0.5, reachable only via an agent-rewritten out/ file — the adversarial input this module is built around) passes the guard and Math.floor(0.5) renders a 0 staged thread(s), none usable annotation, the zero-count warning the doc comment says should never be emitted. Floor first, then check:

Suggested change
if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) {
return [];
}
return [threadSuppressionUnavailableWarning(Math.floor(count))];
if (typeof count !== "number" || !Number.isFinite(count)) {
return [];
}
const n = Math.floor(count);
if (n <= 0) {
return [];
}
return [threadSuppressionUnavailableWarning(n)];
Lower-confidence observations (below the posting bar)
  • workflows/review/lib/threads.ts:66 — thought: the shared-predicate identity guarantee is code-level only; at runtime it relies on REVIEW_BOT_LOGIN reaching both the staging step and the dispatcher identically (mitigated by the README's workflow-level env: guidance, worst case a visible duplicate).
  • workflows/review/lib/threads.ts:65 — question: env-scoped REVIEW_BOT_LOGIN reopens an identity seam between the two processes; staging the resolved login and warning on drift would close it in code.
  • workflows/review/lib/threads.ts:123 — thought: suffix-stripped login equality treats name and name[bot] as one, which can only collide once a custom REVIEW_BOT_LOGIN App is configured; fetching __typename to require a Bot actor would close it.

isResolved
path
line
comments(first: 100) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

note (non-blocking): comments(first: 100) fetches no pageInfo, so a thread with more than 100 comments stages a truncated reply chain with no refusal — at odds with this module's otherwise fail-closed stance (the outer reviewThreads page throws on a missing cursor just above). A >100-comment thread is extreme, but the truncation would silently drop a late author dispute that the reconciler weighs. Consider asserting comments.pageInfo.hasNextPage !== true or paginating the inner connection.

@jwbron
jwbron merged commit 83d14c3 into main Jul 31, 2026
10 checks passed
@jwbron
jwbron deleted the jwies/review-stage-threads branch July 31, 2026 20:19
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.

2 participants