PersonHog was the last of it, and it was not only a name. The package is personinsights and the types in it are already Person* -- the shipped descriptor under personinsights_client/proto/generated says PersonService and the tests say TestFakePersonClient -- but the .proto still declared PersonHogService. A client dialling one service name and a server built from a .proto declaring another is not a naming problem, it is a wire that does not connect; the only reason nobody hit it is that none of these services runs here yet. Five service declarations now say what the generated client has said all along. StreamHog was a second fictional company, invented so a Stripe mock had someone to bill. This fork already has Hedgebox, in five hundred fixtures, so the mock bills Hedgebox and there is one made-up company again instead of two. REVIEW_HOG was an enum member in three enums that had to agree, whose value has been `review` since the app was renamed. Only the member said otherwise. Every rule is in bin/debrand and every spelling is refused there, so a fresh upstream import lands on these names and none of them can come back. Two are named as outstanding rather than refused, with the reason each is still sayable: hogdesk survives inside applied migrations' choices lists, where Django compares it in Python and never writes it -- the member is Desk = "desk" and all three tables that store it hold no rows; sherlockhog keys a service in another repository that still calls itself that, and renaming our half would classify its traffic as a product that does not exist. Co-authored-by: Hanzo Dev <[email protected]>
334 KiB
Review design record & history
The living ARCHITECTURE.md is the present-tense reference — what Review is today (pipeline, models, persistence, triggers). This doc is the full record of why it got there: the staged build history, the design decisions with the alternatives weighed and rejected, the gotchas, and the roadmap — including the designed-but-unbuilt loop and the grounded implementation maps. It is preserved in full so an agent can go check the reasoning behind any part of the reference, not just a summary.
It grows over time, and that's intended: keep ARCHITECTURE.md lean and present-tense, and land new build notes, decisions, and roadmap updates here.
Current state & roadmap
This work (now on signals/review, originally signals/custom-prompt-to-sandbox) predates several
months of master evolution. The work is staged; keep this section updated as stages land.
🎯 NEXT — productionize the 2026-07 reviewer-topology eval (CURRENT start-here; older START-HERE markers below are historical)
Context: a 17-run topology experiment (8 configs, frozen PR #62096, LLM-judged vs the old reviewer's
10-finding yardstick) is archived at products/review/eval/experiments/2026-07-reviewer-topology/ —
read FINAL_REPORT.md there first (config glossary + coverage matrix + ranking). Reusable dump harness:
eval/scripts/dump_result.py (OUT_DIR-overridable). The winning shape: semantic chunking for mid-size PRs
- parallel perspectives + one "what did everyone miss?" blind-spot check per chunk (C4/C7 — most
consistent and highest-output config; the eval and its archive call this unit the "gap pass" — same thing,
renamed below). Expected prod delta on a ~500-addition PR: ~5–6 valid findings / ~19M in /
~23 min, vs today's ~3–4 / ~11M / ~14 min. The experiment code for all topologies sits UNCOMMITTED in
the working tree behind default-off
EXPERIMENT_*constants (297 review tests + ruff green) — the items below convert the winners into prod code and delete the losers. Decisions locked with the user 2026-07-02:
- ✅ DONE 2026-07-02 — chunking numbers: dual-duty constant split.
CHUNK_TARGET_ADDITIONS=1000served as BOTH the single-chunk gate and the chunker's size guidance. Now split (as planned):SINGLE_CHUNK_GATE_ADDITIONS = 400(≤400 reviewable additions → one chunk, no chunking LLM) andCHUNK_TARGET_ADDITIONS = 300/CHUNK_SOFT_MAX_ADDITIONS = 600(chunker guidance; the prompt already forbids single-file fragments and refuses to split atomic concerns — that's the anti-micro-chunk guard).EXPERIMENT_FORCE_CHUNKING,EXPERIMENT_CHUNK_TARGET/SOFT_MAX_ADDITIONS, and theeffective_*()helpers are deleted;plan_deterministic_chunksreads the gate constant and the prompt renders the two guidance constants directly (eval/scripts/dump_result.pyconfig snapshot updated to match). The C6 pinned-chunks instrument (EXPERIMENT_PINNED_CHUNKS+plan_pinned_chunks()+ the pre-resume short-circuit insplit_chunks_activity) was removed too — the earlier "keep as eval instrument" call was reversed (nothing experimental ships; only the winners land). A future eval round that needs chunk structure held constant re-derives it from the eval archive — and must re-learn its one gotcha: the pin has to be checked BEFORE the persisted-chunk-set resume, or a stale set silently voids it. Net behavior change: PRs at 401–1000 reviewable additions now reach the semantic chunker (previously one chunk), aiming at ~300-line chunks. - ✅ DONE 2026-07-02 — Blind-spot check in prod, always-on (every review, incl. single-chunk PRs).
Naming: this is the eval's "gap pass" — renamed because "blind spots" pairs with "perspectives" in
the same seeing metaphor. Built exactly as locked; as-built notes inline:
- Always-on second round inside
ReviewPerspectivesWorkflow(wave → blind-spot check), flag gone;EXPERIMENT_COMPLETENESS_PASS+COMPLETENESS_GAP_PERSPECTIVEdeleted, thegap_passactivity flag is nowReviewChunkInput.blind_spot_check. A separate child workflow was considered with the user and rejected as overengineering: the round shares the review activity/fan-out/persistence, is conditioned on the wave (max pass + lens list, in scope in-workflow), and per-activity retries + DB skip-resume already give child-workflow-grade recovery. Revisit only if the sweep needs its own lifecycle (e.g. a "re-run blind spots" action). Own failure floor ("Blind spots"). - Own step, NOT a fourth perspective — it consumes the wave's findings, so perspective-prefix tricks would still need an ordering layer while contorting the multi-enable semantics.
- Customizable single-active blind-spots skill, exactly the validator pattern: third prefix
review-script-blind-spots-*inReviewSkillConfig(canonical:review-script-blind-spots-general, on disk underskills/),load_blind_spots_skill_for_runmirroring the validator loader (canonical fallback — since 2026-07-08 also when the selected skill's row is dead; raises only if the canonical itself is missing; shared_load_single_active_skill/_register_missing_configshelpers now back both), siblingReviewBlindSpotsConfigViewSetat/blind_spots, seeded by the cold-start sync. (2026-07-02 follow-up, scout-coordinator-style: the cold-start sync now runsprune=True— disk-removed canonicals tombstone on the team's next review — and thesync_review_skillscommand was DELETED as redundant with it; the run path is the one sync moment.) Findings run under the reservedBLIND_SPOT_PASS_NUMBER = 1000— NOT max(wave pass)+1 as originally planned: the adversarial review showed max+1 collides with the persisted(pass, chunk)resume keys when the enabled-perspective set changes between executions at the same head (a newly enabled perspective lands on the stale blind-spot row and is silently skipped, or the sweep lands on an occupied wave pass and silently never runs). A fixed reserved pass can never collide with wave enumeration and keeps theprior_pass < pass_numbersame-turn filter working. (Residual, pre-existing: wave passes are positional over the sorted enabled set, so wave-vs-wave keys can still mis-match across executions if the enabled set changes mid-head — belongs to the finding-identity work, not this change.)source_perspective= the blind-spots skill name (falls out of the existing stamp); sandboxstep_name="blind-spots-c{chunk}". UI label: "Blind spots" (sentence case). - Canonical blind-spots skill v1 = PURE GENERIC — exactly the eval-proven C4/C7 shape: wave findings in, "find real issues NONE of the lenses surfaced", empty-list-over-padding. No category checklist (locked with the user after iteration: static content here is fourth-perspective drift and breaks under per-user perspective customization — anything static belongs in perspectives; the five never-surfaced finding categories go to the content round, item 5). The skill's unique power is being conditioned on the run's actual output.
- Prompt changes: the hardcoded
GAP_PASSjinja branch is gone — the skill-get block is now unconditional, and blind-spot units additionally get the enabled perspectives' names + descriptions ("these lenses already ran") viaWAVE_PERSPECTIVES, threaded from the loaded rows (LoadedPerspectivegaineddescription;ReviewChunkInput.wave_perspectives) — per-user correct (a low-risk addition beyond what the eval measured — the C4/C7 prompt worked without it). Two prompt fixes out of the adversarial review, both beyond the eval'd C4/C7 text: the intro's parallel-isolation paragraph ("report every issue… without worrying about overlap") now has a blind-spot branch — it actively licensed the wave-restating duplicates the dedup nudge exists to mop up — and the lens lead-in only points at "already_covered_findings_for_chunk" when that block actually rendered, saying "they raised no findings on this chunk" otherwise (a clean chunk is a normal wave outcome; the eval prompt dangled there). - Dedup nudge: blind-spot-check output overlaps the wave more than perspectives overlap each other
— 3 duplicate pairs slipped through dedup in the C7 runs. The dedup prompt now tells the agent to
compare blind-spot findings (
source_perspectiveprefixreview-script-blind-spots-) against the wave's on the same chunk first. - Tests (312 review total): loader prefix-isolation + canonical fallback + hard-raise, config-API
single-active + cross-prefix scoping, workflow wave→blind-spot routing (reserved pass, skill + lens
threading, strictly-after ordering), prompt skill-get + lens injection + no-findings lead-in,
activity (pass, chunk) same-turn scoping + step name. A 4-lens adversarial review (Temporal
correctness / prefix scoping / prompt content / data flow, 15 agents) confirmed 4 issues — the
max+1 collision, the two prompt contradictions above, and one flaky order-sensitive test assertion
— all fixed; 3 further claims refuted. e2e ✅ 2026-07-02 on frozen #62096 (no-publish, clean DB):
2 chunks (new 400-gate engaged the semantic chunker) · 8 units (6 wave +
blind-spots-c1/c2strictly after) · 10 raw → 8 dedup → 4 valid · ~26 min effective / ~13.1M input tokens. Canonical skill auto-seeded (v1,reviewcategory); pass-1000 ids flowed through dedup + validation; chunk 2's wave found nothing → its blind-spot unit exercised the "they raised no findings" branch and still swept (its 1 finding deduped out). 1 of the 4 valid findings is blind-spots-only (over-length action name → unhandledDataErrorinstead of a retryable tool error, should_fix) — the eval-predicted uplift, live. First chunking sandbox failed (cold start) and the activity retry absorbed it (+~6 min).
- Always-on second round inside
- ✅ DONE 2026-07-02 — losing experiment paths deleted (no dead code). Executed the full warm-session
removal list:
EXPERIMENT_WARM_REVIEW_SESSION+ its workflow branch,review_perspective_session_activity+ReviewPerspectiveSessionInput+ thetemporal/__init__.pyregistration,build_review_followup_prompt, the three C5 tests, and revertedstart_sandbox_session's model-pin kwargs (the warm session was their only consumer; validate sessions run the default model). Also deletedEXPERIMENT_SEQUENTIAL_PERSPECTIVES+ its workflow branch (2× wall-clock, no coverage gain); the gap unit'ssame_turn_findings/dig_deeperplumbing stays (the completeness-gap unit is now its only trigger, pending item 2). Decision note (keep this): warm per-perspective review sessions (chunks as turns) were built and evaluated 2026-07-02 — mechanics work (P×C sandboxes → P sessions), but anchoring replicated in both runs (later chunk-turns near-silent: 2–3 valid vs 4–6 for isolated sandboxes at equal tokens). Intentionally not adopted; revisit only with an explicit anti-anchoring device and a re-eval against the archived yardstick. - Validator round (after the topology ships). The strict validator killed real findings repeatedly (yardstick #6 in 7 of 8 runs that surfaced it, #2 in 4 of 9) on "speculative/reachability" grounds. Locked plan: (a) first the FREE observational check — valid-rate and argumentation depth by turn position across the archived dumps + prod verdicts (data already persisted; no runs) — to see if session mode itself induces dismissal momentum; (b) the experiment: warm-session turns vs isolated per-issue validation, ×2 runs each, same strict criteria, on the frozen PR via the archived harness — compare valid-rate, #6/#2 survival, positional effects; (c) only then decide criteria loosening (the strictness may be partly mode-induced).
- Perspective-skill content round (later). Five yardstick findings — incl. both must_fix security ones — never surfaced in ANY of 17 runs under ANY topology: the lenses simply don't hunt those grounds (agent-tool privilege wiring, write-path authz ordering, output-channel injection, payload limits, attribution). A legitimate outcome is a fourth canonical perspective (e.g. agent-safety & privilege) rather than diluting the existing three — NOT content for the blind-spots skill (see item 2). Use the eval's coverage matrix as the acceptance test.
- Investigate potential experiments (seed list — investigation task, don't run now): per-stage model/effort tiers (xhigh only where it pays — review; cheaper for chunking/dedup/body); best-of-N or count-guided chunker (kill the 2-vs-3-chunk coin flip); dedup quality at higher finding volume; dynamic blind-spots-skill generation — "look at the PR and the perspectives that ran, generate what would make sense to sweep for", replicating the Signals scout-authoring skill pattern where possible (volatile, +1 LLM step, needs its own eval round — explicitly worth the experiment per the user); per-stage token attribution then context/payload trimming (runs consume 11–21M input tokens — find where); reviewer model retry (gpt-5.5 when its harness stabilizes, Sonnet-tier for cost floors); single-chunk blind-spot-check value (always-on assumed but unmeasured on 1-chunk PRs); warm sessions revisited with an anti-anchoring device; per-chunk review→validate pipelining — when one chunk's review units (wave + blind-spot) are done, dedup THAT chunk and spawn its validation session immediately instead of waiting for every chunk: per-chunk dedup first, a cross-chunk dedup pass later (cross-chunk dupes are rare — chunks are distinct concerns), and dedup as plain LLM calls instead of a sandbox agent (it compares finding texts + prior comments, no repo access needed) — faster for sure (BUILT 2026-07-03 for the size-gated case, chunking included — see the section below).
- 🎯 ACTIVE — prompt caching / review-stage cost (investigated 2026-07-03, program locked 2026-07-06;
next experiment: the warm-up+fork build). Everything lives in
eval/experiments/2026-07-prompt-caching/:INVESTIGATION.md(caching mechanics, what's measured as true, the V1-V3 harness violations forking must fix),CANDIDATES.md(locked constraints 1-9, candidate roster + graveyard so kills aren't re-proposed),HARNESS.md(the proven local two-repo patch loop, T2/T3 patch surfaces, the 1h-TTL findings, ops lessons),PLAN.md(Gate 0, closed — its metrology shipped: the cache-awaredump_result.pysplit, validated at Δ +0.0% vs gateway costs; naive token math overstates true cost ~4.8×). The locked program: one neutral warm-up agent per chunk does the investigation once; every perspective (user-extensible, could be 20) forks its cached session at 0.1× and skips re-investigating, while keeping full tools — quality is the moat, one-shot code investigation is permanently vetoed. No overlap measurement gates the build; gates are mechanics (follower turn-1 cache reads), cost (follower turns drop), and quality (yardstick parity + anchoring guard) on frozen PR #62096. TTL: sandboxes run the 5m cache (proven);ENABLE_PROMPT_CACHING_1H=1per sandbox enforces 1h when needed. Working mode (amended 2026-07-07): everything onsignals/review, experiment code behind on/off constants, losers reverted — no per-experiment branches. - Try dedup at
effort=highinstead ofxhigh(noted 2026-07-07). Measured (local Datastore,ai_stage=dedup, 14d): dedup's output tokens are dominated by adaptive thinking atONESHOT_REASONING_EFFORT=xhigh, NOT by the answer — the schema is ids-only ({duplicates:[{id}]}, ≤~700 tokens even at the 50-finding cap). Worst observed 28,899 output tokens / $0.35 on a 36-finding PR; typical PRs $0.02–0.18, so this is a tail trim worth pennies per review, not a headline saving. Mechanics when picked up: add aneffortparam torun_oneshot_reviewand pass"high"fromdeduplicate_issuesonly — chunking stays at xhigh. Acceptance: on a frozen-PR set, survivor id-sets match the xhigh control (or diffs hand-adjudicated as defensible merges); kill if it collapses non-duplicates or misses obvious dupes. (The related staledirect_llm.pycomment, which claimed dedup "re-emits every surviving issue's full JSON", was corrected 2026-07-07: ids-only since Jun 25.) - Make the reviewers strict-er — stop spamming the validator with findings it refuses anyway (noted
2026-07-08). Live evidence (insights#69066 e2e): 35 post-dedup candidates → 8 valid / 27 dismissed
(77% refusal), and validation was the run's wall-clock long pole (~24 of ~61 min) — each dismissed
finding costs an opus-@-xhigh verdict, so most validation time+spend went to findings the validator
was always going to refuse. Raise the FINDER-side bar, not the validator's: the perspectives already
speak the validator's "concrete trigger + concrete consequence" language (2026-07-07 skill-form pass),
so the lever is tightening each perspective skill's finding bar with negative guidance mined from real
refusals. Mechanics: aggregate persisted
validation_verdict.argumentationfor dismissed findings bysource_perspective(data already in Postgres; no runs needed), distill the recurring dismissal patterns (speculative reachability, style-adjacent, pre-existing-not-worsened, …) into per-skill "do NOT report" examples. Tension to respect: item 4 suspects the validator is itself over-strict (killed real yardstick findings) — the goal here is fewer junk candidates, not fewer real ones; don't tune finders against a validator bar that item 4 may later loosen. Acceptance: dismissal rate drops materially (toward ≤50%) on frozen-PR evals with the valid-finding set intact (item 5's coverage matrix as the guard); kill if valid findings drop with the noise.
✅ BUILT 2026-07-27 — reviews surface exposed as MCP tools (grantable review scope)
Agents needed to drive Review over MCP — kick off a review, poll progress, pull the finished findings
(PR #72917). The capabilities already existed as the reviews
viewset behind the Code review UI; the blocker was auth: the viewset was scope_object = "INTERNAL"
(session-only), unreachable with the personal API key / OAuth token MCP authenticates with. Decisions:
- A grantable
reviewscope, not a widened INTERNAL. The viewset moves toscope_object = "review"withtriggerbehindreview:writeandperspective_statsbehindreview:read(list/retrievemap toreview:readby default). Session UI access is unchanged — this only adds token access. The scope is mirrored across the standard lists (insights/scopes.py, the frontendAPI_SCOPE_OBJECTSarray + the PAK modal inscopes.tsx, the generated agent / OAuth scope lists, the MCP guard lists). - Three thin tools over the existing surface (
products/review/mcp/tools.yaml):review-script-reviews-trigger(write),review-script-reviews-list(status polling — running turns first with theprogressstage / counters),review-script-reviews-get(validated + dismissed findings, published body). All three are gated on thereview-scriptfeature flag — the same flag that gates the Code review menu entry — evaluated fail-closed at MCP init. The other scaffolded operations (settings, perspectives, validators, blind spots, perspective stats) stayenabled: falseuntil an agent job needs them. - Identity + gates inherited, not widened: an MCP-triggered review runs under the token's user — the same
"requester is both run user and acting user" rule as the UI trigger — and the trigger action keeps the
REVIEWFN_TEAM_IDdogfood gate and its synchronous URL / App-access / fork / open-state checks regardless of caller.
✅ BUILT 2026-07-21 — held-back reviews reach PR authors (deep links, authored-PRs scope, truthful drawer + comment)
Dogfooding surfaced a dead-end: a review triggered from the Code review scene runs under the requester's
urgency_threshold ("requester wins" — deliberate, unchanged), and when every finding fell below it, publish
self-skipped and the findings were effectively invisible to the PR author — not in their "For you" tab (which
matched acting_user only), no way to link to the report (drawer state was kea-only), the status comment
blamed "the author's … Review settings" for a threshold that wasn't theirs, and the drawer's "Published (N)"
tab claimed publication for findings computed against the viewer's current threshold. Decisions:
- Report deep links:
/code_review?review=<report UUID>, mirrored both ways by the existing URL sync inreviewSettingsLogic(replace, neverpush, so drawer open/close doesn't stack history). The status comment's held-back sentence links here ("View them in Insights", auth-gated — same posture as Slack links), which makes the param a permanent public contract. A deep link has no list row, so the drawer renders entirely from the loaded detail (skeletons until then) via an id-onlyopenReviewDetailByIdaction. ReviewReport.author_login(nullable, refreshed frompr_metadata.authorevery turn at upsert): the "For you" scope becomesacting_user = me OR author_login ~= my linked GitHub login— the reverse of the author→user mapping the reviewer already uses. Chosen over joining the snapshot jsonb (per-row parse on a list endpoint) and over resolving login→user at write time (the mapping can change; the login is the stable fact). No backfill: old rows stay null and re-stamp organically on their next turn. Accepted limit: authors without a linked GitHub identity only get the PR-comment link.ReviewReport.run_urgency_threshold, stamped at finalize (withrun_count/completed_head_sha) from the same snapshot the body renderer and publish gate consumed — NOT at resolve, which describes the next turn and would drift mid re-review under a different acting user. The drawer buckets by it; the viewer-settings proxy survives only as the fallback for pre-column rows. "Published (N)" now reads "Kept (N)" unless the review actually posted (published_head_shaset).- Default-fallback threshold guard: a default-resolved run (label trigger, unmapped author) now gates at
DEFAULT_URGENCY_THRESHOLDinstead of the borrowed run user's saved threshold — the same "borrowed settings must not leak into someone else's PR" rule that already forcedreview_labeled_prs=True. Applied whereResolveActingUserResultis built so publish, the comment, and the finalize stamp agree. Consequence: a default-resolved run gates atconsider, so its held-back count is always 0 and the comment's "the default" wording variant is defensive-only (kept + render-tested anyway). - Bot-author guard on
_review_already_posted: the publish idempotency marker scan now requiresuser.type == "Bot"like the status comment's_find_marker_commentalways did — on a public repo anyone can paste the marker, and a spoofed match silently suppressed the publish. - Deferred residual (2026-07-24, pre-merge safety review): the drawer's published flag is
report-lifetime, not per-turn —
published=published_head_sha IS NOT NULL, while the drawer buckets the latest completed turn. A once-published report whose later turn finalizes without posting (store-only re-run, or publish failure past retries — finalize stamps before publish by design) shows that turn's never-posted findings under "Published". Deferred as a follow-up with the user: the edge needs a once-published report plus a never-posting later turn, and store-only re-runs are currently internal experiments. Fix sketch in ARCHITECTURE.md's known issues (per-turnpublished_head_sha == completed_head_shaon the detail payload).
✅ BUILT 2026-07-17 — one-shot LLM stages retry across provider overload spells
First cross-repo dogfood run (a Insights/billing PR via the Stage 5c UI trigger) died in dedup on
Anthropic 529s — nothing repo-specific: the review wave completed 9/9, then all four dedup attempts
(2 activity attempts × 2 parent-workflow retries, ~40s apart) landed inside the same overload spell
and the run failed within 2 minutes flat. The same spell had hit chunking ~17 minutes earlier and
recovered on its immediate retry, so overload is intermittent-but-minutes-long — the failure mode is
retry spacing, not retry count. Fix: the fatal one-shot LLM stages (chunking, selection, dedup)
now run under _ONESHOT_RETRY (5 attempts, 30s initial, ×2 backoff capped at 4m — ~7.5m of waits
per run, ~2× with the parent retry) instead of _RETRY's two back-to-back attempts. Failed 529
attempts are free (the call aborts before persistence) and the non-retryable classes (4xx,
max_tokens truncation) still fail fast. The gateway's Bedrock fallback was NOT the lever: it's
deliberately off for one-shot calls, whose output_config (effort pin + schema constraint) the
Bedrock path would silently strip. Sandbox-stage units keep _RETRY — their overload exposure is
absorbed by the best-effort fan-out + failure floor, not by a single fatal call.
✅ BUILT 2026-07-16 — inline finding comments: colored severity badges replace the text meta
The inline comment _format_issue_comment builds (publish_review.py) had its meta line recolored after comparing it against Greptile's PR comments — a deliberately minimal change that leaves the four collapsed sections (Issue description, Suggested fix, Why we think it's a valid issue, Prompt to fix with AI) exactly as they were.
Before: a plain ### title followed by a text meta line (**Priority:** should_fix | **Category:** performance | **Lines:** …).
Now the comment keeps the title, then a line of colored shields.io badges just beneath — a lowercase severity chip (must fix red D1242F, should fix orange E36209, consider blue 0969DA) plus a neutral-grey lowercase category chip — and the four <details> follow unchanged.
No line reference is shown up top: the comment is anchored inline (GitHub renders the code above it) and the lines already appear in the AI-fix prompt, so the **Lines:** part of the meta was dropped.
Badge alt text is the raw enum value () so the priority still reads in email digests, when images are blocked, and to screen readers — and the existing publish-gate test's should_fix-in-body assertion still holds.
Helpers _shields_badge / _finding_badge_line + _PRIORITY_BADGE carry the label/color map.
Color mechanism was a user decision (badge images, Greptile-style, accepting the external-image dependency) over the GitHub-native emoji/alert alternative.
An earlier iteration also surfaced the problem/fix inline and un-collapsed two sections; that was reverted — the collapsed structure is intentional and stays.
✅ BUILT 2026-07-17 — comment readability: bulleted validator verdicts, shown first in the comment
Grilled with the user 2026-07-17 (vocabulary in CONTEXT.md). Problem: published findings are meaningful but read as prose blobs.
Constraint: zero quality loss for the validator and for anything reading validator output downstream.
Key facts driving the design: the finding body is dual-audience (rendered verbatim on GitHub AND consumed by the validator's ISSUE payload, dedup's fresh/prior payloads, and future turns' covered-findings block), while the validator's argumentation is presentation-only for valid findings (its sole pipeline consumer is dedup's prior_ruling, dismissed findings only) — and the full body always travels with the argumentation, in the same comment or payload.
So the risk is compression (dropping information), not structure (same facts as labeled bullets); restructuring the validator's output is safe without an e2e round.
What shipped (all prompt/template-level, reversible — no ADR):
- Verify, don't restate — the validator's
argumentationis now labeled bullets (Checked / Found / Impact / Priority): the verification delta only, never a restatement of the body's claim — restatement is where most of the bloat lived (real comments restated nearly every fact). Changed together: the field description (models/issue_validation.py),prompts/issue_validation/prompt.jinja, and the checked-inschema.json(hand-synced;generate_all_schemas()re-emits it at run start anyway). Not yet observed live — check the argumentation shape on the next dogfood run. - Validation-first comment layout. The argumentation is human-facing, so its
<details>block moved to FIRST under the title + badge line — reading order: claim (title) → why it's real (validation bullets) → description / suggested fix / AI-fix prompt for whoever wants more. Applied in both renderers:_format_issue_comment(publish_review.py) and the body's off-diff section (prepare_validation_markdown.py). Everything stays collapsed (user choice — compact scan on multi-finding reviews). - Reviewer finding body stays prose — deliberately not restructured. It feeds three LLM stages (validator, dedup, covered-findings), so a shape change there would need e2e parity verification; the user dropped that thread (and the staged/eval-plan drafted earlier the same day) as not worth the runs right now. If ever revisited: 4-arm matrix (control / reviewer-only / validator-only / both) on the frozen-PR protocol.
- Rejected alternatives: additive TL;DR field (blob remains when expanded — the actual pain), render-time compression pass (lossy rewriting on the published text humans act on, plus an extra LLM call per finding).
✅ BUILT 2026-07-15 — reviewing-stage progress copy: "Reviewing chunks" → "Running review passes"
Live misread on insights#71025 (63 additions): the
status comment read "Step 3/6 · Reviewing chunks · 2/3" and the author concluded the deterministic
single-chunk gate had failed and split a tiny PR into 3 chunks. It hadn't — the persisted selection
artefact showed one chunk (2 selected perspectives + the blind-spot unit = 3 review units, 2 done),
and no chunking $ai_generation ran for the PR. The reviewing-stage counter has always counted
(pass, chunk) review units, but the PR comment glued it onto a chunk-flavored label; the app UI
renders a percentage, so only the comment invited the misread. Fix: the stage label is now "Running
review passes" on both surfaces (_STAGE_LABELS + FE progressLabel — they must tell the same
story), so the comment's raw done/total counter names its true unit. Grilled with the user
2026-07-15: chunk count deliberately NOT added to the line ("keep it lean" — it stays visible in the
drawer's Chunks tab). Same pass fixed the Preflight section's stale chunking-path numbers (still
said the pre-2026-07-02 ≤1000 gate; the code's SINGLE_CHUNK_GATE_ADDITIONS is 400).
✅ BUILT 2026-07-08 — GitHub I/O moved to the gated egress transport (PyGithub removed)
The pygithub==2.7.0 dependency is gone; every Review GitHub call now goes through
insights.egress.github.transport.github_request — the repo's mandated GitHub client, budget-gated per
App installation and recorded on the egress telemetry by construction (PyGithub's Github(token) bypassed
both). Removing the branch-added dependency also unblocks the Scriptbox preview environment, which mounts PR
code over the published :master image and therefore can't grow new Python deps (ModuleNotFoundError: No module named 'github' at migrate).
- New
reviewer/tools/github_client.py:github_api_request(one gated call; raisesGitHubRateLimitErroron a GitHub rate limit,GitHubAPIErroron any other non-2xx) andgithub_api_get_paginated(per_page=100page loop, aftergithub_integration_base's pattern), both stampedsource="review"with explicit normalizedendpointtemplates for telemetry labels. tools/github_meta.py(PRFetcher,fetch_branch_compare,find_open_pr_for_branch),tools/publish_review.py, and thepublish_reviewmanagement command speak plain REST; PR/comment/file payloads are consumed as the raw response dicts (PyGithub'sraw_dataindirection gone).ReviewCommentis now a local TypedDict inpublish_review.pymatching thePOST .../pulls/{n}/reviewsbody shape.- Behavior preserved: fork detection, file filtering, the compare path's 300-file GitHub cap (truncation
detection still a known follow-up), the review/promo idempotency-marker readbacks with their inverted
failure stakes (unreadable reviews → post; unreadable comments → skip promo), the commit-pin probe that
degrades to an unpinned post on an unresolvable
head_sha, and the body-only fallback when posting with inline comments fails. - The activities'
_installation_tokenbecame_installation_auth, returning(token, github_installation_id)— the installation id is threaded through every call so it's metered against the installation's shared egress budget (defaultCRITICALpriority: gauges + accounting, never denied). One fewer API call per fetch:requested_reviewersreads off the PR payload instead ofget_review_requests(). - Tests re-seamed from PyGithub mocks to
github_api_request/github_api_get_paginatedpatches (reviewer/tests/test_github_meta.py,reviewer/tests/test_publish_review.py,tests/test_publish_idempotency.py,tests/test_branch_targets.py). - Not e2e'd — unit mocks can't prove request shapes against the real API; the next live review run should exercise fetch + publish end to end.
✅ BUILT 2026-07-07 — canonical skill bodies rewritten to the writing-great-skills form (uncommitted)
Form-only pass over the six skills/*/SKILL.md canonicals applying mattpocock/skills → writing-great-skills: single source of truth vs the review harness (the perspectives' parallel-topology/dedup paragraph, the non-test-files rule, and the perf skill's severity guide all restated issues_review/prompt.jinja — cut from the skills, the harness owns them), in-skill duplication collapsed (each perspective said its scope 3×: investigation areas → "Key questions" → "valid finding" noun list), sediment deleted ("Investigation commands" — repo-wide rg in a chunk-anchored review, with a broken --type tsx flag; buffer-overflow/GraphQL template leftovers), no-op virtues pruned ("verify calculations are correct"). Added per the doc: a checkable completion criterion on every perspective ("done when every changed file is flagged or affirmatively cleared against every hunting ground") and shared leading words — "lane"/"hunting grounds"/"negative space" (blind-spots), and the validator's "concrete trigger + concrete consequence" now IS the finder-side finding bar, so finder and judge speak one language. Bar semantics untouched: validator body intact except one restatement collapse (strictness round is item 4); blind-spots stays pure-generic (item 2's lock); NOT the content-coverage round (item 5 — new grounds/4th perspective still pending). review-script-authoring updated so customs get authored to the same shape (and told not to restate harness mechanics). Skill bodies ~halved; canonical sync picks the new bodies up on next run for unedited teams. Not e2e'd — next live run should sanity-check finding volume/quality didn't regress; the real acceptance test stays the eval yardstick (item 5's coverage matrix).
✅ BUILT 2026-07-07 — sandbox workflow ids branded with review + step (Temporal debuggability) (uncommitted)
Temporal used to show every Review sandbox run as task-processing-<task_uuid>-<run_uuid> — undebuggable next to the well-named review-pr:{team}:{owner}/{repo}:{pr} parent (+/review, /validate children); the readable step_name only reached the Task title.
Now every sandbox run's workflow id is {activity's workflow id}:{step}-{task_id}-{run_id}, e.g. review-pr:1:insights/insights:67451/validate:validation-c3-<task>-<run> — one lowercase Temporal-UI search by PR number surfaces the whole review, and a failed workflow is self-describing. Scope: Review only (Signals etc. keep the default id); decided with the maintainer: no schema changes (state JSON only), full-context prefix, lowercase everywhere.
- products/tasks: the previously dead
workflow_id_prefixonexecute_task_processing_workflow{,_async}is now threaded throughTask.create_and_run→create_task_and_trigger→MultiTurnSession.start/start_raw, and ridespending_dispatchso the orphaned-run reconciler rebuilds the same id. Because a prefixed id is not derivable from(task_id, run_id), dispatch persists it in the run'sstateJSON (_record_prefixed_workflow_id, beforestart_workflow) andTaskRun.workflow_idreturns persisted-or-derived — keeping every row→workflow lookup working (the agent-heartbeat relaymodels.heartbeat_workflow, follow-up signals,temporal_ui_url). In-workflow consumers (provision_sandboxModal tags,relay_sandbox_eventsheartbeat handle) now useactivity.info().workflow_idinstead of re-deriving. Default (non-prefixed) dispatches are byte-identical to before — no state write, derived id. - Review:
_sandbox_workflow_id_prefix(step)(activities.py) brands all four sandbox stages (chunking sandbox path, review/blind-spot units, dedup sandbox path, validation sessions) via aworkflow_id_prefixkwarg onrun_sandbox_review/start_sandbox_session/deduplicate_issues.review_pr_workflow_id/review_branch_workflow_idnow lowercase (GitHub owner/repo are case-insensitive) so parent, children, and sandbox ids search as one casing. Temporal id constraints checked::and/already proven in this cluster's ids; ~130 chars ≪ the 1000-byte server cap. - Tests:
products/tasks/backend/tests/test_workflow_id_prefix.py(property persisted-or-derived; dispatch starts under the id it records, default writes nothing; reconciler rebuilds the prefixed id frompending_dispatch) + prefix assertions intest_review_activity.py/test_validate_activity.py(activity tests now run underActivityEnvironment) + a mixed-case id test intest_branch_targets.py. - Follow-up, same day — spawned-task attribution (the Signals pattern, all reused fields): every Review sandbox spawn now passes
origin_product=TaskOriginProduct.REVIEW(new enum member, choices-only tasks migration 0047, SQL no-op),internal=True(Task.internal— not exposed to end users), andai_stage=step_name(existing field → run state → stamped on$ai_generation, giving per-stage sandbox cost attribution like the one-shot calls already had). Set once in the executor (_run_prompt+start_sandbox_session), asserted intest_executor.py. No new fields — review linkage is already carried by the brandedstate["workflow_id"]; threadingsignal_report_idfor inbox-triggered reviews is a possible later reuse (needs stage-input plumbing).
✅ BUILT 2026-07-07 — findings cross Temporal stages by reference, not by value (uncommitted)
Prod-readiness fix (the Temporal-audit "finding C"). The full post-review issue list used to cross five payload boundaries by value (combine result → dedup input → dedup result → validate child input → build-body input) with nothing bounding it: no max PR size, no per-unit issue cap, no text-length caps. Measured density (local runs): ~2 KB per serialized issue, ~9 issues/chunk worst case — a ~14-chunk PR (~4–5K additions, realistic here) crosses the repo's ~256 KB pass-by-reference rule, and a pathological PR would hit Temporal's ~2 MiB cap as a PayloadSizeError at the combine→dedup handoff, after all sandbox spend.
Now only issue ids cross stage boundaries; content reloads from the persisted rows:
- Combine + scope-clean folded into
dedup_activity(they were consecutive, both DB-local; the standalonecombine_and_clean_activityand its by-value return are gone — stages renumbered to /7). Dedup persists survivors as before and returnsDedupResult.issue_idsonly. persist_findingsreturns the persisted issues' live ids (the by-reference handle); newload_run_issues(team_id, report_id, run_index, issue_ids)+_from_finding(inverse of_to_finding; the live id is theissue_key's last:-segment) reconstruct the liveIssues. Round-trip is exact —_issue_keyregenerates identically, so validate's skip-resume keys are unaffected.ValidateIssuesWorkflowtakesissue_ids, groups chunks by parsing the id string directly;validate_chunk_activityandbuild_body_activityreload their issues from the finding rows.- Side effect, strictly better: a survivor that failed durable-finding validation (never persisted → could never publish) no longer reaches validation at all — previously it burned a sandbox verdict that
persist_verdictthen dropped. - Tests: workflow stubs updated to the id contract; a persistence round-trip test guards
_to_finding/_from_findingdrift and the id reconstruction (silent-empty-validation regression). 205 backend tests + ruff green.
✅ BUILT 2026-07-07 — validation turn failures retry instead of silently skipping (uncommitted)
Resilience fix, observed on a real run: a validation turn died mid-session (upstream API timeout → the sandbox task ended failed), validate_chunk_activity best-effort skipped the issue, returned success, and the run finalized without the verdict — the failed validation was never retried.
Now a failed turn fails the activity so Temporal retries it — cheap by design, since skip-resume (load_run_validations → done/pending) re-validates only the issues without a persisted verdict, on a fresh session.
Only the final attempt degrades to the old skip, and it also resets to a fresh session for the remaining issues (the failed one may be wedged after a dead turn), so one persistently failing issue can't sink the chunk — or, through the failure floor, a single-chunk run.
Session-OPEN failure still raises on every attempt (the outage signal the floor counts).
reviewer/constants.py:VALIDATION_MAX_ATTEMPTS = 2(the uniform "1 retry" policy);workflow.pybuilds_VALIDATE_RETRYfrom it andvalidate_chunk_activitykeys its final-attempt check off the same constant, so the policy and the fallback can't drift.- Tests:
test_validate_activity.py(turn failure raises + prior verdicts persisted + done issues not re-sent; final attempt skips + fresh session for the rest; open failure raises even on the final attempt) + a workflow-level retry-wiring test intest_temporal_workflow.py.
✅ BUILT 2026-07-07 — per-chunk perspective selection (one-shot; cost gate before the wave) (uncommitted)
A tiny PR (title change) used to pay every enabled perspective on every chunk — pointless specialist sandbox sessions. Now a cheap one-shot selection (select_perspectives_activity, same run_oneshot_review infra as chunking/dedup, ai_stage="perspective_selection") runs inside ReviewPerspectivesWorkflow between load_perspectives_activity and the fan-out, and only the selected (perspective, chunk) pairs run. Design grilled + locked with the user 2026-07-07:
- Standalone step, NOT folded into the chunking LLM — the ≤400-addition deterministic path (the motivating tiny-PR case) never reaches the chunker, and
chunk_setis keyed by head_sha while the roster is per-user config. Also rejected: staticchunk_type → perspectivemapping (custom perspectives break it) and in-sandbox early bail (cost already spent). - Selector input: PR intent + the prunable-perspective menu (frontmatter
description— an empty-description custom skill never enters the menu and always runs; the selector can't rule out a lens it can't read) + per-chunk context: chunker metadata (files/stats/chunk_type/key_changes) on the LLM path, raw file changes on the deterministic path (bounded by the sameSINGLE_CHUNK_GATE_ADDITIONSgate). Prompt is inclusion-biased ("in doubt, KEEP"). If selection quality disappoints, sharpen the skill descriptions (the blind-spot prompt reads the same ones) — not the prompt. - Zero perspectives per chunk is allowed — the always-on blind-spot sweep guarantees ≥1 pass per chunk and is told per-chunk which lenses ran (
wave_perspectivesis now the chunk's ran-list, with an explicitIS_BLIND_SPOT"you are the only reviewer" prompt branch for zero-lens chunks). Coverage invariant (future-proofing a possible blind-spot off switch):apply_selection(..., blind_spot_runs=)deterministically ignores the selection for any chunk that would end with zero units of any kind — today the guard never fires. - Fail-open everywhere: the workflow catches selection
ActivityError→ dense product with a warning; unknown skill names drop; a chunk missing from the LLM output runs everything; duplicate chunk entries union. The failure floor counts dispatched units, so pruning can't trip it. A run is never failed (or thinned) by its optimizer. - Persistence + observability: the activity normalizes the raw LLM output (
normalize_selection— the persisted plan IS what the fan-out runs) and stores it with the roster as aperspective_selectionworking-state artefact (head_sha-scoped resume likechunk_set; migration 0012, choices-only)._expected_readsreads the plan when present (exact progress totals; dense estimate until it lands). UI (reworked 2026-07-08 with the user): the review DETAIL endpoint exposes the per-chunk plan (perspective_selection {roster, chunks[{chunk_id, chunk_type, files, perspectives, skipped, reason}]}, joined with the chunk set's metadata server-side) and the findings drawer has a "Chunks" tab rendering it per chunk (expandable file lists). A first cut put a review-level "Perspectives run: N of M + fully-skipped rows" line in the expanded list row — dropped: the row panel was overloaded, and review-level aggregation is blind to partial (per-chunk) drops, which are the common outcome on multi-chunk PRs (observed live on insights#69066: contracts-security dropped on 1 of 5 chunks, invisible at review level). - Temporal boundary carries a dataclass
PerspectiveSelectionDTO(the pydantic model stays the LLM/persistence shape — the worker's pydantic converter is deploy-flag-dependent). - In-flight progress (follow-up, 2026-07-08): the progress ladder now covers the full pipeline —
_REVIEW_STAGES= fetching → chunking → selecting (chunk set exists, no plan, no reads) → reviewing n/m → deduplicating (all planned reads in, findings not yet persisted; was a client-side inference) → validating n/m → finalizing (every verdict in; body build + publish). The UI renders it as a 6-step label. Known mislabel windows, both benign: a fallback (selection-less) run shows "selecting" until its first read lands, and best-effort unit failures skip "deduplicating" (reads never reach the plan). - Tests:
reviewer/tests/test_select_perspectives.py(apply/normalize matrix + prompt gates), activity tests intest_review_activity.py(normalized persist + roster, resume skips the LLM, nothing-prunable skips everything), workflow tests (sparse fan-out + per-chunk blind-spot lenses; dense fallback on selector failure), API tests (payload aggregation + head-matching, full progress-stage ladder). - Measured (e2e 2026-07-08, insights#69066, ~2.6k additions, --publish): selector cost $0.043 (10,359 in / 2,184 out — confirming the metadata-only path on a chunked PR); kept 14/15 units — the frontend-only chunk dropped contracts-security with a sound reason ("no new API contracts, injection, or authz surface at this UI-only layer") while all four backend/billing chunks kept all three lenses. Full run: 35 candidates → 8 valid / 27 dismissed, published, ~61 min. A title-change PR (deterministic path, diff-in-prompt selection) is covered by tests but not yet observed live — expected: 1 sandbox session (blind spot only) instead of 4+.
- Scaling limit — selection cannot prune the blind-spot floor (by design, see the invariant above): the sweep runs one sandbox session on EVERY chunk, so per-review sandbox cost is bounded below by the chunk count no matter how aggressively lenses are pruned. On a very large PR this floor dominates: e.g. PR #64651 (~28k additions, ~26% markdown) chunks to ~50–90 chunks → ~that many blind-spot sessions even if every doc chunk gets zero perspectives, on top of wave units for the code chunks — a multi-hour, 100–250-session run. Selection makes such runs safe and cheaper, not cheap. If doc-only/trivial chunks should ever skip the sweep too, that's a deliberate weakening of the coverage invariant — decide it together with roadmap item 6's "single-chunk blind-spot value" question, not ad hoc.
✅ BUILT + e2e ✅ 2026-07-14 — live PR status comment (kickoff → on-persistence edits → outcome) (uncommitted)
Before this, a labeled PR was silent until the review published (or forever, on a failed/zero-publishable run). Now the publish path carries one marker-tagged issue comment per report (<!-- review:status:{report_id} -->), posted at kickoff and edited in place — never re-posted, because comment edits don't notify PR subscribers while every new comment emails everyone. Design locked with the user 2026-07-13: no timer anywhere — updates ride the pipeline's own persistence moments.
- Module:
reviewer/status_comment.py(render + GitHub I/O + debounce); progress derivation extracted fromapi/reviews.pyintoreviewer/progress.py(sharedsnapshot_stats/turn_stats/progress_payload), so the PR comment and the UI's "Step k/6" rows read the same source and can't disagree. The comment mirrors the frontend's 6-step labels exactly. - Kickoff:
post_status_comment_activity, dispatched inReviewPRWorkflowonly wheninputs.publish and meta.pr_number is not None, and only after every gate (already-published, empty-diff, acting-user, opt-outs) — gated no-op turns and eval/CLI/branch runs keep zero GitHub footprint. Reuses the previous turn's comment by stored id, falls back to a marker scan (crash between POST and id-save), and falls back to a fresh POST on a 404'd stored id (user deleted it). - On-persistence edits:
maybe_refresh_status_comment(team_id, report_id)is called by the pipeline activities right after they persist progress artefacts (split_chunks,select_perspectives, eachreview_chunk,dedup, eachvalidate_chunk). Debounce = an atomic claim onReviewReport.status_comment_edited_at(STATUS_EDIT_MIN_INTERVAL60s), so the concurrent fan-out collapses to ≤1 GitHub edit/min; a run with no comment bails on the same claim. Best-effort by construction — every entry point swallows and logs, a status comment can never fail or retry a review. Known quiet window: nothing edits while the first chunk of a stage is still cooking in a sandbox (same granularity the UI has). - Outcome edit (user decision 2026-07-13):
finalize_status_comment_activityrewrites the comment with the full found counts ("Found 1 must fix, 2 should fix, 5 consider") plus the published-vs-held-back split and the review link — so a PR with two inline comments never reads as "the review only found two things", and a zero-publishable run gets explicit closure ("stayed below the author's threshold") instead of the old silence. Counts useeffective_priorityoverload_valid_findings— the same rule publish gates on.fail_status_comment_activityruns in the workflow's except path so a dead run never reads as forever in progress. - Schema: migration 0015 —
ReviewReport.status_comment_id+status_comment_edited_at(nullable, persists across turns; a re-review resets the same comment). - Tests:
backend/tests/test_status_comment.py— render matrix (counts split, zero-found, link-less marker-skip), marker adoption after a crashed run, stored-id reuse without scanning, debounce claim, effective-priority counting with a validator downgrade, failed rewrite. - Measured (e2e 2026-07-14, insights#70571, ~1.7k additions, --publish, ~70 min): one comment id edited in place from kickoff (22:40:46, "Step 1/6") to outcome (23:50:21), zero duplicates; every edit ≥60s apart with fan-out bursts collapsed by the claim (3 units → one "4/19" edit); counters matched the DB artefact rows at every cross-check; outcome read "Found 2 must fix, 4 should fix, 5 consider · Published 6 (link) · 5 stayed below Should fix", published review at 23:50:19 with 6 inline-priority findings. The quiet-window lag showed as designed (comment sat on "17/19" through dedup). The failed-run rewrite path has not been observed live yet (unit-tested only).
✅ BUILT 2026-07-03 — One-shot (sandbox-free) chunking + dedup on Sonnet 5 @ xhigh (uncommitted; eval round DONE — see the experiment's FINAL_REPORT)
eval/POTENTIAL_EXPERIMENTS.md item 7, refined per the user: chunking and dedup are pure text tasks (their
prompts carry everything inline; dedup renders CLAUDE_CODE_CONTEXT=""), so within size gates they now run
as single direct LLM-gateway calls instead of sandbox agents — removing ~55s provisioning per stage on
the serial critical path and two failure classes (Modal provisioning flakes; the 29% chunking
schema-failure class, killed structurally by structured outputs).
- Gates (
reviewer/constants.py, 0 = disabled):CHUNKING_ONESHOT_MAX_ADDITIONS = 5000(reviewable added lines, consistent with the other chunking gates) /DEDUP_ONESHOT_MAX_FINDINGS = 50(issues entering dedup, inclusive). Above a gate the stage takes the previous sandbox path with the same prompt, pinned to the one-shot model via theCHUNKING_*/DEDUP_*trios (Sonnet 5 @ xhigh) — so the delivery path never changes which model judges the stage. - Dedup also gates on prior turns' findings (2026-07-09):
load_prior_findings_with_verdictsfeeds every earlier turn's finding + validator ruling into the dedup pre-filter and prompt (<prior_findings>), so a re-found dismissed/below-threshold problem dies at dedup instead of burning another validation turn. Discovery already sees the same set asCOVERED_FINDINGS; this is the enforcement backstop when a perspective re-raises anyway. No carry-forward yet — a suppressed re-find is absent from the new turn's finding set (the old turn's rows keep it). - Executor:
reviewer/sandbox/direct_llm.py→run_oneshot_review(...)—get_async_anthropic_gateway_client(product="review")(product registered ininsights/llm/gateway_client.py+ the llm-gateway config),ONESHOT_MODEL = "claude-sonnet-5"with adaptive thinking +output_config.effort = "xhigh"(the API-native expression of the sandbox pins), structured outputs from the stage's pydantic model (schema-guaranteed JSON), anai_stageheader stamped on the captured$ai_generationfor dump/cost attribution, Anthropic errors re-raised as compactApplicationErrors (4xx non-retryable except 408/409/429), Bedrock fallback off (that gateway path stripsoutput_config). - Branch points:
split_chunks_activity(additions gate) anddeduplicate_issues(issue-count gate); prompts byte-identical on both paths. - Smoke-verified end-to-end 2026-07-03 against the local gateway (sonnet-5 generation captured with
ai_product=review+ai_stage). Eval round:eval/experiments/2026-07-oneshot-chunking-dedup/(2 unpinned e2e runs on frozen PR #62096 + an offline chunk-plan sample; kill = set the two gates to 0, which restores sandbox behavior byte-identically).
✅ BUILT 2026-07-02 — Inbox "Code review" tab: onboarding/settings UI + ReviewUserSettings backend
The first Review UI surface (uncommitted): one scrollable onboarding-and-settings page, live from load
(no save step), per the Claude Design handoff at playground/review-ui/design_handoff_review_onboarding
— concept kept, all components swapped for UI. Decisions locked with the user: staff-only tab +
Alpha tag; label trigger default ON (opt-out — default-off would have halted the insights/insights
label flow); urgency threshold = pure priority filter (not placement); perspectives keep the min-1
floor and blind-spots/validator are exactly-one-active with deactivation blocked on BE and FE.
- Placement (as of 2026-07-13 — standalone scene): originally shipped as a
'code-review'InboxTabKeyafter'archived'(staff-gated viaINBOX_STAFF_ONLY_TAB_KEYS, "Alpha" tag fromINBOX_TAB_TAG). Moved out of the Inbox to its ownCodeReviewscene at/code_review, registered byproducts/review/manifest.tsxwith an "Unreleased" nav entry (ProductItemCategory.UNRELEASED,tags: ['alpha']). Gating is two-layer by design:FEATURE_FLAGS.REVIEW(review-script) controls only the menu entry's visibility (who discovers it); the scene itself stays staff-only (non-staff getNotFound) regardless of the flag. The old inbox tab and/inbox/code-reviewURL are gone with no redirect. Body unchanged: hero → pipeline diagram → trigger toggles → urgency slider → perspectives → blind-spot check → validation criteria → read-only skill drawer; it no longer sits behind the Inbox onboarding takeover. - Where the code lives:
products/review/frontend/(CodeReviewScene.tsx,reviewSettingsLogic.ts, plus the pre-existinggenerated/orval clients). The kea deps live inproducts/review/package.jsonas"catalog:"entries (kea / kea-loaders / kea-router — thelinks/visual_reviewpattern); the 2026-07-02ERR_PNPM_TRUST_DOWNGRADEfailure that originally forced the code intofrontend/src/scenes/inbox/no longer reproduces with catalog deps. New schema enum membersFileSystemIconType.code_reviewandProductKey.REVIEWback the nav icon (IconPullRequest) and the tree item'sintents. ReviewUserSettings(migration 0008; one row per team+user,db_constraint=FalseFKs):review_inbox_prs(default off; consumed by the Stage-6 inbox trigger since 2026-07-02),review_labeled_prs(default on),urgency_threshold(consider/should_fix/must_fix, defaultconsidersince 2026-07-16 — see the follow-up below; values mirrorIssuePriority). GET/PATCH singleton atreview/settings— the action method isuser_settingsbecause a method namedsettingsshadows DRF'sAPIView.settingsand breaks the whole view (found by test).- Label gate:
resolve_acting_user_activitynow returns the acting user's settings snapshot (fixed at resolve time, so mid-run edits can't flip gates between stages). The workflow skips — before any sandbox spend — wheninputs.acting_user_id is None(cloud label path) and the PR author'sreview_labeled_prsis off; explicit CLI/eval runs stay ungated. - Urgency threshold:
PUBLISHED_PRIORITIESis gone →published_priorities_for(threshold)inconstants.py(+DEFAULT_URGENCY_THRESHOLD); the derived set is a required param throughbuild_review_bodyandpublish_persisted_review/publish_review/_build_inline_comments, fed fromBuildBodyInput.urgency_threshold/PublishInput.urgency_threshold(dataclass defaults keep in-flight payloads deserializing) on the workflow path. "All issues" publishesconsiderfindings inline too. The standalonepublish_reviewcommand always usesDEFAULT_URGENCY_THRESHOLD(considersince 2026-07-16) — see the adversarial-review follow-up below for why it doesn't take a per-user threshold. - Skill lists feed the drawer: the three config list endpoints (+ PATCH responses) now include the
skill
body; "Edit skill ↗" links to/skills/review-script. - "Create your own …": task kickoff mirroring Inbox "Make a scout" (
api.tasks.create→urls.taskDetail), originUSER_CREATED. The frontend prompts are thin scout-style pointers; the actual authoring guide (naming contractreview-script-{perspective,blind-spots,validation}-<slug>, categoryreview, per-kind body shape,insights:skill-createflow, activation steps) lives in the canonicalreview-script-authoringskill — see the follow-up below. - Verified: 321 review tests (
backend/tests+backend/reviewer/tests, the product's actualbackend:testscope) + ruff + tach; jest inbox suite; oxlint/tsgo clean on the touched files (repo-widepnpm formatis a footgun: its--fix-dangerouslypass mangled 5 unrelated files — reverted; lint targeted paths instead). Live smoke on dev: tab renders with real synced skills, drawer shows the canonical SKILL.md body, threshold click PATCHes and persists (must_fixverified in DB, then reset), blind-spot deactivation blocked with the toast. New workflow tests cover the opt-out skip, the CLI-override bypass, and threshold threading into body+publish. - Still deferred: reset-to-canonical (needs the force-re-pull helper in
lazy_seed); non-staff rollout. (The "Review all your Inbox PRs" behavior is BUILT — see Stage 6.)
✅ BUILT 2026-07-16 — default urgency threshold flipped to "All issues" (consider)
The default urgency_threshold moved from should_fix to consider, so out of the box every
validated finding reaches the pull request (consider-level findings survive validation too, so
they're worth surfacing by default). Changed in one place each: the ReviewUserSettings field
default/db_default and DEFAULT_URGENCY_THRESHOLD in constants.py; the temporal input
dataclass defaults (ResolveActingUserResult/BuildBodyInput/PublishInput) follow so "defaults
match the model's" stays true. Migration 0017 alters the column default and resets existing rows to
consider (safe — no persisted rows yet; done for correctness). Slider copy moved the "default"
annotation to "All issues" and the frontend fallbacks (?? 'should_fix') now fall back to
consider. The should_fix-threshold test fixtures were renamed _DEFAULT_PUBLISHED →
_SHOULD_FIX_PUBLISHED (they exercise the publish gate at that threshold; they never meant "the
product default").
✅ Follow-up 2026-07-02 — adversarial review (partial) caught a real regression; fixed 2, reverted 1 as overengineered
A 5-lens adversarial-review workflow (temporal/version-skew, threshold-plumbing, API/tenancy, frontend-logic, test-adequacy — each finding cross-checked by 3 independent verifiers) was launched against the change above. It was aborted mid-run when the session branched — only the "threshold" dimension's findings finished full verification; temporal, API, frontend, and tests dimensions never completed (one finder hadn't even returned; three others' findings were never verified). Treat this feature as reviewed only on the threshold-plumbing axis, not the other four.
Two confirmed findings, handled differently:
- Real regression, fixed. Making
published_prioritiesa required param onbuild_review_body/publish_review/_build_inline_commentsbroke 17 tests inbackend/reviewer/tests/{test_prepare_validation_markdown,test_publish_review}.py(TypeError: missing required argument) — those call sites were never updated. Caught only because the adversarial reviewer ran the product's realbackend:testscript (backend/tests+backend/reviewer/tests); my own verification during the build had only runbackend/tests, silently skipping the second directory the whole session. Fixed: all 8 call sites now pass a_SHOULD_FIX_PUBLISHED = published_priorities_for(IssuePriority.SHOULD_FIX)test constant (matching prior implicit behavior), plus two comments that assumed the old "consider never publishes" absolute rule reworded to "below the default threshold." Lesson: verify against a product'spackage.jsonbackend:testscript, not a hand-picked test path —backend/reviewer/tests/is easy to forget sincebackend/tests/alone still "looks" green. - Real but low-severity, first over-fixed then right-sized. The standalone
publish_reviewcommand rebuilt inline comments/the publish gate from a threshold derived at publish time (--user-id's current settings, or the default), while the frozenreport_markdownbody had been rendered at run time with whatever threshold the acting user had then — the two can diverge if settings change between a run and a later manual publish, posting a body and comments that silently disagree. First fix: persisted the build-time threshold onReviewReport(built_urgency_threshold, migration 0009) and had the command read it back. Reverted per the user's call — a schema migration is disproportionate for a manual, local-only ops tool. Right-sized fix instead: dropped the--user-idflag entirely (the flag was the source of the drift, added earlier in this same session) and made the command always publish atDEFAULT_URGENCY_THRESHOLD— deterministic, no per-run tracking, small residual accepted (a run built at a non-default threshold and republished manually still gates on the default going forward; acceptable for a dev-only tool, unlike the production workflow path which snapshots one threshold to bothBuildBodyInputandPublishInputand has no such gap).
All 321 tests green after both changes. makemigrations --check confirms zero model/migration drift
(migration 0009 was generated, applied, unapplied, and deleted in the same session — nothing landed).
✅ Follow-up 2026-07-02 — full 10-agent adversarial review (qa-team); all five axes now covered
The aborted partial review above was rerun in full: 8 specialists (security, database, reliability,
performance, frontend, compatibility, data-integrity, copy) + 2 independent generalists over the whole
staged diff. Full report with convergence analysis in repo-root QAREPORT.md. Fixed this session:
- Environments 500 (real bug, database agent).
settings.py::_get_or_createmixed a canonicalizedfor_teamfilter with a raw-URL-id create kwarg — on a child (environment) team the get never matches, the row lands on the parent (RootTeamMixin.saverewrite), and every call after the first 500s on the unique constraint. Fixed by resolvingresolve_effective_team_idonce and using it for both; regression test creates a child team and reads twice. The three sibling config viewsets share the latent pattern in their upserts (pre-existing) — follow-up, ideally together with a decision on howLLMSkill(env-scoped lookups) andReviewSkillConfig(root-scoped) should interact under environments. - Slider PATCH flood (5 agents converged).
Slider.onChangefires per mousemove; it was wired straight to theupdateSettingsloader → dozens of PATCHes per drag plus out-of-order responses overwriting newer state. Fixed: dispatch only on a stop change, and a trailingbreakpoint()in the loader drops stale responses (take-latest). Trigger switches gotsettingsLoadingdisabled-guards (double-submission rule); slider/stop-buttons stay drag-friendly by design. - Enum lockstep (4 agents).
UrgencyThreshold↔IssuePrioritymirrored by comment only; drift fails atIssuePriority(...)in build/publish — after all sandbox spend — or silently unpublishes a priority class (a member missing from_PRIORITY_RANKdrops out of every set). Locked with two cheap tests intest_constants.py(value-set equality;considerset == everyIssuePriority). - Load-failure dead-end (5 agents). A failed initial load left permanent skeletons + an untrue
"Loading…" with no retry. Added
loadAll(mount + retry) and an errorBannerwith Retry. text-2xsdoesn't exist (frontend agent; the token istext-xxs) — pipeline fine print and slider hints rendered at inherited size. Fixed (3 spots).- Papercuts: DRF bare-string 400s now surface in toasts (
error?.data?.[0]fallback —ApiError.detailis null for list bodies); dropped the duplicate generic toast inupdateSettingsFailure(global loaders toast already carries the detail); drawer no longer blanks mid-close (open flag separate from content); switcharia-labels + stop-buttonaria-pressed/load-guard; copy fixes (coming-soon leads the inert-toggle blurb, "One validator always runs" grammar, per-user-toggles vs team-wide-skill-edit disclosure, de-jargoned slider/pipeline lines); workflow gate comment now warns the next trigger must add a source input instead of reusingacting_user_id is None. - Right-sized on user review: a serializer
update(update_fields=…)override (concurrent-PATCH lost-update guard) and droppingrequired=False(typed-required GET responses) were built, then reverted as overengineering for a 3-field self-scoped row — the narrow lost-update window and all-optional response types are accepted.
Reviewed and deliberately not fixed (see QAREPORT.md): skill body on INTERNAL config endpoints
bypasses llm_analytics RBAC (LOW — same-team only; route through the skills API or add the resource
check before non-staff rollout); endpoints not staff-gated (intentional, self-scoped — docstring says
so now); workflow.patched() for the gate's deploy-window nondeterminism edge (vanishingly rare for a
staff alpha); unpaginated list responses carrying every skill body (fine at ~5 skills, serve on demand
before GA); single-active double-click can transiently leave two rows enabled cross-tab (alphabetical
loader pick is the backstop); redundant standalone team_id index (sibling-consistent); Title-Case
enum labels leaking into generated docs; the opt-out skips review compute but not PR-snapshot ingestion.
Verified after fixes: 324 backend tests (both dirs), 31 inbox jest tests, ruff + targeted oxlint/oxfmt,
tsgo clean on touched files (whole-app tsgo fails on unrelated stale-typegen in other products),
insightscli build:openapi round-trips with zero generated drift.
✅ BUILT 2026-07-02 — "Your recent reviews" block + ReviewReport.acting_user
Compact proof-of-life block at the top of the Code review tab (between hero and pipeline, hidden
until the user has completed reviews — placement picked by the user from proposed variants), plus UI
polish from the same session: single-active accent border removed (cards identical to perspectives),
"Edit skill" links go to the skill's own page (urls.skill(name), card + drawer footer via
ViewedSkill.skillName), the Inbox-PRs trigger row uses the real Logomark, and the three-bar
ReviewMark is deleted.
ReviewReport.acting_user(migration 0009, nullable FK,db_constraint=False, SET_NULL): stamped in_resolve_acting_uservia a new defaultedResolveActingUserInput.report_id. It's "whose configuration drove this run", not "PR author" — so it survives future non-PR (branch-only) triggers, which was the user's design question. Old reports are null until their next run.GET review/reviews/(ReviewRecentReviewsViewSet): the requesting user's 10 most recent completed reports (acting_user=me, last_run_at set), newest first. Per row: repo, pr_number, head_branch,github_url(pr_url, falling back to the branch tree URL — both PR and branch are valid cases), run_count, last_run_at, published, and valid-finding counts by effective priority fromload_valid_findings(run_index=run_count)(latest turn only). Nostatusfield — astatusChoiceField would collide with existing OpenAPI enum names, and the UI doesn't need it. PR title deliberately omitted (only lives in the heavy pr_snapshot JSON).- UI: rows show
repo#number, colored severity counts, "Not published" tag when applicable, relative time, and a "View PR"/"View branch" button togithub_url. - Tests: resolve stamping; list scoping (mine + completed only); counts scoped to the latest run with
validator priority overrides applied + branch-URL fallback. 327 backend tests + 31 inbox jest green;
migration verified lock-free via sqlmigrate (no
REFERENCES, plain nullable ADD COLUMN).
✅ BUILT 2026-07-07 — rich review rows + findings drawer + live pipeline trace (demo polish)
Supersedes "PR title deliberately omitted" above: the list now extracts PR facts DB-side via jsonb
(Cast(content, JSONField) + KeyTransform/jsonb_array_length — never pulls the ~100KB pr_files
payload into Python) from the turn's working-state artefacts, preferring the snapshot matching
report.head_sha so a fetch for a never-reviewed head can't displace the reviewed one.
- List enrichment (
ReviewRecentReviewSerializer):id,pr_title,pr_author,additions/deletions/changed_files(pr_snapshot meta),files_reviewed(len pr_files),chunk_count(chunk_set),perspective_count/perspective_issue_count/blind_spot_issue_count(perspective_result rows, latest-wins per (pass, chunk), pass 1000 = blind spots), and the funnelcandidate_count/dismissed_countfrom the newload_turn_findings(all judged+unjudged pairs;load_valid_findingsis now a filter over it).pr_numberbecame nullable in the schema (branch targets). Enum pin:ReviewIssuePriorityEnuminENUM_NAME_OVERRIDES(two fields share the set). GET review/reviews/<id>/(retrieve, same viewset, same acting-user scoping → 404 otherwise): fullReviewDetailSerializer= list row +report_markdown+findings(valid, most urgent first,effective_priority+reviewer_priority+validator_note) +dismissed_findings(with the validator's argumentation). Unjudged findings appear in neither list.- UI (CodeReviewTab), after two user-feedback iterations (fonts too small / published-vs-not
unclear / messy row; then: don't conflate below-threshold with dismissed, revert live pipeline
numbers, nest finding text): rows are collapsible — collapsed shows title + repo#number +
counts + time with an explicit chevron button next to "View PR"; expanding reveals
author/±diff/files/reviewed/chunks/turns, the raised→kept→dismissed funnel, and a "View findings"
button that opens
ReviewDetailDrawer(kea:openReviewDetailstores the row for an instant header,expandedReviewIds+toggleReviewRowExpandeddrive rows). Drawer (640px, text-sm/base body copy) has a persistent funnel line and four tabs: Published (N) (findings at/above the CURRENT threshold — the run's own snapshot isn't stored, split via thereviewFindingsSplitselector), Below threshold (K) (validated but under the user's bar), Dismissed (M) (failed validation), and Review body (Markdown). Finding cards mirror the published PR comment: tags + title + file:lines visible, with Description / Suggested fix / Why we think it's a valid issue (or Why it was dismissed) as collapsedCollapsesections — the validator's argumentation now shows for valid findings too, not just dismissed ones. The "How we review your PRs" section is static — the per-step live numbers were built and then reverted on user feedback ("looks meh");perspective_issue_count/blind_spot_issue_countremain in the API, currently unconsumed. - Tests (
test_reviews_api.py): jsonb enrichment + head-matching, retrieve findings split + scoping/garbage-id 404s. Verified live: #67451 = 19 files → 15 kept → 4 chunks → 3 perspectives × 30 issues + 6 blind-spot → 33 candidates → 10 valid / 20 dismissed, md 3.5KB. 392 backend green. - Third iteration (same day): live in-progress rows + perspective scoreboard + GitHub deep links.
The list now surfaces running reviews:
in_progress+ nestedprogress(review_stage∈ fetching/chunking/reviewing/validating + done/total), derived from which working-state artefacts exist at the report's head — findings-at-run_count+1→ validating (judged/total), chunk_set → reviewing (units done / chunks × (enabled perspectives + blind spot, canonical-3 fallback)), head-matched snapshot → chunking, else fetching. ACTIVE-but-quiet runs age out viaIN_PROGRESS_STALE_AFTER(30 min since newest artefact / report update) so a crashed run never shows a stuck spinner; first-turn runs appear only while fresh, ordered before completed rows. FE: first-turn running reviews render a spinner row with the stage label ("Reviewing chunks · 7 of 12"), re-reviews get a warning pill on their normal row; the logic polls the list every 10s viacache.disposableswhile anything is in progress (auto-pauses on hidden tabs). Drawer adds a "Found by" scoreboard (surviving findings per source perspective, blind-spot skills collapsed to one "Blind spots" label) and each finding'sfile:linesdeep-links togithub.com/<repo>/blob/<head_sha>/<file>#L…(detail now returnshead_sha). Test-helper note:_reportstamps status IDLE for completed reports (model default ACTIVE reads as "running"). - Reviewer effectiveness chart (same day, iterated):
GET review/reviews/perspective_stats/(@action,ReviewPerspectiveStatsSerializer) aggregates the user's last 50 completed reviews' latest turns: persource_perspective→ raised / kept / dismissed. UI: THREE "Effectiveness" cards, one per section viaSingleActiveSection.preamble— Perspectives (per-perspective bars), Blind-spot check (per-sweep bars, each custom sweep its own row), Validation criteria (single "Your quality bar" bar, flipped headline "N of M dismissed" — the validator's job is filtering). Bars: greenbg-success= kept, muted = dismissed, 2px gap, widths share one scale (global max raised); tooltip with exact counts; legend. Card headers are an uppercasetext-xxsOVERLINE ("EFFECTIVENESS"), deliberately distinct from skill-card titles — user feedback: the stats block must not masquerade as another skill card. Hidden until data exists. In-progress row label format: "Step k/4 · · NN%" (stages fetching/chunking/reviewing/validating numbered 1-4).
✅ BUILT 2026-07-15 — "For you / Entire project" scope on the recent-reviews block
Mirrors the Inbox scope switch: the block can now list every review on the project, not just the requesting user's.
GET review/reviews/?scope=mine|everyone(ReviewsListParamsSerializer, defaultmine):everyonedrops theacting_userfilter; everything else (running-first ordering, 5-row cap, DB-side enrichment) is unchanged. Bad values 400 viais_valid(raise_exception=True). ThescopeChoiceField's enum stays inline in the OpenAPI query parameter, so noScopeEnumcomponent collision (verified with--fail-on-warn).retrieveis now project-wide (queries withSCOPE_EVERYONEinternally) so any listed review opens; another team's report id still 404s (for_team), garbage ids still 404.perspective_statsstays personal — it describes your reviewers' effectiveness.- Index:
(team, -last_run_at)review_rpt_team_recent_idx(migration 0016,SafeAddIndexConcurrently) serves the everyone-scope completed slice; the mine slice keeps the 0011(team, acting_user, -last_run_at)index. - UI/logic:
SegmentedButton"For you / Entire project" on the section header;reviewsScope+hasUserChosenReviewsScopepersisted reducers inreviewSettingsLogic; auto-default — an empty first mine-scope load flips to Entire project viaapplyDefaultReviewsScope, which is not marked as a user choice so a later explicit pick wins (both directions jest-tested); an explicit pick is mirrored to?reviews_scope=— the auto-default never writes the URL, since hydrating from a link counts as an explicit choice and would make the fallback permanent; the loader takes abreakpoint()after the fetch so a scope flip mid-flight drops the stale response; rows showby <pr_author>in everyone scope only; an empty mine-scope shows an empty state instead of hiding the section (the toggle must stay reachable) — loaded-and-empty hides the section only in everyone scope. - Tests: everyone-scope list (teammate rows in, cross-team rows out, bad scope 400s as the params-serializer wiring guard); retrieve contract flip (teammate's review 200, other team's 404); 2 jest tests for the auto-default rule.
✅ BUILT 2026-07-16 — "Show more / Show fewer" pagination on the recent-reviews list
The 5-row cap became the first page: the list grows by a page per "Show more" click and collapses back in one click, on both scopes.
- Growing
limit, not offset pages: in-progress rows jump to the front and drop out between the 10s polls, so offset pagination would shift page boundaries under the reader.GET reviews/?limit=N(default 5, min 1, max 100 — enrichment is per-row work) caps all three merge slices; the response is now a page envelope{results, has_more}(ReviewRecentReviewsPageSerializer), withhas_morecomputed server-side by probinglimit + 1rows — no dead "Show more" click when the count is exactly the limit. _PageEnvelopeSchema(AutoSchema subclass): drf-spectacular's_is_list_viewheuristic wraps anylistaction's response in an array, which generatedPromise<ReviewRecentReviewsPageApi[]>— forced to False, and the operationId pinned back to*_list(forcing the heuristic renames the operation*_retrieve, colliding with the real retrieve). The two prior overrides in the codebase (event_filter_config, access_control property state) are singletons without a sibling retrieve, so they never hit the collision.- Logic: loader holds the envelope (
recentReviewsPage);recentReviews/moreReviewsAvailableare selectors over it.reviewsLimitreducer: +5 pershowMoreReviews, reset to 5 onshowFewerReviewsand on any scope change (a different scope is a different list, user decision). "Show fewer" collapses instantly from already-loaded rows (reducer slices,has_moreforced true when rows were hidden) while its listener refetches to reconcile — the refetch also breakpoint-drops any wider in-flight poll response that would resurrect the collapsed rows.reviewsExpandingdrives the "Show more" button's loading state (the loader's ownloadingwould flash on every 10s poll);Button.loadinghard-disables, so it's also the double-click guard. Growth clamps atMAX_REVIEWS_LIMIT(100, mirroring reviews.py) andmoreReviewsAvailablegoes false at the ceiling even whilehas_more— otherwise the 20th click sendslimit=105, the API 400s, and the generic failure path strands the page in the settings-failed banner (PR review finding, fixed same day). The limit is deliberately not mirrored to the URL — ephemeral view state, unlike scope. - UI: centered tertiary "Show more" / "Show fewer" footer row inside the card (divide-y gives the hairline);
"Show fewer" only when more than a page is showing, "Show more" only when
has_more. Copy is "Show fewer" (countable rows), per user's "whatever is grammatically correct". - Tests: BE — envelope + limit growth + has_more boundary at exact count + out-of-range 400s (wiring guard);
jest — grow/collapse cycle (instant collapse, has_more preserved, scope flip resets the limit).
Full suite: 503 backend + 3 jest green;
insightscli build:openapiregenerated cleanly (list function keeps itsreviewReviewsListname).
✅ BUILT 2026-07-17 — page-level scope: the stats follow the "For you / Entire project" switch
Supersedes the 2026-07-15 note that "perspective_stats stays personal": the split turned out to be
the confusing part — flipping the list to Entire project left the hero and effectiveness cards
silently personal, so the page showed one scope's list over another scope's numbers. One page-level
switch now governs both. User decisions (grilled 2026-07-17): scope stays project (team), not
org-wide; the switch moved to the top of the page (hero overline row), the Recent reviews
section lost its local copy; project-scope effectiveness rows show every skill that raised
findings (incl. teammates' customs — their names already surfaced via the everyone-scope detail
drawer, and hero totals must equal the sum of rows); all four stat surfaces flip (hero proof
card, Perspectives, Blind-spot, Validation criteria). Skill lists and toggles stay per-user — maybe
skills follow later, deliberately not in this iteration.
- BE:
GET reviews/perspective_stats/?scope=mine|everyone(PerspectiveStatsParamsSerializer, defaultmine) —everyonereuses the list's_reportsscope plumbing; aggregation unchanged. - Logic:
loadPerspectiveStatsthreadsvalues.reviewsScope+ takes abreakpoint(); the scope listeners reload stats alongside the list, andperspectiveStats → null/recentReviewsPage → nullreducers on scope change drop the old data synchronously so the page skeletons consistently instead of showing the wrong scope's stats or rows (the list half was a PR-review finding: a failed reload would have stranded the other scope's rows on screen). PersistedreviewsScope/ URL?reviews_scope=/ auto-default semantics untouched. - UI:
PageScopeSwitchin the hero overline row; scope-aware copy — "findings worth the team's time" / "This project's last N reviews" / validator card reads "Validation · dismissed by validation" on project scope (the aggregate spans every author's active validator, so "your quality bar" would lie). - Tests: BE — everyone-scope aggregation folds a teammate's report in + bad scope 400s (params-serializer wiring guard), extended into the existing stats test; jest — scope flip rescopes the stats request and nulls the stale value.
✅ BUILT 2026-07-02 — authoring guide moved to a canonical skill (review-script-authoring)
The "Create your own …" frontend prompts were fat, self-describing instruction sets — an
anti-pattern (knowledge duplicated in FE strings, editable only via deploy, undiscoverable to other
agents). Reworked to the scout pattern (authoring-scouts): the FE prompts in
reviewSettingsLogic.ts are now thin kickoff pointers ("use the review-script-authoring skill from
the Insights MCP … follow its path", plus a skill-unavailable fallback), and the actual guide
is the canonical review-script-authoring skill at products/review/skills/review-script-authoring/.
- The skill covers all three kinds in one guide: pipeline context, the naming/category contract,
ground-first flow (
skill-list/skill-getthe canonicals), per-kind body-shape guidance, and — matching scouts — instructs the agent to create the row itself viainsights:skill-create(iterate withskill-update; neverskill-duplicatea canonical — seeded metadata rides along and the sync may overwrite/prune the copy). Activation stays a human step in the tab (per-user enablement; the config API is INTERNAL-scoped, deliberately not agent-writable — the one divergence from scouts'scout-config-create). - Seeding:
REVIEW_FN_AUTHORING_PREFIX/_SKILL_NAME(skill_loader.py),discover_canonical_authoring+sync_canonical_authoring(lazy_seed.py— same prefix-and-category reconcile), synced in two moments: the run path (_sync_review_skills, prune=True) and — because the guide must exist before any review has run — the settings GET (_seed_authoring_skillinapi/settings.py, tolerant, effective-team id), the Code review tab's always-called endpoint. Not a run skill: noReviewSkillConfigrows, no loader. - Tests:
test_authoring_skill.py(discover name-contract guard + run-path seed) and a settings-GET idempotent-seed case intest_settings_api.py.
✅ Stage 1 — mergeability + docs (current)
- Merged
origin/master(6 conflicts, all in shared infra — resolved, staged, not committed):products/tasks/backend/services/{sandbox,docker_sandbox,modal_sandbox}.pyand.../temporal/process_task/activities/get_sandbox_for_repository.pytook master's versions (the branch'sbranch-awareclone_repositoryis superseded — master refactored the sandbox into an abstract base class and now checks out the PR branch via agit fetch … && git checkout -B … FETCH_HEADblock inget_sandbox_for_repository.py, driven byctx.branch).pyproject.tomltook master + re-addedpygithub==2.7.0(Review needed it at the time; master had dropped it);uv.lockrelocked withuv lock. Since removed (2026-07-08): GitHub I/O now goes through the egress transport — see the BUILT entry above. - Rewired the sandbox runner integration (this was the "won't run end-to-end" breakage): master deleted
custom_prompt_runner.py+custom_prompt_executor.pyand replaced them withcustom_prompt_internals.pycustom_prompt_multi_turn_runner.py.sandbox/executor.pynow usesMultiTurnSession.start_raw(...)(single-turn:start_raw+session.end()) and importsCustomPromptSandboxContext+extract_json_from_textfromcustom_prompt_internals. Theresolve_sandbox_context_for_local_devhelper (not on master's import path at the time) is inlined intoexecutor.py— later re-exposed via the Tasks facade, see Stage 1.5. The_run_promptseam returns just the agent's final message — it does not re-read the S3 log (the runner already readstask_run.log_urlinternally; the old local_logs.txtartifact was dropped as a redundant second read). Imports cleanly under Django;tests/test_executor.pypasses (7/7); lint clean. Note: this is still single-turn-per-call — Stage 2 replaces it (below).
- Replaced the stale
AGENTS.md(it referenced asandbox/runner.pythat never existed) with thisARCHITECTURE.md, modeled onproducts/signals/ARCHITECTURE.md.
✅ Stage 1.5 — re-merge with master: Tasks moved behind a facade
A later origin/master merge (commit adc5cbe79b6, "feat(tasks): isolate behind a facade with
contracts") made products/tasks an isolated product and relocated the custom-prompt agent
machinery from products/tasks/backend/services/ to products/tasks/backend/logic/services/, exposing it
through a facade at products/tasks/backend/facade/agents.py. The sole merge conflict
(products/tasks/backend/temporal/client.py) kept both newly-added params — master's prewarmed and
the branch's workflow_id_prefix (the merged function bodies already referenced both). Review was
re-pointed accordingly:
sandbox/executor.pyandtests/test_executor.pynow importMultiTurnSession,CustomPromptSandboxContext, andextract_json_from_textfromproducts.tasks.backend.facade.agents— the only sanctioned cross-product path now that Tasks is isolated.tach check --dependencies --interfacesenforces it; importing thelogic/servicesinternals directly would fail the boundary check.- The facade also re-exports
resolve_sandbox_context_for_local_dev, so the executor's inlined_resolve_context_for_local_devis now redundant — Stage 2 can drop it and call the facade helper. tests/test_run.pygained the missingpublish_reviewmock. Its absence was a pre-existing branch gap, not a merge effect: a later branch commit wired realpublish_reviewintomain()without updating the integration fixture, so 6 tests hit the real publish and failed on a missingpr_files.jsonl. With the mock added, the full reviewer suite is green (119 passed); the touched files lint clean andtach checkpasses.
⏭️ What's next — Stage 2 (START HERE on "continue")
✅ Landed so far (subset of Stage 2): the review now runs the three perspectives in parallel per chunk with no cross-perspective context (
review_chunks→asyncio.gatherover(perspective × chunk);load_previous_pass_results/PassContext/ thePREVIOUS_PASSES_CONTEXTprompt block are deleted); the dedupe is hardened with a deterministic positional pre-filter (_select_dedup_candidates— only file+line colliders reach the LLM, and a zero-candidate run skips the LLM call); and each issue carries asource_perspectiveattribution (stamped bycombine_issues). Still TODO in Stage 2: conditional chunking (the chunk gate), the per-chunk batched validator, and explicit--team-id/--user-id/--repositoryconfig. (TheMultiTurnSession.start(model=)migration below is also still pending — the executor remains onstart_raw.)
Goal (agreed with the maintainer): restructure the review into parallel, isolated specialist reviewers — for every
(chunk × specialty)spawn its own single-turn sandbox session via theMultiTurnSessionAPI — plus conditional chunking, a per-chunk batched validator, and explicit team/user/repo config in place of today's hardcoded-context scaffolding.
Decided design principles (do not re-litigate):
- Isolation over reuse. Every LLM call is its own fresh sandbox session:
MultiTurnSession.start(prompt, context, model=Shape)thenawait session.end(). A single-turn session is intended and fine — clean isolation, no cross-talk between reviewers/chunks. We are not sharing a warm clone across steps, and the higher sandbox count is an accepted tradeoff for isolation. - Specialists run in parallel with no shared context. The three reviewers (Logic & Correctness,
Contracts & Security, Performance & Reliability) run concurrently per chunk. Today's sequential
passes and their forward-context plumbing (
load_previous_pass_results/PassContext/PREVIOUS_PASSES_CONTEXT) are removed — overlap is handled by the dedupe step, not by chaining passes. start(model=)does the parsing. It runsextract_json_from_text+model_validateinternally, so the executor stops doing manual JSON extraction.
Target pipeline:
- Fetch PR data (GitHub API) — unchanged.
- Chunk gate → chunk only if needed. (Superseded — now BUILT, see "✅ BUILT 2026-06-29 — size-aware
chunk gate" below: the final gate is additions-only (1000 as built; split into
SINGLE_CHUNK_GATE_ADDITIONS=400on 2026-07-02 — see that section's note), no file-count gate, and the chunker prompt was rewritten for semantic, uncapped-count, size-targeted chunks. The original ~8-files / ~400-linesMAX_\*sketch never landed.) Chunk whenchanged_files > MAX_FILES_BEFORE_CHUNKINGORchanged_lines > MAX_LINES_BEFORE_CHUNKING. Below the gate, treat the whole PR as a single chunk and skip the chunker agent entirely. Above it, run the existing meaning/area chunker (sandbox). - Per-chunk analysis (KEEP) — one isolated analysis sandbox per chunk; its
goaltext is injected into that chunk's reviewers (analysis finishes before the chunk's reviewers start). - Parallel specialist review — for each
(chunk × specialty)spawn an isolated single-turn sandbox (≈3 × num_chunks, all concurrent, bounded by the semaphore). Each reviewer gets the chunk's files + diff@path#L…code-context refs + the chunk analysis + its specialty focus. No cross-specialty / cross-chunk context.
- Combine all findings (local).
- Scope-clean (KEEP, local) — drop findings off the PR's changed lines.
- Dedupe (sandbox) — across all chunks/specialties (and vs prior bot comments). This is what absorbs the overlap from running specialists in parallel.
- Validate — one agent per chunk (KEEP, simplified). Group the surviving deduped in-scope issues by chunk
and send all of a chunk's issues in one sandbox call that returns a per-issue valid/invalid verdict
(
O(chunks)calls, notO(issues)). Keeps each chunk's code context for accuracy. - Build report (markdown, local).
- Publish (GitHub API).
Concrete changes vs current code:
tools/issues_review.py: replace the 3 sequential passes with a single parallel fan-out over(chunk × specialty). Deleteload_previous_pass_results,PassContext, thePREVIOUS_PASSES_CONTEXTprompt block. Keep the threeprompts/issues_review/pass_contexts/pass{1,2,3}_focus.jinjaas the specialist focuses.run.py(or the chunking tool): add the chunk gate; putMAX_FILES_BEFORE_CHUNKING/MAX_LINES_BEFORE_CHUNKINGinconstants.py.tools/issue_validation.py: rewrite from per-issue to per-chunk batched (one call, list-in / list-out). Update the schema to a list of{id, is_valid, argumentation, category}. This also retires the "neutered parallelism" bug.sandbox/executor.py: switchrun_sandbox_reviewtoMultiTurnSession.start(prompt, context, model=…)+end()(dropstart_raw+ the manualextract_json_from_text/model_validate). It stays as the single-turn isolated-call helper.- Config: delete
_resolve_context,_resolve_context_for_local_dev, and_CLOUD_TEAM_ID/_CLOUD_USER_ID/_CLOUD_REPOSITORY/_LOCAL_REPOSITORY. Add--team-id/--user-id/--repositorytorun_review(or settings) and thread themrun.py→ executor. The sandbox repo to clone is a real input, not aDEBUG-switched default. (This is the direct answer to "why do we need_resolve_context_for_local_dev" — we don't, once ids are explicit.)
Helpers — drop vs keep:
- Drop:
_resolve_context*, the hardcoded id/repo constants, the executor's directextract_json_from_textimport, and the sequential-pass context machinery (load_previous_pass_results/PassContext). - Keep:
sandbox/code_context.py(@path#L…refs),run_sandbox_review(simplified tostart(model=)), the three specialist focus templates, scope-cleaning, combine, dedupe, markdown, publish.
Read these first (reference implementations): products/tasks/backend/logic/services/mts_example/runner.py
(canonical MultiTurnSession.start(model=) + end()), and
products/tasks/backend/logic/services/custom_prompt_multi_turn_runner.py (start(model=) returns a validated
model; start_raw for raw text). products/signals/backend/report_generation/research.py shows the
production pattern — it's multi-turn; here we use the single-turn subset.
Acceptance: the 3 specialists run in parallel (no load_previous_pass_results); small PRs skip the
chunker (gate works); validation is one call per chunk; executor.py uses start(model=) and no longer
calls extract_json_from_text; no _resolve_context* / hardcoded ids remain and run_review takes explicit
team/user/repository; tests updated & green; ruff check products/review/ clean.
Out of scope for Stage 2 (later stages): productize beyond the CLI (Temporal parent workflow / API trigger
/ Postgres run state — run.py carries the TODO: Make it a parent workflow…); the remaining
Known issues; product isolation (contracts + facade). Durable Postgres run
state + cloud persistence is its own effort — now Stage 3 below.
🔭 Stage 3 — durable persistence & the loop-y review (cloud)
Status: Stage 3 complete (steps 1–14 built & green; 15–16 remaining). Foundation, persist-after-success, explicit team/user identity, the per-turn point-in-time diff snapshot, step 8 — Postgres is the single source of truth and the on-disk
reviews/<pr>/store is gone, and step 9 —reset_review(DEBUG-only full wipe). The pipeline passes objects in-process within a run and persists every stage to rows; ahead_sha-scoped DB-driven resume reuses the turn-stable sandbox stages (chunk / analyze / perspective review) on a re-run. The sandbox executor returns a validated model (viaMultiTurnSession.start(model=…)) instead of writing a file, and publish is DB-driven (body fromReviewReport.report_markdown, inline comments from the finding/verdict rows). No object storage. Step 10 — perspectives as LLMA skills is now built: the review "lens" is renamed perspective throughout, and the three jinja focus templates are gone — each perspective is a DB-syncedLLMSkill(products/review/skills/review-script-perspective-*/SKILL.md, the Signals-scout canonical-skill pattern) that the sandbox agent pulls over MCP viaskill-get; the three couple into one orderedPERSPECTIVESregistry. Lint + tach + the Review backend suite (138) + the Signals artefact suite pass. Step 11 — prompt iteration is now built (A-lite): all three review prompts (chunking, chunk-analysis, issues-review) are perspective-agnostic (the perspective's identity + focus live entirely in the pulled skill) and inject only what the agent can't self-derive — the per-chunk change-set plus the PR's title + description as intent; the fullPRMetadatadump is gone. A lead-in tells the agent the repo is checked out (read files freely) but shallow + single-branch (no base ref / history → don'tgit diff). The reviewed-commithead_shapin was deliberately deferred to the Temporal step, where it's independently load-bearing. Step 12 — pre-Temporal cleanup is done: there were no duplicate model definitions to collapse (the premise was wrong —tools/github_meta.pyimports the models frommodels/). The real smell was an inconsistent import path (four consumers reached thegithub_metamodels throughtools/instead ofmodels/); fixed that, folded five copy-pasted prompt-asset loaders + thepr_intentone-liner + the per-chunk context block into a sharedtools/prompt_helpers.py, and removed two dead-code spots (an unreachablei += 1; a never-emitted"modification"change-type branch). Step 13 — validator-as-skills is now built: the validation keep/drop criteria are a team-pulledreview-script-validation-criteriaskill (theissue_validationprompt is criteria-agnostic); the step-10 sync was generalized (onereviewcategory for both review skill sets → a single "Code review" Skills-UI tab; command renamedsync_review_skills); e2e ✅ on #63625. Step 14 — dedup cleanup is now built (Variant A: prior-reviewer dedup generalized off the hardcoded bot to all prior inline comments, any author, treated uniformly; aggressive + uniform). What remains: (15) the Temporal migration —run.py main()→ a single-turnReviewPRWorkflowwith the fan-out stages as child workflows, landed as a single change (decided 2026-06-25); the reviewed-head_shacheckout pin is split to conditional step 16. The loop-y re-check, cross-turn lifecycle (resolve/update), and thetask_run/notework-log artefacts are deferred to a follow-up after the single-turn workflow lands. The full Temporal conventions + integration map (file:line cites) is captured in Everything on Temporal below. See the step list and the Deferred / future section below.
Why. Today every run writes Pydantic-serialized JSON/MD to a gitignored reviews/<pr_number>/ tree — no
DB, no team_id, no run identity (a "run" is just the PR-number directory). That blocks two things: running
in the cloud, and the intended loop-y behavior — after the first pass Review should re-check the PR for
new commits and new comments (from humans or other bots) and take another turn, repeating until nothing
significant has changed. A PR review is therefore a living document, not a one-shot job — which is exactly
the shape Signals' report/artefact store was built for, so we reuse that design.
Decided design (final — implement, don't re-litigate). Settled across a research pass; the placement, the "not a SignalReport", and the "reuse the leaf, own the model" calls are made — build them.
Placement & boundaries
- Review stays a top-level peer product (
products/review/). It is not nested underproducts/signals/— the repo has zero precedent for a product inside another, and the exact precedent for "an agentic product that feeds Signals" isproducts/replay_vision/, a sibling that emits findings through Signals' facade. Review is the same shape. - A PR review is NOT a
SignalReport. SignalReport's lifecycle (Datastore embeddings → similarity grouping →total_weightaccrual →signals_at_runpromotion gate → autonomy auto-start) answers "is this worth acting on, and which group does it join?" — a PR answers both by its identity(repo, pr_number). Modeling reviews as SignalReports would mean faking embeddings/weight, defeating the promotion gate, and polluting the Status enum. So: separate parent entities, shared substrate only. - Reuse path = "peer + reuse the leaf" (chosen over extracting a shared abstract base). Import mechanics
decided it: Signals'
artefact_schemas.pyis dependency-light (pydantic + one tasks-facade DTO,RepoSelectionResult— no Django/core/temporal), so Review imports the content models from it cheaply. The Django artefact model (funnel + fields + atasks.TaskFK) is the entangled part — hoisting it into core would invert the dependency (core→product FK) and force re-parenting migrations, and the repo rule is nest-then-promote (don't pre-build shared infra). So Review reuses the leaf models and owns its own model; a shared abstract base is deferred (see below).- Correction from the build: the registry helpers
artefact_type_for/parse_artefact_contentare not reusable — they close over Signals' module-globalARTEFACT_CONTENT_SCHEMASand take no registry argument, so they can't see Review's types. Review therefore defines its own ~6-line copies over its own registry (reviewer/artefact_content.py). Only the content models +ArtefactContentValidationErrorare imported from the leaf. (Cleaner anyway — zero shared mutable state.)
- Correction from the build: the registry helpers
- Signals stays untouched at the table & behavior level. So far the only Signals change is a pure code
move:
ArtefactAttributionwas relocated fromproducts/signals/backend/models.pyinto a new zero-dependency leafproducts/signals/backend/artefact_attribution.py(stdlib only — leaner thanartefact_schemas.py, which drags the tasks facade) and re-exported frommodels.pyfor the 9 existing importers — no migration, no table change. Review imports attribution from that module with no transitive weight. Allowed exception (step 7): a Signals-schema edit is permitted only when it is a minor, additive, optional field that Signals never populates and that cannot change Signals behavior — e.g. the optionaldiff: str | None = NoneonCommit(defaultNone; old rows still parse). Anything beyond that shape stays off-limits.
What Review reuses vs owns
- Reuse directly (shared infra, already legal):
MultiTurnSessionvia the Tasks facade (products.tasks.backend.facade.agents, already imported byexecutor.py);GitHubIntegration.get_diff/first_for_team_repository(the latter for cloud auth;get_diffonly for the next turn's current diff). - Reuse from Signals' leaf (
products.signals.backend.artefact_schemas): the content modelsCommit,CodeReference,TaskRunArtefact,NoteArtefact, plusArtefactContentValidationError(andArtefactAttributionfrom the newartefact_attributionleaf, after the move above). Not the registry helpersartefact_type_for/parse_artefact_content— they close over Signals' module-global registry, so Review defines its own (see the correction above). - Review owns:
ReviewReport+ReviewReportArtefact(own tables + funnel mirroringSignalReportArtefact) and its product-specific content schemas (ReviewIssueFinding,ValidationVerdict). The per-turn diff snapshot (step 7) reuses thecommitartefact via a minor optionaldifffield on Signals'Commit— not a new owned type.
Data model (products/review/backend/models.py)
Both models are fail-closed team-scoped (CLAUDE.md IDOR rule) using the proven base order
class X(UUIDModel, TeamScopedRootMixin) — see products/wizard/backend/models.py::WizardSession and
products/mcp_analytics/backend/models.py::MCPSession. UUIDModel gives the UUID7 PK; TeamScopedRootMixin
gives the fail-closed TeamScopedManager + canonical-team save(); the subclass declares its own team FK.
ReviewReport(UUIDModel, TeamScopedRootMixin)— the living per-PR document:teamFK →insights.Team(CASCADE);repository(owner/repo);pr_number;pr_url;head_branch;base_branch.statusTextChoices —active/idle/closed(job/lifecycle state, not SignalReport's machine).run_count(default 0);last_run_at(null);created_at/updated_at.- Watermark:
head_sha+last_seen_comment_id— what a turn has already reviewed, so the loop knows what's new. - Rendered report markdown: inline
TextField(TOAST transparently compresses / out-of-lines large values;.defer()keeps it off hot reads — no object storage). - Unique on
(team, repository, pr_number)— one living report per PR; this is the idempotency key, so re-runs append turns rather than create a new report.
ReviewReportArtefact(UUIDModel, TeamScopedRootMixin)— the append-only work log, mirroringSignalReportArtefact:teamFK;reportFK →ReviewReport(CASCADE,related_name="artefacts");typeCharField(choices);contentTextField (JSON viamodel_dump_json());created_at;updated_at(null);created_byFK →insights.User(SET_NULL, null);taskFK →tasks.Task(SET_NULL, null).ArtefactType:issue_finding,validation_verdict,task_run,commit,code_reference,note.- Funnel (adapted from
SignalReportArtefact):_createderivestypefrom the content-model class viaartefact_type_for, serializescontent.model_dump_json(), and mapsArtefactAttribution→created_by_id/task_id; public appenders areappend_finding/append_verdict/add_log(no genericappend/status routing — Review has no status types). No Signals auto-start hook.- Fail-closed divergence: unlike
SignalReportArtefact(plainUUIDModel),ReviewReportArtefactis fail-closed, so_createwrites viacls.objects.for_team(team_id).create(...)— the cloud/Temporal orchestrator has no ambient team scope and the bare manager would raiseTeamScopeError.for_teamis the CLAUDE.md-blessed out-of-request write path;team_idis still passed explicitly (queryset filters don't propagate into row creation).
- Fail-closed divergence: unlike
- Registry + helpers: a Review-local
ARTEFACT_CONTENT_SCHEMASmapping its type strings → content models (reusedCommit/CodeReference/TaskRunArtefact/NoteArtefact+ ownReviewIssueFinding/ValidationVerdict), plus Review's ownartefact_type_for/parse_artefact_contentover that registry (the Signals helpers can't take a foreign registry — see the correction above). A test asserts the registry keys equal theArtefactTypeenum exactly. - Indexes mirroring Signals:
(report),(report, type),(report, type, -created_at)for latest-wins seeks. Index names are kept ≤30 chars (DjangoE034) andreview_*-prefixed to avoid colliding with Signals'.
Content schemas (products/review/backend/reviewer/artefact_content.py, pydantic):
ReviewIssueFinding—file,lines, anissue_key, arun_index,title,body,suggestion,priority,source_perspective,is_directly_related_to_changes. A verdict reuses its finding'sissue_keyso latest-wins perissue_keypairs them 1:1. The key isr{run_index}:file:start:perspective:{pass}-{chunk}-{issue}— therun_indexprefix makes it turn-unique (a later turn can't collide with an earlier one's key) and the trailing pipeline id keeps it unique within the turn. It's an occurrence id, not a cross-turn problem identity; recognizing the same problem across turns (for resolve/update) is the dedup's same-problem match, deferred to the loop.ValidationVerdict—issue_key,is_valid,category,argumentation(latest-wins per issue).- Reuse
Commit/CodeReference/TaskRunArtefact/NoteArtefactfrom the Signals leaf for the commit / code-pointer / turn / note entries.
Loop-y mapping: issue → issue_finding (latest-wins per issue_key); validation → validation_verdict
(latest-wins); a review turn → task_run (the sandbox Task, already created by MultiTurnSession);
triggering commits + the reviewed diff snapshot → a per-turn commit artefact (head-commit metadata
tagged with head_sha, plus the point-in-time diff in Commit's new optional diff field); triggering
comments → note entries.
Storage split — Postgres-first, point-in-time
Everything lives in Postgres; there is no object storage. Structured results (findings, verdicts, the rendered
report) are JSON/text in TextFields — Postgres TOAST transparently compresses and out-of-lines large values,
and .defer() / .only() keep those columns off hot reads. The reviewed diff snapshot is persisted, not
re-fetched on demand. A review is a point-in-time judgment: a finding's line numbers only make sense against
the code as of the reviewed commit. Re-fetching later returns the current code (wrong once new commits land),
and even re-fetching pinned at head_sha isn't durable — a force-push can orphan and GC that commit. So each
turn stores its own snapshot (the reviewed files' raw unified patch at that turn's head_sha, captured to
pr_diff.patch at fetch time) as an append-only per-turn commit artefact, the append gated on the
report's head_sha watermark so re-runs of an unchanged PR are idempotent. This is identical for a single
review (turn 1) and the loop (turn N appends its own snapshot only when the head moved), so the full per-turn
history is reconstructable regardless of later force-pushes. The snapshot rides on a per-turn commit
artefact: the reused Signals Commit schema gained one minor optional diff field (default None,
Signals-neutral — see step 7) to carry the point-in-time diff alongside the head-commit metadata. get_diff
re-fetch is reserved for the next turn's current diff — never for reconstructing a past turn. The data is moderate (tens–hundreds of KB per turn) and Postgres handles
it comfortably (the team-scoped worktree cache stores far larger blobs in Postgres successfully); object storage
is reserved for a blob that is both large and irreproducible, of which Review has none. There is no
on-disk store — step 8 made the DB authoritative for inter-stage state too; per-stage prompts/outputs are
either DB rows, in-process values within the run, or the S3 agent log (task_run.log_url).
Implementation steps (ordered)
-
✅ Signals leaf move (no migration):
ArtefactAttributionmoved to the new zero-depproducts/signals/backend/artefact_attribution.py, re-exported frommodels.py(the now-unuseddataclass/Literalimports were dropped frommodels.py). No behavior/table change; the re-export keeps all 9 importers working (verified by import smoke + green Signals suite). -
✅ tach: added the
[[modules]] path = "products.review"block withdepends_on = ["ee", "insights", "products.signals", "products.tasks"];tach check --dependencies --interfacesis clean. (Signals isn't interface-gated; the tasks-facade import inexecutor.pysatisfies thebackend.facade.*interface — no[[interfaces]]entry needed.) -
✅ Models: added
products/review/backend/models.py(ReviewReport+ReviewReportArtefact+ funnel) andreviewer/artefact_content.py(content schemas + local registry/helpers). Both fail-closed viaTeamScopedRootMixin. Tests inbackend/tests/test_models.py(5, green). -
✅ Migration:
0001_initialgenerated viamakemigrations review(two additiveCREATE TABLEs + indexes- the
(team, repository, pr_number)unique constraint);sqlmigrateclean, drift check clean,max_migration.txtpinned. Fail-closed by birth viaTeamScopedRootMixin(nothing added tobaseline_unmigrated.txt).
- the
-
✅ Persist-after-success: added
reviewer/persistence.py, wired intorun.py(the file-basedreviews/<pr>/tree stays as sandbox scratch). After fetch it resolves the team via the now-publicexecutor.resolve_sandbox_contextandupsert_review_report(idempotent on(team, repository, pr_number), viafor_teamsince the orchestrator is outside request context). After dedup it persists the canonicalissues_found.jsonasissue_findingartefacts; after validation, the verdicts asvalidation_verdicts; after the report builds,finalize_review_reportstores the markdown and bumpsrun_count/last_run_at. Each append batch runs in a narrowtransaction.atomic(); the async orchestrator calls the sync helpers viasync_to_async. Attribution issystem()— a combined/deduped finding is aggregated across many sandbox tasks, so no single task produced it. A shared_persistable_findingsgate means a verdict is only written for an issue that produced a finding (the finding schema is stricter), and the verdict reuses that finding'sissue_key. Deferred (data not yet plumbed): thetask_run/commit/notework-log artefacts and thehead_sha/last_seen_comment_idwatermark — they need per-call task ids, commit SHAs, and comment ids that the current pipeline doesn't surface; they land with the loop-y turn tracking. -
✅ team/user:
team_id/user_idare now required--team-id/--user-idCLI args onrun_review, threadedrun_review → main(pr_url, *, team_id, user_id). The hardcoded_CLOUD_TEAM_ID/_CLOUD_USER_ID,_resolve_context_for_local_dev, and thesettings.DEBUGbranch are deleted. The executor now holds a run-scopedcontextvars.ContextVaridentity:maincallsbind_sandbox_identity(team_id, user_id)once (it validates the team'skind="github"integration viaaexists(), then.set()s the identity in the orchestrator's task context, so theasyncio.gatherfan-out inherits it); every sandbox call builds its context via_sandbox_context_for(repository), which reads the identity. The 5 review tools are unchanged — they still thread onlyrepository. (The Temporal trigger will later supply the PR's author + their team; for now the CLI does.) -
✅ Point-in-time review snapshot (Postgres, per-turn). Each turn's reviewed diff is captured at review time, in the fetch boundary and persisted as a per-turn
commitartefact, so a finding stays anchored to the exact code reviewed even after later force-pushes (never re-fetched). Concretely:- Capture (fetch layer).
PRMetadatagainedhead_sha(pr.head.sha— the exact commit a review judges) andPRCommentgainedid(comment.id, for the comment watermark); both optional so a stale pre-snapshotpr_meta.json/pr_comments.jsonlstill parses.PRFetcher.fetch_pr_fileswrites the reviewed (filtered) files' raw unified patch (file.patch) to a dedicatedpr_diff.patch— deliberately not a field onPRFile, whichsplit_pr_into_chunksdumps wholesale into a prompt (PR_FILES=[x.model_dump_json() …]), so the raw patch never bloats the prompts. It is written beforepr_files.jsonl(the idempotency cache key) so an interrupted fetch re-runs fully next time rather than caching a snapshot-less state. - Schema. Signals'
Commitgained one optional, Signals-neutraldiff: str | None = Nonefield (defaultNone; the Signals pipeline never sets it, old rows still parse) to carry that patch. The fallback, if that ever became undesirable, is a Review-owneddiff_snapshottype — but reuse is preferred. - Persist (
persist_commit_snapshot, called inrun.pyright afterupsert_review_report). Appends thecommitartefact —commit_sha = head_sha,branch/messagefrom PR metadata,diff=pr_diff.patch,system()attribution — only whenhead_shadiffers from the report's watermark (a re-run with no new commits records nothing; a real new commit appends its own snapshot, never mutating earlier ones — exactly the loop's "new commits → new turn" trigger), advancing bothhead_shaandlast_seen_comment_idin the same transaction. Missinghead_shaskips cleanly. A genuinely missingpr_diff.patch(incomplete capture) defers — appends nothing and leaves the watermark unadvanced, so a later fresh run still captures that commit (advancing past it would lose the diff forever); a legitimately empty patch (all files filtered) records the commit withdiff=None.
Works for a single review (turn 1) and under looping (turn N appends). Stays in Postgres (
TextField+ TOAST); no object storage. The remaining per-turntask_run/notework-log artefacts are deferred to the loop — they need per-call task ids / comment-driven notes the pipeline doesn't surface yet. - Capture (fetch layer).
-
✅ Postgres is the single source of truth; the on-disk
reviews/<pr>/store is removed. The pipeline no longer writes any durable files — every piece of inter-stage state is a DB row or an in-process value passed within the run. What was built:- Three new working-state artefact types (
ReviewReportArtefact):chunk_set,chunk_analysis,perspective_result(renamed fromlens_resultin step 10), each carrying the turn'shead_sha. Their content schemas live inartefact_content.py(they embed the liveChunk/ChunkAnalysis/IssuesReviewpipeline models — per-turn scaffolding, not cross-turn-stable findings, so tracking the live shape is fine). A newadd_working_stateappender + theWORKING_STATE_ARTEFACT_TYPESset gate them, mirroringadd_log. Migration0002adds the enum values (state-only, no SQL). - DB-driven, head_sha-scoped resume (
persistence.py:persist_/load_chunk_set,…chunk_analyses,…perspective_results, with_load_working_statelatest-wins per key for the current head).split_pr_into_chunks/analyze_chunks/review_chunksnow taketeam_id/report_id/head_sha, check the rows first, run the sandbox only for missing items, persist, and return objects. A re-run on the same head reuses these turn-stable sandbox stages. Dedup and validation recompute on a re-run — their post-dedup issue set (and the per-issue ids) isn't stable across runs, so per-issue resume would be unreliable; this is a deliberate scope boundary (the costliest stage, validation, therefore re-runs — acceptable until the loop). - Fetch is in-process.
PRFetcher.fetch_pr_data()returns(pr_metadata, pr_comments, pr_files, diff)and writes nothing; the reviewed diff snapshot rides back as thediffstring and is persisted bypersist_commit_snapshot(…, diff=…)(the watermark guard is unchanged).pr_files_scope.jsonland its generator are deleted (no reader). Combine → scope-clean → dedup chain entirely in-process (list[Issue]). - Executor returns a model.
run_sandbox_review(...) -> Model | NoneviaMultiTurnSession.start(model=…)— nooutput_path, no_error.txt, no manualextract_json_from_text. This also closes the pending Stage-2start(model=)item; the session is ended on the success path andstartends its own session on failure. - Publish is DB-driven & unified. The body is
ReviewReport.report_markdown(rendered once in-process bybuild_review_bodyand stored at finalize); inline comments are rebuilt from the finding/verdict rows viaload_valid_findings(latest-wins perissue_key, valid-only), positioned against the current diff. The old second from-disk report rebuild is gone. A chunk whose (best-effort) analysis failed but that has a validated issue is shown in the body with a placeholder, so the body and the posted inline comments stay consistent. - Cleanup:
reviews/<pr>/is gone; the orphanedreviewer/utils/json_utils.py(+ emptyutils/) deleted.
Debuggability is the three durable, queryable, cross-worker stores the plan called for: the artefact rows (inter-stage + output state), the Temporal workflow history (later), and the S3 agent logs at
task_run.log_url(full prompt + conversation per sandbox call). Nothing on the orchestrator host is load-bearing — the natural companion to the Temporal migration (each stage an activity exchanging row ids by reference; see Cloud host, Temporal below).Known follow-ups (deferred to the loop):
load_valid_findingsis nowrun_index-scoped, so publish posts only the current turn's findings (no cross-turn accumulation). What's still unmodeled is supersession (resolve / still-open / newly-appeared) — the dedup already matches a finding to a prior comment each turn; the loop persists that match + the comment id to resolve/update, no semantic key needed. Thetask_run/notework-log artefacts and validation resume also land with the loop. - Three new working-state artefact types (
-
✅
reset_reviewmanagement command — wipe Review's DB state for a clean slate. A one-shotpython manage.py reset_reviewdeletes all Review rows across every team — everyReviewReportArtefact(findings, verdicts, commit snapshots, and thechunk_set/chunk_analysis/perspective_resultworking state) and everyReviewReport— so a re-run starts genuinely fresh instead of resuming or accumulating against stale turns. Now that Postgres is the single source of truth, this is the entire "clean state" story — there are no files left to remove. Local iteration helper only: it refuses to run unlessDEBUG=True(the only guard — a full wipe is intentionally non-prod), and is deliberately unscoped — no--team-id/--pr-url, it just clears everything.--dry-runpreviews the counts without touching the DB;--yesskips the interactive confirm for scripted runs. The wipe goes through the fail-closed managers' cross-team escape hatch (ReviewReport.objects.unscoped()/ReviewReportArtefact.objects.unscoped()), not raw SQL. (Tests inbackend/tests/test_reset_review.py: the DEBUG gate, the cross-team wipe, and--dry-runsafety.) -
✅ Perspectives as LLMA skills — rename "lens" → "perspective" + DB-synced skill pipeline (before Temporal). The three review "lenses" (Logic & Correctness / Contracts & Security / Performance & Reliability) are renamed perspectives — "lens" collided with Replay Vision, which already abandoned it (migration
0006_rename_lens_to_scanner; "scanner" is taken too). The rename touched the model (PassType→PerspectiveType,Issue.source_lens→source_perspective), the durable artefact (LensResultArtefact→PerspectiveResultArtefact, thelens_result→perspective_resultchoice via migration0003), andpersist_/load_lens_results→…_perspective_results. The static jinja focus templates (prompts/issues_review/pass_contexts/pass{1,2,3}_focus.jinja, now deleted) became first-class LLMA skills atproducts/review/skills/review-script-perspective-*/SKILL.md, synced into per-teamLLMSkillrows byreviewer/lazy_seed.sync_canonical_perspectives(seeded_by="review",category="review_perspective") — driven by thesync_review_skillscommand and a cold-start sync at run start. Delivery flipped to pull:issues_review/prompt.jinjainstructs the agent toskill-get(review-script-perspective-…, version=N)over MCP (the sandbox's defaultfullscope carriesllm_skill:read). The three couple into one orderedPERSPECTIVESregistry (reviewer/skill_loader.py) the Temporal fan-out will iterate. Per-team custom perspectives stay a later iteration. The perspectives surface in the Skills UI under a Review perspectives tab (category="review_perspective"→SKILL_CATEGORY_TABSinproducts/skills/frontend/llmSkillsLogic.ts+ the/skills/perspectivesroute inproducts/skills/manifest.tsx; mirrors the Signals "Scouts" tab — shows only when the team has ≥1 perspective row). Superseded by step 13: perspectives + validation now share the unifiedreviewcategory under one "Code review" tab (/skills/review-script); the perspective rows were re-tagged fromreview_perspectiveby the sync's category self-heal. Full design in Perspectives as LLMA skills under Deferred / future below. -
✅ Iterate on the review prompt — perspective-agnostic base + leaner context (A-lite). Both coupled changes shipped across all three review prompts (chunking, chunk-analysis, issues-review): (a) the prompts are perspective-agnostic —
PASS_NAME/PASS_NUMBERare gone from the prose and the renderers; the only perspective-specific input is theskill-get(name, version)line, and the skill owns the perspective's identity + focus (forward-compat for per-team custom perspectives). (b) the injected context is trimmed to what the agent can't self-derive: the per-chunk change-set (the diff — the only source of removed lines, which aren't in the working tree) plus the PR's title + description as intent (<pr_intent>); the fullPRMetadatadump — process state, identity (bias), timestamps, size counts, and git refs/SHAs the shallow clone can't act on — is dropped. A<pr_file_changes_for_chunk>lead-in tells the agent the repo is checked out (read files freely + grep) but shallow + single-branch (no base ref, no history → rely on the injected change-set, don'tgit diff/git log). Option B (agent self-derives the diff) was ruled out: the depth-1 single-branch checkout gives no base ref and no history, so the agent can't derive a diff — B would need a deeper-clone infra change strictly larger than thehead_shapin that was already declined. Thehead_shapin is deferred to step 16 (its own conditional change), where pinning the reviewed commit is independently load-bearing (by-reference activities + the loop both need it); until then the injected change-set is the authoritative record and branch-tip drift stays the same rare edge case as today. Data-hygiene audit (done): A-lite changed prompt construction only — no persisted artefact (commitdiff snapshot,chunk_set/chunk_analysis/perspective_result, findings / verdicts) carries new or now-excessive data; the one hygiene finding (the duplicategithub_metamodules) became step 12. Full as-built record in Prompt iteration under Deferred / future below. -
✅ (done) Pre-Temporal cleanup — clear duplications + dead code. The roadmap premise here was wrong:
reviewer/tools/github_meta.pydoes not redefine the models — itimportsPRMetadata/PRComment/PRFile/PRFileUpdatefromreviewer/models/github_meta.py(their single home) and only addsPRFetcher/PRParser/PRFilter. The real smell was an inconsistent import path: four consumers (split_pr_into_chunks,sandbox/code_context,tests/conftest,tests/test_split_pr_into_chunks) reached the models throughtools.github_metainstead ofmodels.github_meta. As-built: (a) re-pointed those four tomodels.github_meta(run.py/test_github_metastay ontools— they use the IO classes); (b) folded the five copy-pasted prompt-asset loaders (chunk-analysis / issues-review / validation / dedup / chunking), the thrice-inlinedpr_intentone-liner, and the duplicated per-chunk context block into a sharedtools/prompt_helpers.py(load_template_and_schema/format_pr_intent/build_chunk_prompt_context); (c) removed two dead-code spots — an unreachablei += 1aftercontinueinparse_patch, and the never-emitted"modification"branch inissue_cleaner. Behavior-preserving (the chunking loader's template-failure now raisesFileNotFoundErrorlike the others — the caller already caught both; one test assertion updated to the unified contract). 138 tests green, ruff + tach clean. Full record in Pre-Temporal cleanup under Deferred / future below. -
✅ (done) Make the validator a team-customizable skill. Move the validation stage's keep/drop criteria out of
prompts/issue_validation/*into a DB-synced LLMA skill the validator agent pulls (the step-10 pattern), so the bar for "this issue matters" is owned by the team, not the prompt — different teams care about different things. The prompt keeps the I/O contract (deduped issues in → per-issue verdict out) and theskill-getline; the skill owns "what matters". The default criteria keep real correctness / security / data-loss / contract / performance issues that plausibly affect users and drop overengineering, speculative "what if", defensive paranoia, never-gonna-happen edge cases, and pure style (bias to drop when uncertain — a noisy reviewer gets ignored). A team retunes the bar by editing its row, no code change. As built: the criteria live in one canonicalreview-script-validation-criteriaSKILL.md, pulled viaskill-get; the step-10 sync was generalized (onereviewcategory for both skill sets, command renamedsync_review_skills); a single "Code review" Skills-UI tab covers perspectives + validation. 221 backend tests + ruff + tach green; e2e ✅ on #63625 (9/9 stages; the agent confirmed-pulled the criteria skill viaskill-get[success:true] and dropped an "unreachable" finding the criteria-less baseline kept — seeeval/RUN_LOG.md). Full design in Validator as a team-customizable skill under Deferred / future below. -
✅ Dedup cleanup — prior-reviewer dedup generalized off the hardcoded bot (Variant A). The dedup stage used to recognize one competing reviewer (
_PRIOR_REVIEWER_BOT = "greptile-apps[bot]"), missing CodeRabbit / Copilot / a team's own bots. As built (2026-06-25): dropped the hardcoded handle and the_previous_bot_issuesauthor filter —deduplicate_issuesnow feeds every prior inline comment, with its author handle (already serialized onPRComment.user), to both the deterministic positional gate (_select_dedup_candidates, which already only sees comments resolving to a file+line, so top-level chatter is excluded) and the LLM input (the prompt var renamedPREVIOUS_ISSUES_JSON→PRIOR_COMMENTS_JSON, block<previous_issues>→<prior_comments>). User decision (2026-06-25): aggressive + uniform — keep collapsing hard, but treat all authors the same (no ours-vs-theirs branching; the author is context, not a rule). The rewritten prompt (and the realigned_SYSTEM_PROMPT, which used to say "be conservative") anchors "duplicate" on same concrete problem, not bare co-location — so a useful finding isn't dropped just for sitting near an unrelated comment, while genuine same-problem findings still collapse to the single most comprehensive one. Self-dedup stays uniform (our own prior comment is just another comment → don't re-post it); the richer cross-turn finding lifecycle (resolve / update our own findings, matched against the DB findings not the comment text) is the loop's job, deferred with the loop. Also tightened theIssueDeduplicationmodel/schema description (no "explanations" field exists). 221 backend tests (incl. a regression test that a prior comment from a non-privileged author still drops a colliding finding — guarding against re-introducing a single-reviewer filter) + ruff + tach green. e2e: only bites when the reviewed PR carries prior inline comments from other reviewers, so a fresh e2e shows nothing on a comment-free PR — verify opportunistically. Full design in Dedup cleanup under Deferred / future below. -
✅ (after the dedup cleanup) Made the pipeline Temporal — single-turn workflow, landed as one change (2026-06-25; 201 backend tests + ruff + tach green; e2e ✅ #63625 ran end-to-end through Temporal, exit 0 — workflow executed in the worker,
pr_snapshotreload + resume confirmed, behavior-preserving).run.py main()is now a single-turnReviewPRWorkflowparent (backend/temporal/workflow.py) orchestrating:validate-integration→fetch→sync-skills→schema-gen→split-chunksactivities, then the three fan-out stagesAnalyzeChunksWorkflow/ReviewPerspectivesWorkflow/ValidateIssuesWorkflow(child workflows, kebab namesreview-pr/review-analyze-chunks/review-perspectives/review-validate-issues), thencombine+clean→dedup+persist-findings→build-body+finalize→ gatedpublishactivities. As built / decisions that landed: - Everything by reference via a newpr_snapshotworking-state artefact ({head_sha, pr_metadata, pr_comments, pr_files}, migration0004) — the fetch activity persists it once; every stage activity reloads its inputs from the DB by(report_id, head_sha). So onlyreport_id+head_sha+ small unit keys / JSON issue slices cross any boundary — nopr_files/ diff / perspective-results ever do (respects the ~2 MiB cap). This subsumes the planned "narrow validate to one file" optimization: narrowing happens inside the validate activity, nothing big crosses at all. - Identity is explicit — deletedexecutor.py's_sandbox_identityContextVar /bind_sandbox_identity/_sandbox_context_forand the module-global_sandbox_semaphore;run_sandbox_review(team_id, user_id, repository, branch, …)buildsCustomPromptSandboxContextinline. Each fan-out child bounds its sandbox-turn activities with a freshasyncio.Semaphore(MAX_CONCURRENT_SANDBOXES)+gather(return_exceptions=True). - Best-effort partial-unit failure (per the decision); per-unit activities are idempotent (check-and-skip already-persisted units) so resume + Temporal retries are safe. Publish stays gated off. - Tool modules keep only their pure prompt builders (build_analysis_prompt/build_review_prompt/build_validation_prompt/generate_chunking_prompt+*_SYSTEM_PROMPT); the fan-out orchestrators andrun.pyare deleted (deduplicate_issueskept as a callable the dedup activity wraps, now taking explicit identity). Newpersist_pr_snapshot/load_pr_snapshot/persist_verdicthelpers. - Trigger:run_reviewcallsbackend/temporal/client.py:execute_review_pr_workflowwhich blocks (client.execute_workflow,id_reuse_policy=ALLOW_DUPLICATE) so the CLI eval loop stays intact; stage progress streams in the worker log viaworkflow.logger. Registered onvideo-export-task-queue(start_temporal_worker.py+tach.tomlinsights → products.review+ thecontainer-images-cd.ymlvideo-export change-detection grep). The worker now needsGITHUB_TOKENin its env (fetch runs there). - The reviewed-head_shacheckout pin stays conditional step 16; the loop-y re-check, cross-turn finding identity, andtask_run/noteartefacts remain deferred. Design + grounded map (file:line cites) in Everything on Temporal below. -
⏭️ (conditional) Pin the sandbox checkout to the reviewed
head_sha(products/taskschange). Only if actually needed — resume-correctness, re-enabling publish, or the loop: thread a reviewed-commit SHA through the Tasks sandbox chain so the working tree == the injected change-set == the persisted snapshot == the finding line-anchors ("reviewed == recorded"). Additive + backward-compatible (every hop an optionalhead_sha: str | None = None, default → today's branch-tip behavior), but it crosses the isolatedproducts/tasksfacade and touches a shared model + migration, so it lands as its own self-contained, tasks-owned change ahead of Review consuming it. Full chain in the grounded map under Everything on Temporal. May be skipped entirely if the single-turn workflow (publish off) never needs reviewed-vs-tip consistency.
Evaluating review quality — the run log (pseudo-evals)
products/review/eval/RUN_LOG.md is a committed, agent-maintained log of every end-to-end
run_review, so we can tell whether a prompt/code change actually moved review quality instead of
iterating blind. After each e2e run, append an entry (read ReviewReport + ReviewReportArtefact
on team 1): the codebase-state label (what changed in the reviewer + the reviewed PR's head_sha),
the pipeline shape (stages, chunk count, perspective_result count), the findings (perspective /
file:line / priority / title), and the validator's kept/dropped verdict per finding. Then diff it
against prior entries to judge the delta.
Reproducibility caveat: the findings are a judgment over one specific commit, so quality is only
comparable across runs that reviewed the same head_sha. If the PR moves between runs the
comparison is confounded — it happened in practice (#65862 moved mid-iteration; runs #2 vs #4 reviewed
different commits, so only the structural verdict-pairing fix was clean signal, not the finding count).
So point the eval at a stale PR (a branch that hasn't moved in a while) — every run then reviews
identical code. The standing sample is #63625 (insights-code/fix-stickiness-dw-timestamp-field,
head 243ddf40295c frozen since 2026-06-15). This is a deliberate stopgap while the eval is a
temporary tool; the eventual proper
fix is the step-16 head_sha pin (review a fixed commit, not "current head"), which makes any PR
reproducible and is independently required for resume and the loop.
Cloud host, Temporal & GitHub (assumed / later)
- Cloud host assumed. The orchestrator (
run.py main(), a management-command coroutine —run.pycarriesTODO: Make it a parent workflow) is to be reworked into a Temporal parent workflow in a later pass; for now assume a cloud host runs it. On Temporal, persist large artifacts (e.g. the per-turn diff snapshot) inside the activity and pass Postgres row ids by reference (~2 MiB payload cap) — the payload cap is a Temporal serialization limit, ortscriptonal to the store, so Postgres-first stands. - GitHub auth stays basic. Keep the
GITHUB_TOKENpath; move PR-fetch/publish onto the team-scopedGitHubIntegration(get_diff/first_for_team_repository) only when genuine per-team cloud auth is needed.
Deferred / future (do not build now)
-
Shared abstract artefact base. If a second consumer proves it out (nest-then-promote), extract an abstract
AttributedArtefact(funnel + fields, withtask/reportFKs declared per-subclass to avoid a core→tasks FK) into a shared home; Signals would converge onto it via a mixin only (no table change). Not now. -
Perspectives as LLMA skills — built in step 10 (landed before Temporal; design record here). Two coupled changes, both shipped:
- Rename "lens" → "perspective" everywhere. The word "lens" collides with Replay Vision, which already
abandoned it (migration
0006_rename_lens_to_scanner; its replacement "scanner" is now taken too). "perspective" is collision-clear and idiomatic. The rename touchesIssue.source_lens→source_perspective,PassType→PerspectiveType(the{pass}-{chunk}-{issue}id keeps "pass" — a separate ordinal index, not the perspective name), the pydanticLensResultArtefact→PerspectiveResultArtefact, theARTEFACT_CONTENT_SCHEMASkey + the stored DB choiceReviewReportArtefact.ArtefactType.LENS_RESULT = "lens_result"→PERSPECTIVE_RESULT = "perspective_result"(a Django migration generated viamakemigrations— not a pure code rename), andpersist_lens_results/load_lens_results→persist_perspective_results/load_perspective_results. - Move the three perspectives into DB-synced LLMA skills (the full Signals-scout pattern, "v2"). On disk,
products/review/skills/review-script-perspective-{logic-correctness,contracts-security,performance-reliability}/SKILL.md(frontmatternameunder aREVIEW_FN_PERSPECTIVE_PREFIX = "review-script-perspective-"constant +description; body = the perspective text moved out of the jinja focus blocks and reworded to standalone perspective framing — the pass-numbered "three-pass" wording was wrong once they run in parallel; substance kept). Async_review_skillscommand (ported from Signals'sync_canonical_skills/lazy_seed.py) mirrors each diskSKILL.mdinto per-teamLLMSkill/LLMSkillFilerows (content-hashed; buckets created/updated/diverged/resurrected/pruned since 2026-07-08 — an all-dead canonical is recreated, not tombstoned; ownership guardmetadata.seeded_by == "review",category = "review_perspective"), driven both bymanage.py sync_review_skills(--team-id/--all-teams/--dry-run) and a lazy cold-start sync at the start of a review run (mirroring the scout runner — no Temporal coordinator needed yet). Reuses the sharedLLMSkillmodel, so addproducts.skillstoproducts.review'stachdepends_on(skills is un-isolated; no facade). Delivery flips to pull: the issues-review prompt stops splicing the focus text and instead instructs the sandbox agent toskill-get(skill_name="review-script-perspective-…", version=N)over the Insights MCP (the sandbox context already carriesllm_skill:readvia the defaultfullscope); the version is pinned per run,references/load on demand viaskill-file-get. - Selection: fixed three for now. A registry-driven loader loads the three canonical perspective skills
(pinned versions) and every run executes all three — preserving the stable ordinal source attribution
(the
{pass}-{chunk}-{issue}id +source_perspectivederived from registry order) and its test. Per-team custom perspectives (aReviewPerspectiveConfigper(team, perspective)+ aregister_missing_configsprefix-scan, exactly like Signals'SignalScoutConfig, so a team runs a chosen subset or authors its own) is a next-iteration follow-up — it breaks the positional attribution (issues would key by perspective name, not ordinal), so it's deferred until that capability is actually needed. - Why before Temporal: v2 collapses the three pass→perspective couplings (
PASS_ENUM_MAP, thepass{N}_focus.jinjafilename convention, and the positionallist(PassType)[n-1]) into one orderedPERSPECTIVESregistry — the single source of truth the Temporal "perspective review" fan-out (ReviewPerspectivesWorkflow) iterates — so the perspective-loading code isn't touched twice. - Acceptance gate — ✅ passed. The e2e
run_reviewagainst PR #65862 (--team-id 1 --user-id 1,reset_reviewrun first) completed the full 9-stage pipeline (exit 0): 5 findings + 5 verdicts, a 12.5 KB rendered body. The cold-start sync seeded the three perspectives into team 1'sLLMSkillrows (v1,seeded_by="review",category="review_perspective"), and the sandbox agents pull-delivered them — the prompt renderedskill-get(review-script-perspective-…, version=1), the agents loaded and invokedskill-get/skill-file-getover MCP with noisError/SkillNotFound/403, and produced perspective-specialized output (9perspective_resultrows = 3 perspectives × 3 chunks; e.g. a Contracts & Security finding onserializers.pykeyed…:Contracts & Security:1-1-1). The unit suite (thePERSPECTIVES-vs-enum order assert, the sync buckets, and the loader test) guards the wiring (138 backend tests green).
- Rename "lens" → "perspective" everywhere. The word "lens" collides with Replay Vision, which already
abandoned it (migration
-
Prompt iteration — step 11 (✅ built, A-lite). Design detail + analysis below; what shipped is summarized here. Two coupled prompt changes, both also de-risking Temporal. As built: both (a) and (b) landed across all three review prompts (chunking, chunk-analysis, issues-review), not just
issues_review. (a) The prompts are fully perspective-agnostic —PASS_NAME/PASS_NUMBERremoved from prose and renderers; the only perspective-specific input is theskill-get(name, version)line. (b) Option A-lite was chosen: keep injecting the per-chunk change-set (load-bearing — see the git-access note below), trimPRMetadatato a<pr_intent>block of title + description only, and add a<pr_file_changes_for_chunk>lead-in. Thehead_shapin was deferred to step 16 (its own conditional change) — independently load-bearing there, and A-lite is correct without it (the injected change-set is the record; drift stays a rare edge case). Option B was ruled out by the git-access reality: the sandbox checkout isgit fetch --depth 1 origin -- <branch>— depth-1, single-branch — so the agent has the full working tree (reads / greps any file) but no base ref and no history; it cannotgit diff base..headorgit log/blame, and removed lines aren't in the tree. The injected change-set is therefore the only source of "what changed", which is why A-lite keeps it and B (self-derive) would need a deeper-clone infra change larger than the declined pin. Data-hygiene audit (done): prompt-construction-only change; no persisted artefact gained excessive data; the duplicategithub_metamodules became step 12. The original analysis follows:(a) Make the base review prompt fully perspective-agnostic. Analysis — is this worth it or over-engineering? Today
issues_review/prompt.jinjaalready keeps the perspective's focus out of the prompt (it's pulled from the skill), but still names the perspective in the prose —PASS_NAME("Contracts & Security") in ~4 spots andPASS_NUMBER("perspective 2"). None of that is load-bearing: the agent pulls the skill as its first move (the skill body announces its own name + focus), and the pipeline stampssource_perspectivefrom the registry ordinal incombine_issues— not from anything the agent emits (the e2e proved this: the Contracts agent emitted issue ids1-1-…with pass=1 while it was ordinal 2, and attribution was still correct). So the only perspective-specific input the prompt actually needs is theskill-get(name, version)line. Verdict: worth doing, low-effort, low-stakes now — but not over-engineering; it's strictly less coupling. The real payoff is forward-compat: per-team custom perspectives (the deferredReviewPerspectiveConfigiteration) must not have the base prompt hardcode the three canonical names. Change: drop thePASS_NAME/PASS_NUMBERprose, keep the generic "you are one of several parallel perspectives, report everything, dedup happens later" framing + theskill-getline; removePASS_NAMEfrom_render_prompt(the issue-id ordinal can stay agent-chosen — we don't depend on it).(b) Settle the context-injection strategy (the wordiness concern). Today the prompt injects, per chunk:
@path#L…change refs + the full<pr_file_changes_for_chunk>diff hunks +PR_METADATA+PR_COMMENTS+ theChunkAnalysisgoal — and the sandbox also has the cloned working tree. That's the same changes represented three ways (refs, hunks, tree), which is the real over-engineering risk — not the idea of injecting context per se. The injected diff is not gratuitous: it's currently the only reliable source of the base→head changes, because the sandbox checkout isgit fetch --depth 1 origin -- <branch> && git checkout -B <branch> FETCH_HEAD(products/tasksget_sandbox_for_repository.py) — i.e. the moving branch tip, shallow, single-branch. Two consequences: (1) the working tree can drift from thehead_shawe snapshotted (a commit landing between our GitHub fetch and sandbox boot — exactly the "changed 15 min later" case, and worse under the loop), so the@path#Lline numbers and the agent's reading can disagree with the reviewed/persisted diff; (2) with only the branch fetched shallowly, the agent cannot self-git diff <base>..<head>(no base ref, no history). Consistency principle: the reviewed artifact is one pinned commit — the working tree, the injected context (or pointers), the persistedcommit-artefact snapshot, and finding line-anchors must all agree onhead_sha. "Let the agent fetch its own diff from a moving branch" is the anti-pattern (reviewed ≠ recorded); self-derivation is only safe after the checkout is pinned. Two options:- Option A — inject, but pin + dedupe (recommended; lower risk, no deep infra ask). Ask
products/tasksto check out the exacthead_sha(not the branch tip) so tree == snapshot (fixes line-drift + the loop staleness). Keep injecting the reviewed diff as the consistency contract, but stop triple-representing it: keep the@path#L…pointers + changed-file list + a trimmed diff, trimPR_METADATAto intent-bearing fields (title/body/author/base/head), keepPR_COMMENTS(dedup signal). Leaner prompt, fully consistent. - Option B — pin + let the agent self-derive (leanest prompt, more infra). Pin to
head_shaand fetch the base ref / enough history so the agent cangit diff <base>..<head_sha>and read files itself; then drop the big diff injection, inject only pointers (changed files, base/head SHA, PR title/body, prior comments). Costs a deeper clone (boot time) + spends agent turns running git, and depends onproducts/tasksfor both the SHA pin and the base fetch.
Recommendation: Option A — the
head_shapin is the keystone (independently required for the loop and for Temporal's by-reference activities), and dedupe captures most of the token win without the deeper-clone cost. Revisit Option B only if prompt size is still a problem after dedupe.Data-hygiene audit (do as part of this step): once the context approach changes, re-check that the models/artefacts don't persist now-excessive or redundant data. Specifically audit: the
commit-artefactdiffsnapshot (still needed as the point-in-time record? size?), thechunk_set/chunk_analysis/perspective_resultworking-state rows (do they embed more of the chunk/PR payload than the resume needs once the agent reads from the pinned tree?), and whetherPRFile/pr_filescarry fields no longer used. Trim anything stored only to feed the old injection path. - Option A — inject, but pin + dedupe (recommended; lower risk, no deep infra ask). Ask
-
Pre-Temporal cleanup — this was step 12 (✅ done). Audited the whole reviewer package for clear duplication
- dead code (a parallel multi-lens sweep, each finding adversarially verified) to shrink the surface before the
Temporal rewrite threads through it. Correction to the original premise: there were no duplicate model
definitions —
tools/github_meta.pyimportsPRMetadata/PRComment/PRFile/PRFileUpdatefrommodels/github_meta.py(their only home) for itsPRFetcher/PRParser/PRFilterIO classes. The actual smell was an inconsistent import path: four consumers (split_pr_into_chunks.py,sandbox/code_context.py,tests/conftest.py,tests/test_split_pr_into_chunks.py) reached the models throughtools.github_metarather thanmodels.github_meta. As-built: - Re-pointed those four imports to
models.github_meta(canonical home).run.py/test_github_meta.pykeep importing the IO classes fromtools.github_meta— correct, those genuinely live there. - New
tools/prompt_helpers.pyholds the prompt-construction logic that was copy-pasted across stages:load_template_and_schema(subdir)(replaces five near-identical Jinja-template+schema loaders in chunk-analysis / issues-review / validation / dedup / chunking),format_pr_intent(meta)(replaces the thrice-inlined title+description one-liner), andbuild_chunk_prompt_context(...)(the per-chunk file-filter → code-context → comments/files-serialization block that was byte-identical in the two_render_prompts). Theid/created_atcomment-exclusion (watermark contract) is preserved in the shared builder. - Removed two dead-code spots: an unreachable
i += 1aftercontinueinparse_patch, and the"modification"change-type branch inissue_cleanerthatparse_patchnever emits (only addition / deletion / context). - Unifying the five loaders made the chunking loader raise
FileNotFoundErroron template-load failure (it used to raiseRuntimeError);split_pr_into_chunksalready caught both, so end-to-end behavior is unchanged — one test assertion was updated to the unified contract. Behavior-preserving overall; 138 tests green, ruff + tach clean. - Deliberately left alone (not duplication / dead-code; touching them would be speculative refactor):
PRFilter/PRParseras static-method classes vs free functions,PRFetcher's single-use statefulness, and the dead-but-harmlessReviewReportArtefact.updated_atcolumn (removing it is migration churn for no Temporal benefit). The twosplit_pr_into_chunksmodules are not a duplicate either —models/is theChunk/ChunksListschema,tools/is the logic; legitimate split, kept. - Insights-conformance pass (same window, 7-dimension adversarially-verified audit). Removed import-time
side effects from library modules (
load_dotenv()in bothsplit_pr_into_chunksmodules → relocated to therun_reviewcommand;logging.basicConfig()inrun.py→ relocated to the command; three stray shebangs) — hidden global-state hazards oncerun.pyis the Temporal-imported workflow module. Closed strict-typing gaps (run_review.handle(*args: Any, **options: Any),_load_working_state -> list[ReviewArtefactContent], a test-helper return type), deleted# Configure logging/# Load environment variableswhat-comments and name-restating test docstrings (house rule), parameterizedtest_github_meta.py's hand-rolled case-loops, fixed theis_directy_related_to_changes→is_directly_…field typo (model + regeneratedissues_review/schema.json- refs), and added
products/review/package.json(name+backend:test; nobackend:contract-checksince it's non-isolated) so the suite is discoverable by Turborepo/CI selective testing. Leftparse_artefact_content's baredict/listparam alone (verbatim mirror of Signals' helper — changing only our copy would diverge it). The heavy Temporal-structural items the audit surfaced (the_sandbox_identityContextVar, by-valuepr_files/diff payloads,GITHUB_TOKEN-vs-team-integration) are deferred to step 15 — they're the documented workflow rewrite, not standalone debt. 215 tests green, ruff + tach clean.
- refs), and added
- dead code (a parallel multi-lens sweep, each finding adversarially verified) to shrink the surface before the
Temporal rewrite threads through it. Correction to the original premise: there were no duplicate model
definitions —
-
Validator as a team-customizable skill — step 13 (✅ built; as-built + original design here). As built: the validation keep/drop criteria moved to a canonical
products/review/skills/review-script-validation-criteria/SKILL.mdmirrored into per-teamLLMSkillrows bylazy_seed.sync_canonical_validation. The step-10 sync was generalized, not duplicated:_sync_canonicals/_discover_canonicalare prefix+category-driven and back bothsync_canonical_perspectivesandsync_canonical_validation; the management command was generalized + renamedsync_review_perspectives→sync_review_skills(syncs both sets;--all-teamskeys offseeded_by="review").skill_loader.load_validation_skill_for_runpins the run's version;validate_issues(team_id=…)threads it into the render; theissue_validationprompt is criteria-agnostic (keeps the deduped-issues-in / per-issue-verdict-out contract + askill-get(review-script-validation-criteria, version=N)block — the agent pulls the bar over MCP). Cold-start sync (run.py _sync_review_skills_for_team) seeds both skill sets per run. Skills-UI grouping (decided with the maintainer 2026-06-25): both review-script skill sets share one categoryreview(constantREVIEW_FN_SKILL_CATEGORY) and surface under a single "Code review" tab (/skills/review-script); the perspective-vs-validation split is carried by the skill-name prefix (review-script-perspective-*vsreview-script-validation-*), which the skills UI groups on. A category is a tab here (the list endpoint filters one exactcategory=;products/skills/backend/api/skills.py:283), so "two categories in one tab" would have needed acategory__inendpoint change + a frontendtab→categories[]generalization — not worth it when the name prefix already distinguishes the sets. This superseded step 10's separatereview_perspectivecategory / "Review perspectives" tab; the sync now owns the category tag and self-heals a seeded row whose category drifted (in-place re-stamp, no version bump), so the rename propagated to existing perspective rows on the next sync. 221 backend tests + ruff + tach green. Original design: Today the validation stage (tools/issue_validation.py+prompts/issue_validation/*) sends each chunk's deduped, in-scope issues to a sandbox agent that returns a per-issue keep/drop verdict — but the criteria for "keep" are baked into the prompt. Different teams care about different things: one wants only shipping-blockers, another wants style and docs too; the bar for "overengineering / too paranoid / will never happen" is a judgment call that should be owned by the team, not the prompt. So move the validation criteria into a DB-synced LLMA skill the validator agent pulls — the exact step-10 pattern (canonicalSKILL.md→ per-teamLLMSkillvialazy_seed.sync_*,category="review_validation", pull-delivered viaskill-get, surfaced under its own Skills-UI tab). The validator prompt becomes criteria-agnostic (it owns the I/O contract — list of deduped issues in / per-issue verdict out — and theskill-getline; the skill owns "what matters"). The default canonical skill encodes the house bar: keep real correctness / security / data-loss / contract / performance issues that plausibly affect users; drop overengineering, speculative "what if", defensive-coding paranoia, never-gonna-happen edge cases, and pure style — biased toward dropping when uncertain (precision over recall, since a noisy reviewer gets ignored). A team edits its row to retune the bar without touching code. "Any relevant cases" audit: the validator is the primary judgment gate; scope-cleaning is deterministic (positional, no LLM — leave it), dedup criteria are mechanical "same issue?" (skill-ify only if a team asks), and report-building is formatting. So this step is one validation-criteria skill + the criteria-agnostic prompt; per-perspective validation skills are a later refinement if needed. Reuses step 10'slazy_seed/ sync-command / pull machinery, so it's cheap once that exists — and landing it before Temporal means the workflow threads the final skill set, not a half-migrated one. -
Dedup cleanup — step 14, ✅ as built (2026-06-25).
tools/issue_deduplicator.pyused to dedupe Review's findings against exactly one competing reviewer —_PRIOR_REVIEWER_BOT = "greptile-apps[bot]", applied by a_previous_bot_issuesauthor filter — feeding two places: (1) the deterministic positional gate_select_dedup_candidates(a finding sharing a file + overlapping line with a prior comment becomes an LLM dedup candidate; the rest are kept without an LLM call), and (2) the LLM dedup input. That single handle missed CodeRabbit / Copilot / a team's own bots. As built (Variant A):- Dropped the hardcoded handle and the
_previous_bot_issuesfilter entirely (not renamed).pr_commentsfromfetch_pr_commentsis already inline-only (pr.get_review_comments()), sodeduplicate_issuesnow buildsprior_comment_linesand the LLM input straight frompr_comments— every prior inline comment, every author (other bots, humans, Review's own), uniformly. (_comment_linealready drops comments with no resolvable file+line, so top-level "LGTM" chatter never reaches the gate.) - Both the gate and the LLM input get all inline comments, with author handles — already serialized on
PRComment.user, so no data-flow change. The prompt instructs the agent to use the author to read what a comment is: a code-review bot's comment is usually a formal finding (so a bot already raising the same problem is strong duplicate evidence), a human's may be a question / nit / discussion (weaker evidence) — without privileging or excluding any reviewer. The prompt var was renamedPREVIOUS_ISSUES_JSON→PRIOR_COMMENTS_JSONand the block<previous_issues>→<prior_comments>for clarity. - User decision (2026-06-25): aggressive + uniform. Keep collapsing hard (fewer, useful findings is the goal).
Uniform means our deterministic code privileges/excludes no reviewer — no hardcoded handle, no ours-vs-theirs
branching, the positional gate is author-blind — but the LLM still uses the author as a bot-vs-human signal
(above). The prompt was rewritten and
_SYSTEM_PROMPTrealigned (it used to say "be conservative", contradicting the prompt's "AGGRESSIVE MODE"). The anchor is same concrete problem, not bare co-location — two findings on the same line flagging different problems are kept; a finding near an unrelated comment (a nit, a question, praise) is kept; genuine same-problem findings still collapse to the single most comprehensive one. - Self-dedup is uniform here; lifecycle is the loop's job. Not re-posting our own prior finding is handled by turn-scoped publish + treating our comment as just-another-prior-comment in dedup. The richer behavior — knowing a finding was resolved across turns and updating/closing our own comment — needs the dedup's same-problem match persisted against the DB findings (lifecycle-aware) plus the comment id, not re-parsing comment text; ships with the loop. The DB is the source of truth for "what we already raised"; comments are the GitHub-side reflection.
- Also tightened the
IssueDeduplicationmodel/schema description (no "explanations" field exists; the prompt no longer asks for one) and added a regression test that a prior comment from a non-privileged author still drops a colliding finding — guarding against re-introducing a single-reviewer filter. (No author parameterization: the dedup never branches onc.user, so enumerating handles would just re-run the same path; one arbitrary non-special author is the meaningful case.) - Out of scope (config, not this step): if a team wants to exclude a specific reviewer from dedup, that's a team-config allowlist later — not needed now.
- Verification: 221 backend tests + ruff + tach green. e2e only bites when the reviewed PR carries prior inline comments from other reviewers, so a comment-free PR shows no behavior delta — verify opportunistically.
- Dropped the hardcoded handle and the
-
Everything on Temporal — this is step 15 (the major step after the validator-skill + dedup-cleanup work; design detail here). Rework
run.py main()into a single-turnReviewPRWorkflowparent workflow (one run = one review turn); the loop is a deferred follow-up (below). Topology, all on a newproducts/review/backend/temporal/package:- Parent
ReviewPRWorkflow— pure orchestration mirroring today'smain(): inlineparse_github_pr_url→ activities → three child workflows → activities. Stages exchange onlyreport_id+head_sha(+ small unit-key lists); every consumer reloads its inputs from artefact rows (persistence.load_*), so no diff /pr_files/ perspective-results ever cross the workflow boundary (respects the ~2 MiB cap). - Pin the checkout to
head_sha— split out to conditional step 16 (decided 2026-06-25). Threading the reviewed commit SHA soproducts/taskschecks it out instead of the moving branch tip (get_sandbox_for_repository.py) makes the working tree == the injected change-set == the persisted snapshot == the finding line-anchors ("reviewed == recorded"). It is no longer part of the step-15 (Temporal) PR: it crosses the isolatedproducts/tasksboundary (model + migration) and has no user-facing effect while publish is off, so the single-turn workflow ships on today's branch-tip checkout and the pin lands as its own tasks-owned change only if/when resume-correctness, re-enabling publish, or the loop actually needs it. Full chain in the grounded map below. - Per-step split: parse = inline workflow-code; fetch + diff snapshot = one activity (writes the diff
- a new
pr_filesartefact internally, returns a smallReviewMeta); validate-integration, schema-gen, split-into-chunks, combine+scope-clean, dedup+persist-findings, build-body+finalize, and publish = activities; the three fan-out stages analyze chunks, perspective review (3 perspectives × N chunks), and validate = child workflows (AnalyzeChunksWorkflow/ReviewPerspectivesWorkflow/ValidateIssuesWorkflow), each dispatching its per-unit sandbox-turn activities in bounded batches and persisting per-unit artefacts.
- a new
- Per-unit activity inputs carry only that unit's slice (flow-analysis 2026-06-25): when the fan-out stages
become activities, dispatch each unit its own data, not the whole list. Most concretely, the validate activity
needs only the issue's own file context, never the full
pr_files— today_validate_onethreads all ofpr_filesbut only readsprepare_code_context([issue.file], pr_files), so pre-narrow to the single file (preserving first-match semantics) before dispatch. In-process today this is a no-op (the list is shared by reference); it only matters once it crosses the activity boundary, so it lands with the rewrite, not before. - Identity (delete the contextvar): remove
executor.py's_sandbox_identityContextVar +bind_sandbox_identity_sandbox_context_for; thread(team_id, user_id, repository, branch)explicitly into every sandbox-turn activity input and buildCustomPromptSandboxContextinline — a ContextVar doesn't cross worker boundaries and risks cross-tenant bleed.
- Throttle: the in-process
asyncio.Semaphore(MAX_CONCURRENT_SANDBOXES)is removed from any workflow-reachable code; each fan-out child dispatches its sandbox-turn activities in bounded batches (width =MAX_CONCURRENT_SANDBOXES, repurposed as a fan-out width). The true global ceiling stays thetasks-task-queueworker's own concurrency — where the sandboxProcessTaskWorkflows actually execute. - Task queue: register the orchestrator + child workflows + Review activities on
video-export-task-queue(settings.VIDEO_EXPORT_TASK_QUEUE) for now — it already hosts Signals' product workflows, so Review co-locates with its mirror; a dedicatedreview-task-queueis a later split. SandboxProcessTaskWorkflows stay ontasks-task-queue. Wire it via a newproducts/review/backend/temporal/__init__.py(WORKFLOWS/ACTIVITIES) + a facade re-export, append to thevideo-export-task-queuetuple instart_temporal_worker.py, and update the CI worker-trigger. - Trigger:
run_reviewstartsReviewPRWorkflowvia the Temporal client (locally the dev worker on the collapseddevelopment-task-queuehandles both the orchestrator and the sandbox sub-workflows). Publish stays disabled through the migration; its idempotency guard (apublished_head_shawatermark + comment-dedup) ships with the loop. - Deferred to a follow-up (after the single-turn workflow): the loop-y re-check — a per-PR singleton
continue-as-newworkflow, timer-driven +signal-with-starton a GitHub webhook, advanced by the report'shead_sha/last_seen_comment_idwatermark, the trigger supplyingteam_id/user_id= the PR's author and their team — plus cross-turn matching (the dedup's same-problem judgment, not a new key) and thetask_run/notework-log artefacts. (See Cloud host, Temporal & GitHub above.)
Grounded implementation map (researched 2026-06-25 — file:line cites for the rewrite). A 6-reader sweep of Insights's Temporal subsystems confirmed the conventions and the exact hook points; captured here so the rewrite (and any future agent picking it up) starts from facts, not re-derivation.
Conventions Review must follow:
- Workflow shape: inherit
InsightsWorkflow+@temporalio.workflow.defn(name="…")with explicit kebab names, each mapping to its class:review-pr(ReviewPRWorkflow),review-analyze-chunks(AnalyzeChunksWorkflow),review-perspectives(ReviewPerspectivesWorkflow),review-validate-issues(ValidateIssuesWorkflow). Thereview-prefix keeps the registered names globally unique on the sharedvideo-export-task-queueeven where the class name omits it. Activities are bare@temporalio.activity.defn async def.insights/temporal/common/base.py:5-37;insights/temporal/delete_teams/workflows.py:70-71; Signals'products/signals/backend/temporal/summary.py:125-141(dataclass inputs +parse_inputs+ aworkflow_id_for(team_id, artefact_id)classmethod). - Inputs:
@dataclass(not Pydantic), with an optional@property properties_to_log -> dictto keep sensitive fields out of Datastorelog_entries(insights/temporal/delete_teams/types.py:13-14). Review's inputs (team_id/user_id/repository/pr_url) carry nothing secret but follow the convention. - Determinism (the rewrite's biggest hazard): workflow
@runis pure — no clock /random/ IO / ORM (insights/temporal/README.md:24-29). All ORM in activities viadatabase_sync_to_async(..., thread_sensitive=False)/@asyncify(products/signals/backend/temporal/summary.py:429;insights/temporal/common/utils.py:49-93). Review'spersistence.pyhelpers are already sync and called viasync_to_asynctoday → each wraps as an activity body cleanly. Auditrun.py main()for any clock read / direct ORM before it becomes the workflow body. - Idempotency: state-mutating activities
select_for_update()+ guard on status + atomic transition + return an idempotency flag; keep thehead_shawatermark guard so a retry can't double-advance (products/signals/backend/temporal/summary.py:408-460,474-531).upsert_review_report/persist_commit_snapshot/finalize_review_reportalready hold narrowtransaction.atomic()blocks. - Bounded fan-out (replaces
executor.py's module-global_sandbox_semaphore— illegal inside a workflow): createasyncio.Semaphore(N)fresh inside@run,async withit around eachexecute_activity,gather(*tasks, return_exceptions=True), then inspect withisinstance(r, BaseException).insights/temporal/session_replay/session_summary/workflow.py:431-469;insights/temporal/experiments/workflows.py:60-88;insights/temporal/data_modeling/workflows/execute_dag.py:272-377. Width =MAX_CONCURRENT_SANDBOXES, repurposed as the per-child fan-out width; the true global ceiling stays thetasks-task-queueworker's own concurrency (where the sandboxProcessTaskWorkflows execute). - Child workflows: the parent
awaits its three children (they return results it persists — NOT fire-and-forgetParentClosePolicy.ABANDON, which is the deferred-loop/notification pattern). Deterministic child ids anchored toworkflow.info().workflow_id.products/signals/backend/temporal/agentic/scout_coordinator.py:448,490-516;insights/temporal/data_modeling/workflows/execute_dag.py:331. - Timeouts / retries / heartbeats: every
execute_activitysetsstart_to_close_timeout+ an explicitRetryPolicy(maximum_attempts=…)(never the infinite default) +non_retryable_error_types(class-name strings); long sandbox-turn activities addheartbeat_timeout+async with Heartbeater():, modeled on Signals'run_agentic_report_activity(4h start-to-close + 5m heartbeat) —products/signals/backend/temporal/agentic/report.py:256-268;insights/temporal/common/heartbeat.py:17-108;insights/temporal/delete_teams/workflows.py:40-58,94-114. - DB-connection scoping: decorate activities with Signals'
@scoped_temporal()+@close_db_connections(products/signals/backend/temporal/summary.py:409-410) — the closer mirror than core'sdatabase_sync_to_async_pool(). - Testing:
WorkflowEnvironment.start_time_skipping()+env.client.execute_workflow(..., runner=UnsandboxedWorkflowRunner())with activities mocked as@activity.defn-decorated stand-ins (notunittest.mock);@pytest.mark.django_db(transaction=True)for ORM-touching async tests.insights/temporal/tests/test_alerts_workflows.py:127-148;products/tasks/backend/temporal/code_workstreams/test_workflow.py:26-53;insights/temporal/README.md:498-541. (Today'stests/test_run.pypatchesrun.main()— that target moves oncemain()becomes the workflow body.)
Files to create (all Review-owned, additive):
products/review/backend/temporal/__init__.py— module-levelWORKFLOWS = [ReviewPRWorkflow, AnalyzeChunksWorkflow, ReviewPerspectivesWorkflow, ValidateIssuesWorkflow]+ACTIVITIES = [...](mirrorproducts/signals/backend/temporal/__init__.py:75-137).…/temporal/workflow.py— parent + 3 children. Parent body mirrors today'smain(): inlineparse_github_pr_url→ fetch+snapshot activity → validate-integration / schema-gen / split-chunks activities → 3 child workflows → combine+scope-clean / dedup+persist-findings / build-body+finalize / publish activities. Onlyreport_id+head_sha(+ small unit-key lists) cross boundaries; every consumer reloads its inputs from artefact rows.…/temporal/activities.py— wrap therun.py/persistence.pystage functions as@activity.defn; the three children dispatch per-unit sandbox-turn activities callingrun_sandbox_reviewin bounded batches.…/temporal/client.py—execute_review_pr_workflow(...)(sync_connect()+asyncio.run(client.start_workflow(...)),WorkflowIDReusePolicy.ALLOW_DUPLICATE_FAILED_ONLY, start-retrymaximum_attempts=3, deterministic id, Team-existence check), modeled onproducts/tasks/backend/temporal/client.py:219-306.
Files to modify:
…/management/commands/run_review.py:35-43— replaceasyncio.run(main(...))withexecute_review_pr_workflow(...).…/reviewer/sandbox/executor.py— delete_sandbox_identityContextVar +bind_sandbox_identity+_sandbox_context_for+ the module-global_sandbox_semaphore;run_sandbox_reviewtakes(team_id, user_id, repository, branch[, head_sha])explicitly and buildsCustomPromptSandboxContextinline (a ContextVar doesn't cross worker boundaries → cross-tenant-bleed risk).insights/management/commands/start_temporal_worker.py:324-333— append Review'sWORKFLOWS/ACTIVITIESto the existingVIDEO_EXPORT_TASK_QUEUEtuple (queue constantinsights/settings/temporal.py:111; DEBUG collapses todevelopment-task-queue, so one local worker runs the orchestrator and the sandboxProcessTaskWorkflows). Core importing a product's temporal lists is the established Signals exception..github/workflows/container-images-cd.yml(~356-363) — add^products/review/backend/temporalto the video-export-worker change-detection grep so the pod redeploys on Review temporal changes.tach: no newdepends_on— the pin goes throughproducts.tasks.backend.facade.*; re-runtach check --dependencies --interfacesafter wiring.
The head_sha pin chain (conditional step 16 — researched end-to-end against the real code; the existing hops below are cited as they are today, and the
head_shafield / property / checkout branch are the additions to make (none of the head_sha code exists yet). Additivehead_sha: str | None = Noneat each hop, default → today's branch-tip; precedence:head_shawins overbranchat the checkout):facade/agents.py(contract surface) →MultiTurnSession.start(custom_prompt_multi_turn_runner.py:52-101) →create_task_and_trigger(custom_prompt_internals.py:114-154) →Task.create_and_run(products/tasks/backend/models.py:522-587) →TaskRun.create_run(:266-307) → newTaskRun.head_shaCharField (:702-784, Django migration) →TaskProcessingContext._head_sha+ property (get_task_processing_context.py:43-80,169-178,555; old in-flight workflows replayhead_sha=None→ degrade gracefully) →get_sandbox_for_repository.py:288-322(git fetch --depth 1 origin <sha> && git checkout <sha>when set, else fall back to branch). Carrier = an explicithead_shakwarg parallel tobranch(keepsCustomPromptSandboxContextstable; the precedence rule lives in one place at the checkout activity).Resolved decisions (2026-06-25): steps 13 (validator-as-skill) and 14 (dedup cleanup) land first — both now done; step 15 ships as a single change (not incremental); partial-unit failure stays best-effort (
gather(return_exceptions=True)→ placeholder, matching today — publish is off, so a partial review has no blast radius); the head_sha pin is conditional step 16, its own tasks-owned change, skipped unless actually needed. - Parent
-
Cost optimization — collapse per-call sandboxes into warm multi-step sessions (PARTLY BUILT). A single review fans out N single-turn
run_sandbox_reviewsandboxes, each paying a cold-start + repo-checkout. The fix is to run a stage's calls as sequential turns inside one long-lived warm session (start_sandbox_session→continue_sandbox_session→end_sandbox_session, the executor helpers) so checkout + agent boot amortize and context stays warm across turns. ✅ Realized for VALIDATE (2026-06-29): one warm session per chunk, fixing the chunk and walking its issues as turns (one verdict/turn, resume-aware viaload_run_validations) — collapsed per-issue → per-chunk-with-findings. The analyze→review candidate is moot (analyze stage removed entirely).- NEXT candidate — per-PERSPECTIVE warm review session (the mirror of the validate change). Today review fans
out
P × Cisolated single-turn sandboxes (e2e #66840: 3 perspectives × 4 chunks = 12 review sandboxes). Instead, run one warm session per perspective, fixing the perspective and walking the chunks as sequential turns (turn 1 = chunk 1, turn 2 = chunk 2, …; stable chunk-id order). CollapsesP × C → Pwarm sessions (#66840: 12 → 3), checkout/boot paid once per perspective. Why it's safe: the perspective stays constant within a session — this is NOT the rejected "merge all perspectives into one pass" (that bled the lenses together); each chunk is still its own turn with its ownIssuesReviewoutput, exactly like the per-issue verdicts in the validate session. The context win the maintainer flagged: chunks of one PR are related, so when the session reaches chunk N the agent has already seen chunks 1..N-1 (shared types, cross-chunk contracts, the PR's overall shape) — new files, still related, so each turn re-establishes less context. Reuses the warm-session executor helpers AND the existing resume infra:perspective_resultartefacts are already keyed by(pass_number, chunk_id), soload_perspective_resultsalready gives per-chunk skip-resume for free (split done/pending chunks like the validator splits done/pending issues). First chunk = fullbuild_review_prompt; later chunks = a lean follow-up (the perspective skill + prior chunks are already in-session). Tensions to weigh: cross-chunk context within a perspective is mostly a feature (catches cross-chunk issues / shared-contract violations) but could anchor — measure finding quality; chunks are reviewed sequentially per perspective (slower per perspective, far fewer sandboxes; cross-perspective stays parallel — 3 sessions at once); the failure floor becomes per-perspective (coarser); dedup still runs after across perspectives + chunks. Do not implement now — note only; sequel to the validate change once its cost/quality is measured.
- NEXT candidate — per-PERSPECTIVE warm review session (the mirror of the validate change). Today review fans
out
-
Evaluate GPT-5.5 (or non-Claude models) as the sandbox model for some steps (not scheduled). Every sandbox step runs on Claude today; the executor already takes a per-call
model(MultiTurnSession.start(model=…)), so A/B-ing GPT-5.5 as the main model for specific steps (e.g. chunking or validation) where a cheaper/different model may match quality is a per-step config experiment, not a refactor. Measure finding quality + cost per step. -
Split the sandbox model by step — PARTLY DONE (perspective review on Codex; dedup still deferred). The perspective review now runs on OpenAI Codex
gpt-5.5@xhighwhile chunking, dedup, and the validators stay Claude — see "✅ BUILT 2026-07-01 — Codex reviews" below. Remaining candidate ("one by one", not scheduled): move dedup to Codex too (issue-consolidation / code reading) and A/B finding quality + cost before committing. Validators stay Claude (keep/drop judgment against the team's criteria, where Claude's calibration is the current baseline). -
Improve the chunking prompt — it over-splits small PRs (not scheduled). Observed: a ~155-line PR was split into 2 chunks needlessly, doubling the per-chunk fan-out (2× agents / tokens / sandboxes across analyze + every perspective + validate). Bias
CHUNKING_SYSTEM_PROMPT/prompts/chunking/toward fewer, larger chunks — a small PR should usually be one chunk; pairs with the conditional "skip chunking below a size threshold" gate. Pure prompt/heuristic tuning; measure chunk count vs PR size. -
Re-tune (or drop)
MAX_CONCURRENT_SANDBOXESfor review speed — it is NOT a global team cap (investigated, no urgent fix). The worry was that merging would throttle the whole team's concurrent sandboxes to 10. It won't:MAX_CONCURRENT_SANDBOXES = 10(constants.py) is a freshasyncio.Semaphoreconstructed inside each child-workflowrun()(workflow.py:118review fan-out,:195validate fan-out), so it bounds one review's per-stage fan-out, per execution. Two concurrent PR reviews each get their own semaphore; nothing is shared across reviews, and the two stages run sequentially (so it's 10-at-a-time within a stage, not additive). The real global ceiling lives elsewhere: the parent + children run onVIDEO_EXPORT_TASK_QUEUE, but every sandbox turn triggers aProcessTaskWorkflowon the tasks queue where the Modal sandbox actually runs — bounded by that worker'smax_concurrent_activities/max_concurrent_workflow_tasks(default 50,insights/temporal/common/worker.py) and Modal's account-level sandbox concurrency. So even with no semaphore, a big review's fan-out queues at the worker/Modal level, not unboundedly.- For speed: the cap serializes a large review (e.g. 3 perspectives × 8 chunks = 24 review activities → 14 queue behind the first 10), and "spawn now, not later" is right up to the worker/Modal ceiling. Options: (a) raise it — the Tasks product's own
code_workstreamsfan-out usesTEAM_FANOUT_CONCURRENCY = 20, so 10 is conservative; (b) drop it and let the tasks worker + Modal be the only throttle. - Weigh before dropping: the per-review cap is a fairness / blast-radius control — the tasks worker (50 slots) + Modal are shared with every other review and all other Tasks-product work (signals scouts, code workstreams). Without a per-review cap, one giant review can monopolize that shared capacity and stall concurrent reviews. Likely best: keep a per-review cap but size it right, not remove it.
- Before picking a number, get real data: the PROD tasks worker's actual
max_concurrent_activities(the 50 default, or overridden?) and the Modal account's sandbox-concurrency limit. Target roughlyMAX_CONCURRENT_SANDBOXES ≈ min(worker slots, Modal limit) / expected concurrent reviews. Pure constant tune (+ maybe lift the cap into per-team config later).
- For speed: the cap serializes a large review (e.g. 3 perspectives × 8 chunks = 24 review activities → 14 queue behind the first 10), and "spawn now, not later" is right up to the worker/Modal ceiling. Options: (a) raise it — the Tasks product's own
-
Let the validator adjust a finding's priority (raise/lower), not just keep/drop (not scheduled). Today priority is set once, at review time: each perspective stamps
Issue.priority(IssuePriority=must_fix/should_fix/consider,models/issues_review.py), it's frozen onto the durable finding (_to_finding→ReviewIssueFinding.priority), and the validator can't touch it —IssueValidationis onlyis_valid+category+argumentation, a binary keep/drop. This matters because priority gates publishing:consideris excluded from inline comments (PUBLISHED_PRIORITIES = {MUST_FIX, SHOULD_FIX}, dropped atpublish_review.py:250), so the perspective's first-pass severity directly decides whether a finding ever posts. The validator does the deepest per-issue investigation, yet it has no middle gear between "keep at the reviewer's level" and "kill entirely." Concrete case (wizard #760): the validator dropped ashould_fixpath-traversal finding as below-the-bar when the right action was arguably to downgrade it toconsider(keep it, lower it) — and the reverse gap exists too (a reviewer'sconsiderthe validator realizes is serious can't be upgraded toshould_fix, so it stays unpostable even when valid). Investigate: add an optionalpriority(oradjusted_priority) toIssueValidation, carry it onto the verdict, and let it override the finding's review-time priority at body-render + publish (the publish gate then reads the validator's level). Weigh: it gives the validator a softer lever than drop and uses its better-informed judgment, but it widens the validator's contract (it's currently a pure keep/drop calibrator) and means two stages can disagree on severity — decide precedence (validator-wins is the natural default since it investigated last). Schema + a few consumers; measure whether validator re-rating tightens the kept set vs the perspective's raw priorities. -
NEXT-NEXT — surface valid findings that can't be positioned on a diff line (the step after the per-perspective review session above). A valid finding on a changed file but an unchanged line (e.g.
email.py:240when the PR's diff only touches 220–227) survives scope-cleaning (its file is in the diff) but is silently dropped at publish:_find_valid_comment_positionrequires the finding's start line to be one of the diff's changed lines, so no inline comment is built. The finding still lives in the report DB — it's correct and important, it just has nowhere on the diff to anchor. Observed twice now: #66456 (2should_fixfindings onemail.py:240— transaction rollback, row-lock hold) and #66840 (2 of 6 valid findings —serializers.py:704unvalidated Slack target, the verbatim agent-draft render — posted only 4 of 6 inline). Find a way to still surface these line-less-but-valid findings: a file-level review comment (GitHub'ssubject_type=file), anchoring to the nearest changed line in the finding's range, or rolling them into a "Findings outside the changed lines" section of the review body rather than dropping them. Weigh the noise trade-off (off-diff findings are more likely to be context the author didn't touch) — favor the body-section option first (lowest risk, no mis-anchored inline comments). The count of dropped findings is already knowable at publish time (valid findings minus posted inline), so the gap is measurable. -
Emit into the Signals inbox. Like
replay_vision, Review could emit notable findings into Signals via the facadeemit_signalso they surface in the inbox — a product feature, separate from this storage work.
Reuse ledger: reuse directly — MultiTurnSession (Tasks facade), GitHubIntegration.get_diff (next
turn's current diff only); reuse from the Signals leaf — Commit / CodeReference / TaskRunArtefact /
NoteArtefact content models + ArtefactContentValidationError (from artefact_schemas), and
ArtefactAttribution (from the new artefact_attribution leaf); Review-owned — ReviewReport +
ReviewReportArtefact, the funnel, its own registry + artefact_type_for / parse_artefact_content helpers
(the Signals helpers close over Signals' module-global registry and can't take ours), the
ReviewIssueFinding / ValidationVerdict schemas, and the working-state schemas
(ChunkSetArtefact / ChunkAnalysisArtefact / PerspectiveResultArtefact). There is no on-disk store.
🔁 Stage 4 — the loop (design — not yet built)
Status: design only — nothing here is built. The single-turn
ReviewPRWorkflow(Stage 3, step 15) is the per-turn body; the loop wraps it so a PR review becomes a living, multi-turn process instead of a one-shot job. This section is the canonical loop design — it supersedes the scattered "deferred to the loop" notes above and the Cloud host, Temporal & GitHub stub. Researched 2026-06-26 against the real code (a parallel reader sweep of every subsystem the loop touches + the Tasks write/push path verified directly); cites are to currentmaster/signals/review. Triggers (areviewlabel, à la Stamp) and the publish identity are assumed inputs, deferred — the maintainer wires the label trigger later and supplies the publish identity (the team's GitHub App installation token, or a service user + project API key). This section designs the loop itself. Variants are presented with a decision each (the maintainer is not in the loop to answer questions).
The target (maintainer's words). Today users hand-run a sandbox agent and put it on /loop every 15 min from a
chat window — workable but blunt. Review should do it properly. One turn of the loop:
- Route — decide if the PR passes through (Stamp-style approve / skip) or needs review, and which perspectives to enable.
- Chunk by meaning / area (only if needed — the existing chunk gate).
- Spawn specialist reviewers in parallel (Logic & Correctness, Contracts & Security, …, plus per-team custom perspectives).
- Combine → dedup → implement the fixes.
- Ask the user for context if needed (store it for self-improvement) and check if new comments / major code changes appeared.
- Loop if justified, finish if not — repeat until ready.
Steps 1–4 are mostly the single-turn pipeline already (route is the one net-new stage; chunk / review / combine /
dedup are built; "implement the fixes" is net-new — see Action plane). Steps 5–6 are the loop's control logic. The
living ReviewReport (unique on (team, repository, pr_number), with run_count / last_run_at / status /
head_sha / last_seen_comment_id and head_sha-scoped resume — models.py:27-54, persistence.py:56-132) is
already the loop's memory: every turn appends to it, and a re-run reuses turn-stable sandbox work. The loop adds
four things on top: the control plane (what advances a turn, what ends it), the router, the cross-turn
finding identity the watermark plumbing was shaped for, and the action plane ("implement the fixes").
Control plane — how a turn is triggered and the loop lives
ReviewPRWorkflow is single-shot today: started by execute_workflow (blocks), deterministic id
review-pr:{team}:{owner}/{repo}:{pr}, ALLOW_DUPLICATE reuse policy, no @workflow.signal / @workflow.query
/ continue_as_new (client.py:24-57, workflow.py:246-426, types.py:39-41). Two real topologies (a third,
"per-PR cron", is just the timer mechanism either uses, not a standalone choice):
- Variant B — event-driven re-trigger (decision: build this first; smallest delta). Keep the workflow
short-lived / single-turn. Each trigger (label add, new commit, new comment, a "re-check" timer) calls
client.start_workflow("review-pr", …, id=review_pr_workflow_id(...), id_reuse_policy=ALLOW_DUPLICATE)— a fresh turn starts once the prior finished; a concurrent trigger on the same PR collapses by id. The loop has no live workflow — the durableReviewReportis the only long-lived state, exactly the "a PR review is a living document, not a job" framing the store was built for. "Loop if justified" is the router + watermarks deciding whether a triggered turn does real work or no-ops — a first turn-level no-op already ships:ReviewPRWorkflowearly-exits whenalready_published(this head reviewed + posted), see Stage 5b → Early-exit gate; the router generalizes it (comment-aware, pass-through tiers). "Ask the user and wait" is a posted question whose reply is the next webhook — no live wait. Time-based re-checks (no webhook arrived) are a per-PR Temporal Schedule or aworkflow.sleepfollow-up. Pros: reuses today's idempotent, resumable,ALLOW_DUPLICATEworkflow as-is; zero history bloat; none of thecontinue_as_newtraps; every turn is a clean, inspectable execution; restart-robust. Cons: no live "talk to / pause / inject-context mid-turn" — control is between turns; "force another turn" is a DB write + astart_workflow, not a signal to a running run. - Variant A — per-PR singleton loop (decision: the end-state; promote to it when live control is the actual ask).
ReviewPRWorkflowbecomes a long-livedwhile Truesingleton (one running execution per PR), parking between turns onworkflow.wait([wait_for_event, wait_for_inactivity], return_when=FIRST_COMPLETED)— the exact "wake on signal OR inactivity timeout" select Tasks'execute_sandbox/process_taskuse (products/tasks/backend/temporal/execute_sandbox/workflow.py:318-351,424-449;process_task/workflow.py:210-268). Webhooks signal-with-start the deterministic id (create-if-absent, else deliver);continue_as_newbounds history (carry watermark + buffered events + user-context in the input dataclass, drain in-flight handlers viaawait workflow.wait_condition(workflow.all_handlers_finished())first); the run returns to finish (PR merged/closed, idle, converged, or a max-turn budget). Copy the in-repo singletons wholesale:BufferSignalsWorkflow(products/signals/backend/temporal/buffer.py:136-205—workflow_id_for,@workflow.query get_buffer_size,@workflow.signal submit_signal, the loop,continue_as_new) andTeamSignalGroupingV2Workflow(grouping_v2.py:92-124,302-349— pause/unpause signals + a query + classmethods that drive it via signal-with-start, handlingRPCStatusCode.NOT_FOUND). Pros: live@workflow.query(stage, findings-so-far, lifecycle counts, watermarks — zero history cost) and@workflow.signal(inject-context, pause, cancel, force-turn); a durable "ask the user and wait for the reply" within a turn; the conversational agent maps straight onto it. Cons: a genuine topology change with easy-to-miss traps — drain handlers beforecontinue_as_new;continue_as_newisNoReturn(nothing follows it); Temporal stringifies int dict keys across legs so per-chunk/per-file maps must be re-cast (insights/temporal/ai_observability/trace_clustering/coordinator.py:122-129); per-legrun_timeoutresets on CAN; Modal sandboxes must be reaped within a turn so they don't leak across legs (à laexecute_sandbox's_reap_orphaned_sandbox); one long-lived workflow per open PR.
Decision: build B first, promote to A when live control / a talk-to-it agent is genuinely wanted. B is the
smallest prod-usable step — the workflow, its idempotency, its resume, and ALLOW_DUPLICATE already exist — and it
supports all six steps (each "wait" is a webhook gap, which is more restart-robust than a live wait). A is the
right home for the rich "loop till ready + talk to it + pause/inject" experience and is a clean copy of buffer.py
/ grouping_v2.py when we get there. This is the doc's own nest-then-promote rule: don't stand up a long-lived
singleton until the cross-turn prerequisites (below) are paid off and live in-run control is the real requirement.
Triggers — GitHub events → turns (assumed input, deferred)
The label trigger (à la Stamp's stamp label) and the publish identity are deferred per the maintainer;
sketched only so the loop's contract is clear. The unified webhook dispatcher already exists (github_webhook,
HMAC-verified, insights/urls.py:101-145,465-466) with installation→team resolution (_resolve_external_team,
products/tasks/backend/webhooks.py:220-232). Three advance signals, all net-new dispatcher branches (today
labeled / synchronize are dropped, issue_comment routes only to conversations, pull_request_review_comment
is unrouted — webhooks.py:78-143): label add → start the loop; synchronize (new commit) → advance a turn
(carry the new head_sha); comment on a tracked ReviewReport → advance a turn / feed step 5. In B each is a
start_workflow; in A each is a signal-with-start. Two non-negotiables from the existing handler: dedupe on
X-GitHub-Delivery + hand off fast (200/202) to Temporal, and the fork guard (head.repo == base repo,
webhooks.py:127-133) before reviewing — and certainly before implementing — on an attacker-influenced head ref.
Publish/fetch move off the static GITHUB_TOKEN onto the team's installation token (first_for_team_repository +
get_access_token, insights/models/integration.py:2504-2519, github_integration_base.py:1433) — or the
maintainer's service-user + project key — when multi-tenant identity is needed; a PR-review POST helper
(POST /pulls/{n}/reviews with inline comments) is net-new (only create_issue / create_pull_request exist).
Router (step 1) — pass-through vs review, perspective selection
Stamp already implements the deterministic half, and it's pure, dependency-free logic (gates.py:1-10 — only
re / collections / fnmatch / pathlib). But Stamp lives in tools/pr-approval-agent/ as a standalone
uv / PEP-723 script with flat relative imports — not importable app code; the repo's owned-product /
no-cross-import-from-tools/ norms say copy-and-own, not import. So re-vendor gates.py's logic (+ the
dismiss_check delta classifier) into products/review/ as a deterministic route activity at the top of each
turn:
- Pass-through = Stamp's T0 / allow-listed-only / test-only path (
is_allow_listed_only,test_only,assign_tier→T0 —gates.py:457,371,556): skip the expensive fan-out, optionally with one cheap single-turn LLM "showstopper confirm" (Stamp'sVERDICT_SCHEMA+ the gates-authoritative, LLM-tightens-never-loosens invariant,review_pr.py:459-481). - Perspective selection =
detect_deny_categories(gates.py:392) emits exactlyauth/crypto_secrets/migrations/infra_cicd/billing/public_api/deps_toolchain. Invert Stamp's semantics: where it denies (T2-never → human), Review enables the matching specialist (auth/crypto → Contracts & Security; migrations/deps → a data/dependency perspective; infra_cicd → infra/CI). Reuse the detection, drop the deny→skip routing. - Fan-out width = the T1 sub-tier (
t1_risk_subclass,gates.py:578) +scope_breadth(single / two / cross-cutting,gates.py:362): T1a-trivial → minimal pass, T1d-complex / cross-cutting → full perspective set. - Loop-continuation (steps 5–6) =
dismiss_check.evaluate_delta(dismiss_check.py:168-209): a first-parent commit walk classifying new commits trivial-vs-non-trivial (force-push detection) is the cheap "did a major change appear since last review?" gate — but driven from Review's own persistedhead_sha(already stored per turn), not from a bot-approval review Stamp posts and Review won't.
github.py (gh-CLI + local git) and reviewer.py (Claude Agent SDK) are execution-model-specific and not
reusable — Review fetches via fetch_pr_data_activity and reviews via MultiTurnSession; only the prompt/schema
design transfers. The thresholds were calibrated for "safe to rubber-stamp", not "how much review", so expect
re-tuning for Review's objective.
Future — relevance-gate the enabled perspective set per PR/chunk (noted 2026-07-02). The selection above only
adds specialists off deterministic categories; as custom perspectives multiply (e.g. a team's
review-script-perspective-sql-checks), the router should also decide which enabled perspectives make sense for
this PR at all — a SQL lens must not spend a sandbox call per chunk on a PR that touches no SQL. Two placement
options: piggyback chunking (step 4 already reads every changed file in one sandbox call and returns structured
output — feed it the enabled perspectives' names + descriptions and have it stamp the applicable ones per chunk
into ChunksList), or a separate cheap route step deciding per PR. Per-chunk (chunking) is the better cut:
relevance is genuinely per-chunk (a migrations chunk wants the SQL lens even when the rest of the PR doesn't), and
it skips exactly the irrelevant (perspective × chunk) sandbox calls — the direct spend saver as lenses multiply.
Guardrails: broad canonical lenses (logic-correctness) likely stay always-on; a deterministic file-pattern floor is
authoritative Stamp-style (*.sql / migrations/ hits force the lens ON — the LLM may add a lens, never remove
a deterministically-matched one); skipping is a per-run routing decision (the user's enablement config is
untouched); and the blind-spot sweep stays unconditional as the safety net for a wrongly-skipped lens.
Cross-turn finding identity & watermarks (the hard prerequisite)
The append-only artefact log + latest-wins-per-issue_key read (load_valid_findings, persistence.py:348-389) is
the right substrate — keep it; the loop only fixes what's positional. Six net-new pieces, in priority order:
- Cross-turn matching — and it is NOT a "stable key" problem (the reframe). The duplicate this was feared to
need a semantic key for — a prior turn's finding re-posted — was actually a publish-accumulation bug, now
fixed:
issue_keycarries arun_indexprefix (r{run}:file:start:perspective:{pass}-{chunk}-{issue},persistence.py:402) and publish is scoped to the turn (load_valid_findings(run_index=…),persistence.py:348), so a later turn never replays an earlier one's findings. Thatissue_keyis a unique occurrence id (turn-local; good for debugging) — it is not, and need not be, a cross-turn problem identity. The only thing that still needs cross-turn matching is resolve / update of our own comment (run N re-finds the problem run 1 commented on), and that match is a "same concrete problem?" judgment the dedup LLM already computes every turn against prior comments. A normalized title/body signature or content hash is the wrong tool — brittle to re-phrasing and line drift, and a human-readable name doesn't help either. So the loop work is persist the dedup's existing match + the GitHub comment id and act on that comment — not invent a key/name. - Per-finding lifecycle status.
ReviewReport.status(ACTIVE/IDLE/CLOSED) is the report's job-state, not a finding's. Add a lifecycle field on the finding (newly-appeared / still-open / resolved-by-a-new-commit / superseded) and turn-over-turn diffing to set it. - Decouple the comment watermark.
last_seen_comment_idonly advances insidepersist_commit_snapshot, gated onhead_shamovement (persistence.py:104-132) — so a comments-only change (step 5's "new comments appeared" / "ask the user") never advances it and never triggers a turn. Add an independent advance + a "new comments since the watermark → justify a turn" trigger. - Resolve / supersede detection. Per-turn
commitartefacts already store each turn's reviewed diff, but nothing compares prior-vs-current to mark a finding's lines fixed. The data exists; the consumer is net-new. - Writers for
note+task_run. Both types are registered but have zero writers (persistence.py:21,125;artefact_content.py:139-158). Step 5's "ask the user, store for self-improvement" → anote(author = user viaArtefactAttribution.from_user, read by the next turn's prompt); each turn's sandbox runs →task_run; the implemented-fix commit → anothercommitartefact. - Persist the route decision + a CLOSED transition. Nothing records "passed through" vs "reviewed", or which
perspectives ran (re-resolved fresh each run), and
status.CLOSEDis never written. Persist the per-turn routing decision (anoteor a small field) and transition to CLOSED on PR merge/close.
Load-bearing: publish no longer accumulates —
load_valid_findingsisrun_index-scoped (persistence.py:348), so it posts only the current turn's findings (the cross-turn re-post is gone). What's still missing for the action variants is #2 (lifecycle) — so we can resolve/update a prior comment instead of leaving it; that rides the dedup's same-problem match above, not a new key. This lands before publish is re-enabled for the loop.
Action plane — "implement the fixes" (step 4)
Today Review only reviews + comments; editing code is net-new. Three tiers (verified against the real Tasks write path):
- C — native GitHub suggestion blocks (decision: first; small). Emit a one-click suggestion block inside the
inline comment Review already posts (today
_format_issue_commentrenders prose,publish_review.py:70-142); rides the existing gated publish path almost unchanged (pr.create_review(event="COMMENT", …),publish_review.py:202-238). Net-new: the validator must emit a line-accurate literal replacement (a finding-schema field — todaysuggestionis prose) landing on the diff's RIGHT side (the position finder already drops the rest,publish_review.py:145-199). Safe by construction — inert until a human clicks, no auth/push/branch — so it's prod-usable the moment the publish flag flips. #1 (no cross-turn re-post) is now done by turn-scoped publish; spam-safety across turns still needs #2 (lifecycle), so doing C first forces that to land. - A — delegate true implementation to the Tasks agentic-coding engine (decision: next; medium). Verified: the
Tasks sandbox already does edit→commit→push→open-PR.
agent-server --createPr <bool> --baseBranch <branch>(products/tasks/backend/logic/services/modal_sandbox.py:683-696) is the engine; the sandbox is handed a real GitHub token rewritten into both the git remote (git remote set-url origin https://x-access-token:<token>@github.com/<repo>,products/tasks/backend/temporal/process_task/sandbox_credentials.py:49-67) and the env, refreshed mid-run, with an explicit bot/installation-token authorship mode (PrAuthorshipMode,sandbox_credentials.py:149,240-276). So Review (behind the publish flag + per-PR opt-in) builds an implement-prompt from the kept findings, creates a Task via theproducts/tasksfacade targeting the PR repo at its branch withcreate_pr=True, and Tasks opens a companion PR (never force-pushes the author's branch — satisfies "never auto-push without opt-in" for free) and persists its ownTaskRun; Review writes atask_runartefact linking finding→task (the dedup key: only enqueue still-open findings with no prior linked task). Cost: a full agentic sandbox per fix-batch is the most expensive tier — the "loop if justified" gate must throttle it. This is notcode_workstreams(that's a scheduled PR poller / grouper,code_workstreams/workflow.py:1-27) — it'sprocess_task/execute_sandbox. - B — a Review-owned write sandbox (decision: skip). Re-derives at large effort exactly what A reuses
(writable checkout, push credentials, branch / PR creation, fork-safety) — today's executor is read-shaped
(shallow, single-branch, returns JSON,
executor.py:49-74). Only if cross-product coupling to Tasks proves unacceptable.
Decision: C now (forces the persistence prerequisites, prod-usable the moment publish flips), A next (opt-in real fixes via a companion PR, maximum reuse of a verified engine), skip B.
Conversational / control surface (optional; channel-agnostic)
Per the maintainer the interaction channel is pluggable (Slack, GitHub comments, Insights Desktop, …) and out of
scope — design the durable part, leave the UI a thin adapter. Must-have (ships with Variant A): @workflow.query
for live state (stage, findings-so-far, lifecycle counts, watermarks — zero history cost; copy get_buffer_size /
get_paused_state) and @workflow.signal for inject-context / pause / cancel / force-turn (copy submit_signal /
set_paused_until). These are how steps 5–6 are even possible in the singleton; in Variant B their stand-ins are a
DB read and a start_workflow. Nice-to-have (defer): a resumable talk-to-the-run agent maps onto Tasks'
execute_sandbox interactive mode (signal-driven follow-ups with ack-dedup + resume snapshots,
execute_sandbox/workflow.py:549,1020) — a ready-made template, but heavy. "Store context for self-improvement"
is a per-turn note artefact now (cheap, read by the next turn's prompt); editing the team's perspective /
validation skills from feedback (learning across PRs) is a much larger effort, later.
Recommended build order
- Cross-turn lifecycle + watermarks (#2–#3) — the duplicate/accumulation (#1) is already fixed by turn-scoped publish; what's left is resolve/update (reuse the dedup's match) + the comment watermark. Independent of topology.
- Variant C (suggestion blocks) behind the existing publish flag — exercises #1/#2 end-to-end and is the first prod-usable fix tier.
- The router as a deterministic
routeactivity (re-vendored gates) — pass-through + perspective selection + the loop-continuation delta gate. - Variant B control plane — event-driven re-trigger; the living
ReviewReportis the loop's memory. At this point the six-step loop runs end-to-end (webhook/timer-driven), publish flag flippable. - Variant A +
@workflow.query/@workflow.signal— promote to the per-PR singleton when live control is the real ask; copybuffer.py/grouping_v2.py. - Variant A action upgrade — delegate real fixes to Tasks (companion PR),
task_runlinkage, throttled by the "loop if justified" gate. - Deferred: the talk-to-the-run conversational agent; per-team-skill self-improvement; the
head_shacheckout pin (conditional step 16) becomes required here ("reviewed == recorded" under looping).
Risks / decisions already made
- Topology = B-then-A (not A-first): smallest prod-usable step; defers the
continue_as_newtraps until live control is genuinely needed. - Router = copy-and-own Stamp's pure gate logic into
products/review/, invert deny→enable; don't cross-importtools/. - Fix engine = reuse Tasks (A), not a new write sandbox (B). The Tasks write/push/createPR path is verified; the contents-write identity is Tasks' installation-token authorship mode, not a new Review credential.
- Publish accumulation is fixed (turn-scoped
load_valid_findings); lifecycle is what remains — resolve/update of our own comments still needs the dedup's cross-turn match persisted before publish is re-enabled for the loop. - Fork-PR safety (
head.repo == base) gates any review, and hard-gates implementing, on attacker-influenced head refs. - Sandbox-leak across
continue_as_newlegs (Variant A) — reap within the turn, à laexecute_sandbox. - Triggers + publish identity are assumed / deferred per the maintainer (label later; App installation token or service-user + project key).
What to verify in iterative / loop e2e runs (next PR)
The first multi-turn run (#66456, 2026-06-29) proved run-scoped publish (no cross-turn re-post) but left the
covered-set inert — its prior findings predated run_index, failed to parse, and never reached the prompt. A
clean DB + fresh PR (every finding stamped) is where it actually fires. Wipe ONCE to start the experiment —
DEBUG=1 python manage.py reset_review --yes (deletes every ReviewReport + ReviewReportArtefact across all
teams; DEBUG-only; GitHub comments untouched) — then do NOT wipe between turns: the accumulating DB is the loop's
cross-turn memory (covered-set, same-head resume, and watermarks all read prior-turn rows). Per turn N (N≥2):
- No duplicate. Bot comments grow only by genuinely-new findings; none byte-identical to a prior turn's. DB:
findings stamped with the turn's
run_index;load_valid_findings(run_index=N)returns only turn-N findings. Red flag: a prior turn's finding re-posted, or a missingrun_indexon a fresh PR. - Covered-set fires (the still-unproven part). Pull a turn-N review prompt from the agent log
(
task_run.log_url); the<already_covered_findings_for_chunk>block must be present and list prior turns' findings on that chunk's files. Red flag: block absent on turn ≥2, orrun_index Field requiredparse warnings (pre-migration data → covered-set empty). - Its unique value = DB-only findings. The covered-set's payoff over the existing comment-avoidance path is the
sub-
should_fix/is_valid=Falsefindings we keep in DB but never post. Confirm one appears in the covered set and is NOT re-investigated/re-validated that turn. - Cost drops only where it can. The win is fewer findings reaching the validate fan-out (the expensive per-issue stage), NOT a cheaper review pass — that's fixed (every chunk is read every turn). Compare findings-into-validate turn-over-turn; it should fall as covered ground grows, the review pass won't.
- Watermarks + gate.
run_countbumps once per finalized turn;published_head_shaadvances only on a real post (a no-op publish leaves it); a re-trigger at an already-published head early-exits (no review). - Off-diff valid findings. A valid finding on a non-added line is dropped at publish (no inline position) — intended, but watch it isn't silently dropping one you'd want surfaced.
🚀 Stage 5 — production trigger: label → review → publish (BUILT 2026-06-26)
Status: BUILT (Phases 1–4) 2026-06-26. Code landed (uncommitted), 218 product tests + ruff + tach green. This resolves Stage 4's deferred "triggers + publish identity" note. The single-turn
ReviewPRWorkflow(Stage 3, step 15) is the body; this section is how it gets started in prod and how it publishes. Publish capability was validated 2026-06-26: the GitHub App installation token posted a comment to PR #64651 → HTTP 201, headerX-Accepted-GitHub-Permissions: issues=write; pull_requests=write. So no dedicated Review GitHub App is needed — the existing integration's installation token posts reviews. What changed vs the original design (see "Build order" below): the endpoint returns the workflow id (not a report id — the report row is created inside the run's fetch activity, after the non-blocking start); fork rejection moved to the Action gate + the fetch activity (authoritative, server-side) rather than the endpoint (which stays free of GitHub I/O); and the worker'sGITHUB_TOKENenv dependency is gone — fetch and publish both resolve the team's installation token server-side. Not yet: Phase 5 label lifecycle (strip/keep-on-error/dismiss-on-push) and the Stage 5b iterate-on-same-PR validation.
Goal (maintainer). A maintainer adds the review label to a non-fork PR in Insights/insights → Review
reviews it and posts the review back to the PR. v1 scope: team 2 (the main team) in prod, Insights/insights
only, non-fork only, trigger + publish together (not shadow-first).
Architecture — "Action calls the app" (Path A). Review is app-embedded (Temporal workflow + the Tasks
Modal sandbox + Postgres + DB-synced LLMA skills) — it cannot run on a GitHub Actions runner the way Stamp
does. So the label trigger is a thin GitHub Action that calls a Insights endpoint, which starts the Temporal
workflow; the workflow fetches + publishes server-side via the GitHub App installation token. Rejected:
Path B (label → the existing webhooks/github/pr dispatcher → Temporal, no CI — diverges from the team's
Stamp model) and Path C (re-platform Review as a standalone CI script — discards the whole Temporal
pipeline just built + hardened).
review label on a non-fork Insights/insights PR
└─ .github/workflows/review-script.yml (gates: label==review by a human, head.repo==base.repo, concurrency; any author)
└─ one authenticated curl → POST /v1/review/trigger {repo, pr_number} (Authorization: Bearer <secret>)
└─ endpoint: verify shared secret · validate repo allowlist (forks blocked upstream by the Action
+ downstream by the fetch activity) · resolve team-2 integration + run user
· start_workflow (NON-blocking) · 202 + workflow_id
└─ ReviewPRWorkflow (Stage 3, unchanged) — fetch/publish via the App installation token
└─ publish: head_sha-pinned COMMENT review posted to the PR
Why Review ≠ Stamp (and what we borrow). Stamp (.github/workflows/pr-approval-agent.yml +
tools/pr-approval-agent/) is self-contained in CI: uv run a standalone Claude-Agent-SDK script on the runner;
its own dedicated GitHub App (GH_APP_PR_APPROVAL_AGENT_*), its own STAMPFN_ANTHROPIC_API_KEY, and github.token
(github-actions[bot]) for approvals (App-bot approvals show author_association: NONE, so don't count toward
branch protection). Its INSIGHTS_API_TOKEN is an analytics ingestion key (hanzo_insights.capture) — not
endpoint auth, so Stamp is no precedent for the trigger credential (it never calls an app endpoint).
Borrowed: the label trigger, the non-fork + bot-author gates, run-script-from-master untrusted-code safety,
head_sha-pinned review posting (commit_id= avoids force-push drift), and the v2 label lifecycle. Differs:
Review runs the LLM + fetch + publish server-side, only posts COMMENT reviews (no approve → no
github-actions[bot] trick), and its Action carries one secret (no Anthropic / GitHub-app keys in CI).
Decisions locked (maintainer):
- Auth = a plain shared secret, NOT PSAK. The endpoint compares the
Authorizationheader to asettings.REVIEWFN_TRIGGER_TOKEN— exactly how the existing GitHub webhook endpoint is gated byGITHUB_WEBHOOK_SECRET. A PSAK + a newreview:runscope is over-engineered for a single-team dogfood (and the "onlyendpoint:readexists today" is just because PSAK is new, not a reason). Graduate to PSAK only if this becomes a multi-caller service needing per-key rotation / throttles / audit (adding-project-secret-api-key-auth). - GitHub identity = the existing App installation token — no dedicated Review GitHub App. Local =
insights-local-devapp (Integration rowid=6, team 1, installation142734631,created_by=user 1); prod = the realinsightsApp on team 2. Resolved server-side viaGitHubIntegration.first_for_team_repository(team_id, "Insights/insights").get_access_token()— never a CI secret. - Run
user_id= resolved server-side (team-2integration.created_by, or aREVIEWFN_RUN_USER_IDsetting). The sandbox fan-out needs a real Insights user; the CI caller never carries or chooses it. (A new dedicated service user is optional polish, not required.) - Publish control = a per-run
publish: boolinput that replaces the globalPUBLISH_REVIEW_ENABLEDconstant (constants.py:11, checked inpublish_review_activity). The trigger endpoint setspublish=true; the eval CLI setsfalse. Cleanly separates "real run posts" from "eval run doesn't" — no team-wide flag, no feature flag. - The endpoint is the durable reusable interface — the label Action is its first client;
publishand the PR ref are per-request, so other callers (manual, future automations) reuse it unchanged. - Both entry points stay first-class — the label is an entry, not the only one. The existing
manage.py run_reviewCLI remains fully usable for manual / eval / debugging runs (blockingexecute_workflow, returnsreport_id, default no-publish, with an optional--publishto post); the label → endpoint path is the non-blocking,publish=trueentry. Both drive the sameReviewPRWorkflow— the trigger work adds an entry point, it does not remove the CLI.
Build order (as built — Phases 1–4 done; Phase 5 + Stage 5b pending):
- ✅ Trigger-ready (internal):
start_review_pr_workflow(client.start_workflow, non-blocking, returns the workflow id) added alongside the still-blockingexecute_review_pr_workflowthe CLI uses; both share_build_inputs.publish: boolthreaded throughReviewPRWorkflowInputs(defaultFalse) → the workflow's stage-9 gate is nowif inputs.publishand the globalPUBLISH_REVIEW_ENABLEDconstant is deleted;run_reviewgained--publish(default off). The PAT → installation-token swap landed in the fetch activity (_installation_token(team_id, repository)→GitHubIntegration.first_for_team_repository(...).get_access_token()), dropping the worker'sGITHUB_TOKENenv dependency. - ✅ The endpoint (
products/review/backend/api/trigger.py+routes.py, mounted unscoped at/v1/review/trigger): plainviewsets.ViewSet,@action-+-@extend_schema, shared-secret check (hmac.compare_digestvssettings.REVIEWFN_TRIGGER_TOKEN, fail-closed outside DEBUG/TEST), repo allowlist ({"insights/insights"}, case-insensitive), team fromsettings.REVIEWFN_TEAM_ID, run user fromsettings.REVIEWFN_RUN_USER_ID(fallback: the team's GitHub integration creator),start_workflowwithpublish=true, returns202 + {workflow_id, status}. Fork rejection is not here (no GitHub I/O) — it's the Action gate + the fetch activity. - ✅ Publish for real:
_post_github_reviewandPRFetchernow take a passed-in installation token (no env); the review is pinned to the reviewedhead_shaviacommit=repo_obj.get_commit(head_sha). Fork rejection is authoritative in the fetch activity (PRMetadata.is_fork, non-retryableApplicationErrorbefore the report row is created). - ✅ The Action:
.github/workflows/review-script.yml—on: pull_request_target [labeled],permissions: {}, per-PR concurrency,if:gates (human-applied label + non-fork; any author — unmapped authors resolve via the acting-user fallback), onecurlwith the bearer secret.pull_request_targetruns the base branch's version of the file (a PR can't edit its own trigger) and fires even on conflicted PRs; it exposes secrets to fork PRs, so the non-forkif:gate is the fork barrier — safe because the job never runs PR code. Residual gap: stacked PRs whose base branch predates this file still dispatch nothing (no workflow on the base). A pairedbot-labeler-skipjob (Stamp's pattern) turns the bot-labeler skip into feedback: it comments why and strips the label so a human can re-add it. 2026-07-14 update:synchronizedropped (ADR 0002) — pushes no longer re-trigger; re-review = re-add the label. - ⏳ Later (v2): label lifecycle (strip-on-non-approval / keep-on-error / dismiss-stale-on-push). (
synchronize/ready_for_reviewevents are already wired in step 4.) Then Stage 5b (iterate-on-same-PR validation).
Deploy prerequisites the maintainer provides (before the prod label trigger works):
- ✅ Team 2 (prod) has the
insightsGitHub App installed forInsights/insights(maintainer-confirmed 2026-06-26; also satisfied locally on team 1 viainsights-local-dev). Still confirm before turning publish on in prod: thepull_requests=writevalidation (the duck comment) was on the separateinsights-local-devapp — confirm the prodinsightsapp's installation also grantspull_requests: write. If it's read-only, publishing needs a permission bump or a dedicated app. settings.REVIEWFN_TRIGGER_TOKEN— generate a value (openssl rand -hex 32), put it in both the Insights deploy config (envREVIEWFN_TRIGGER_TOKEN, read ininsights/settings/access.py) and the GitHub Actions secretREVIEWFN_TRIGGER_TOKEN. Shared password between CI and the app; the only secret the Action carries. Unset = endpoint fails closed (403) outside DEBUG/TEST.settings.REVIEWFN_TEAM_ID=2(envREVIEWFN_TEAM_ID). Unset ⇒ the endpoint returns 503.settings.REVIEWFN_RUN_USER_ID— optional; unset falls back to the team-2 GitHub integration'screated_by.- (Optional) GitHub repo/org variable
REVIEWFN_ENDPOINT_URLto override the Action's defaulthttps://us.hanzo.ai/api/review/trigger/.
File pointers (where the work landed):
backend/temporal/client.py—execute_review_pr_workflow(blocking, CLI, returns report_id) + newstart_review_pr_workflow(start_workflow, non-blocking, returns workflow_id); shared_build_inputs.backend/temporal/types.py—ReviewPRWorkflowInputs.publish: bool = False.backend/temporal/workflow.py— stage-9 gateif inputs.publish(thePUBLISH_REVIEW_ENABLEDconstant is gone).backend/temporal/activities.py—_installation_token(team_id, repository); fetch activity resolves the token + rejects forks (PRMetadata.is_fork, non-retryable);_publishforwards token +head_sha.backend/reviewer/tools/github_meta.py(PRFetcher(__init__ token=...),is_forkonfetch_pr_metadata) andbackend/reviewer/tools/publish_review.py(publish_review/_post_github_reviewtaketoken+head_sha,commit=-pinned) — envGITHUB_TOKENfully removed.insights/models/github_integration_base.py:1433(get_access_token, auto-refresh) ·insights/models/integration.py:2505(first_for_team_repository).- New:
products/review/backend/api/trigger.py+backend/routes.py;insights/settings/access.py(3 settings);.github/workflows/review-script.yml. Tests:backend/tests/test_trigger_api.py,backend/reviewer/tests/test_publish_review.py, publish-gate cases inbackend/tests/test_temporal_workflow.py, fork-flag case inbackend/reviewer/tests/test_github_meta.py. - Reference:
.github/workflows/pr-approval-agent.yml+tools/pr-approval-agent/(Stamp).
🔁 Stage 5b — iterate on the same PR (⏭️ THE NEXT STEP — run WITHOUT publish first)
Status: NEXT STEP, not yet run. Re-triggering has never been tested, so the first re-trigger run must be without publishing (
run_reviewwithout--publish, or the endpoint withpublish=false) — verify the re-trigger mechanics + dedup behavior safely before letting it post anything. Stage 5 (single turn) is e2e-proven (full publish on #66168, 2026-06-26); this validates the loop-readiness of a second turn.
Goal. Re-trigger the same PR (no publish) and confirm a second turn does not re-surface findings it already accounted for — it consumes prior context instead of duplicating, and only genuinely new findings (new commits / newly changed lines) would survive. #66168 is the ideal target: the 2026-06-26 publish run already posted Review's own inline comments there, so a no-publish re-trigger exercises the "consume-our-own-comments" dedup without posting anything new — strictly safe.
✅ Early-exit gate — a stale re-trigger is now a no-op (BUILT 2026-06-29). Before this, a re-trigger at an
unchanged head still ran the whole pipeline: it reused the turn-stable sandbox stages (chunk_set /
chunk_analysis / perspective_result via the head-scoped resume) but re-ran dedup + per-issue validation
(fresh sandbox cost) and bumped run_count, only for publish to self-skip (published_head_sha == head_sha) and
post nothing. Now ReviewPRWorkflow short-circuits right after the fetch stage: the fetch activity returns
ReviewMeta.already_published (bool(head_sha) and report.published_head_sha == head_sha) and the parent
returns the report id immediately when it's set — no sync-skills / schema-gen / chunk / analyze / review /
dedup / validate / publish, no run_count bump. published_head_sha == head_sha is the deliberately precise
gate key: it means this exact head was already reviewed and posted, so it can't skip an incomplete review
(publish only runs at the very end), can't skip a turn that still needs to publish (a prior no-publish turn
leaves published_head_sha unset → gate doesn't fire → the cheap resumed pipeline runs and publishes), and can't
skip a moved head (different sha). A no-publish eval run is never gated (no published head), so the frozen-PR
eval loop still recomputes to measure reviewer changes. New inline comments are counted + logged at fetch
(ReviewMeta.new_comment_count) but do not force a turn — see the comment-reaction decision below.
(temporal/activities.py _fetch_and_persist + ReviewMeta; temporal/workflow.py gate after STAGE 1; test
test_review_pr_workflow_early_exits_when_already_published.)
📌 New comments at an unchanged head → nothing, for now (decision 2026-06-29). When important new human/bot
review comments appear but the SHA hasn't moved, Review currently does nothing: there's no comment-event
trigger (the Action fires only on [labeled, ready_for_review] since ADR 0002), and a manual same-head re-trigger
hits the early-exit gate above (or, pre-publish, only re-feeds dedup). This is the right scope while Review
only reviews. It changes once the Action plane ("not just find issues — fix them", see Stage 4 →
Action plane) lands: a human reply ("this is wrong" / "please fix this" / answering a finding) should then
advance a turn — to respond, re-evaluate, or implement. That needs the three deferred pieces already on the
roadmap: a pull_request_review_comment / issue_comment → trigger branch, the decoupled comment watermark
(last_seen_comment_id advanced independently of head_sha — Cross-turn finding identity #3), and cross-turn
finding identity. The early-exit gate is structured for this flip: it already surfaces new_comment_count, so the
condition becomes "skip unless the head moved or new comments arrived" the moment comment-reaction is in scope.
Future behavior (when the action plane is implemented — TODO, not built). Once Review can fix issues, a tracked PR should be polled on a cadence (≈ every 30 min) — a per-PR Temporal Schedule /
workflow.sleepre-check (the Variant B timer in Control plane) — and on each tick: (1) read new comments since thelast_seen_comment_idwatermark; (2) for each genuinely new issue raised (by a human or another bot), implement the fix (Action plane tier C/A — suggestion block or a companion-PR via the Tasks engine); (3) reply to the comment that raised it (the conversational surface), then advance the watermark so each comment is acted on exactly once. This couples the comment trigger (Cross-turn finding identity #3), the action plane, and the conversational reply into one loop turn — and is exactly why the early-exit gate must learn to wake on new comments, not just a moved head.
What it exercises (already coded):
- Cross-turn positional dedup (step 14).
deduplicate_issuesdrops any finding that collides (same file + overlapping lines) with any prior inline comment on the PR — including Review's own from a prior turn (re-fetched viaPRFetcher.fetch_pr_comments→get_review_comments()). With #66168 already carrying our comments, turn 2's dedup should suppress the previously-postedshould_fix+ findings → strictly fewer surviving findings, and (publish off) nothing posted. - Deterministic per-PR workflow id + reuse/conflict policy (
review_pr_workflow_id,client.py): a re-trigger at the same head joins the in-flight run (USE_EXISTING) or, once closed, starts a fresh turn (ALLOW_DUPLICATE). NOTE: a same-head re-trigger after a successful publish now skips the whole turn via the early-exit gate above (already_published) — strictly more than the old publish-only skip. head_sha-scoped resume: for a re-run at the same head that the gate does not short-circuit (a no-publish run, or a not-yet-published head), the pipeline reuseschunk_set/chunk_analysis/perspective_resultand only re-derives dedup + validation; a new head re-snapshots and re-reviews.
🔁 How a repeated run actually spends — we investigate-then-filter, not filter-then-investigate (clarified 2026-06-29).
The pipeline order is search → combine → scope-clean → dedup → validate (workflow.py stages 4→7): the
perspective agents investigate each chunk in full first, and de-duplication is a post-hoc filter, not a
pre-investigation skip. The only thing ahead of the dedup LLM call is _select_dedup_candidates, a deterministic
positional pre-filter that just decides which already-found findings are worth sending to the dedup LLM — it
does not reduce the search work. Consequences for cost on a re-run:
- The search agents do see prior PR comments — as soft context, not a hard skip.
build_chunk_prompt_contextinjects{{PR_COMMENTS}}into both the review and chunk-analysis prompts (<pr_comments_for_chunk>), and Step 3 of each prompt instructs the agent to read all comments on the chunk's files and list issues already identified / the author already agreed to fix. But it's only inline review comments that survivefetch_pr_comments(no general/issue comments; test + filtered paths dropped;id/created_atstripped), it's scoped to that chunk's files, and it is prompt guidance — the authoritative collapse is still the downstream dedup, which likewise matches only against PR comments. - The search agents do not see our own sub-threshold history. Sub-
should_fix(CONSIDER) andis_valid=Falsefindings live only asissue_finding/validation_verdictDB rows — never posted (publish filters tois_valid=True ∩ {MUST_FIX, SHOULD_FIX} ∩ on-diff), so never inPR_COMMENTS, and neither the search agents nor the dedup read DB findings. So they are re-discovered and re-validated every turn (real sandbox $), then dropped again at publish — never reconciled. This is precisely the gap below. - Where the spend lands: a new head re-runs chunk/analyze/review from scratch (no resume) → full re-investigation cost each turn; a same head reuses chunk/analyze/review from DB working-state but still re-runs dedup + per-issue validation (validation is the expensive repeated stage), unless the early-exit gate short-circuits the whole turn (already-published head). So the wasted spend is concentrated in re-investigating a moved head and in re-validating findings we already judged on a prior turn.
The remedy, partly built: the covered-set now feeds prior turns' DB findings (incl. sub-should_fix) into the
review prompt (load_prior_findings → build_review_prompt), so the search agents skip already-covered ground
instead of re-deriving it. Still to come: carry the prior verdict forward so we also skip re-validating a
finding we already ruled on — matched by the dedup's same-problem judgment against (prior PR comments ∪ prior DB
findings), not a stable key.
⚠️ The sub-should_fix / invalid re-propose gap (raised 2026-06-26 — the key thing to fix for the loop).
We persist every post-dedup finding (all priorities incl. CONSIDER) as issue_finding rows and every
verdict incl. is_valid=False (_persistable_findings has no priority filter; persist_verdict(s) stores all).
The PR only ever receives the publishable subset (is_valid=True ∩ {MUST_FIX, SHOULD_FIX} ∩ on-diff). So a
finding we saw and decided not to surface (a CONSIDER, or one the validator dropped) lives only in our DB,
never on the PR — and the dedup reads PR comments only. The covered-set now closes the re-discover half:
prior DB findings are fed into the review prompt (load_prior_findings), so the search agents are told what's already
covered and don't re-derive it. The re-validate half remains — a re-found finding still reaches the per-issue
validator. Remaining fix (the loop's reconcile step):
- Match a re-found finding to its prior record — via the dedup's "same concrete problem?" judgment (already
run each turn against prior comments), extended to prior DB findings. Not a
file + normalized-location + root-causesignature key — that's brittle to re-phrasing and line drift. See Cross-turn matching above (Stage 4). - Reconcile each turn's raw findings against the report's prior DB findings, not just PR comments. Before
re-validating/surfacing, match new findings to prior ones by that identity and carry the prior decision
forward — skip re-validating a finding we already ruled
is_valid=False, skip re-surfacing aCONSIDERwe already filed, and only spend sandbox turns on genuinely new findings. Dedup against (prior PR comments ∪ prior DB findings). - This subsumes the "finding lifecycle" item (resolve/update/close our own prior finding when the code changed) and is the hard prerequisite for the loop — must land before publish-on-loop.
Other gaps:
- Promo comment — fixed 2026-06-26: posted once per report (
post_promo = published_head_sha is None), and the watermark is only recorded on an actual post, so a no-op turn doesn't consume it. - Confirm the fetch returns our own comments — dedup only sees comments that survive
fetch_pr_comments(filtered paths / test files dropped; line-less chatter excluded by_comment_line). Verify Review's own inline comments come back so the loop converges.
How to run (no publish): manage.py run_review --pr-url <#66168> --team-id 1 --user-id 1 (NO --publish),
twice. Assert turn 2 produces strictly fewer surviving post-dedup findings than turn 1 (the previously-posted
ones are deduped against the PR comments), and log turn-over-turn counts in eval/RUN_LOG.md. Restart the worker
first so the latest fixes are loaded.
⏭️ Publish-path gaps found 2026-06-26 (next steps):
- Don't silently drop valid findings that have no diff position — surface them anyway.
_build_inline_commentsskips any valid finding whose line isn't inside the PR diff hunk (_find_valid_comment_position→None, logs "No valid diff position … skipping"); and if every valid finding is off-diff,publish_reviewreturnsFalseand posts nothing at all — not even the review body. Observed on #66456: of 2 validSHOULD_FIXfindings,insights/email.py:276had no resolvable diff position and was dropped, onlyemail.py:104posted inline. A valid finding should never be lost just because its line isn't in a diff hunk (it can be valid yet reference a line just outside the changed range, or the line map can be imperfect). Fix idea: keep the inline path as primary, but for any valid finding that can't be positioned, fall back to surfacing it — either (a) an "Other findings (off-diff)" section appended to the review body listing title +file:line+ suggestion, or (b) a general PR issue comment. Optionally snap to the nearest in-diff line when one exists before falling back to body-side. Either waypublish_reviewmust post the body whenever there are valid findings, even if zero inline comments resolved.- OPEN QUESTION (unresolved — keep visible until designed): how do we make off-diff surfacing reliable for
every valid finding that doesn't fit the diff, not just patch one case? GitHub only accepts inline review
comments on lines present in the diff, so a valid finding can legitimately reference code outside the changed
hunk (context lines, a caller/callee, a line the model mis-numbered, or an imperfect diff-line map). The design
must decide, generically: when does a finding go inline vs. body-side vs. general comment; how to attach it
to the nearest meaningful location; how off-diff findings participate in cross-turn dedup (they have no inline
comment to dedup against — see the finding-identity work); and how to keep them from being noisy. On #66456
one such finding (
email.py:276, duplicate-emails bug) was posted manually as a one-off general comment — that is a stop-gap, not the answer. No implementation now; this question must stay on the roadmap.
- OPEN QUESTION (unresolved — keep visible until designed): how do we make off-diff surfacing reliable for
every valid finding that doesn't fit the diff, not just patch one case? GitHub only accepts inline review
comments on lines present in the diff, so a valid finding can legitimately reference code outside the changed
hunk (context lines, a caller/callee, a line the model mis-numbered, or an imperfect diff-line map). The design
must decide, generically: when does a finding go inline vs. body-side vs. general comment; how to attach it
to the nearest meaningful location; how off-diff findings participate in cross-turn dedup (they have no inline
comment to dedup against — see the finding-identity work); and how to keep them from being noisy. On #66456
one such finding (
- CLI
--publishis silently ignored when a run for the same PR is already in flight.execute_review_pr_workflowuses a deterministic per-PR workflow id +id_conflict_policy=USE_EXISTING. If a no-publish run is mid-flight and you invoke again with--publish,execute_workflowjoins the runningpublish=Falseexecution and blocks on it — the newpublish=Truenever reaches a workflow (confirmed on #66456: the single execution's start input waspublish=Falseandpublish_review_activitywas scheduled 0×; the--publishinvocation attached to the already-running no-publish run). USEEXISTING is _correct for the production label trigger (wherepublishis always True and asynchronizepush must not 500 or double-run). For the CLI this is a footgun: either detect an in-flight run for the PR and warn that--publishwon't take effect until it closes (re-run after it finishes —ALLOW_DUPLICATEthen starts a fresh turn that honors the flag), or simply don't overlap runs. Not a code defect — the publish path itself works.
✅ Stage 5c — UI trigger: review any installation-accessible PR from the Code review scene (BUILT 2026-07-16)
Status: BUILT 2026-07-16 (same-day decision round with the maintainer, then implemented — see "As built" at the end of this section). Not yet e2e'd against a live non-
insights/insightsPR.
Motivation. The label trigger needs .github/workflows/review-script.yml in every repo it serves — only
Insights/insights has it, so every other insights/* repo is out of reach unless we replicate the workflow
file across the org. Instead: a "Review this PR" field in the Code review scene that starts the same
server-side review for any PR the team's GitHub App installation can access — no per-repo CI configuration
at all. Everything below the trigger already supports this: _build_inputs takes any owner/repo, the
fetch activity resolves the installation token per repository (first_for_team_repository), fork rejection
is authoritative server-side, and the deterministic per-PR workflow id + USE_EXISTING absorbs re-triggers.
Decisions locked (maintainer, 2026-07-16):
- Always publish — no per-run toggle. The UI trigger is label-path parity for repos the label can't
reach;
publish=Trueis fixed. Results are additionally viewable in the findings drawer as usual. - Team gate =
settings.REVIEWFN_TEAM_IDonly (prod team 2; local team 1). The endpoint rejects any other team. No server-side feature-flag check — the scene'sREVIEWflag gates discoverability only, and the hard team check bounds the cost surface for now. Widening beyond the dogfood team is a later, deliberate decision. - Requester wins. The acting user is the requesting Insights user via the existing
acting_user_idoverride (resolved_from="override"): their perspectives / blind-spots / validator / urgency threshold drive the run, and the report lands in their "For you" recent-reviews scope. The PR author'sreview_labeled_prsopt-out stays label-only (its copy is label-specific; an explicit human ask is like a manual CLI run). The GitHub author can be anyone, including a bot — no author→user mapping is needed. - Repo scope = installation access, not a hardcoded org list.
GitHubIntegration.first_for_team_repository(team_id, repository)is the boundary — for team 2 that IS theinsightsorg — checked synchronously at trigger time so an inaccessible repo 400s immediately with a clear message. The label endpoint's "no GitHub I/O" principle deliberately does not apply here: that endpoint has the Action's upstream gates; the UI has none, and a silent async failure (the fetch activity fails before the report row exists) would leave the user staring at nothing.
Design shape (v1):
- New team-scoped, session-authenticated trigger action (e.g.
POSTtriggeron thereview/reviewsviewset), takingpr_url. The unscoped shared-secretReviewTriggerViewSetand itsALLOWED_REPOSstay untouched as the CI interface — the two callers have different auth and scope rules, so they don't share an endpoint. - Sync pre-checks in the action: parse the URL (
PRParser), team gate, installation-access check (above). Optionally one PR-meta fetch to reject 404s/forks synchronously — authoritative fork rejection stays in the fetch activity either way. Drafts remain reviewable + publishable (existing posture; the UI copy should say the review is posted to the PR). start_review_pr_workflow(pr_url=…, publish=True, acting_user_id=request.user.id, trigger_source=TRIGGER_UI)withuser_id = request.user.idtoo (inbox precedent: the run user must be a real active org member — the requester is one by construction). NewTRIGGER_UI = "ui"constant beside label/inbox/manual; ungated in the workflow's gate map (explicit ask); update thetrigger_sourcecomment onReviewReport.- Frontend: a PR-URL input + submit button in
CodeReviewScenenear "Your recent reviews" (reviewSettingsLogicaction + generated client; button disabled/loading in flight). On 202, reload the recent-reviews list — the row appears once the fetch activity creates the report, and the existing in-progress poll takes over. Hide the field when the trigger isn't available for the team (surface acan_triggerboolean, e.g. on the settings GET, computed from the team gate). - Accepted / out of scope for v1: no rate limit beyond the per-PR workflow-id dedupe (internal dogfood
team; revisit if abused); PR URLs only (no branch targets from the UI); the label Action keeps working
unchanged for
Insights/insights.
As built (2026-07-16):
backend/api/reviews.py—triggeraction onReviewRecentReviewsViewSet(POST /v1/projects/:id/review/reviews/trigger/): team gate vssettings.REVIEWFN_TEAM_ID(403),PRParserURL parse (400), syncGitHubIntegration.first_for_team_repositoryaccess check (400), thenstart_review_pr_workflow(publish=True, user_id=acting_user_id=request.user.id, trigger_source=TRIGGER_UI)→202 {workflow_id, status}. The URL is canonicalized (trailing/filesetc. dropped) before it reaches the workflow id.backend/temporal/types.py—TRIGGER_UI = "ui"; ungated in the workflow's gate map (no workflow.py change, so no Temporal versioning concern).backend/api/settings.py—can_trigger_reviews(read-only method field) on the settings GET; the scene hides the trigger field when it's false.frontend/CodeReviewScene.tsx—TriggerReviewSection(PR-URL input + submit, above the recent-reviews block; button loading/disabled while in flight);frontend/reviewSettingsLogic.ts—triggerPrUrl/triggeringReviewstate +submitTriggerReviewlistener (toast on error, clear + reload recent reviews on 202; the new report row appears once the fetch activity creates it and the in-progress poll takes over).- Tests:
backend/tests/test_ui_trigger_api.py(gate, URL parse, access check, workflow kwargs,can_trigger_reviewsexposure),test_settings_api.pydefaults updated, tworeviewSettingsLogic.test.tscases (in-flight flag resets on both outcomes). - Sync PR fetch (added 2026-07-16, closing the original "v1 gap"): the action makes one
GET /pulls/{n}(_fetch_pr_metadata) so the answer is honest — nonexistent (404), fork, and closed PRs reject immediately with a message instead of dying async before the report row exists, and a PR whose current head equals the report'spublished_head_shareturns200 {status: "already_reviewed"}(no run, info toast, watch not armed) instead of a false "started". The report lookup isrepository__iexact— triggers store differing casings. The fetch activity keeps the authoritative fork gate. - Cross-caller same-head caveat (pre-existing resume identity, now easier to reach): the per-head
working-state cache is roster-blind, and the UI trigger lets different acting users hit the same
head. Three of four paths are safe: already-published head → the trigger answers
already_reviewedwithout starting (the workflow's fetch early-exit backstops the race; the report is per-PR, not per-caller); run in flight → the deterministic workflow id +USE_EXISTINGjoins it; new head → nothing persisted under the newhead_sha, full recompute under the new caller's roster. The unsafe window: a prior turn that persisted working state without stampingpublished_head_sha(a zero-findings turn — the watermark records only on a real post — or a crashed turn). A different caller's re-trigger then resumes those rows: the persisted perspective selection is reused verbatim (name-mapped, so the new caller's customs fall out of planned chunks), and perspective results resume on positional(pass_number, chunk_id)keys — slot = position in the acting user's sorted enabled set — so differing rosters silently mis-map slots. Identical rosters (canonicals only, or the same customs) are always safe. This is the finding-identity residual already noted in the blind-spot section; the fix belongs there: key results byskill_name(+version) instead of slot, and drop persisted selection/results whose recorded roster differs from the run's. - Generated clients (
frontend/generated/*) come from CI's OpenAPI auto-commit (insightscli build:openapiwas unavailable in the authoring environment).
✅ Stage 6 — Inbox trigger: auto-review self-driving implementations (BUILT 2026-07-02, trigger redesigned + dogfood e2e ✅ 2026-07-03)
Status: BUILT + e2e'd, uncommitted. (The implementation spec,
STAGE_6_PLAN.md, was deleted after the build — this section is the surviving as-built record.) Thereview_inbox_prsswitch is live: a signals-origin implementation run that records a PR (or a pushed branch) now triggers a publishing review. Verified: review tests (both dirs) + full signals suite (1304) + ruff + tach + startup-import-budget green;insightscli build:openapidrift = the newcode_reviewenum member only.🔁 TRIGGER REDESIGNED 2026-07-03 (live-e2e discovery): the receiver fires on the save that records
output.pr_url/output.head_branch— NOT on run completion. The plan's premise ("when the implementation run completes") is a lifecycle event that never happens on the current tasks architecture: a successful run deliberately staysin_progressforever —execute_sandbox/workflow.py::_maybe_record_terminal_status("TaskRun stays inprogress on successful completion _and on inactivity timeout — the run is always followable"), the task-management orchestrator "runs for the lifetime of the task run" and only records cancelled/failed, signals-origin tasks opt into the PR follow-up loop unconditionally (get_task_processing_context.py— babysits CI + review threads), andTaskRun.mark_completed()has zero production callers (tests only — which is why the unit suite couldn't catch this; the live click-through e2e did). New receiver contract: ignore creation saves and declared-fields saves not touchingoutput; skip failed/cancelled runs; target =output.pr_url(PR leg, publishes) elseoutput.head_branch(branch leg, stores + receiptoutcome="stored", upgraded to the PR by the laterpr_urlsave — resume at the same head skips recompute and publishes).output.head_branchis trustworthy: the agent server syncs it at the end of every agent turn whose current git branch changed (agent-server.ts::syncCloudBranchMetadata, which also overwrites theTaskRun.branchFIELD — so that field is base-or-head depending on path and stays banned as a target). A turn ending on the base branch can transiently synchead_branch=base; the empty-diff self-skip absorbs it (base == default → empty compare). Trigger timing now matches the Inbox UI: the sameoutput.pr_urlthat moves a report into the "Pull requests" tab starts its review.✅ Dogfood e2e (plan step 5) RAN 2026-07-03 — full live chain on a synthetic report + real Inbox click + real PR (#68141): receiver fired on the
pr_urlsave, inbox workflow with creation-time provenance, single-chunk pipeline (size gate), zero findings on the clean typo PR → publish self-skipped (no noise comment) → receiptoutcome="stored"counts 0/0/0 on the report. The manual CLI leg (same day, PR #68108: 6 inline comments published, watermark advanced) plus a same-head re-trigger (early-exit, provenance backfill, no receipt) cover the rest of the matrix. The e2e also caught the trigger-premise bug itself: the FIRST click-through (PR #68132, since closed) stalled forever on the old completion gate — that live failure is what exposed it. Still unobserved live: receiptoutcome="published"(needs a findings-bearing inbox PR) and the repeat-turn re-review.⚠️ Receiver branch fallback DISABLED (adversarial review, 2026-07-03). The plan's "no pr_url → review
(task.repository, run.branch)" fallback shipped, then was removed:TaskRun.branchholds the BASE branch the agent started from (auto_start passes the autostart base; the agent-server receives it as--baseBranch, the branch it must NOT commit onto) — never the pushed head. A compare against it either no-ops (base == default → empty diff self-skip) or reviews a base branch's entire divergence (base ≠ default viaautostart_base_branches) — always the wrong target. No pr_url → skip; the webhook pr_url backfill re-fires the receiver. Branch targets remain fully supported at the client/workflow layer (head_branchinput,review-branch:id, compare fetch, empty-diff skip, branch-keyed rows + PR upgrade) for callers that know a real head branch; the receiver rejoins once tasks records the pushed head branch. → It rejoined 2026-07-03:output.head_branch(agent-server-synced pushed head) is that trustworthy carrier — see the 🔁 trigger-redesign note above. TheTaskRun.branchFIELD stays banned.2026-07-03 adversarial review (7 finder dimensions × 3-skeptic refutation panels, findings in
/tmp/review-signals-combination-adversarial-review.md) — 4 fixes applied: (1) the confirmed test gap —test_acting_user.pynow setsreview_inbox_prs=Trueon the row and asserts the resolve passthrough (a surgical drop of that one line would have silently disabled the whole inbox trigger with the suite green); (2) the branch-fallback removal above; (3) the success-path receipt append is nowbest_effort=Truelike the failed path (a published review must not be marked failed over bookkeeping — the retry's already-published early-exit would skip the receipt anyway); (4)upsert_review_reportregained the lost-get_or_createrace self-heal: the create runs in a savepoint and anIntegrityErrorre-selects the winner's row instead of relying on the activity retry. Dismissed-with-reasons (§3 of the findings file): branch↔PR two-workflow-id divergence (bounded, publish double-guarded, resume shared) and the base-=-default compare choice (spec'd).As-built notes on top of the plan (differences/gotchas an agent needs):
- Gate map in
workflow.pyis exactly the plan's; old in-flight payloads deserialize astrigger_source="manual"(dataclass default) — a one-deploy window where a pre-deploy label payload resolving its gate post-deploy is ungated (accepted, transient).publish_persisted_review/publish_reviewnow return aPublishOutcome(posted, review_url)(was bool) —review_urlfeeds the receipt;_post_github_reviewreturns the posted review'shtml_url(None on the marker-found idempotency skip). Thepublish_reviewcommand reads.posted.- The publish stage posts to
meta.pr_number(the fetch-resolved destination — input PR or the open PR discovered for a branch target), notinputs.pr_number; skipped when None.- Branch identity:
upsert_review_reportkeys branch targets (pr_metadata.number == 0) on(team, repository, head_branch) WHERE pr_number IS NULL; the PR path also checks for such a row and upgrades it in place (backfillspr_number/pr_url; empty branch-turnpr_urlnever erases a stored one). Provenance:signal_report_idfill-only-when-NULL,trigger_sourcecreate-only.- The
code_reviewreceipt is appended byappend_code_review_artefact_activity(best-effort: swallows its own failures; checks theSignalReportstill exists — plain-UUID provenance onReviewReportis the durable link). Failed turns appendoutcome="failed"from the workflow's except path (guarded so it can't mask the original error); a workflow-retry success after a failed attempt yields failed + published receipts (a truthful log, accepted). Counts = validator-passed findings ateffective_priority(validator-wins).- Receiver import discipline (startup budget):
receivers.pymay import Django + reviewmodelsONLY at module level. Eventemporal/types.pyis off-limits — reaching it executes the temporal package__init__, which registers all activities (temporalio + modal + insights.schema onto everydjango.setup()). Both the client ANDTRIGGER_INBOXare imported call-time inside_start_review. The receiver is recorded ininsights/test/setup_receivers_baseline.txt(regenerate withUPDATE_SETUP_RECEIVERS_BASELINE=1 pytest insights/test/test_startup_import_budget.py -k receivers_match).- Test gotcha: a test that
patch()es the temporal client without importing it at module scope makes mock.patch import it mid-test — the tasks sandbox-class resolution then runs under test settings and a localSANDBOX_PROVIDER=modal_dockerenv raises (DEBUG=False). Import the client at collection time (seetest_inbox_trigger.py).- The receiver gates on
instance.team_id(TaskRun's own team), skipstask.internaltasks (research/repo-selection/custom-agent plumbing also carriessignal_report_idbut runs as the GitHub-integration creator and pushes nothing — only the auto-start implementation task is non-internal), and resolves the acting user from the report's latestsuggested_reviewersartefact (the Inbox "For you" semantics:task.created_bywhen they're among the resolved reviewers — the "I asked for this PR" case — else the first org-member-resolving reviewer; the acting reviewer's toggle gates). Targets come fromoutputonly —pr_urlwins, elsehead_branch(needstask.repository, forwarded as stored: lowercased) — see the 🔁 trigger-redesign note above.Task.signal_reportcarries a stale "DEPRECATED" comment — it is actively written by signals auto-start and indexed; the plan's wiring is correct.- Toggle copy in
CodeReviewTab.tsxupdated from "Coming soon" to live behavior copy.
Goal. Every self-driving (Signals) implementation gets reviewed automatically: when a signals-origin
implementation task run records what it produced (its PR, or a pushed branch), Review reviews it. If the run opened
a PR, the review publishes back as PR comments (Stage-5 behavior); if there is no PR (future branch-only
implementations), the same review runs and its findings/verdicts/body are stored only — usable for
shadow-fixing before anything is published, and publishable on a later turn once a PR exists. The signal
report's artefact log records the review either way, so a report reads as the full story: signals →
research → implementation task_run → commits → code_review → merge/RESOLVED.
Decisions locked (maintainer, 2026-07-02):
- Two triggers only: the Stage-5
reviewlabel (unchanged) and "self-driving implementation recorded its output" (new, internal; AMENDED 2026-07-03 — originally "task finished", but successful runs never complete; see the 🔁 trigger-redesign note above). No GitHub webhook for the inbox path — the implementation runs in our own Temporal worker; itsoutput.pr_url/output.head_branchsave is a DB state change we observe first-hand. (Apull_request openedwebhook trigger was considered and rejected: PR-centric, and it dies the day implementations stop opening PRs. The decision's spirit survives the amendment: internal DB observation, no webhook dependency.) - Scope: tasks with
Task.signal_report_idset, nothing else (Slack / MaxAI / user-created task origins excluded for now). - The review target is the implementation output (branch/diff) — the PR is a publish destination, not the identity. PR resolvable → publish; no PR → store-only. No shadow-mode flag: the target's shape decides.
- Failed run, or a run that pushed nothing → do nothing.
- Repeat turns are allowed from day one. Re-review relies on previous findings (maintainer: assume
the loop works) — the
head_shawatermark early-exit, working-state resume, and covered-set (Stage 5b) are the mechanism; the first dogfood turns are its first real exercise. - Acting user = the report's assigned reviewer (AMENDED at build time, maintainer 2026-07-02 —
was
task.created_by). Signals tasks are created in the background (the pipeline's research/repo-selection/custom-agent tasks literally as the GitHub-integration creator viaresolve_user_id_for_team), socreated_bycarries no assignment meaning. Assignment = the Inbox "For you" semantics: the report's latestsuggested_reviewersartefact, logins resolved to org members exactly like the reports-listsuggested_reviewersfilter and the Slack inbox notifications. The acting reviewer is the task's own user (created_by— whoever clicked "Create PR", or the auto-start assignee) when they are among the resolved reviewers (someone who asked for the implementation gets their own rules applied to its review — maintainer, 2026-07-03); otherwise the first login resolving to an org member is canonical. The acting reviewer'sreview_inbox_prsgates the review, and they become both the acting user and the sandbox run user — their perspectives / blind-spots / validator / urgency threshold drive the run; the single existing threshold knob applies (no separate inbox threshold). A non-acting reviewer's opt-in never hijacks whose options apply; acting reviewer missing or opted out → skip. - Gate:
review_inbox_prs(default off — the budget gate for 100%-coverage cost), checked cheaply at the trigger and snapshotted inresolve_acting_userlikereview_labeled_prs. Gates become trigger-aware: label →review_labeled_prs, inbox →review_inbox_prs, CLI/eval → ungated. (Theworkflow.pygate comment already warns the next trigger must add a source input instead of reusingacting_user_id is None.) - Agent read path =
execute_sql(MCP) over the review tables, joining from the artefact'sreview_report_id;ReviewReport.report_markdowncarries the full rendered body even for stored-only turns. No dedicated read API is required by this stage.
Trigger mechanics (the new entry point). A post_save receiver on tasks.TaskRun, registered by
review — exact precedent on the same model: track_task_run_completion
(products/tasks/backend/models.py:1739). Fires when status=COMPLETED and task.signal_report_id is
set and the run pushed commits (exact marker — output keys vs the report's commit artefacts — to pin
down at build time). Then: settings gate → transaction.on_commit →
start_review_pr_workflow(..., publish=True, acting_user_id=task.created_by, trigger_source="inbox", signal_report_id=...).
- Why not the alternatives. Calling reviewscript from tasks (the webhook handler or
ProcessTaskWorkflow) is a product dependency cycle — review already imports the Tasks facade (facade.agents). The receiver keeps every edge in the existing direction (review → tasks, review → signals) with zero tasks changes. Temporal composition — starting"ReviewPRWorkflow"by registered name string, no import edge — is the right shape _later, when the shadow-fix loop becomes an orchestrated graph (implement → review → fix → publish); the receiver stays a thin adapter so that second entry slots in beside it, not inside it. - Idempotency. The deterministic per-target workflow id collapses duplicate fires (see the Stage-5b
USE_EXISTINGnote for the join semantics); a run completing mid-review doesn't queue a turn — the next trigger starts one, and a same-head turn hits the existing "nothing changed" early-exit. Keep the receiver module import-light (django-startup-time): types + client only, never workflow/activity imports.
Target resolution & publish rule. The fetch stage resolves an open PR for the run's head branch
(one API call — precedent: code-workstreams discover_branch_prs). Found → the existing PRFetcher path
(PR comments keep feeding dedup) and the publish stage runs. Not found → fetch the compare diff (head
vs base — the same files+diff shape; precedent: GitHubIntegration.get_diff, already behind Signals'
commit-artefact diff action), the publish stage is skipped, outcome=stored. The pipeline middle
(chunk → perspectives → dedup → validate) is untouched — it consumes pr_files/diff and doesn't care
about the source. A stored review publishes on the next turn for that target once a PR exists (resume
reuses rows; published_head_sha still gates double-posting).
Data-model changes.
ReviewReport:pr_numbernullable + branch-based identity for PR-less targets (split unique constraint; a branch-keyed workflow-id variant) — cheap now, a production migration later. New provenance columnssignal_report_id(+trigger_source) — durable on the review row because signals artefacts are API-deletable.ReviewPRWorkflowInputs: +trigger_source,signal_report_id.ResolveActingUserResult: +review_inbox_prs.- Signals side — the report's record of the review: new
SignalReportArtefact.ArtefactType.CODE_REVIEW(log type) + content model in signals'artefact_schemas.py(its registry has an exact-coverage test):{review_report_id, repository, head_sha, head_branch, base_branch, pr_number?, pr_url?, review_url?, outcome: published|stored|failed, counts: {must_fix, should_fix, consider}}. Appended once per turn at completion or failure (not on gate-skips — nothing was done), attributedArtefactAttribution.system(). Pointer-first by design (maintainer): enough for an agent to go look by itself (SQL join onreview_report_id), no digest/summary duplication. The write direction review → signals matches the existing edge (theartefact_attributionimport). - Rejected: merging the two artefact tables — their similarity is pattern-level by design. Different
parents/identity (label reviews have no
SignalReport; one report can produce several targets); Review's working-state rows (pr_snapshotholds whole diffs) are MB-scale resume substrate, not activity-log content — the Inbox fetches a report's artefacts atlimit=1000to render; and signals artefacts are user-mutable (PATCH/DELETE) while Review's rows are resume-correctness substrate that must not be.
Out of scope (this stage): GitHub webhooks for the inbox path; a shadow-mode flag; a dedicated findings read API/MCP tool; non-signal-report task origins; promo-comment copy + fleet cost controls before non-staff rollout (the per-user default-off switch is the budget gate; consider a feature flag for fleet-level control during alpha).
Build order:
- ✅ Inputs + gates + provenance —
trigger_source/signal_report_idthroughReviewPRWorkflowInputs; the trigger-aware settings gate inresolve_acting_user(+review_inbox_prsin the snapshot);ReviewReport.signal_report_id/trigger_sourcecolumns (migration 0010). - ✅ The signals
code_reviewartefact — type + schema (signals migration 0054) + the end-of-turn write (completion and failure paths; non-writable through the artefact API). - ✅ The trigger — the
TaskRunreceiver behind the settings gate. REDESIGNED 2026-07-03 (see the 🔁 note above): fires on theoutput-recording save —output.pr_url(PR leg) elseoutput.head_branch(branch leg) — never on completion (successful runs stayin_progressforever) and never on theTaskRun.branchfield. - ✅ Branch targets — PR-by-branch resolve in fetch, the compare fallback (empty diff →
self-skip), nullable
pr_numberidentity + the branch workflow id. Receiver-reachable again viaoutput.head_branchsince the 2026-07-03 trigger redesign. - ✅ Dogfood e2e — RAN 2026-07-03 — synthetic report + real Inbox "Create PR" click + real
PR #68141: receiver fired on the
pr_urlsave, inbox workflow with creation-time provenance, single-chunk pipeline, zero findings on the clean typo PR → publish self-skipped → receiptoutcome="stored"counts 0/0/0 on the report. Still unobserved live: receiptoutcome="published"(needs a findings-bearing inbox PR; publish machinery proven by the manual leg on #68108) and the repeat turn on the same report — the first real exercise of re-review. Log those here when they happen.
🔮 Future directions (product, post-Stage-5 — not scheduled)
Adversarial validation — a 3-skeptic panel instead of a single verify pass (noted 2026-07-03)
Upgrade the validate stage from one verdict per finding to a panel of 3 independent skeptics, each prompted to REFUTE the finding (distinct lenses — e.g. correctness / reproducibility / scope — rather than 3 identical verifiers, so diversity catches failure modes redundancy can't). Only findings surviving majority verification (≥2 of 3 not refuted) get reported. Rationale: a single validator both misses (the topology experiment showed validator strictness killing real findings, e.g. #6 in 7/8 runs) and lets plausible-but-wrong findings through; refute-framed skeptics are a stronger filter than confirm-framed validation. Cost-wise this triples the validate fan-out, so it likely pairs with the per-chunk warm-session mechanics (one panel session per chunk, or 3 parallel per-chunk sessions) and should be measured against the eval fixtures before becoming the default. The per-user single-active validator skill can stay: it becomes the panel's shared criteria, with the three lenses layered in the prompt.
⭐ Customizable perspectives — DECIDED 2026-06-29: team-level skills + PER-USER enablement; unified chunking
Decision (AskUserQuestion). Skill ownership + editing stay team-level (perspectives/validator are shared
team LLMSkill rows, edited in place); but perspective enablement — which perspectives run on a given PR — is
per-USER (the ⭐ NEXT IMPLEMENTATION spec below). Chunking stays one unified logic. The per-user skill
ownership idea was dropped after research found Signals custom scouts are themselves team-scoped — load_skill_for_run(team, …) resolves by team, and a "custom scout"
is just a team-wide renamed duplicate_skill. There is no per-user skill ownership anywhere in the codebase
(LLMSkill is (team, name, version)-scoped, no user dimension; created_by is audit-only), so per-user would be
net-new infrastructure with no precedent. Team-level matches what Scouts actually do — and is mostly already built.
Related future note: per-PR/per-chunk relevance gating of the enabled perspective set (a custom SQL lens skips a PR with no SQL) is sketched in Stage 4 → Router (step 1) → "relevance-gate the enabled perspective set" — the natural companion once teams author narrow custom lenses.
Status — team-level editing of the 3 perspectives + the validator already works, with zero new code. A team
edits the canonical-named LLMSkill row (e.g. review-script-perspective-logic-correctness,
review-script-validation-criteria) via the skills API/MCP → load_perspectives_for_run /
load_validation_skill_for_run read the team's latest by name → the sandbox skill-get pulls the edit; the
cold-start sync_canonical_* sees the row's hash diverge from metadata.canonical_hash and leaves it alone
(lazy_seed.py diverged branch), so the edit survives every run. Disk SKILL.md stays the canonical baseline.
Guarded by tests: test_perspectives.py::{test_leaves_team_edited_row_alone, test_pins_the_latest_version} and
test_validation_skill.py::{test_leaves_team_edited_row_alone, test_pins_the_team_edited_version}.
Chunking — stays ONE unified logic (decided NO to editable). Chunk ids are a structural invariant: chunking
runs once per PR, shared by every perspective + the validator, and the id is baked into finding identity
({pass}-{chunk}-{issue}) and the persistence key. A divergent chunk set silently orphans findings and drops
validations (_prepare_validation_prompt warns-and-skips on a missing chunk id, activities.py). It also has no
skill abstraction today (hardcoded CHUNKING_SYSTEM_PROMPT, tools/split_pr_into_chunks.py). If team
customization is ever truly needed, promote chunking to a team-wide versioned skill — never per-user.
⏭ DEFERRED — "reset to canonical" (ships with the UI). 2026-07-08 update: a crude revert path now exists —
archive_skill soft-deletes all versions, and the next sync's resurrect branch recreates the vanilla canonical
(skill deletion is not the opt-out; the config toggle is — see the archived-canonical outage fix). So archive-then-
resync IS a reset for canonicals. Still deferred for the UI: an in-place force re-pull of a live edited row
(overwrite with disk canonical as a new version, re-stamping canonical_hash) via a small lazy_seed helper, so
users don't need the archive detour (the sync_review_skills command this was once sketched as a flag on was
deleted 2026-07-02; the helper's main consumer is the future UI button anyway).
✅ 2026-07-02 — the Review UI landed as the Inbox "Code review" tab (see the dated BUILT section near
the top): enable/select skills per user, view bodies read-only, trigger + threshold settings; editing still
happens in the Skills editor (/skills/review-script, linked from every card). Reset to canonical remains
deferred — it still needs the lazy_seed force-re-pull helper plus a button on that tab.
✅ BUILT 2026-06-29 — per-user perspective ENABLEMENT (users can author + run custom perspectives)
Per-user skill ownership stays off (skills remain team-level, edited in place as above), but which
perspectives run is per-USER, mirroring Signals scouts' SignalScoutConfig enable/disable. A user authors a
custom perspective (any review-script-perspective-* skill, via the skills API/MCP) and enables it via the config
API; the review then fans out over that user's enabled set. The settled design is below; see "As built" at the
end of this subsection for the few places the implementation deviated from this sketch.
What a perspective is. Any team LLMSkill named review-script-perspective-* (the prefix is the sole marker,
exactly like signals-scout-*). Canonical and custom are handled identically — no branching anywhere. The only
difference: the 3 canonicals are auto-seeded (existing lazy_seed.sync_canonical_perspectives); a "custom
perspective" is just a prefixed team skill a user creates (skills API/MCP now; UI later) — any content, even a
100% copy of a canonical. Skills stay team-level. The validator (review-script-validation-criteria) stays a single
team-level skill — enablement is perspectives only.
Config model (mirror SignalScoutConfig, minus the clock). Shipped as ReviewSkillConfig (renamed from
the originally-sketched ReviewPerspectiveConfig so the one table also holds the future per-user validator —
discriminated by skill-name prefix; see the validator next-step below) (UUIDModel, TeamScopedRootMixin) in
products/review/backend/models.py:
teamFK,userFK,skill_nameCharField(max_length=200),enabledBooleanField(default=True, db_default=True),created_at/updated_at. Unique(team, user, skill_name). No extra fields (user: skill name is the identity; nothing else).- Drop scouts' scheduling fields (
emit,run_interval_minutes,last_run_at) — Review is PR-triggered, not scheduled; there is no coordinator/clock. SkipModelActivityMixin/all_teamsunless an admin page is later wanted. Migration viamakemigrations(/django-migrations; delete stray file,DEBUG=1 ./manage.py makemigrations review). - Mirror:
SignalScoutConfig=(team, skill_name, enabled)onTeamScopedRootMixin, UUIDModel(products/signals/backend/models.py:919, 946-947, 992-994). Identity isskill_name, nevercreated_by(created_byis audit-only and reassigns on edit). We add theuserdimension scouts lack.
Default-on-start seeding (per-user register_missing analogue). register_missing_perspective_configs(team, user): for each canonical skill_name, ReviewSkillConfig.objects.for_team(team.id) .get_or_create(team_id=…, user_id=…, skill_name=…, defaults={"enabled": True}) — pass team_id explicitly in
both filter + create (the fail-closed for_team().get_or_create() does not inject it, per CLAUDE.md). Auto-enables
only the canonicals (this is the one allowed canonical/custom difference: "auto-added on the start"); customs
are enabled by the user via the toggle. A new canonical added later auto-enables for everyone on next resolve; a
row the user disabled is left alone (get_or_create no-op). Scouts call register-missing each coordinator tick
(config_registry.py); we call it when resolving the acting user, before loading.
PR-author → Insights user (label trigger falls back; other triggers don't). The acting user whose perspectives
apply = the PR author, from pr_metadata.author (tools/github_meta.py:244), resolved via
resolve_org_github_login_to_users(team_id, [login]) -> {login: User}
(products/signals/backend/report_generation/resolve_reviewers.py:241; org-scoped, case-insensitive, backed by
User.get_github_login() precedence UserIntegration-github → UserSocialAuth-github → team-integration login,
insights/models/user.py:438; already used in prod). CORRECTION to earlier docs: this reverse-lookup DOES exist
(a prior research pass wrongly said it didn't). When the author does not map to a Insights org user, the label
trigger falls back to the run user (inputs.user_id, threaded into the resolve activity as default_user_id
— the same identity the sandboxes execute under, so acting and sandbox identity can never drift) — someone
explicitly asked for the review, so a borrowed user's perspectives beat no review. The
review_labeled_prs opt-out stays author-only: a fallback-resolved acting user never imports their personal
switch into someone else's PR (forced True in the resolve snapshot). Inbox/manual/branch triggers keep the
no-fallback contract → unmapped author = no review. Gate after fetch, before the perspective fan-out.
inputs.user_id is the sandbox/MCP identity (integration creator lineage), resolved separately from the acting user.
Loader change — load_perspectives_for_run(team_id, acting_user_id) (skill_loader.py:50): after
register_missing_perspective_configs, read the user's enabled set
ReviewSkillConfig.objects.for_team(team_id).filter(user_id=acting_user_id, enabled=True) .values_list("skill_name", flat=True); resolve each to its live LLMSkill (latest, non-deleted) →
LoadedPerspective. pass_number = per-run index (enumerate over the user's enabled perspectives,
deterministically ordered, e.g. sorted by skill_name) — NOT the enum ordinal. source_perspective carried as the
skill_name (user: skill name is readable enough, no extra label field). Min-1 floor: raise if zero enabled.
Keep the enabled-but-missing-skill ⇒ raise branch (real sync error, not a soft skip).
Decouple source_perspective from the enum. combine_issues recovers it via list(PerspectiveType)[ pass_number-1] today (issue_combination.py:21) — replace with stamping source_perspective from the skill that
ran (the run's pass_number → skill_name map = the loaded perspectives). PerspectiveType is demoted to just
the canonical-seed list (no identity role). Issue.source_perspective becomes the skill_name string; it is
code-stamped in combine_issues, not LLM-emitted, so this does NOT touch the fixed LLM output schema — verify
before editing.
Keep pass_number (DECIDED — demote, don't delete). It survives ONLY as the compact per-run index inside
issue.id = {pass}-{chunk}-{issue} and the (pass_number, chunk_id) result keys (activities.py:547). It must
stay because dedup keys on issue.id (issue_deduplicator.py:118 → the id must be perspective-unique) and the
validation parser splits issue.id on - to recover the chunk (activities.py:612-620 → the token must be
hyphen-free/compact). The long, hyphenated skill_name cannot live in the id. Net: pass_number = dumb per-run
index; skill_name = identity.
Fan-out / persistence need no structural change. ReviewPerspectivesWorkflow already fans out over exactly
what the loader returns (workflow.py:153; pass_number=perspective.pass_number at :169); the (pass, chunk)
results read (activities.py:547) and the run_index-scoped _issue_key (persistence.py:431) tolerate a variable,
non-contiguous set. Only the banner that hardcodes "3 perspectives" (workflow.py ~349) should log the resolved
count instead.
Toggle surface (this round = API; UI later). Team+user-scoped ReviewPerspectiveConfigViewSet (list +
partial_update(enabled)), on the project (team-scoped) router — NOT the root trigger router; apply
/improving-drf-endpoints. Min-1 enforced at the toggle (reject disabling the user's last enabled perspective)
AND in the loader (raise on zero).
Tests (/writing-tests): config model + register_missing (canonicals auto-seed enabled, idempotent, customs
NOT auto-seeded); loader (returns only the user's enabled set; source_perspective=skill_name; per-run
pass_number; min-1 raise; missing-skill raise; cold user with no rows ⇒ canonicals); combine_issues stamps
source_perspective from the skill incl. a custom perspective with no enum entry; author-resolution gate
(mapped ⇒ their set; unmapped ⇒ no review); toggle rejects disabling the last.
As built (deviations from the sketch above — read these before extending):
register_missing_perspective_configs+load_perspectives_for_runboth live inskill_loader.py, notlazy_seed.py. Putting seeding inlazy_seedwould have created a circular import (lazy_seedalready imports the prefix constants fromskill_loader). Co-locating inskill_loader— the perspective-resolution module — avoids the cycle and keeps seeding next to the loader that calls it. The loader callsregister_missingitself, so it is self-contained (a cold user always gets the canonicals on the first resolve). This mirrors Signals, whereregister_missing_configslives in its ownconfig_registry.py, notlazy_seed.py.source_perspectiveis stamped at REVIEW time, not incombine_issues.review_chunk_activitysetsissue.source_perspective = input.skill_nameon each issue before persisting theperspective_result(which round-trips it).combine_issuesonly re-stampsissue.idnow and preserves the already-stampedsource_perspective— it no longer importsPerspectiveTypeat all. This is cleaner and more robust than the sketched "combine gets apass_number → skill_namemap": no map threading, no acting-user in the combine activity, and the skill name is captured by the exact activity that ran the skill (immune to a mid-run config change).PerspectiveTypeis demoted to the canonical-seed list (CANONICAL_PERSPECTIVE_SKILL_NAMES = names of PERSPECTIVES); the obsoletetest_registry_order_matches_perspective_type_enumwas deleted.- The author gate is its own activity,
resolve_acting_user_activity, run in the parent right after fetch (before any sandbox spend).fetchreturnsauthor_loginonReviewMeta; the resolve activity maps it viaresolve_org_github_login_to_users(or returns an explicit override). The workflow bails (return report_id) when it resolves to None. Anacting_user_id: int | Nonewas added toReviewPRWorkflowInputs(and threaded throughclient.py): the cloud trigger leaves it None (resolve the author); the CLI/eval pins it to--user-idso an eval run applies a known user's perspectives against any PR (the PR author need not be a Insights user). This is an explicit, exercised override, not dead fallback code. - The toggle viewset is
scope_object = "INTERNAL"(session/UI-only, not MCP/PAK), keyed byskill_name.ReviewPerspectiveConfigViewSetat/v1/projects/:team_id/review/perspectivesexposeslist(the full perspective menu joined with the user's enable state — canonicals seed enabled, an un-toggled custom shows disabled) andpartial_update(PATCH…/{skill_name}/ {enabled}), which upserts the config row so enabling a freshly authored custom works in one call. Min-1 is enforced at the viewset (reject disabling the last enabled) with the loader (NoEnabledPerspectivesError) as the backstop. The viewset floor is best-effort (count + write, no row lock — same posture as Signals' scout config viewset); a rare concurrent double-disable that slips through fails loudly at the loader rather than producing a wrong review. Deliberately not hard-guarded with a transaction/select_for_update— that's overengineering for a borderline single-user race. - A dangling enabled config (skill archived after being enabled) soft-skips, it no longer fails the run
(2026-07-08, reversing the earlier "enabled-but-missing-skill ⇒ raise" decision — archiving from the general
Skills UI was a permanent, non-self-healing outage: tombstone never recreated, config never re-evaluated, loader
raised, workflow died). Now: perspectives warn-and-skip dead names (
NoEnabledPerspectivesErroronly when nothing resolves), validator/blind-spots fall back to the canonical, and the sync resurrects all-dead canonicals instead of tombstoning (PerspectiveSkillNotFoundErrordeleted). The config toggle is the one opt-out lever; skill deletion is not a signal.
Still deferred (own steps): the Review skills UI (add / enable-disable perspectives, edit, reset-to- canonical) — API/MCP only for now; the "reset to canonical" force-re-pull (above) ships with that UI; a future per-PR coordinator step that picks which of the enabled perspectives best fit a given PR (the user's "what perspectives make sense for this PR").
✅ BUILT 2026-06-29 — per-user custom validators (single-active) (uncommitted)
The same move as perspectives, applied to the validator, reusing the same ReviewSkillConfig table (the
model was renamed generic for exactly this). Validator rows carry the review-script-validation-* prefix; the prefix
is the only thing that distinguishes them from perspective rows — no kind column. The one structural difference: a
review runs exactly one validator (the bar for "does this issue matter"), so this is a single-active selection
per user, not a multi-enable set — enforced in app code, not a DB constraint (the user explicitly ruled the
partial unique index overengineering since the code check is good enough). A user authors a custom
review-script-validation-* skill and selects which one validates their reviews; absent any enabled validation row the
canonical review-script-validation-criteria is the default, so there is no min-1 floor (always a fallback).
What shipped:
load_validation_skill_for_run(team_id, acting_user_id)(skill_loader.py, replacing the old single-arg team-latest-by-name resolve) seeds the canonical config, reads the user's single enabledreview-script-validation-*row (canonical default if none), pins its latest non-deleted version, and raisesValidationSkillNotFoundErrorif the selected skill's row is missing (hard error, uniform with perspectives — the locked decision). Thesorted(...)[0]is only a deterministic tiebreak; single-active is an app invariant.register_missing_validation_configis theregister_missing_perspective_configsanalogue (seeds the one canonical enabled;CANONICAL_VALIDATION_SKILL_NAMESis a single-element tuple).- Shared-table hardening (the non-obvious part): because perspectives and validators now share
ReviewSkillConfig, the perspective-side queries that assumed "every row is a perspective" were prefix-scoped —load_perspectives_for_run's enabled-set read and the perspective viewset's min-1-floor count both gainedskill_name__startswith=REVIEW_FN_PERSPECTIVE_PREFIX. Without this, an enabled validator row would be loaded/counted as a perspective. The validator-side select's bulk deactivate is symmetrically scoped to the validation prefix so it never disables the user's perspective rows. All three are guarded by regression tests that fail if the prefix is dropped. - Temporal threading mirrors perspectives exactly:
acting_user_idwas added toValidateIssuesInputs, the parent passes the resolved value (fromresolve_acting_user_activity, the same gate perspectives use), and the activity input swapped fromValidateIntegrationInput(team_id)to a newLoadValidationInput(team_id, acting_user_id)(theLoadPerspectivesInputanalogue).ValidateIntegrationInputis still used byvalidate_github_integration_activity. - A sibling viewset
ReviewValidatorConfigViewSet(api/validators.py,scope_object="INTERNAL") at/v1/projects/:team_id/review/validators— chosen over branching the perspectives viewset because the two have different interaction semantics (radio vs checkbox, no floor vs min-1) and one handler carrying both is a conditional-behavior-by-prefix smell; the shared model + loader helpers mean no logic duplication.listshows every validator with the user's active one flagged (canonical auto-seeds active);partial_updatePATCH{active: true}selects — inside onetransaction.atomicit flips the user's other validation rows off (bumpingupdated_atto match the per-row save) then enables the chosen one (upsert + re-enable branch for switch-back).active: falseis rejected (single-active: switch by selecting a different one).
Tests (/writing-tests): validator loader (cold→canonical pinned, team-edited version, selected custom resolves,
fallback-when-none-enabled, missing selected skill raises, per-user isolation), register_missing_validation_config
(seeds canonical enabled + idempotent, never re-enables a deselected canonical), validator config API (list active/
inactive, list excludes perspective skills, select flips canonical off, switching between two customs keeps
exactly one active incl. the re-enable branch, selecting a validator leaves perspective configs enabled, reject
deactivate, 400 non-validator, 404 unknown), and a workflow assertion that the resolved acting_user_id (not the
None input) threads into both the perspective and validation loads.
Adversarial review (6-dimension fan-out + per-finding verification) found no production bug — the shipped code
was correct on every dimension. It surfaced test-coverage gaps (since closed: the two-custom switch, the cross-table
write prefix-scope guard, the list cross-prefix guard, the workflow thread-through assert) and two trivial hygiene
fixes (a 3-line comment tightened to 2; updated_at bumped on the bulk deactivate to match the per-row save). One
scary-looking "prefix filter is ABSENT" report was a false alarm — a parallel verifier had temporarily edited the
file to prove a gap and another agent read it mid-experiment.
Still deferred (own steps): validator selection in the Review skills UI (ships with the perspective UI — API/MCP only for now); a partial unique index if a hard DB single-active guarantee is ever wanted (app-level matches perspectives today).
✅ BUILT 2026-07-16 — per-user VISIBILITY of custom review skills (menus show canonicals + your own)
Custom review skills were team-visible: all three config menus (perspectives / validators / blind-spots) listed every team LLMSkill with the prefix,
so one user's custom appeared in — and was enableable by exact name from — every teammate's Code review tab, with the clutter compounding as customs multiply.
Locked with the user (AskUserQuestion, 2026-07-16):
- Visibility rule: a skill is visible to a user iff its name is canonical (
CANONICAL_*_SKILL_NAMES— by name, so a team-edited canonical stays visible to everyone) OR the earliest live version'screated_byis that user. This refines the 2026-06-29 "created_byis audit-only" lock: still audit-only for identity and editing (edits mint new versions stamped with the editor and do NOT transfer visibility); the first version's author now governs menu visibility only. Earliest live version rather than literallyversion=1: deleted rows are excluded, so an archived name later recreated by another user belongs to the new author (deleted(name, version)pairs can duplicate — the unique constraints only coverdeleted=False). - Strict, no grandfathering (decided while the feature had no external adopters):
partial_update404s on a non-visible skill exactly like a missing one (a distinct error would leak that the name exists), closing the enable-any-team-skill-by-name backdoor. No data migration; the loaders backstop instead. - Loader backstop:
load_perspectives_for_runwarn-and-skips an enabled name outside the acting user's visible set (same posture and hole-preservingpass_numbersemantics as the dead-skill skip), and_load_single_active_skillfalls back to the canonical on a foreign selection — so a pre-fix foreign-enabled row stops running rather than running invisibly, self-healing with no migration. - Scope: all three review menus. The team-level Skills page (
/skills) intentionally keeps showing all team skills — this is a clutter fix, not confidentiality: bodies stay team-readable there, and teammates can still edit each other's customs (accepted risk). Signals scouts share the identical all-team-menu pattern; same future problem, deliberately out of scope here. - Consequences: a custom whose author's account is deleted (
created_by→ NULL on SET_NULL) is visible to no one and stops running (its config rows CASCADE with the account); cleanup is archiving from the Skills page. A skill authored through an agent session is stamped with that session's user. "Adopt a teammate's skill" is now duplicate-under-a-new-name (v1created_by= the duplicator), not enable-by-name.
Implementation: visible_skill_names(team_id, user_id, prefix, canonical_names) in skill_loader.py (one DISTINCT ON (name) ORDER BY name, version query over live rows),
used by all three config viewsets (list + partial_update) and both loader paths.
Tests: per surface, a teammate's custom is hidden from list and 404s on PATCH; the perspective loader skips a foreign-enabled row keeping pass-number holes;
the single-active loader falls back to canonical on a foreign selection; existing custom-skill fixtures now stamp created_by.
The fact that keeps all of this safe: the output schema is fixed; only the skill (logic) is editable. Every
perspective validates against IssuesReview/Issue, the validator against IssueValidation, chunking against
ChunksList — all code, not skills — so editing a skill cannot change the output format and the downstream
pipeline (combine → dedup → validate → publish) stays schema-uniform.
✅ BUILT 2026-06-29 — size-aware chunk gate + semantic chunker rewrite (uncommitted; 9 chunking tests + downstream green, ruff+ty+tach clean)
Numbers superseded 2026-07-02 (see 🎯 NEXT item 1): the dual-duty
CHUNK_TARGET_ADDITIONS=1000was split intoSINGLE_CHUNK_GATE_ADDITIONS=400(the gate) +CHUNK_TARGET_ADDITIONS=300/CHUNK_SOFT_MAX_ADDITIONS=600(chunker guidance), per the reviewer-topology eval. The gate mechanics below are unchanged.
The long-planned "chunk gate" (Stage 2 sketched it at lines ~117/143 as MAX_FILES_BEFORE_CHUNKING / MAX_LINES_BEFORE_CHUNKING ~8 files / ~400 lines — never actually landed; split_chunks_activity always called the LLM) is now built, but refined to the final decisions below. The motivating bug: a 155-line PR was being split into 2 chunks, doubling the dominant cost (analyze fans out 1 sandbox turn per chunk; review fans out 1 per perspective × chunk), because the chunker was size-blind and its prompt was biased toward more, smaller chunks. As built:
- The gate is deterministic and runs before the LLM.
plan_deterministic_chunks(pr_files)(tools/split_pr_into_chunks.py) returns a single all-filesChunksListwhen reviewable additions ≤CHUNK_TARGET_ADDITIONS(1000), elseNone(defer to the LLM).split_chunks_activitypersists that single chunk and returns without a sandbox turn — so small PRs skip the chunking LLM and never multiply the per-chunk fan-out. Empty reviewable set → zero chunks (run no-ops). - Metric = additions only, summed over the already-
PRFilter-filteredpr_files(lock/build/generated/images/.txt/.schema.pyare stripped upstream infetch_pr_files). Deletions don't drive chunking (cheaper to read);PRMetadata.additionsis raw/unfiltered and deliberately not used. - One number, two roles.
CHUNK_TARGET_ADDITIONSis both the single-chunk threshold and the LLM's per-chunk target, so the boundary is smooth (a PR just over 1000 comes back as ~one chunk near target, no halving discontinuity). The deterministic gate is purely a cost optimization for the common case — it gives the same single chunk the LLM would. - No mechanical size cap. Chunking exists to make a 10k PR as reviewable as a 1k one, not to bound chunk size.
CHUNK_SOFT_MAX_ADDITIONS(1500) is prompt guidance only: prefer chunks under it, split a large concern at natural sub-seams, keep a genuinely atomic concern whole even if it runs over. There is no upper limit on the number of chunks. - Heavy
prompts/chunking/prompt.jinjarewrite. Stripped the "prioritize more, smaller chunks", the 3–8-files / ≤10-files heuristics, the single-file "red flag", and the layer-splitting bias. New philosophy: fewest coherent independently-reviewable chunks the PR's distinct concerns require; group by concern (tests with impl, tightly-coupled files together); split only at real seams; size by added lines toward the target/soft-max injected from constants. Plus the explicit rule the user asked for: auto-generated files (generated API types/clients/schemas) are not real code → don't let their size drive chunk count/sizing, attach them to the source that produces them, never give them a chunk just to isolate bulk. - Output schema unchanged (
ChunksList), so finding identity{pass}-{chunk}-{issue}, persistence, and the body renderer are untouched. A deterministic single chunk carrieschunk_type=None/key_changes=[], which the body renderer already handles (falls back to a "## Changes" heading; key-changes block skipped). - Assumption flagged to the user (not yet revisited): the "generated stuff shouldn't count" rule is prompt-only (a soft down-weight is inherently the LLM's job; a deterministic counter can only include/exclude). So a generated-heavy small PR can still cross the 1000-addition gate and reach the LLM, which then groups it sensibly — acceptable, no second deterministic filter list added.
Tests: test_split_pr_into_chunks.py::TestPlanDeterministicChunks (parameterized inclusive-boundary threshold; additions-only / deletions-ignored; single-chunk shape = all files under chunk_id 1 with neutral metadata; empty → no chunks) + the existing prompt test extended to assert the budget numbers render. Knobs live in constants.py (CHUNK_TARGET_ADDITIONS, CHUNK_SOFT_MAX_ADDITIONS). User commits manually — not yet committed.
e2e ✅ #66842 (838 additions / 15 files → 12 reviewable after PRFilter): the worker logged "PR within the single-chunk size; … skipping the chunking LLM turn", produced 1 chunk (chunk_id 1, 12 files, chunk_type=None) with no chunking task and all sandbox tasks -c1 (1 analysis + 3 perspective reviews). Review quality held — 3 distinct should-fix findings (unescaped user input into Slack mrkdwn; unbounded sync Slack calls in the request path; reply-backfill silently dropping on partial failure). ~4 sandbox turns vs. the prior chunking-turn + multi-chunk fan-out.
✅ BUILT 2026-06-29 — standalone publish_review command (publish a computed review, no recompute) (uncommitted; tests green, ruff+ty+tach clean)
"I reviewed without --publish, the result is good, post it without paying for another run." Cheap because publish is already DB-driven. As built:
publish_persisted_review(...)extracted intotools/publish_review.py— the DB-driven publish path (idempotency skip onpublished_head_sha == head_sha, load snapshot → rebuild inline comments from the run's valid findings, post, set the watermark only on a real post).activities._publishis now a thin wrapper (_installation_token→publish_persisted_review); one code path, no duplication.test_publish_idempotencypatch targets moved to the tool accordingly.- New
publish_reviewmanagement command (--pr-url --team-id): parses the PR, loads the(team, repository, pr_number)report, errors clearly if no review exists / no run completed (run_count == 0) / nohead_sha, resolves the team's GitHub App installation token, best-effort warns if the PR head moved past the reviewed commit, then publishes the latest completed turn (run_index == run_count, at the report's reviewedhead_sha). No Temporal, no sandbox. - Tests:
test_publish_review_command.py(no-review error, never-completed error, happy-path arg resolution =run_index/head_sha/owner-repo-number/token) + the existing idempotency suite now coverspublish_persisted_reviewthrough_publish. User commits manually — not yet committed.
✅ BUILT 2026-06-29 — per-chunk warm validate session + ANALYZE stage removed (cost optimization) (uncommitted; 271 tests + ruff + ty + tach green; e2e ✅ #66840)
Two cost cuts, both validated on a real 1465-line multi-chunk PR (#66840). Goal: stop paying a cold-start + repo-checkout per LLM call where calls share context.
- Validate: per-issue fan-out → one warm multi-turn session PER CHUNK. New executor helpers (
reviewer/sandbox/executor.py):start_sandbox_session/continue_sandbox_session/end_sandbox_sessionover the Tasks-facadeMultiTurnSession(start+send_followup+end); the caller drives turns and ends in afinally.validate_chunk_activityopens one sandbox per chunk and validates its issues as sequential turns — one verdict per turn (NOT the old "neutered" all-issues-in-one-output batch; each issue keeps its own independent judgment). Checkout/boot paid once per chunk instead of once per issue.ValidateIssuesWorkflowfans out per chunk (_chunk_id_ofgroups survivors by the{pass}-{chunk}-{issue}id), bounded by the semaphore. - Skip-resume (the maintainer's fix, not redo-on-retry).
load_run_validations(team, report, run_index, issues)(persistence.py) reconstructsIssueValidationfrom each persistedValidationVerdict(lossless), keyed by live issue id, scoped torun_indexvia the issue_key prefix, latest-wins. The activity splits issues intodone/pendingand validates onlypending— a retry (or crash-recovery re-run) re-researches nothing already judged; a crash-after-complete does zero sandbox work. Raises on session-OPEN failure (Temporal retries + the floor catches a real outage); a single later issue's turn failure was best-effort skipped (superseded 2026-07-07: a failed turn now fails the activity so Temporal retries it; only the final attempt skips — see that section). - Body sourced from the DB, not the workflow return.
build_body(_build_and_finalize→load_run_validations) reads verdicts from the samevalidation_verdictrows publish reads, so a chunk that exhausts retries after partial progress can't show different findings in the body vs the posted comments. Dropped thevalidations_jsonthreading through the workflow. - ANALYZE stage removed entirely. The reviewer already self-investigates from the diff +
<pr_intent>(its mandated 40%-investigation step), and the chunker'skey_changesalready gives the body its per-chunk narrative — the analysis was a redundant, lossy summary costing C sandbox turns + tokens injected into everyP×Creview prompt. Deleted:analyze_chunk_activity,AnalyzeChunksWorkflow,tools/chunk_analysis.py,models/chunk_analysis.py,prompts/chunk_analysis/,ChunkAnalysisArtefact+ its registry entry + theArtefactType.CHUNK_ANALYSISenum member (migration 0007, a choices-onlyAlterField— old rows stay readable, nothing queries them),persist/load_chunk_analyses.build_review_promptdropped itsanalysisparam + theCHUNK_ANALYSIS_CONTEXTprompt block;build_review_bodydroppedanalyses+ the "Full analysis" section +_placeholder_analysis, and now skips chunks with no validated finding (no analysis narrative left to justify rendering an empty chunk). Investigated whether to keep analyze forC>1: no — the reviewer never needed it at any C; the per-chunk distinction was a body concern already covered bykey_changes. - Verification. 271 tests + ruff + ty + tach green. Adversarial 4-dimension review (validate-session / resume-consistency / analyze-removal / temporal-determinism), each finding independently verified: 0 real bugs — every
must_fixclaim (over-count, 30-min timeout, payload size, heartbeat, wedged session) traced and dismissed; only confirmed = stale "analyze" comments (fixed) + an honest-count nit (fixed: increment only on a real persist). e2e ✅ #66840 (1465 additions / 21 files → 4 chunks): 0chunk_analysisartefacts, 12perspective_result(3×4), 3validation-c{1,2,3}warm sessions → 19 verdicts (vs 19 sandboxes under the old design; chunk 4 had no survivor → no session), 6 valid findings (sharp: REST-send auth bypass, unvalidated Slack target, verbatim agent-draft render, silent draft revert). Published via the standalonepublish_reviewcommand (zero recompute): 4 inline comments (2 of 6 unpositionable on the diff — pre-existing behavior) + promo + watermark set. - NEXT (note only, in Deferred/future): the per-PERSPECTIVE warm review session — fix the perspective, walk the chunks as turns (
P×C → Psessions, #66840: 12 → 3), reusing the existing(pass_number, chunk_id)-keyedperspective_resultresume. The mirror of this validate change. User commits manually — not yet committed.
✅ Change A + B BUILT 2026-06-30 — surface off-diff valid findings + validator priority override
Two Tier-1 polishes to the single-turn label → review → publish flow (explicitly NOT loop work). Scoped with the maintainer 2026-06-30 — both built (288 review backend tests + ruff + ty + tach green; uncommitted, user commits manually). Each shipped behind its own 3-lens adversarial review (find → independently verify) = 0 confirmed production bugs; B's review surfaced one real test gap, since closed. Locked decisions (AskUserQuestion):
- Off-diff surfacing = a review-body section only — no nearest-changed-line inline snap (mis-anchored comments are noise), no per-finding general comment.
- Validator priority = optional override, validator-wins — the verdict carries an optional
adjusted_priority;None= keep the reviewer's; when set it overrides at the publish + body gates. considerstays DB-only for now — not surfaced anywhere. A future UI lets teams choose the severity bar we highlight (deferred note at the end of this subsection).
The problem (grounded in the code). publish_review._build_inline_comments (publish_review.py:249-256) drops
any valid finding that is either not must_fix/should_fix (PUBLISHED_PRIORITIES, constants.py:16) or whose
start line isn't an added diff line (_find_valid_comment_position → None); then publish_review.py:107-109
(if not comments: return False) posts nothing — not even the body when every valid finding is off-diff. The
body renderer (prepare_validation_markdown.py) is a per-chunk summary that lists no individual finding and counts
only PUBLISHED_PRIORITIES, so an off-diff (or consider) valid finding vanishes entirely. Observed on PRs #66456
and #66840 (4 of 6 posted). Separately, priority is frozen at review time (Issue.priority →
ReviewIssueFinding.priority) and the validator (IssueValidation = is_valid + category + argumentation) can
only keep/drop.
Change A — stop dropping off-diff valid must/should-fix (✅ BUILT 2026-06-30; as planned below, diff_position.py also holds the moved format_line_ranges).
- New
reviewer/diff_position.py: extractbuild_diff_line_map(pr_files)+find_diff_position(file, lines, diff_lines)out ofpublish_review.py(both already operate onfile+list[LineRange], satisfied by bothIssueandReviewIssueFinding), so the body renderer and the publisher share one positionability check. tools/prepare_validation_markdown.py:build_review_body(...)gainspr_files; after the per-chunk summary it renders a flat "Other findings (outside the changed lines)" section listing every validmust/should-fixfinding whose line isn't in the diff (### title,`file:line`, priority, the problem, the suggestion, and the validator's "why valid"). Rendered at finalize so the storedreport_markdownis complete and both publish paths — the workflow publish activity and the standalonepublish_reviewmanagement command, which both readreport.report_markdownverbatim — inherit it.activities._build_and_finalizethreads the snapshot'spr_filesin.publish_review.py: change the empty-gate fromif not commentsto "≥1 valid publishable finding exists". When all publishable findings are off-diff,comments=[]and_post_github_reviewalready falls through to a body-only post (publish_review.py:331-340), so the body (carrying its Other-findings section) still reaches the PR.
Change B — validator priority override (✅ BUILT 2026-06-30).
adjusted_priority: IssuePriority | None = Noneadded toIssueValidation(models/issue_validation.py, importsIssuePriority;schema.jsonregenerated — now carries$defs/IssuePriority) and toValidationVerdict(artefact_content.py) — no Django migration, the verdict is JSON in aTextField, old rows parse with the field defaulting toNone(locked by a legacy-JSON parse test).- Prompt (
prompts/issue_validation/prompt.jinja): the validator overrides severity only on evidence (raise when impact is worse than flagged, lower when milder), leaving it null to keep the reviewer's; lowering toconsidersoft-suppresses a real-but-minor finding rather than dismissing it. (The keep/drop bar stays the team-owned criteria skill; this is just the I/O contract.) adjusted_prioritythreaded throughpersist_verdict/persist_verdictsintoValidationVerdict;load_run_validationsreconstructsIssueValidation(adjusted_priority=…)lossless._to_findingdeliberately keeps the reviewer's base priority on the durable finding — the override lives on the verdict and is resolved at read time.- One
effective_priority(base, adjusted) = adjusted if adjusted is not None else basehelper (constants.py, besidePUBLISHED_PRIORITIES) used at every gate AND every displayed priority so gating and display never disagree: the publishpublishablegate +_build_inline_commentsfilter +_format_issue_commentdisplay (publish_review.py), and the_off_diff_publishable_findingsfilter +_render_off_diff_sectiondisplay + the per-chunk count (prepare_validation_markdown.py). Validator-wins. WithconsiderDB-only: upgradeconsider → should_fixsurfaces an otherwise-invisible finding; downgrade soft-suppresses everywhere (inline, body, count, and the all-downgraded run returnsFalse→ posts nothing) with the reasoning recorded. - Body-count wrinkle (handled):
_assemble_report/_render_review_bodysaw only theIssue, not its verdict, soeffective_prioritywasn't a drop-in there. Resolved by adding a requiredeffective_priorityfield toValidationMarkdownReportIssue(models/prepare_validation_markdown.py), set in_assemble_reportfrom the walrus-captured validation; the per-chunk "Issues: N" count readsvi.effective_priority. The publish-side gates already had the(finding, verdict)pair, so only the body path needed this plumbing.
Tests (/writing-tests): adjusted_priority round-trips persist_verdict → load_run_validations and stays
None when unset; a legacy verdict JSON (no key) parses to None; the publish gate upgrade-publishes /
downgrade-suppresses; the off-diff section membership + the per-chunk count both honor the override; and
_build_inline_comments includes/excludes an on-diff comment by effective priority (the gap B's adversarial review
found — the publish-gate tests use off-diff findings that yield no comment, so they couldn't catch a regression to the
raw priority in the inline filter).
e2e ✅ (publish, 2026-06-30). Ran run_review --publish against PR Insights/insights#67015 (team 1) with both
changes hot-loaded in the worker. Full pipeline ran clean and published a real review (2 inline comments, both
should_fix, pinned to head 776e5e3f); funnel = 5 raw perspective findings → 4 after dedup → 2 passed the validator
(2 dismissed) → 0 dropped by priority → 2 published. This confirms integration + no-regression and that Change B's
data layer round-trips on real data (the validator produced valid output against the regenerated schema.json;
persist_verdict wrote + load_valid_findings reloaded the new adjusted_priority field, defaulting None). Caveat
recorded honestly: this PR's findings did not trigger A's off-diff section (0 off-diff valid findings) nor B's
override (validator set 0 adjusted_priority), so those branches stayed inert-and-correct this run — they remain
covered by the 288 unit tests, not positively shown live. Both behaviors are LLM-emergent; provoking them needs a PR
whose reviewer flags an unchanged line / whose validator disagrees on severity.
Supersedes the deferred "NEXT-NEXT — surface valid findings that can't be positioned on a diff line", the "let the validator adjust a finding's priority" note, and the publish-path OPEN QUESTION — all folded into this plan.
⏭ DEFERRED (new) — let teams configure the severity bar Review highlights. With consider DB-only, a team
can't yet say "also surface considers" or "must-fix only". When the Review skills UI lands (with "reset to
canonical"), add a per-team (or per-user, mirroring perspective enablement) minimum-priority setting that both the
inline path and the body Other-findings section read. Until then the bar is the hardcoded
PUBLISHED_PRIORITIES = {MUST_FIX, SHOULD_FIX}.
✅ BUILT 2026-07-01 — Codex reviews: the perspective review runs on a different model family (gpt-5.5 @ xhigh)
Status: BUILT, uncommitted (user commits manually). 292 review backend tests + the Tasks forwarding test + ruff + ty + tach green. Shipped behind a 4-lens adversarial-review workflow (find → 2 independent skeptics refute each finding) = 0 production bugs; the review's one confirmed finding was a test-coverage gap, since closed.
The idea (REVERSED from the earlier "Codex validates" plan). GPT-5.5 Codex is markedly stronger at finding issues than Claude Opus, so the roles flip: Codex reviews (the perspective pass — the issue-finder) and Claude validates. This sharpens the "attach a label → get a great review → publish" flow before any loop work. The prior plan's mechanism analysis still held; only the target stage flipped (find, not judge).
Decisions (locked, via AskUserQuestion + follow-ups):
- Scope = the perspective review ONLY —
review_chunk_activity'srun_sandbox_reviewcall. "One by one": chunking, dedup, and the validator all stay Claude-default. (User: "change perspective review only, as it's the most important one in searching the issues".) - Reuse the existing single-turn path
run_sandbox_review→MultiTurnSession.start. The review is a genuine one-shot, so nothing about the Claude-specific agent-stop / next-step-scheduling wait is touched;start()already parses + validates + salvages the end-turn. A Codex-specific agent-stop check (needed only for real multi-turn / next-step scheduling) is explicitly deferred — the user accepted any single-turn end-turn wrinkle as an e2e risk. - Model + effort =
gpt-5.5atxhigh.gpt-5.5is the only Codex model that supportsxhigh(others cap athigh). Hardcoded as a Review constant (one model for everyone) — a per-user model field stays deferred.
How the sandbox picks a model (the mechanism — fully in place and used in prod).
- The in-sandbox agent is
@hanzo/agent(products/tasks/scripts/runAgent.mjs; installed inproducts/tasks/backend/sandbox/images/Dockerfile.sandbox-base). It reads three env vars set bybuild_agent_runtime_env_prefix(products/tasks/backend/logic/services/sandbox.py:148-150):INSIGHTS_CODE_PROVIDER/INSIGHTS_CODE_MODEL/INSIGHTS_CODE_REASONING_EFFORT. - These flow from
TaskRun.state→TaskProcessingContext.{provider,model,reasoning_effort}(…/activities/get_task_processing_context.py:100-116,200-203) →sandbox.start_agent_server(provider=…, model=…, reasoning_effort=…)(…/activities/start_agent_server.py:245-247). Verified end-to-end for this change — the pins reach the sandbox env, not justextra_state. - The registry —
products/tasks/backend/temporal/process_task/utils.py:RuntimeAdapter=claude | codex;LLMProvider=anthropic | openai;RUNTIME_PROVIDER_BY_ADAPTERmapscodex → openai;get_provider_for_runtime_adapter()derives provider from adapter;get_reasoning_effort_error()validates the (adapter, model, effort) combo.CODEX_XHIGH_REASONING_MODELS = frozenset({"gpt-5.5"})(utils.py:129). These enums + helpers are exposed framework-free via the Tasks facadeproducts/tasks/backend/facade/run_config.py(this change addedReasoningEffortto its__all__) — import from there, never the internal module.
What was actually left to build (the plan's "gap" had mostly closed). Between the plan and this build, the shared
Tasks plumbing landed on the multi-turn path for two of the three routing fields: CustomPromptSandboxContext already
carried runtime_adapter, create_task_and_trigger already forwarded model + runtime_adapter, and
Task.create_and_run / _build_task already accepted all three and wrote {runtime_adapter, provider(derived), model, reasoning_effort} into extra_state. Only reasoning_effort was still un-plumbed on this path. Note _build_task
does not raise on a bad (adapter, model, effort) combo — the plan's ValueError idea was not adopted; we lock the
hardcoded combo with a unit test against the registry instead.
As-built — Tasks (shared infra, only adds an optional capability; every existing caller unchanged):
CustomPromptSandboxContext(logic/services/custom_prompt_internals.py): addedreasoning_effort: str | None = None(mirrors the existingruntime_adapter/modelfields).create_task_and_trigger(same file): now also forwardsreasoning_effort=context.reasoning_effortintoTask.create_and_run(the one missing hop).facade/run_config.py: exposedReasoningEffort(added to imports +__all__) so Review imports it from the public facade.
As-built — Review (products/review):
reviewer/sandbox/executor.py:run_sandbox_reviewgains optionalruntime_adapter/model/reasoning_effort(defaultNone) → threaded into itsCustomPromptSandboxContext. Default-Nonemeans chunk + dedup callers are untouched.model(the LLM id) is distinct frommodel_to_validate(the output schema).start_sandbox_session(validate) is not touched.reviewer/constants.py:REVIEW_RUNTIME_ADAPTER = RuntimeAdapter.CODEX,REVIEW_MODEL = "gpt-5.5",REVIEW_REASONING_EFFORT = ReasoningEffort.XHIGH(fromproducts.tasks.backend.facade.run_config), andREVIEW_INITIAL_PERMISSION_MODE = "full-access"— see the headless-Codex approval gotcha below.temporal/activities.py:review_chunk_activitypasses those constants to itsrun_sandbox_review(...)call. Every otherrun_sandbox_review(chunk, dedup) and thestart_sandbox_session(validate) call is left untouched — that's the Codex-reviews / Claude-validates invariant.
⚠️ Headless-Codex approval gotcha (why full-access is mandatory, not optional). Codex's default permission
mode is "auto" (set by _build_task for any codex run), and "auto" only auto-approves read/search/fetch/think —
NOT mcp_tool_call. The review pulls its perspective skills over MCP (skill-get), so under "auto" every fresh
review sandbox parks on an approval prompt no headless run can answer, and the workflow fails. "full-access" (the
Codex equivalent of Claude's bypassPermissions) auto-approves every tool kind. This threads through the SAME path as
the model pins: CustomPromptSandboxContext.initial_permission_mode → create_task_and_trigger → _build_task →
extra_state → agent env. Any future headless Codex stage (e.g. moving dedup to Codex) must also pass
"full-access". The mode lives in @hanzo/agent codex-client.ts (AUTO_APPROVED_KINDS / shouldAutoApprove).
Tests (4; each guards a distinct real regression; /writing-tests gate).
- Tasks forwarding (parameterized, extends
TestCreateTaskAndTriggerForwardsContext): a context carryingmodel/runtime_adapter/reasoning_effort→create_and_runreceives all three; all-None→ allNone. Catches: the forwarding drop that silently reverts a pinned run to the Claude default (reasoning_effortwas the last unforwarded field). - Review executor threading (parameterized): mock
MultiTurnSession.start(the true sandbox boundary), assertrun_sandbox_review(runtime_adapter=codex, model=gpt-5.5, reasoning_effort=xhigh)builds a context carrying the three; unset → allNone. Catches: the executor dropping a pin, or defaults breaking so chunk/dedup get pinned. - Constants validity (pure):
get_reasoning_effort_error(REVIEW_*)isNoneand the adapter resolves to provideropenai. Catches: a futureREVIEW_MODELbump to a Codex model that caps athigh(or an effort flip) — a mis-route that would otherwise only surface at e2e. - Activity routing contract (
test_review_activity.py): run the realreview_chunk_activitywith the sandbox + DB + heartbeat boundaries mocked; assert it callsrun_sandbox_reviewwith theREVIEW_*trio. Catches: the confirmed gap — dropping the pins at the one call site (they default toNone) reverts review to Claude with every plumbing test still green. This test was added after the adversarial review flagged it as the one uncovered regression (both skeptics confirmed).
Adversarial review (ultracode; the confidence gate). A 4-lens workflow (routing correctness / scope containment /
convention + import safety / test quality) → each finding independently refuted by 2 skeptics. Result: 1 confirmed
finding — the routing-contract test gap above (a test gap, not a prod bug) — now closed. 0 production bugs; the
end-to-end routing chain to INSIGHTS_CODE_* was verified real, not cosmetic.
Risks (surface at e2e; none block the change).
- Codex single-turn schema adherence — a different model family reliably emitting the exact
IssuesReviewJSON end-turn is the biggest unknown. Safety net:MultiTurnSession.start'sextract_json_from_text+ salvage. The #1 thing e2e must prove. - Codex skill-get over MCP — the review pulls the enabled
review-script-perspective-*skills viaskill-get; the sandbox installs the Codex skills dir + MCP serves live, so it should work, but confirm under the codex adapter. - End-turn detection under the codex adapter —
@hanzo/agentnormalizesstopReason == "end_turn"across adapters; single-turn only, so no multi-turn agent-stop work is needed here (that's the deferred item).
e2e ✅ (2026-07-01, no-publish, PR #67371). All three perspective-review sandboxes launched + completed under
runtime=codex / provider=openai / model=gpt-5.5 / reasoning_effort=xhigh / initial_permission_mode=full-access, while
dedup + validation-c1 stayed Claude (no pins). The local $ai_generation trace confirms the actual calls: 174
gpt-5.5/openai generations (the reviews at xhigh) + 34 claude-opus-4-8/anthropic (dedup + validate). Funnel: 4
findings → 4 verdicts → 2 valid → 1261-char body. The first run (pre-full-access) failed exactly as predicted —
every review sandbox stalled on the MCP approval prompt — which is what forced the gotcha fix above.
Scope footprint. 3 small edits in products/tasks (shared infra — only adds an optional capability; now carries
model / runtime_adapter / reasoning_effort / initial_permission_mode) + 3 in products/review (executor,
constants, one activity wiring) + 4 tests + this doc. No DB migration.
📍 Reference — where the sandbox model + reasoning-effort switch lives (both repos)
Read this before testing another model (Sonnet, a new Codex model, a different effort). It's a two-repo path:
insights/insightssets the knobs, the@hanzo/agentpackage applies them. Verified end-to-end 2026-07-01.
A pinned run is three values — runtime_adapter + model + reasoning_effort. provider is derived from the
adapter (claude → anthropic, codex → openai), never set by hand.
insights/insights — where the knobs are set + validated.
- Pick the values (Review):
reviewer/constants.pyREVIEW_{RUNTIME_ADAPTER,MODEL,REASONING_EFFORT}→ passed at thereview_chunk_activityrun_sandbox_review(...)call →run_sandbox_review(reviewer/sandbox/executor.py) threads them onto theCustomPromptSandboxContext. To test another model, change these constants (or make them per-stage — the executor kwargs already exist for every single-turn stage). - The registry (source of truth for what's allowed) —
products/tasks/backend/temporal/process_task/utils.py, re-exported framework-free from the facadeproducts/tasks/backend/facade/run_config.py(import from the facade):RuntimeAdapter(claude|codex),LLMProvider,ReasoningEffort,RUNTIME_PROVIDER_BY_ADAPTER,CLAUDE_REASONING_EFFORTS_BY_MODEL(per Claude model),CODEX_MODELS+CODEX_REASONING_EFFORTS+CODEX_XHIGH_REASONING_MODELS(onlygpt-5.5allowsxhigh), and the pure checksget_provider_for_runtime_adapter/get_supported_reasoning_efforts/get_reasoning_effort_error. A new model/effort must be added here or the combo is rejected.test_constants.pylocks the Review combo to this registry at unit time. - Transport into the sandbox:
Task._build_taskwritesextra_state[{runtime_adapter, provider, model, reasoning_effort}]→get_task_processing_contextreads it back →start_agent_server→build_agent_runtime_env_prefix(logic/services/sandbox.py) emitsenv INSIGHTS_CODE_{RUNTIME_ADAPTER,PROVIDER,MODEL, REASONING_EFFORT}=…prefixed onto the agent launch command (guarded bytest_agentsh.py).
@hanzo/agent — where they are consumed + applied (the Insights Desktop monorepo, not this repo). Clone via
LOCAL_INSIGHTS_CODE_MONOREPO_ROOT (legacy alias LOCAL_TWIG_MONOREPO_ROOT); package packages/agent
(npm @hanzo/agent, baked into Dockerfile.sandbox-base).
- Entry
src/server/bin.ts: reads + zod-validatesINSIGHTS_CODE_{RUNTIME_ADAPTER,MODEL,REASONING_EFFORT}, guards withisSupportedReasoningEffort(src/adapters/reasoning-effort.ts— the agent-side mirror of the Python registry; it hard-errors server startup on an unsupported combo), then constructsnew AgentServer({runtimeAdapter, model, reasoningEffort}). - Adapter split
src/server/agent-server.ts:- Codex →
codexOptions = {model: config.model ?? DEFAULT_CODEX_MODEL, reasoningEffort: config.reasoningEffort}→agent.ts→src/adapters/codex/spawn.ts::buildConfigArgspushes-c model="…"and-c model_reasoning_effort="…"onto the Codex CLI, talking to the Insights gateway (model_provider="insights",wire_api="responses"). This is the line that applies ourxhightogpt-5.5. - Claude →
buildClaudeCodeSessionMetasetsoptions.model+options.effort(effort only whenruntimeAdapter === "claude") on the Claude Code session — effort routes here, not through the codex spawn. agent.tsalso fetches the gateway model allow-list and drops the model if it isn't served (sanitizedModel/allowedModelIds), so a typo'd/unavailable model silently falls back to a default rather than erroring.
- Codex →
Recipe — testing e.g. Sonnet. Set runtime_adapter = "claude", model = "claude-sonnet-…" (must be a key in
CLAUDE_REASONING_EFFORTS_BY_MODEL), and an effort that model supports; provider auto-derives to anthropic and the
effort flows through the Claude session path. For a new Codex model: runtime_adapter = "codex", model in
CODEX_MODELS, effort in CODEX_REASONING_EFFORTS (xhigh only if the model is in CODEX_XHIGH_REASONING_MODELS). If
the model/effort is brand-new, add it to the registry in both repos (utils.py here + reasoning-effort.ts / the
gateway model list in @hanzo/agent) or startup validation rejects it on one side.