Job plans
For proposals of human-executed issue and PR mutations, see operator actions.
A job plan is a proposed agent run, written as a comment on the issue it serves (#46). Dispatch starts exactly the job the newest accepted plan describes and adds nothing of its own, so where and how a job runs is a record on the issue rather than a decision made in the controller.
Format
A plan is one fenced block with the info string agent-board-plan, at the
start of a line, holding one JSON object:
```agent-board-plan{ "schema": "agent-board/job-plan/v1", "repo": "ostreedev/ostree", "item": 3665, "kind": "implement", "runner": "hosted", "pool": "claude", "brief": "Follow up on the review of ostreedev/ostree#3665: ..."}```| Field | Required | Meaning |
|---|---|---|
schema | yes | agent-board/job-plan/v1 |
repo | yes | Target repository, OWNER/NAME; the caller's bounds must allow it |
item | yes | Positive issue number in repo |
kind | yes | implement, triage or research: the profile the run gets |
runner | yes | hosted, the only runner the caller has |
pool | no | Inference pool named in the caller's pools.json: lowercase letters, digits, - and _, starting with a letter or digit, at most 32 bytes (gpt5, claude-opus), and not a tier- name; absent leaves it to the caller's variables |
brief | yes | The task, at most 16,000 characters; the run gets it after the kind's profile |
stacks_on | no | Another issue number in repo whose pull request this implement run builds on (stacking) |
Every field but schema and runner maps onto one input of the caller's dispatch workflow
(dispatch; brief is task; stacks_on becomes
base, a branch dispatch finds).
Anything else, including null for a field, is refused: the base branch,
budget and allowed outputs have no caller input yet, and a plan that names
them must not look honoured when it is not. A devspace runner is refused for the same reason; the caller has no
devspace runner for upstream forks yet. Each is added to the schema when the
caller can carry it. One plan per comment; a comment with two is refused.
Who may write one
A plan counts only in a comment by the operator (--operator) or the login the
planning run comments as (--planner); anyone else's is ignored, so a visitor
can neither start work nor block it. A writer replaces a plan by replying with
another or by editing one: the plan most recently written or edited wins. If
that plan is malformed, or was last edited by someone other than a writer
(anyone with write access can edit others' comments) or by an account GitHub
no longer names, dispatch stops instead of falling back to the plan it
replaced. A writer's comment edited by someone else counts as such a plan even
if the edit removed its fence. A plan quoted in a reply (> ), or shown
inside another fenced block or an HTML comment, is not a plan. An issue
with more than 1,000 comments is refused, since its newest plan could be
unread.
The planner login is trusted as a whole: any run that comments as it could write a plan. The caller's bounds still check every dispatched run, so a plan can ask only for what they allow; a plan is a proposal, not write authority.
Dispatch
agent-board dispatch "$ISSUE" --dry-run \ --caller "$CALLER" --workflow "$WORKFLOW" --operator "$OPERATOR" --planner "$PLANNER"prints the workflow_dispatch REST request (method, URL and JSON body) that
would start the caller's --workflow at --ref (default main), and names
the plan's comment on stderr. It needs a token that can read the issue. The
caller, workflow and logins come from the caller's own configuration (its
repository variables), never from a plan or a dispatcher's inputs.
--apply sends that request, once per plan. A plan is its comment and the
time that comment last changed, so a writer's edit, or a reply with a plan, is
a new plan. Before sending, dispatch posts one comment on the issue whose
hidden first line names the plan (<!-- agent-board-dispatch: COMMENT-ID@TIME -->),
reads the comments again, and goes on only if the plan is unchanged and no
earlier comment names it, so
two dispatches racing on the same plan start one run as long as GitHub's reads
show the comments posted before them; a caller that runs dispatch in one
concurrency group does not rely on that. The comment is then edited to link
the run, or to say why it was not started, and record= and (where GitHub
names it) run= lines go to stdout; with --bot, a run held back writes no
record and prints waiting=true instead (overlap).
Records count only from the writers, so a visitor's copied marker does not
block a plan, and GH_TOKEN must be a writer's user token (dispatch asks
GitHub whose it is): two accounts that each read only their own records would
both dispatch. A plan with a record is refused, including one whose
dispatch failed or whose dispatcher died between record and request: an
ambiguous dispatch is not retried, and a writer edits or replaces the plan to
try again. Anyone who can delete comments on the issue can remove a record and
so have its plan dispatched again. The run is started with DISPATCH_TOKEN if
it is set, else with GH_TOKEN.
Collaborator input
--collaborators-only (off by default; from the caller's configuration)
refuses a plan unless every issue the run reads (the one holding the plan, the
one it targets, and each batched extra) has a locked conversation and was
written only by accounts with write, maintain or admin permission on its
repository: its body and the body's edits (userContentEdits), every comment
and its last editor, and on a pull request every review and review comment
(#111). It stops a
visitor's text from reaching a run as task data. The error names the login and
where it wrote; --dry-run checks the same. Nothing requires it: the
supervisor's dispatches do not (agent-board#155), as the sandbox, the bounded
safe outputs and the review before merge are the boundary.
It reads whole or refuses: an item with more than 100 of any of those, or more
than 30 distinct writers, is refused, as is a deleted or unnamed account, any
app bot, or an issue whose repository's collaborators GH_TOKEN cannot read
(a target in a repository we do not own).
--status-app-id ID (with --collaborators-only; off by default; from the
caller's configuration, never from a plan) admits the status app's run-status
comments (per-role apps): an issue comment whose author is an app's
bot counts when REST's performed_via_github_app.id on it is ID, and so do
that bot's edits of it, at one request per bot. The bot's login is not
evidence: another app's bot, or a user account of the same name, is refused
as before, and so is a bot anywhere else (a body, review or review comment).
The poll's lockCollaboratorItems admits no app. Titles and a pull request's commits
are not checked, nor a comment's editors before its last.
Locking new items, the poll's backstop and fix-round check, and the dispatched
agent's own sanity check are not implemented here.
Batching
--batch N (0 to 5, default 0: off; take N from the caller's configuration)
lets one implement run and its pull request also do up to N other sub-issues of
the issue's epic (#86),
so small fixes stop paying a runner, a review and a merge round each, and stop
conflicting with each other while they wait. The issue and every extra must be:
- open, in the same repository, labelled
smalland notno-batch: the label is the size, so an unlabelled issue, large or P0, runs alone; - unblocked: no open dependency, and no "blocked by", "depends on" or "waiting on/for" in its body; batch members cannot depend on each other;
- without a pull request that closes it, open, merged or closed;
- for an extra, planned by its own accepted implement plan for that issue, with the primary's runner and pool, and not recorded already.
Extras are taken oldest first, from at most ten candidates read, while the
whole task stays within 32,000 characters. An epic or a sibling that cannot be
read only leaves extras out, and the issue runs alone. The task is the primary plan's brief,
then a request for one commit per issue and one pull request with a
Closes #N line for each issue finished (and the unfinished ones named, with
why), then each extra's brief. Every extra gets the same kind of record as the primary,
with a hidden second line <!-- agent-board-batch: OWNER/REPO#N --> naming the
primary, and all are read back before the run starts. A conflict on the
primary's record starts nothing; a conflict on an extra's leaves only that extra
out, and its record says so. An extra whose record cannot be read back is left
out too, and its record loses its marker, so a later dispatch may take it. While a record is in place, the plan is not dispatched again.
GitHub closes the finished issues when the pull request merges. Once the primary
is closed, an extra still open was not finished, and its batch record no longer
holds its plan, so a later dispatch may take it, unless an open pull request
still closes it (the primary was closed before the batch's pull request merged). --dry-run prints the batched
request and names the extras and the candidates it left out on stderr.
Overlap
--bot LOGIN (off by default; the login that opens the implement runs' pull
requests, from the caller's configuration) checks an issue against that
login's open pull requests and the implement runs in flight on the repository
the plan changes (its repo, which may not be the issue's) before it is dispatched
(#88), since runs
that change the same files end in rebase fix rounds, and a pull request can run
out of them. The overlap is a coarse guess: the paths the issue's title, body
and plan brief name (tokens with a file extension, dotfiles, or ending in /;
not abbreviations such as e.g.), against the files each of the newest 50 open
pull requests changes (100 each) and the paths each of up to ten running issues
names (the same bare .name, such as .patch, in both texts is taken for an
extension, not a shared file). Runs in flight are the caller workflow's queued
and running runs whose name is implement OWNER/REPO#N.
Then:
- a run in flight or an open pull request overlaps: dispatch waits. Nothing is
written,
waiting=truegoes to stdout, and the next pass decides again; - otherwise the issue runs independently.
The run is not stacked on an overlapping pull request it was not planned to stack on: a guess from named paths does not choose a base. A run that stacks is not checked for overlap.
Each record (the primary's and each extra's) ends with the decision. A check
that cannot read runs the issue independently, as without --bot: it is a
scheduling heuristic, not a boundary. --dry-run prints the decision on stderr.
Stacking
Slices of one piece of work that change the same files stack instead of
conflicting while they wait
(#133). An
implement plan with stacks_on: N (an issue of its own repo, usually set by
an epic plan) is dispatched only with --bot, and then:
- with one open pull request of the bot from a
dispatch/branch ofrepoitself whose description closes #N as GitHub reads it (a closing keyword anywhere in the text, such asCloses,fixes:orresolved, then#N,OWNER/REPO#Nor the issue's URL), among the repository's newest 50 open ones, the request carries that branch as thebaseinput, the run's task says what it stacks on and asks the pull request description to begin withStacks on #P., and the record saysStacked on URL (branch). The run is neither checked for overlap, as it shares the files of the stack below it by design, nor batched; - with #N open and no such pull request, it waits, as an overlap does, naming why; with two, it is refused;
- with #N closed, it runs on the default branch like any plan, and the record says it was not stacked.
A read that fails refuses the dispatch rather than running it unstacked. The
base is never the plan's to name: only the bot's own dispatch/ branch can
become the code a run starts from, so a visitor's pull request that claims to
close #N, or a release branch, cannot. A pull request's base is a branch of
its own repository, so a lower pull request from the bot's fork, where
dispatch pull requests come from today, is not one to stack on: the upper
slice waits. A plan without stacks_on sends no base, so a caller without
that input is unaffected; a stacked one needs the caller to take base, to
open the lower slice's pull request from a dispatch/ branch of the target
repository, and its bounds to allow such bases. That is the caller's part.
The poll keeps the stack moving: it never merges or queues a pull request whose base is not the repository's default branch, and when the bot's newest pull request from that base has merged it retargets the upper one to the branch that one merged into. An approval from before the base changed does not merge it (see pull request follow-up).