arc: the review stage
The gate's review stage.
The review stage#
reviewis a read-only radian session, not the worker session. It runs at the same time ascommandswhen the profile lists both and beforehuman; a profile that lists both must listcommandsfirst. For each run it starts oneradian execin its own slot checkout,.radian/arc/gates/review-<n>/, on the candidate, with the candidate as the workspace and only theread,grepandglobtools mounted: no shell tool, no write tool, and no MCP server. Its model route and time limit come from the reviewer its gate profile names: the profile'sreviewerpicks a[gate.reviewers.<name>]section, whoseexec_argsandtimeout_secsthe session uses. A task's first review is a review stage run when no earlier attempt of the task has areviewverdict. It reads the reviewer'sfirst.exec_argsandfirst.timeout_secswhen they are set; an unset key falls back to the reviewer's own key and, forexec_args, then toworker.exec_args. A rework review never reads the reviewer'sfirsttable. Both keys are read when the stage starts, so a change takes effect at the next review. The prompt is built in: the task's brief, the commit list and the diff from the merge base of the remote target branch andverified_shatoverified_sha(the task's own change, not the target branch's), and the instruction to check the change against the brief and the repository's rules (AGENTS.mdand the design records the brief names). A rework round's prompt holds the increment instead of the full diff (see A rework round). The reviewer'sprompt_fileis appended to it. The session must end with one fencedjsonblock, and arc parses the last such block:{"summary": "what the change does", "findings": [{"severity": "major", "file": "path", "line": 12, "summary": "what is wrong", "trigger": "the input that shows it"}], "verified": []}summaryis 2 to 6 sentences on what the change does. Each finding is an object:severityandsummaryare required, andfile,lineandtriggerare given when they apply. An absenttriggerreads as empty.severityis one ofcritical,major,minor.triggeris the input or state that shows the problem.findingslists every problem found, not only the first. The three severities are calibrated by these examples:critical: security, data loss or corruption, a state the program cannot recover from, a hang or a leak. Examples: a cancel signals only the direct child, so a command the wrapper started in another process group keeps running after the task is cancelled; a file the code reads whole is replaced by a FIFO, and the read blocks forever before any timeout applies; a struct that holds an API key derivesDebug, so a panic or an error path prints the key.major: a wrong result, a contradiction of the brief or a design record, a manual sentence the code does not do, a test that fails or cannot fail. Examples: the test binary no longer compiles on Linux because a helper has no such method; a JSONnullfor a field deserializes as absent, so the documented "a present value that is not an array is an error" never fires; the manual says the flag is set only for a hook exit 2, but an exit 0 with a block decision also sets it; a new edge-case test passes against the code before the change, so it proves nothing; attempt directories are sorted by name, so attempt 10 sorts before attempt 2 and the newest verdict is the wrong one.minor: everything else: a rare edge case, wording, naming, style. Examples: a pronoun in a doc sentence can refer to two nouns, say which; the summary row's indentation no longer aligns under the id column after a column was inserted; a comment describes a key-handler arm that an earlier handler now intercepts, so the arm is unreachable; a tool's schema still says "default: every plan" after the default changed.
A finding at or above the effective
fix_leveland notpre_existingfails the stage with reasonreview_findings. The task goes toreview.rework_pendingandrework_reasonlists the failing findings, highest severity first, each as[severity] path:line summary — triggerwith the parts a finding does not have left out; the— triggerpart goes when the trigger is empty. A finding belowfix_level, and apre_existingone, is recorded and does not fail the stage.fix_levelis the reviewer'sfix_level:critical,major(the default) orminor.criticalsends back only critical findings,majorsends back critical and major ones, andminorsends back every finding. A task's ownreview_fix_level, when its file carries it, replaces its reviewer'sfix_levelfor every review round of that task, so a one-line fix can ask forminorand a risky change can be held tocriticalwithout changing the gate profile's reviewer.showprints the effective value asreview_fix_leveland whether it came from the task or the reviewer asreview_fix_level_source.verifiednever fails the stage. A verdict written before findings existed holdsblockingandsuggestions; it still reads back as findings: ablockingitem at its ownlevel, elsemajor, asuggestionsitem at its ownlevel, elseminor, itstextas the summary, and no trigger. Atextthat starts with a tag that repeats that level loses the tag.showprints the same items for such a verdict. A verdict whose report is in the old shape, whatever itsreasonsays, was decided beforefix_levelexisted: every item of itsblockinglist is a failing finding whatever its level and whatever thefix_levelsays, forshow, the rework reason, the rework prompt and the window anchors alike, and itssuggestionsare never failing, whatever their level; thefix_levelrule applies only to a report that carriesfindings. No parsable block, a missing field, aseverityoutside the three values, a timeout, or a session error runs the session once more; when the second run is unusable too the stage fails with reasonreview_output, orreview_timeoutwhen the last run timed out. Both runs' session ids and their summed cost are in the verdict. A session whoseResultcarries amodel_errorwithkindunavailableorusage_limitis model unavailable: the stage does not run it a second time, fails at once with reasonreview_unavailable, and its verdict records the session id, its summed cost and thatmodel_errorobject asreview_model_error(see A review wait on an unavailable model). A gate that is started again for the same attempt does not reuse a review session whose journal exists: the stage names the run<attempt id>-review-<run>-<k>with the smallest freek, writes its logs asreview-<run>-<k>.jsonlandreview-<run>-<k>.stderr.log, andreview_session_idslists the ids it used, so every run's session is found. The review verdict isruns/<task>/attempts/<attempt id>/verdicts/review.json; it recordsreview(summary,findingsandverified),review_session_ids,review_cost_micros,review_model_error(only on areview_unavailablefailure),review_settings(round,firstorrework;reviewer, the section's name; the effectivefix_level; theexec_argssource key and the session's own arguments asexec_args_values;timeout_secsand the key it came from),runs(one{run, session_id, started_at, ended_at, metrics}entry per run, in order) and, in a rework round,review_pre_existing(how many findings arc marked), beside the usualsha,main_sha,candidate_shaandprofile. It is bound toverified_sha, is part ofverdict_set_digest, andreverifyruns it again.reviewfailure reasons:review_findings(with its failing findings inrework_reason),review_output(no usable block after two runs),review_timeout(the last run hit the reviewer'stimeout_secs), andreview_unavailable(the session's model was unavailable; see A review wait on an unavailable model).review_blockingis the reason a verdict or task file written by an earlier build may carry; this build reads it and never writes it.When the profile lists both
commandsandreview, the round's outcome follows the profile order. Acommandsfailure decides it with the commands reason, even when the review passed or failed too: the review verdict is still written, its cost counts,showlists its items, and arc writes no automatic review rework for that round. Whencommandspasses andreviewfails, the review path runs (see A failed review). Both verdicts are written when each stage ends, and a verdict already written is reused: a gate that died while the pair ran resumes only the stage without a verdict. The gate waits for both stages to end before it decides; a failing stage does not kill the other, and the review is bounded by its own limit. A stop signal to the gate reaches both running stages.
A rework round#
A review is a rework round when an earlier attempt of the same task has a review verdict for a sha other than the commit under review; the newest such verdict is the previous review.
A review of the same commit as the previous review (a reverify of an unchanged head) gets the first review's prompt: the full diff, and no finding is pre-existing.
It reads the reviewer's own settings, not its first table, because an earlier review verdict exists, so it is not a first review.
A genuine first review, one with no earlier verdict at all, keeps the full-diff prompt and the reviewer's first settings.
A rework round's prompt holds the brief, the previous review's failing findings (at or above that round's recorded fix_level, not pre_existing), the commit list <previous sha>..<sha>, and the increment instead of the full diff.
It lists each failing finding as [severity] path:line summary — trigger, with the — trigger part left out when the trigger is empty, and it does not list a finding below that round's fix_level or a pre_existing one.
It asks the session to check that each failing finding is fixed, to review the increment, and to list every problem it finds; the session can read the files.
A previous verdict in the old shape has no findings: its blocking items are the failing findings the prompt lists and the window anchors below, at their own level, else major, whatever the level and the fix_level, because that verdict already failed on them.
The increment is the task's own diff restricted to the hunks that hold a line the rework changed.
A line is an increment line when it is a new-side line of git diff <previous sha> <sha> and also a new-side line of the task's own diff, which starts at the merge base of the remote target branch and sha.
A line that came in only with a merge of main is not in the task's own diff, so it drops out.
arc, not the session, decides whether a finding is pre-existing.
A pre_existing value in the session's report is ignored: arc reads it only from a verdict it wrote.
After a rework round's report is parsed, a finding is marked "pre_existing": true unless one of these holds:
- its
file:lineis within 3 lines of a line the task's own change added or changed in that file (the new side ofgit diff <the merge base of the remote target branch and the head>..<head>): a problem in code the task itself wrote in an earlier attempt is the task's, not pre-existing, so the rework's increment does not decide this; - it is in the same file as and within 10 lines of a failing finding of the previous review, so a finding that was not fixed stays failing; only the previous round's failing findings are anchors, never a finding below that round's
fix_levelor apre_existingone; - its
severityiscritical: acriticalfinding fails in every round, wherever it is; - it has no
fileor noline: it stays failing at its severity, because the review prompt asks for both fields and an omission is not evidence that the problem is old.
A marked finding keeps its severity (major included), and a major finding in a file the task's own change does not touch, or more than 3 lines from any changed line in that file, is still marked, so the rework is not sent back for a problem that predates the task; minor findings follow the same window rules.
The stage's decision is the task's effective fix_level: it passes when no finding at or above that level is left unmarked, and the verdict records how many findings arc marked as review_pre_existing.
The follow-up task of a plan carries a marked finding at its severity (see The review's follow-up).
When a rework round's review still fails on a failing finding and the plan's auto is not off, arc writes the next attempt's brief itself (see A failed review).
A failed review#
A review that failed with review_findings (or an old verdict's review_blocking) returns the task to todo.ready with a brief arc writes, the way a conflict with main does, when the plan's auto is not off.
The heading is ## Rework <n> (review), with the lead's add_rework numbering.
The text says the review found these items, to fix them and nothing else, to append commits and not to rebase; it lists the round's failing findings, one per line, as [severity] path:line summary — trigger, with the — trigger part left out when the trigger is empty and the parts a finding does not have left out; a finding below fix_level or a pre_existing one is not listed; and it names the review verdict to read for detail.
The worker cannot read runs/, so the wrapper copies the verdict into the worker's worktree at .radian/arc-verdicts/<task>/<attempt>/<file> and puts that worktree-relative path in the brief; the task file keeps the absolute path. The copies' directory holds a .gitignore with *, so git ignores it whatever the repository's own rules say; when the repository tracks that file, the rule goes into each task's copy directory instead, and when it tracks that one too, the rule goes into each attempt's copy directory; a symbolic link where any of them belongs is not followed (see the files page). A verdict that is missing, is not a regular file, is larger than 1 MiB, or cannot be copied stays in the brief as it is, and the attempt record carries verdict copy skipped: <path>: <reason>, which radian arc show prints as attempt_note; a .gitignore that cannot be written leaves the copies unignored and adds verdict copy ignore file skipped: <reason>, and the attempt runs.
It counts as an automatic rework: it raises auto_rework, and max_auto_rework pauses the plan (see the limits.max_auto_rework row); the task then stays in review.rework_pending.
With auto off, the task stays in review.rework_pending and waits for you, as before.
radian arc run prints <task> review blocking -> todo.ready (automatic rework).
The review's follow-up#
A task is open when it is in todo.ready, doing.* or review.*; a todo.proposed, done.* or failed.* task is not open.
When a plan has no open task, arc collects the review findings of the merged tasks that merged under this rule and proposes one follow-up task for the plan, and no more.
arc checks after every change that can leave a plan with no open task: a merge, a cancel, and every scheduling pass.
The check is idempotent: running it again adds no task.
The check and the write are one transaction under state.lock: the open-task condition and the set of merged tasks to collect are re-read under the lock that appends the follow-up, so a confirm between a scheduling pass's read and its write cannot add a follow-up beside an open task.
When a task reaches done.merged, arc records followup_pending on it if gate.review.propose_followups is true at that moment and the task is not itself a follow-up.
That is the per-plan rule: every merge path writes the field under the same switch the follow-up check reads.
A merged task is collected only when it carries that field, is not itself a follow-up, and no earlier follow-up of the plan names it in its after.
A follow-up's after lists the merged tasks it collected, so a task is collected at most once, and a collected task whose follow-up is later cancelled stays collected.
A merged follow-up is never collected.
A task merged before the field existed (by an older arc), or merged while propose_followups was false, does not carry it and is never collected, whatever its review verdicts say: turning the switch back on does not bring it back.
So an old per-task follow-up (the title Follow-ups from <task id>'s review, with that task as its only after entry) collects nothing, and it is never collected itself.
For each collected task arc reads every review verdict of the task, oldest attempt first, and collects every finding of every round that is below that round's fix_level or pre_existing; these are the findings the round did not fail on.
A round whose report is in the old shape is the exception: every suggestion is collected whatever its level, and no blocking item is, because the fix_level rule applies only to a report with findings.
Every round is read, not only the newest: a round that failed can carry a below-threshold or pre-existing finding, and a later round can pass with nothing to carry.
Nothing from the failing findings is collected: a merged task's failing findings were fixed or the review would not have passed, so a round that only had failing findings contributes nothing.
Two findings of the same merged task are the same when their file, line and summary are equal after a leading tag that repeats the finding's own severity is removed and whitespace is collapsed; arc keeps the newest copy.
A finding with the same file:line and another summary is kept as its own finding.
Findings of different tasks are never merged.
When at least one item is left, and [gate.review] propose_followups is true (the default), arc adds one task to the plan, and no more:
- the id is the next task id;
- the slug is
<plan slug>-followups, cut to the 48-character slug limit with a trailing-removed; when the plan already holds a task with that slug,-2,-3, … is appended within the limit; - the title is
Follow-ups from <plan id>'s reviews; - its
afteris the collected merged tasks, in merge order; - its phase is
todo.proposed, so the loop never claims it until you confirm it (seeconfirm); - its
allow_noopistrue: its brief lets each item be left with a one-line reason, so an attempt that adds no commit may deliver nothing (see An empty change);
Its brief is fixed: one line saying the items are from the reviews of the plan's merged tasks and that each item is to be fixed or left with a one-line reason in the final answer; then one section per collected task, headed From <task id>, holding that task's findings grouped by the round they came from, each group headed From review <n> (attempt <n>):, one finding per line as [severity] path:line summary — trigger, with the — trigger part left out when the trigger is empty; then the path of every review verdict.
The worker gets each verdict as a copy at .radian/arc-verdicts/<task>/<attempt>/<file> in its worktree, one copy for every task and attempt the brief names, and the task file keeps the absolute paths; the copy directory's .gitignore and the skip and ignore notes are the same as for a review rework (A failed review).
A finding whose summary starts with a tag that repeats its own severity prints the severity once, as in the rework reason above.
A plan whose merged tasks left no item gets no follow-up.
A follow-up task gets no follow-up of its own: a merged follow-up is never collected, so the review's unfixed items stay in its verdict and in the task's final report.
arc recognises a follow-up by a shape it gave it: the per-plan shape above (title Follow-ups from <plan id>'s reviews, the <plan slug>-followups id, allow_noop, a non-empty after list, and the fixed first line of its brief), or the per-task shape an earlier arc wrote (title Follow-ups from <task id>'s review with that task as its only after entry).
With propose_followups false arc adds none (see the propose_followups row); the lead can still add a task to the plan with MCP add_task.
radian arc run prints <new task id> proposed (follow-ups from <plan id>).
A follow-up arc cannot add (the plan's file changed outside arc) does not undo the merge: radian arc merge prints it as a warning: line after the merge, and radian arc run reports it in the summary.
The plan keeps the state it had.
A review wait on an unavailable model#
A review stage whose session ended because its model was unavailable fails with review_unavailable (see the review stage) and the task waits in review.rework_pending. Its rework_reason is review_unavailable: model <kind> (HTTP <status>), retry at <UTC time>, with the parts that are null left out. arc writes no rework section, does not raise auto_rework, does not touch retry_count, and does not pause the plan; the wait counts the review's cost toward the budget as any review's does.
The wait is the task's retry_at: the failing session's model_error.retry_after_secs when it is present, at least 60 s and at most 6 hours; otherwise 5 minutes for the first review_unavailable of a verified_sha, doubled for each further one in a row on the same verified_sha, at most 60 minutes. Any other review outcome ends the run of waits.
A scheduling pass reverifies the task by itself once retry_at has passed, the way radian arc reverify does: it claims a new attempt without a worker and starts its gate. It applies when the task's plan has auto other than off. It needs no moved main, does not use up limits.max_auto_reverify, and has no count limit, because a usage window always resets. The claim is not a retry and not an automatic rework: it changes neither retry_count nor auto_rework, and it does not pause the plan. Your own radian arc reverify may start it earlier, as for any failed verification stage.
radian arc run prints <task> review model unavailable -> retry at <UTC time> when the stage fails and <task> review retry_at passed -> auto reverify when it starts the reverify (the loop's lines). radian arc show prints the reason as rework_reason and the wait as retry_at, and radian arc list ends the task line with retry at <UTC time>.
A worker attempt that ends on an unavailable model waits like the review stage, and uses no retry (see A worker wait on an unavailable model).