Skip to content

Issues, PRs and reviews ​

How the chapter runner works an issue node: the go-ahead a person gives first, the pull request an agent ends at, and what happens after it opens. Then expediting, cloud agents and review nodes.

Issue nodes, and the go-ahead ​

Issue nodes are launchable too, with two checkpoints a person owns:

  1. The go-ahead. The loop claims no issue until a person has approved it for agents — the approve for agents button on the plan page (POST /api/plans/{slug}/nodes/{id}/approve). An issue's scope is whatever its text says, so starting on one is a decision. There is deliberately no MCP tool for it: an agent that can approve the work it is launched against is no checkpoint. The approval is recorded on the plan with who and when, is never part of an export, and is cleared if the node is changed or rebound — it covered what the person saw.

    A whole column at once: when a layer of the plan is ready for agents, the approve all (n) button above a board column approves every card in it that can take a go-ahead — an issue node that is bound, not done and not yet approved — after a confirm that names each card. It is one request (POST /api/plans/{slug}/approve with { "nodes": [...], "by": "..." }) and one approval per node with the same who and when, so withdrawing, the export and clear-on-change keep working card by card. A card that went done, lost its binding or was approved by someone else after you confirmed is skipped and named, never approved behind your back. withdraw all (n) takes the column's go-aheads back (DELETE /api/plans/{slug}/approve?node=a&node=b). Which cards can take one is the server's call (approvable on each node's status, beside approvedForAgents), so plan_status, next_ready_nodes, /api/plans/ready, the runner and the page all read the same answer. Like the card's button, there is no MCP tool for either.

  2. The merge. The agent's work ends at a pull request that says Fixes #n, and the agent never merges it. The node is done when GitHub observes the issue closed. Who merges is the node's merge policy, set per node:

    • manual-review (the default, ◉ review on the card): a person reviews and merges.
    • merge-on-green (⇢ auto-merge): the runner merges the PR itself, through the API (gh pr merge with the repository's own allowed method, squash first; never --auto), once Stoat observes its CI green. It does not use GitHub's native auto-merge, so it works the same in a repository that has auto-merge turned off, and with or without required checks. It holds, and says why in a merge callout on the ready board, when CI fails, when the PR reports no CI at all (no green to merge on), when two open PRs close one issue, when a review is required or changes were requested, and before trying a PR that conflicts with its base or is behind a base it must be up to date with: #586 conflicts with main — resuming its agent to rebase (1/2). That hold hands the branch back to its agent (see After the PR opens). A merge is tried once per PR head, so when the agent pushes its rebase and CI is green on the new head, the merge is tried again with no restart. A refusal GitHub gives for the branch itself (a conflict, a stale base) re-arms the same way; any other refusal (a required review, a protection rule) waits for a person until the node is restarted. A paused run merges nothing.

    The policy is merge: in the plan, and a person flips it with one click on the card's pill (POST /api/plans/{slug}/nodes/{id}/merge), which asks before turning review off and, unlike a node change, keeps the go-ahead. Only a person can turn it on. Like the go-ahead, there is no MCP tool for it, and plan authoring over MCP (add_plan_node, change_plan_node, import_plan) refuses to move any node to merge-on-green: an agent that could would skip the review its own PR waits on. An agent editing a node a person already set keeps the setting, and may always switch one back to manual-review. A person's YAML import (the REST PUT, or the plans directory) may set it. The same policy on a pr node makes the runner merge that PR on green too.

An issue is only launchable from a checkout of its own repository (read from the project's git remote), and only once it is bound to a real issue number. Each waiting reason is said once:

  ⚠ fix-lanes: awaiting a person's go-ahead — approve it on the plan page before the loop may claim it
✓ 'fix-lanes' has a PR open to close its issue — it is done when a person merges it.
✓ merged JasperFx/stoat#71 for 'fix-docs' — merge-on-green, and its CI is green.

Slices take no go-ahead: the specs bound to a slice already define its work.

After the PR opens: red CI and conflicts ​

An issue agent's work is not over when its PR opens. Its prompt says so: wait for the PR's CI (gh pr checks <n> --watch); when it goes red, read the failure, fix it on the same branch and push; when it is genuinely unrelated or flaky, re-run the failed jobs once and say so in a PR comment with the evidence; when the PR conflicts with the default branch, rebase onto it, keep both sides' intent, regenerate generated files rather than hand-merging them, re-run the tests and push with --force-with-lease; and anything it cannot resolve goes to a person rather than ending in a quiet exit.

The runner backs that up, because an agent often exits first and the PR goes red, or conflicts as its siblings merge, after it has gone. On every poll it reads each issue node's PRs (the same prs the card shows) and, for a PR an agent of this run opened:

  1. A conflict (or behind a required up-to-date base) comes first, then red CI. It resumes that same agent session, in its own worktree, with the problem named: the failing checks, or the base and the PRs that merged into it since the agent was launched. The node is claimed again for it, with the same session, so ask_person works and the board shows it taken.

      ↻ 'theory-row-identity': CI red on #585 (test) — resuming its agent to fix it (1/2)
      ↻ 'wire-contracts': #587 conflicts with main after #583 merged — resuming its agent to rebase it (1/2)
  2. Once per PR head, at most --max-fix-attempts per node (default 2, CI fixes and rebases sharing it). A resume is judged after --settle-seconds like any attempt, since a push takes a sweep to show.

  3. Then a person. Past the bound, when the agent was resumed and pushed nothing, or when the launcher cannot resume a session (codex, gemini), the node joins the ready board's "needs a human" strip as a fix callout with the way back into the agent's context: ✗ CI red on #585 (test) after 2 fix attempts — needs a person. A conflict says the files it is in (git merge-tree against the fetched base, in the agent's worktree; nothing is merged). A merge-on-green node says it in its merge callout instead, so it is said once.

The runner never fixes anything itself. It never resolves a conflict, never edits a test, and never merges a manual-review PR: it hands the problem back to the agent that made the PR, and then to a person. A PR this run did not open (a person's, or an earlier run's: the sessions are in the runner's memory) is never resumed; its card and the strip still say it is red or conflicting. Merge policy is unchanged: a manual-review PR still waits for a person, and while its agent is fixing it the review callout says it is not ready for review yet.

When main is red, not the PR ​

The GitHub sweep reads each repository's default branch and the CI on its latest commit. When main is red and a PR fails only the checks failing on main, the PR is red because of main: no agent can fix that in its own PR. Its line says main is red (Retries.count_twice), not this PR; no agent is resumed for it; and the runner raises one main-red callout per base, naming the merge it went red at and every PR it holds:

main is red since 1a2b3c4 'Merge pull request #639' (Retries.count_twice) — needs a fix on main, not in any PR.
Holding the fixes for #641 ('ui-preview'), #642 ('ui-watch'); each is rebased once it is green.

Once main is green, each held PR's agent is resumed to rebase, so its CI runs against the fix. A failing check's annotations name the failing test where the test reporter writes them (CI ✕ 1 failed (test: Retries.count_twice)), on the card, the strip and the agent's prompt; warnings and the runner's own Process completed with exit code 1 are left out.

Stalled on the fix ​

"Its agent is fixing it" has to mean progress. An agent alive with a red or conflicting PR and no push on that head for --fix-stall-minutes (default 20; 0 never) is stalled: the runner stops it and resumes it in its own session, once, telling it why. If it stalls again, or exits without pushing, it is a person's (fix). A PR waiting on a red main is never called stalled.

Merge races ​

Two PRs, each green alone, can merge minutes apart into a red main: the second was never tested against the first. So merge-on-green merges only a head that contains its base's tip (compare base...head says behind_by: 0). One that does not is handed back to its agent to rebase, and its CI runs again before it merges. A base with a merge queue gets the PR in its queue instead (gh pr merge with no method): the queue tests the merge result itself. When GitHub cannot be asked, the merge waits for the next poll rather than going in blind.

Fresh worktrees for issues ​

An issue node's worktree is cut from a fresh origin/<default>, fetched just before (or --base), never the checkout's HEAD, which can be far behind or on someone else's branch. Its PR lands on the default branch, so that is what it starts from, with or without an integration branch. A retry reuses the worktree it already has.

Expediting a node ​

With more ready nodes than slots the runner goes by leverage, which is usually right and sometimes not: a flaky test turning every agent's PR red has to be fixed first, even when it unblocks fewer nodes than the work queued ahead of it. The ⇡ on a card expedites it: the runner launches it ahead of every other launchable node, then the rest by unblocks and plan order as usual. Expedited nodes among themselves go by unblocks, then plan order. It says so when it does:

  ⇡ 'flaky-tunit-resident' is expedited by jeremy — launching next
  • A person's, like the go-ahead. POST /api/plans/{slug}/nodes/{id}/expedite (and DELETE to take it back) records who and when on the plan; there is no MCP tool, and plan authoring cannot set it (the YAML has no word for it, and an import neither carries nor clears it). An agent that could expedite its own node would jump the queue a person is ordering.
  • It changes the order, nothing else. An expedited issue still needs its go-ahead; an expedited node still waits for its dependencies and its shared resources.
  • It stops counting once the node is done, so a finished node never holds the head of the queue. It survives a change to the node (its turn is not what it says) but not a rebinding to another issue.
  • Only the kinds the runner launches can be expedited: issue, slice, test-run-gate and review.
  • --expedite-slot lets an expedited node take one slot past --parallel instead of waiting for a running agent to exit — never a second while one is already over the cap. Off by default, because --parallel is the cap for the machine.

Cloud agents ​

Some work suits a cloud agent (Claude Code's remote agents) better than this machine: long-running, self-contained work, work in another repository, or anything that would hog a local slot or the box's databases. The ☁ on a card keeps the node for one, and the plan can say so up front:

yaml
  - id: weasel-tests
    kind: issue
    repo: JasperFx/weasel
    issue: 88
    agent: cloud
  • A person's mark, like the expedite. POST /api/plans/{slug}/nodes/{id}/cloud (and DELETE to take it back) records who and when; there is no MCP tool, and the export never carries it. agent: cloud in the YAML is the plan's own word (also placement on add_plan_node / change_plan_node, and the editor's checkbox), exported like any field; taking it back means editing the node. Only the kinds the runner launches take either, and neither counts once the node is done.

  • Never a local slot. The runner does not launch a cloud node in a worktree. Without a cloud launcher it says so once and raises a cloud callout when the node is ready (an issue after its go-ahead), so a person or the lead launches one by hand.

  • --cloud-launcher <profile> hands cloud-marked issue nodes to a cloud agent itself, beside the --parallel local ones. It names a profile in ~/.stoat/launchers.json with "cloud": true: a command that takes {prompt}, starts the cloud agent and returns, printing the session's URL if it has one. The runner claims the node first under its own identity with where: cloud and the longest lease (4 hours), and records the printed URL as the claim's link. A cloud issue may be in any repository, since the agent clones its own. Slices and gates are not sent: their evidence is a run this machine has to observe.

    json
    [{ "name": "remote", "command": "/usr/local/bin/send-to-cloud", "arguments": ["{prompt}"], "cloud": true }]
  • The prompt is the issue prompt's conventions (the claim named, Fixes owner/repo#n, the merge left to its policy, the PR watched through), plus what the cloud changes: nothing on this machine can see the agent, so it renews its own lease (POST …/nodes/{id}/report with leaseMinutes and its session's link, or report_node) and releases when its PR is green. --cloud-url is the Stoat address it can reach (default --url; a localhost one is warned about).

  • Tracked like any claim. The card says ☁ cloud beside the holder and links the session. Its health is unlinked by construction, and says why. When the claim goes (released, or the lease lapsed) the attempt is judged by the evidence like any other: the PR it opened is observed through the issue.

Review nodes ​

A review node is launched like a gate, with a prompt to review the integration branch as a whole and change nothing. Its agent records findings with submit_review; a person accepts each one (it becomes a fix node the review depends on), rejects it, or signs off a review that found nothing. See Deciding a review.

See also ​

Licensed under the Business Source License 1.1.