Worker Review Loop
The worker review loop is a way to build a change with several coding agents without letting any of them grade their own work. Workers write code from a written brief, one branch each, in their own git worktree. A separate reviewer reads the code, not the worker’s summary, and posts a verdict. The worker’s fix pass goes back to the same reviewer, and nothing merges until that reviewer says it is clean.
SessionFS holds the state of the loop, so it works with any coding agent (Claude Code, Codex, Cursor, Gemini CLI and others) and anyone on the project can see where each piece of work stands. The worker-review-loop skill in the launch catalog teaches an agent to run it. For wrapping agents you launch yourself in agent runs, see also External Agent Orchestration.
| Piece of the loop | SessionFS primitive |
|---|---|
| Brief | A ticket with acceptance criteria |
| Each worker or reviewer launch | An agent run |
| Review loop | A work queue in review_until_clean mode |
| Reviewer | A registered trusted reviewer posting verdicts |
| Worker that runs out of turns | A continuation run or a handoff |
| Discoveries | Knowledge base entries (add_knowledge) |
Tickets and agent runs are on every plan. Review loops, workflows, releases, merge checks and loop share links need Team or Enterprise. Registering a trusted reviewer needs an org owner or admin on Team or Enterprise.
1. Write the brief as a ticket
Section titled “1. Write the brief as a ticket”The brief is the worker’s only context: anything not in the ticket, the worker does not know. Create one ticket per branch with the create_ticket MCP tool or sfs ticket create, and put each testable outcome in its acceptance criteria. A good brief names:
- the goal, in one sentence;
- the spec, with the parts that matter quoted;
- the files to read first, in order;
- the scope: which paths may change, which may not, and whether new dependencies are allowed;
- the vendor documentation for every external API the change calls;
- the required tests and the command that must pass, plus a migration run on a real database if the change has migrations;
- what “done” means, including a status line the worker prints at the end (for example
WORKER-STATUS: DONE).
sfs ticket create --title "Add retry to the export client" \ --description "$(cat brief.md)" \ --criteria "Retries 429 and 503 responses with backoff, up to 3 attempts" \ --criteria "tests/test_export_client.py covers both status codes" \ --file src/export/client.py --output-idTickets an agent files start as suggested. Approve them (sfs ticket approve or approve_ticket) before a work queue will pick them up.
2. One worker per branch, one worktree per worker
Section titled “2. One worker per branch, one worktree per worker”git worktree add ../wt-export-retry -b export-retry mainWorkers never share a worktree or a branch, so they cannot see or overwrite each other’s files.
3. Launch workers as agent runs
Section titled “3. Launch workers as agent runs”Record each launch as an agent run, then start your coding agent inside the worktree with the brief:
sfs persona create implementer --role "Implements ticket briefs" \ --content "Work the ticket's brief in this worktree, commit, and report."RUN_ID=$(sfs agent run implementer --ticket <ticket_id> --output-id)The worker calls start_ticket first, which loads the ticket, the persona and related knowledge, and records what it learns with add_knowledge as it goes. When several workers run at once in different worktrees, give each its own run id in SFS_AGENT_RUN_ID: each worker then keeps its own active ticket, a copy for the worktree is kept under ~/.sessionfs/workspaces/ (never inside the repository), and captured sessions are tagged with a ticket only when the ticket’s repository matches the session’s. Keep the full output in a log file. A fast, inexpensive model often works well as a worker when a stronger model reviews, but that split is optional.
4. Detect how the worker exited
Section titled “4. Detect how the worker exited”A worker’s own summary is not evidence that it finished. Check the log and the branch:
- Search the whole log for the status line. Do not anchor the check to the start of a line. Tools wrap, indent or prefix their final message, and an anchored check has missed real out-of-turns exits.
- Confirm with git that the worktree is clean and the branch has the commits.
- Look for your tool’s turn-limit message or exit status to tell running out of turns apart from a crash.
| Exit | Signs | Next step |
|---|---|---|
| Finished | Status line present, tree clean, commits on the branch | sfs agent complete $RUN_ID --summary "...", then, on the first pass only, sfs ticket complete <ticket_id> --notes "..." --file <path> so the ticket moves to review (fix-pass workers skip this: the ticket is already in review), then one ticket comment with the branch, commit and test command. |
| Out of turns | Turn-limit message, no status line | Commit what exists as work in progress, complete the run with --status errored, and start a continuation. |
| Crashed | Non-zero exit, no turn-limit message | Complete the run with --status errored, fix the cause, relaunch the same brief in the same worktree. |
A continuation is a short new brief: continue this ticket, the spec is the ticket, the previous worker stopped at commit <sha> (read its diff first), and these criteria are still open. Create it with sfs agent replay <run_id> --prompt-file continuation.md --context-ref <sha> (MCP: replay_agent_run) so the new run links to the one that stopped, and start it with sfs agent run --run-id <new_run_id>. The ticket is already in progress, so a continuation worker skips start_ticket. If a person should take over instead, push the session and send a handoff that carries the ticket.
5. Register the reviewer and create the queue
Section titled “5. Register the reviewer and create the queue”Give the reviewer an identity nothing else uses, usually an org service key, and register it as a trusted reviewer:
sfs admin service-keys create --org <org_id> --name reviewer \ --scope tickets:read --scope tickets:write \ --scope work_queues:read --scope work_queues:write \ --scope agent_runs:write --scope knowledge:read \ --project <project_id> --output-key
sfs persona create reviewer --role "Reviews branches read-only" \ --content "Review the code, not the worker's report. Post one verdict per round."
sfs admin trusted-reviewers add --org <org_id> \ --service-key-id <key_id> --persona reviewerThen create a work queue with the create_work_queue MCP tool:
{ "name": "export-retry review", "mode": "review_until_clean", "selector": {"ticket_ids": ["<ticket_id>"]}, "assigned_persona": "reviewer"}Run the reviewer under that key only, for example as its own auth profile (sfs auth login --profile reviewer --key <key>, then SESSIONFS_PROFILE=reviewer in the reviewer’s shell), never under a worker’s identity.
knowledge:read lets the reviewer read the project context and wiki pages (v0.29.1) in addition to the ticket and queue — useful when the brief points at a spec page or a compiled context section to judge against. Keep it read-only: this key has no knowledge:write, so the reviewer cannot alter the knowledge base or wiki it is judging.
assigned_persona must match the persona the reviewer is registered for. The server counts a verdict only when the caller’s identity is registered for exactly that persona, and it ignores any persona name a client sends.
6. Independent, read-only review
Section titled “6. Independent, read-only review”The reviewer runs in its own checkout, may not edit files, and never commits. On each wake it calls run_work_queue_step (no more often than the queue’s cadence, which is at least 120 seconds). A post_review directive means a worker posted new work; it carries the review_round number and the findings still open from earlier rounds.
Brief the reviewer with the branch, the commit, the base and the ticket, not with the worker’s conclusion. Ask it to read the diff against the acceptance criteria, run the tests, check every external request against the vendor’s documentation rather than the test mocks, confirm migrations ran on a real database, and re-check every open finding.
The reviewer delivers its verdict through complete_work_queue_step, passing the directive’s ids, outcome: "posted_review" and the verdict as verdict_content. The server writes the verdict comment itself, with the queue’s persona and its server-stamped trusted provenance. Post the verdict through the queue rather than add_ticket_comment, so the round and directive stay linked and the loop can close. The server reads this format, with the reviewer’s own name in place of <Reviewer> whichever tool reviews and <review_round> taken from the directive:
<Reviewer> R<review_round> review on <ticket_id>: CHANGES_REQUESTEDFindings:- HIGH — src/export/client.py:88 retries POST without an idempotency key- LOW — tests/test_export_client.py does not cover the 503 path<Reviewer> must start with a letter and contain only letters, digits, ., _ or - (at most 32 characters). A verdict_content whose header does not parse is rejected with verdict_header_unparseable before anything is written.
The reviewer may also pass reviewed_commit — the full commit id (40 or 64 lowercase hex characters) of the head commit it actually read — so the verdict is bound to the code it reviewed. A merge gate later requires the clean verdict to be bound to the current head, so a verdict on a SHA that has since moved no longer marks the branch ready.
The reviewer also records its own agent run, with the severity and findings, so the review shows up next to the worker runs. Findings on a run can carry round, severity, class, harm and stage keys (see complete_agent_run), so mistakes can later be counted by kind and by whether they were caught in design or in code.
7. Fix pass and re-review
Section titled “7. Fix pass and re-review”- Read the open findings with
get_ticket_review_stateorsfs ticket review-state <ticket_id>. - Launch a fix worker in the same worktree with a brief listing those findings. Handle its exit as in step 4, except that the ticket is already in review: the fix worker calls neither
start_ticketnorsfs ticket complete. - The fix worker commits and posts one ticket comment with the new commit. Any comment that is not a trusted verdict hands the turn back to the reviewer, so post one comment per pass.
- The same reviewer re-reviews. The loop for that ticket ends only on a trusted
<Reviewer> R<N> review on <ticket_id>: VERIFIED-CLEANwith no open findings.APPROVEDand similar wording is displayed as clean but never ends the loop.
8. Merge
Section titled “8. Merge”Merge when the ticket is ready at the head commit you are about to merge. check_merge_readiness (or sfs loop ready <ticket_id> --sha <head>) returns ready: true only when all of these hold:
- a trusted strict
VERIFIED-CLEANverdict exists; - its
reviewed_commitequals the requested head exactly (a prefix match is not accepted); - the reviewer’s identity differs from every implementer identity recorded for the ticket, so no one grades their own work (a service key counts as the person who created it, so two keys minted by the same person are one identity);
- no open finding is harmful.
A finding’s harm is read from a harm: <tag> marker in its line (data-exposed, money-spent, wrong-actor, crash, other or none). harm: none marks a finding as checked and harmless; an untagged finding of medium severity or higher counts as harmful. If no implementer identity was recorded for the ticket, the answer is not ready.
get_ticket_review_state still shows the raw state (sfs ticket review-state prints Strict VERIFIED-CLEAN: yes), but readiness is the merge gate: it also enforces the commit binding and the reviewer-identity check that review state alone does not. Run the full test suite on the merged result, then resolve the ticket (resolve_ticket or sfs ticket resolve) and remove the worktree. The work queue never changes a ticket’s status on its own.
The one-call flow (v0.29.3)
Section titled “The one-call flow (v0.29.3)”The worker-review-loop skill (version 2.0 in the launch catalog) runs the loop with one call per step instead of the manual wiring described above. The steps in sections 1 to 8 still apply; these calls do the bookkeeping for them.
| Step | MCP tool | CLI |
|---|---|---|
| Start the loop | start_review_loop |
sfs loop start |
| A worker starts | start_worker_run |
sfs loop run start |
| A worker finishes | finish_worker_run |
sfs loop run finish |
| Record a review round | record_review_round |
sfs loop review |
| Is it safe to merge? | check_merge_readiness |
sfs loop ready |
| Where does it stand? | get_loop_status |
sfs loop status |
- Design review first. One reviewer reads the spec, not code, and answers four questions in writing: what happens to access when a step is slow, where money or quota is spent and whether every path counts it, what runs twice or in parallel, and which user or model values end up as identities, permissions or stored data. Record it as round 0 with
record_review_roundandstage: "design"against the loop’s parent issue, and fix the spec before any worker starts. - Start the loop with
start_review_loop. The result carries a rendered brief for each child ticket (the description, the acceptance criteria and a Known traps section from the project’s checklist), the reviewer queue, whether the reviewer is trusted, and the environment each role needs. Run each role under its own profile. - A worker takes the ticket with
start_worker_run, which takes the ticket’s lease, opens a run and returns the run id and what is left of the budget. It refuses withloop_needs_humanwhen the item is parked for a person. - The worker builds in its own worktree, then you confirm with git, not the worker’s summary, and call
finish_worker_runwith the commit it left, how it exited (finished,out_of_turnsorcrashed) and its tokens. Calling it again with the same commit is safe and never counts usage twice. A worker that ran out of turns gets a new run on the same ticket with a short continuation brief. - The reviewer records each round with
record_review_round: the round number, the stage, the full commit it reviewed, the verdict (VERIFIED-CLEANorCHANGES-REQUESTED), its findings and its tokens. Each finding has a severity, a class (a checklist slug, ornew) and a harm (data-exposed,money-spent,wrong-actor,crash,otherornone). Whether the verdict is trusted comes from the credential that sent it, never from the request. A finding with classnewproposes a checklist item, so the next brief warns about it. Going past the round cap or the token budget parks the ticket for a person. - Harm decides whether there is another round. A finding with real harm is fixed by a new worker run and re-checked against the new commit. Findings with harm
nonego into one follow-up ticket. After three rounds on one branch, look at why findings keep coming before paying for a fourth. - Merge.
check_merge_readinessis ready only for a trusted clean verdict bound to the exact commit, from a reviewer who is not an implementer, with no harmful finding open. A review round never closes a ticket when the reviewer is one of its implementers or the implementers can’t be determined. A person merges.
What each of these calls guarantees when a worker crashes, a client retries or two requests race (idempotency keys, lease fencing and what to do on a timeout) is set out, with the test behind each claim, in Review Loop Guarantees.
Merge check on GitHub and GitLab
Section titled “Merge check on GitHub and GitLab”With the GitHub App installed, pull requests get a SessionFS review loop check that is green only when every ticket linked to the pull request is ready to merge at exactly that commit. Branch protection can require it. An install without the Checks permission gets a pull request comment instead, and POST /api/v1/projects/{id}/tickets/{ticket_id}/merge-check recomputes it on demand. GitLab merge requests get the same answer as a commit status. See Git Integration.
Pull requests link to tickets from tk_ ids in their title, body or branch name and from loop branches. Record a release by hand with sfs release record and sfs release deploy, or let the GitHub App and the per-org delivery webhooks record it. The Ship Report, per release or per month (sfs release ship-report <version> --markdown), summarises loops, review rounds, findings, harmful findings and whether all were fixed before merge, cost per accepted change, checklist classes learned, gates, deploys and pull requests, with a markdown snippet for release notes. In loop insights, a model scoreboard compares each worker and reviewer model pairing by harmful findings per accepted change, rounds to clean and cost per accepted change.
To show a result to someone without an account, sfs loop share story <ticket_id> or sfs loop share ship-report <version> mints a link that shows no dollars, emails or user ids, expires after 7, 30 or 90 days and can be revoked (sfs loop share list, sfs loop share revoke).
In the dashboard, a Loop Board shows one lane per ticket with a ring per review round sized by its harmful findings, a budget forecast, the Ship Reports with image and markdown export, the model scoreboard and where each ticket shipped. A Needs you panel collects what is waiting for a person, including proposed checklist items, and a Review loops page under Inbox lists the loops of every project you can see.
Start a loop and set limits (v0.29.2)
Section titled “Start a loop and set limits (v0.29.2)”Instead of wiring the queue by hand you can start a loop in one call: sfs loop start (MCP: start_review_loop, API: POST /api/v1/projects/{id}/loops). Pass an existing issue to adopt it as the loop parent, or a title to create one. Each brief becomes a child task, and SessionFS creates the reviewer’s review_until_clean queue.
sfs loop start "Billing fixes" --reviewer-persona reviewer \ --ticket "Fix proration::Round to the cent" --token-budget 400000 --max-review-rounds 4sfs loop status # every loop, or pass a loop id for its childrenA loop can carry three limits: a token budget, a dollar budget (set only by org owners and admins) and a cap on review rounds. There is no default round cap. When a limit is reached the item stops and is handed back to a person as needing a human instead of continuing to spend. A person can raise a limit with sfs loop update (MCP: update_review_loop) or Raise limit in the dashboard, or settle the item with sfs loop close-item or Close item. Closing items is for people only; service keys cannot do it, and the ticket keeps its own status.
The review checklist is a per-project list of traps that every brief carries. Seed it with the generic starters (sfs loop checklist seed), propose items by hand, and accept or retire them; new kinds of finding from reviews are proposed automatically. sfs loop report <ticket_id> shows each review round’s findings by harm, how many were known classes versus new, and what the round cost; sfs loop insights rolls that up over a date range. In the dashboard, the project’s Work section shows runs, a story of each ticket, and the Checklist panel.
Track spend
Section titled “Track spend”Each run can record its model, role and token usage: pass --model, --provider and --role to sfs agent run, report usage while it runs with sfs agent usage (MCP: report_agent_run_usage), and send the final figures with sfs agent complete --input-tokens --output-tokens --cost-usd --cost-basis. sfs agent spend --ticket <ticket_id> (MCP: get_spend_summary) then shows what a ticket cost across workers, reviewers and review rounds. Token totals are visible to everyone who can read the project; dollar amounts only to org owners and admins, or the owner of a personal project.
Lessons
Section titled “Lessons”- The brief is the only context. Name the spec, the files to read first, the scope limits and the required tests.
- A self-report is not a gate. The trusted verdict is.
- Mocked tests can encode the wrong API. A branch can pass its tests while asserting the wrong request shapes for a vendor API. Check against the vendor’s documentation, and prove migrations on a real database.
- Write down what you learn. Vendor quirks, dead ends and conventions go into the knowledge base as they are found, so the next worker starts with them.