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. No worker can give it: an agent that can approve the work it is launched against is no checkpoint. The one MCP twin, approve_for_agents, is the plan's live lead agent relaying your word, recorded as yours, and refused for a node the lead holds itself. 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.

    Many at once: when a repository's work is ready for agents, approve all N in its band's header approves every card in the band that can take a go-ahead — an issue node that is bound (or, with runners creating issues, unbound), not done and not yet approved — after a confirm that names each card. The plan's Table view does the same for any selection of rows. 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 takes the band'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.

  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, 🔎 on the card, ◉ review in its zoom): a person reviews and merges.
    • merge-on-green (🤖 on the card, ⇢ auto-merge in its zoom): 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, its one MCP twin (set_merge_policy) is the plan's live lead relaying your word, 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, and on a consume node it covers the pin-bump PR the node rides.

    Merge-on-green waits for the node's dependencies. While any node it depends on (in its plan or borrowed from another) is not done, its PR is held, so an edge added under an open PR holds it. Under manual review nothing stops a person merging out of order on GitHub, so Stoat lists merges in the order they should land instead (see What is waiting on you).

  3. Landing. An issue node can read done while a PR that closes it is still open: its issue was closed by hand, or a person accepted it as partial. The card says done · landing #790, and everything that depends on it waits until that PR merges or a person closes it, so no dependent is cut from a branch without its work. Its PR is still under its merge policy: a manual-review one is in "needs a human" as a merge, and a merge-on-green one is merged by the runner on green, as it would be before the node read done.

A PR attaches to its node however it is worded. GitHub ties a PR to its issue only through a closing keyword, so a Part of #n PR is invisible to the sweep. The agent links it with report_node's pr (the PR's number in the node's repository), and the runner links what its worker's session journal says it opened. A linked PR rides the card's PR line, the follow-ups and merge-on-green like a closing one, and never closes the node: the issue closing does.

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 (or, with runners creating issues on, approved unbound). 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. Two budgets, by cause. A conflict (or behind) on a head the agent has not changed since the PR was last mergeable, with PRs merged to its base since, is another PR's merge: it spends --max-rebases (default 5), at most one per pair of PR head and base tip, so each new merge to the base buys one rebase and a busy base cannot loop. Everything else (red CI, a conflict on a head the agent pushed, an owed commit gate) is its own, and spends --max-fix-attempts (default 2), once per PR head. A resume is judged after --settle-seconds like any attempt, since a push takes a sweep to show.

      ↻ 'marten-tfm': #5852 conflicts with main after #5851 merged — resuming its agent to rebase it (rebase 1/5)

    Both budgets are kept on the node (each resume is recorded on its claim stream), so a restarted runner picks them up where they were, and the card's PR line says what is spent: ⇄ #5852 open · conflicts with main · rebase 1/5, or fix 2/2 in red once the agent's own fixes are spent. A usage limit or a question for a person gives the resume back, and a restart of the node starts both over.

  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.

  4. A person's reply re-drives it. A fix callout opens the chat, like a question. Your reply goes to the runner (answer-question), which resumes the agent with your words as its brief and one more attempt in the budget that ran out, recorded as yours (fix 3/3, by you, on the claim stream). Only a person grants one: a plan's live lead can relay your reply (answer_question with relayFor, which records you both), and no agent tool reaches it.

  5. An agent that cannot be resumed (codex or gemini, its worktree gone, another machine): a PR that needs a rebase on the runner's own branch, stoat/{plan}/{node} in the node's repository, gets a fresh resolve worker in a worktree checked out at that branch (only ever fast-forwarded to the PR's head), with the same rebase brief, told to push there with --force-with-lease and nowhere else. It spends the budget its cause picks. A PR on any other branch (a person's, a fork's) stays a person's.

  6. With CI off, an untested resolution (resolved at 1a2b3c4, not tested) resumes its agent to run the repository's commit gate on that head and nothing else (stoat gate run <plan>/<node> --step <gate>, or a scoped stoat gate exec … --step <gate> -- <command>), under its own budget. With no commit gate declared, or no agent to resume, the callout says so.

The runner never fixes anything itself. It never resolves a conflict, never edits a test, never pushes, and never merges a manual-review PR: it hands the problem back to the agent that made the PR (or a resolve worker on its own branch), and then to a person. A restarted runner still looks after a PR its agent opened: a node whose latest claim is this runner's (--agent), in this checkout's repository, is its own again. A PR no agent of this runner opened (a person's, another runner's) is never resumed; its card and the strip still say it is red or conflicting. A landing node (done, its PR still open) is followed up the same way, since its dependents wait on that PR. 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.

When a plan node's own merge turned main red ​

When the sweep saw main turn red exactly at a plan node's merge, the card that merged it says so (merged #819 · main red since 7c438f1 (build)), and the runner whose checkout is that repository adds the fix as a node on its own: one per repository and red commit, {node}-fix-{branch}, an unbound issue node on manual review, with a brief naming the failing checks and tests, the merge and its PR, "fix it on main in a new PR; do not revert unless a person says", and that a workflow-only fix needs a token with workflow scope (the worker asks rather than failing quietly). It links every node whose open PR is red only because of main to depend on it, so merge-on-green waits for the fix and those cards name it as what they wait on; nodes that have not started are not linked.

  ⚑ 'bobcat-core-tfm''s merge turned main red at 7c438f1 — proposed 'bobcat-net11/bobcat-core-tfm-fix-main' to fix it, ahead of bobcat-aspire-testing; it waits for a person's go-ahead

It launches nothing until a person gives it the go-ahead (with runners creating issues on, the runner files the issue first). Then, when the agent that merged the breaking node can be resumed on this machine (its claim was this runner identity's, its worktree and its session's transcript are here, nothing is running it), the runner resumes that session, since it knows the change: in its own worktree, which it first moves to a new branch cut from origin/main when that worktree is clean. Otherwise, or when the worktree will not move, a fresh worker, and no attempt is spent on the resume that did not happen. If main goes green some other way first, the runner removes its own proposal, but only while it is still unbound, unapproved, never claimed and untouched by anyone else, unlinking it first and leaving a note on the plan. Stoat never reverts a merge.

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. Every other kind starts fresh too (the rules). A retry reuses the worktree it already has, fast-forwarded to the fresh base first when it has no commits or changes of its own.

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; over MCP only the plan's live lead relays it (expedite_node), 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; over MCP only the plan's live lead relays it (mark_for_cloud), 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.