# Architecture

How `bees run` works, for the person running it: what one pass of the
scheduler does and in what order, how a developer worker carries an issue from
`bees:ready` to a merged pull request, what a session is and what it sees, how
the roles talk to each other, and what the state directory holds when
something looks wrong. For the label state machine see
[workflow.md](workflow.md), for each role [roles.md](roles.md), for every
`bees.toml` key [configuration.md](configuration.md) and for the commands
[cli.md](cli.md). The package layout and the test-suite are contributor
material and live in
[CONTRIBUTING.md](https://github.com/kpenfound/busybees/blob/main/CONTRIBUTING.md).

## The scheduler loop

`bees run` starts by creating the state directory, pruning stale worktree
metadata in the clone, and creating any workflow label the repository is
missing: one `gh label list`, then one `gh label create` per name it does not
find, matched case-insensitively. A label that exists is left exactly as it
is, recoloured or not, and a failure here only warns. It then logs `scheduler
started` with the build it runs (the role prompts are compiled into that
build; see *Prompts* under [Running a session](#running-a-session)) and ticks
until it is told to stop.

**Ticks.** A tick comes every `scheduler.poll_interval` (default 5m), and
sooner whenever a local event wakes the loop (*Waking up*, below). Each tick
is either a **full pass** or a **local pass**. A full pass runs when the tick
is at or past the next scheduled GitHub poll, and schedules the one after it:
`poll_interval` later, or `off_hours_poll_interval` later when
`scheduler.work_hours` is set and the moment falls outside the window (see
[Work hours](configuration.md#work-hours)). When the window opens before that
interval would elapse, the next poll is scheduled for the opening, so the work
day starts on time. Without `work_hours` every scheduled tick is a full pass,
and the local passes are the ones a wake asks for. When the poll fails with a
rate-limit error (the message names a rate limit, abuse detection, an
overloaded service or a usage limit), the next poll waits
`scheduler.rate_limit_backoff` (default 15m) if that is longer than the
interval in force.

**Stopping.** Ctrl-C, and `q` in the live view, is the cool-down: polling and
dispatch stop, and the work in flight finishes: every running session, and
every issue a developer worker holds, on through the stages it has left. A
second interrupt stops the running sessions too. Both are described under
*Stopping* in [Running a session](#running-a-session).

A full pass is:

1. **Poll.** `gh issue list` and `gh pr list` with the filter's query (label,
   assignee, milestone, creator): two calls, or four under a `filter.creator`
   that is not the account the factory acts as, because `--author` takes one
   login and the factory's own items must stay visible (`github.Query.Self`).
   Every open issue is bucketed by its state
   label (`triage`, `ready`, `in-progress`, `blocked`, `review`, `approved`,
   `needs-human`, or none), each bucket sorted oldest first. An issue carrying
   `bees:feedback` or `bees:feature` is set aside for the product manager
   instead, so it never gets a state label. The queue counts (the buckets,
   plus `feedback`, `features`, `proposals` and `open_prs`) go into
   `status.json`. A ready issue that declares a blocker still open is listed
   there as waiting on it and is not dispatched (see
   [Dependencies](workflow.md#dependencies)), unless `scheduler.stacked_prs`
   lets it build on the blocker: a blocker under the same feature whose pull
   request is open holds nothing back.
2. **Comments on an in-flight issue.** For every issue in `in-progress`,
   `review`, `approved` or `blocked` whose `updatedAt` moved past the issue's
   `issue_human_seen_at` clock, the comments written since that clock are
   fetched (one call) and what people wrote goes out as one message from
   `human` (`issue == N`) to the role that can act on it: the developer for
   `in-progress` and `approved`; the developer and a copy to the reviewer for
   `review`, so the round in flight sees it; and for `blocked`, whoever asked
   the question, read off the worker's bookkeeping: a recorded branch or pull
   request means a developer session asked, and reconcile then moves the issue
   back to `ready`; nothing recorded means triage asked, so the project
   manager gets it. A comment is a bee's, and dropped, when its last line is a
   `<!-- bees:<role> -->` marker or its author is the login `[github]` gives
   the factory. With `[github]` unset, bees and people share one account and
   the marker is the only signal; only the last line counts, so a person
   quoting the bee they answer still gets through. The first pass that sees an
   issue in one of those four states with no clock records the poll time and
   delivers nothing: a zero clock must not mean "replay every comment this
   issue ever received". An issue in `triage` or `ready` has its clock
   refreshed on every pass and delivers one thing only: a comment that
   `@`-mentions the login `[github]` gives the factory, which goes to the
   project manager. A `bees:feature` or `bees:feedback` issue is read the same
   way, and a mention on one goes to the product manager. Everything else
   people write on those issues stays where it is, because the session that
   acts on the issue next renders the whole comment history in its prompt; a
   mention is a person asking for the role now instead. With `[github]` unset
   the factory has no name of its own to mention, and none of them deliver
   anything. That refresh is what makes an answer to a blocking triage
   question arrive: the issue was observed in triage before it could be
   blocked, so it has a clock by the time it is blocked. What the mail adds
   over the comment history in a prompt is that the comment is fresh, that it
   is a person's, that it reaches the reviewer, that it unblocks a blocked
   issue, and that it wakes the loop. See
   [Commenting on the issue](workflow.md#commenting-on-the-issue).
3. **Feedback on a pull request.** For every open pull request whose closing
   issue is visible, when its `updatedAt` is later than the issue's
   `human_seen_at` clock (or the pull request's creation time), its reviews,
   inline review comments and conversation comments are fetched with `gh api
   --paginate` (three calls). Bee comments and empty approvals are dropped by
   the rule above. The rest go to the developer as one message from `human`
   (`issue == N`, `pr == M`) whose body carries each item's id and the `gh`
   command to reply to it, and the clock advances to the newest item. An
   `approved` issue that received feedback goes back to `ready` and its pull
   request loses `bees:approved`, so a developer worker picks it up in step 7,
   unless a worker still owns the issue (the checks stage), whose labels are
   left to that worker. Issue comments are delivered before pull request
   feedback on purpose: an approved issue that gets both leaves the in-flight
   buckets here, after step 2 has read them. The two clocks are separate so
   neither stream suppresses the other.
4. **Merge state.** `gh pr list` already reports `mergeable`,
   `mergeStateStatus` and the head commit, so this costs no call. For an issue
   in `review` or `approved`, a `CONFLICTING` pull request (with
   `scheduler.pr_fix_conflicts`) or a `BEHIND` one (with
   `scheduler.pr_keep_updated`) gets the developer one message from
   `orchestrator` (`issue == N`, `pr == M`) asking it to merge the default
   branch, resolve, test, push and report `pr-updated`. The head commit is
   recorded as `conflict_notified_sha`, so one head is mailed about once; a
   push changes the head and, if it still conflicts, is notified again. An
   approved issue goes back to `ready` as in step 3. An `UNKNOWN` or empty
   merge state means GitHub has not computed it yet, and the pull request is
   left alone for this poll. See
   [Conflicts with the default branch](workflow.md#conflicts-with-the-default-branch).
5. **Reconcile.** Label transitions driven by local state, in this order:
   - an issue with no state label and neither `bees:feature` nor
     `bees:feedback` is a person handing the factory an idea, not a spec:
     when the product manager is enabled it gets `bees:feedback` (and the
     base label, when the filter does not require it) and joins the product
     manager's list in the same pass. A person who wants it built without
     that hop labels it `bees:triage` or `bees:ready` themselves;
   - a `bees:blocked` issue with unread developer mail about it becomes
     `bees:ready`; one with unread project manager mail about it becomes
     `bees:triage`. Mail from a person counts as an answer too;
   - a `bees:ready` issue with no size label gets `bees:size/m`, the default
     size (see [Sizing](workflow.md#sizing));
   - a `bees:ready` issue sized above `roles.developer.max_size` (default `l`,
     so normally a `bees:size/xl` one) goes back to `bees:triage` without a
     comment, for the project manager to split;
   - every feature is checked for `bees:proposal`, and the pass that sees a
     person remove it records the approval: a label edit leaves no comment,
     and nothing else would bring the feature back to the product manager.

   Sizing runs after unblocking so that an issue which becomes ready in a pass
   is sized in the same pass. Every edit is also written back to the cached
   poll that local passes classify from; without that they would see the old
   labels and repeat the edit.
6. **Pauses.** Two conditions stop steps 7 and 8 from starting anything;
   workers already running finish their loop either way. Each pause is logged
   once when it starts and once when it lifts, and shown by `bees status` and
   in the live view's header. A cancelled loop context gates the same two
   steps, so a pass still finishing when the factory was asked to stop starts
   nothing the cool-down promised not to.
   - **Daily cost budget.** With `scheduler.max_cost_per_day` set, the ledger
     is summed over the last 24 hours before anything is dispatched. Dispatch
     pauses when the sum reaches the budget and resumes only once it has
     fallen under `max_cost_per_day_resume_percent` of it (default 100, which
     is the plain "under budget" test), so the factory backs off instead of
     oscillating on the edge. The sum is recomputed from the ledger on every
     pass; a restart loses only the hysteresis. The other two budgets are
     enforced elsewhere: `max_cost_per_issue` between a developer worker's
     stages, `max_cost_per_session` after a session ends. See
     [Cost budgets](configuration.md#cost-budgets).
   - **Claude session limit.** Recorded from a finished session rather than
     computed here: a session whose last `rate_limit_event` was blocking, or
     that failed without reporting an outcome and whose result text names a
     session or usage limit, pauses dispatch until the reset time the event
     carried (`rate_limit_backoff` when it carried none or the time is already
     past, and never more than 8 hours). The limit is per account, so it holds
     every role. A session that reported no outcome returns to its worker at
     once, spending no retry; one that did its work and reported is read
     normally. The pause is in memory only: after a restart the first session
     that hits the limit re-establishes it. See
     [The claude session limit](configuration.md#the-claude-session-limit).
7. **Dispatch developers.** The candidates, in order: issues in `in-progress`
   and `review` that no worker owns (resumed after a restart, never
   reordered); an `approved` issue whose worker was killed in the
   post-approval checks stage or in the developer round those checks sent
   back, told apart from a pull request waiting for a person by the stage
   recorded in the state directory and an open pull request on the branch;
   `ready` issues that already have an open pull request on their branch (sent
   back by feedback or a conflict; finished before new work, oldest first);
   then the rest of `ready`: `bees:priority` first, then
   `scheduler.dispatch_order` (smallest size first by default), ties by age.
   Priority reorders the queue and lifts no cap. A ready issue whose declared
   blockers are still open is skipped without taking a slot. With
   `scheduler.stacked_prs`, a blocker that is a sub-issue of the same feature
   and has an open pull request is not waited for: the worker cuts the
   issue's branch from the blocker's branch instead of the default branch,
   and the developer's pull request targets that branch. A `bees:size/l`
   issue that is new work waits while `scheduler.max_large_in_flight` of them
   are owned; the check runs before a slot is taken, so a held issue does not
   keep a free developer idle. Each remaining candidate takes a slot from a
   pool of `max_developers` (default 1), or one slot per attempt, all of them
   or none, when its size fans out under `best_of_n_by_size` or
   `moe_experts_by_size`; when the pool
   cannot supply it the pass stops dispatching. A goroutine runs the worker
   ([The developer worker](#the-developer-worker)) and returns the slot when
   done, and the worker records the issue's size, which is what the cap counts
   and what `bees status` shows. A worker that fails with an error, rather
   than escalating its issue, backs that issue off for five poll intervals.
   See
   [Size decides what gets built next](workflow.md#size-decides-what-gets-built-next).
   Then the requested reviews: every pull request in the poll carrying
   `bees:review-requested` that no session is already reviewing gets a
   reviewer session in a slot from the same pool, so a review a person asked
   for never starves a ready issue. With `scheduler.review_assigned_prs` a
   pull request whose head branch does not start with
   `project.branch_prefix`, one the factory did not write, is dispatched the
   same way without the label, unless it is a draft or a review has already
   looked at this head. The label is removed before the session
   starts, which claims the request: one label is one pass whatever the
   session does, a failure or a killed scheduler included, and the head
   commit is recorded in `issues/<pr>.json` before the session for the same
   reason. Only a full pass dispatches one, because a local pass classifies
   the cached pull request list, which still carries a label removed on
   GitHub. The session is
   a detached checkout of the head branch, or of the default branch when the
   remote does not have it, and is recorded like a worker under the pull
   request's number. A failed session backs the pull request off for five poll
   intervals; there is no issue to escalate.
8. **Dispatch singletons.** The project manager, product manager and QA each
   run in a goroutine of their own, at most one session per role at a time, in
   a detached worktree of the default branch. When a session ends the role is
   not started again for one poll interval; when it fails, five. What starts
   each:
   - the **project manager**: triage issues (it takes the first
     `scheduler.triage_batch_size`, default 5, each fetched in full with its
     parent feature) or unread mail;
   - the **product manager**: unread mail; never having run;
     `scheduler.product_manager_interval` (default 1h) elapsed; a proposal a
     person approved since its last run; a feature whose every recorded
     sub-issue has closed; or a fresh feedback or feature issue: one updated
     since the last run, whose comments are fetched (one `gh issue view` each)
     and on which a person had the last word by the bee-comment rule of step 2
     (a tie in the same second is broken by the comments' order, so a person
     answering right after a bee still counts). A fresh issue carrying
     `bees:question` has that label removed on the spot. A proposal
     (`bees:proposal`), and an issue in
     [planning mode](workflow.md#planning-with-the-product-manager), counts as
     fresh only once a person has *commented*: the creation does not count,
     because nobody has commented on a proposal a bee has just written and it
     would otherwise be fresh forever. `bees:planned` wakes nothing; the issue
     waits for the interval;
   - **QA**: unread mail; never having run (the first run looks back seven
     days); or `scheduler.qa_interval` (default 30m) elapsed since it last ran
     or last looked, and something merged since its last run. The merged-PR
     query runs at most once per interval, recorded as `last_check` in
     `<state_dir>/qa.json`.

   The **product manager** is shown: the fresh feedback issues; the fresh
   features, with proposals in a section of their own; the issues in planning
   mode, and the planned ones that still need acting on, each in a section of
   its own (a planned feature drops off once it has sub-issues, a planned
   feedback issue once it is closed; a feature whose sub-issue lookup failed
   waits for the next run rather than being presented as not yet broken down,
   and one still carrying `bees:proposal` stays a proposal); every open
   feature with its sub-issue progress (one `gh api repos/../issues/N` per
   feature); every open work item with the feature it belongs to (one GraphQL
   query per work item, because the progress summary carries counts, not
   numbers); and the features whose work is done. That last list costs no
   GitHub call: every product manager run records each feature's open
   sub-issue numbers in `<state_dir>/issues/<n>.json`, and a later pass
   notices that every recorded number is absent from the poll. Such a feature
   is presented once and marked; a recorded set that changes clears the mark,
   so a feature that gains a sub-issue is presented again when that one
   closes. A run whose parent lookups did not all answer records nothing,
   since a partial answer would look like children that closed, and a feature
   no run has recorded children for waits for the interval.

   **Planning mode.** The planning section of the prompt lists no breakdown
   step and the planned section says the scope is settled; the enforced half
   is that `bees issue create` and `issue_link` refuse a `bees:planning` issue
   as a parent, as they refuse a proposal while `scheduler.feature_proposals`
   is on, so a planning issue grows no sub-issues whoever asks. Neither
   planning label is ever written by the factory.

   **Sub-issues and milestones.** Work items are native GitHub sub-issues of
   their feature. Roles create issues through the `issue_create` tool (or
   `bees issue create`), which labels for the filter and for kind and state,
   resolves the milestone as the explicit one, else the parent or related
   issue's, else `filter.milestone`, creates the issue, and attaches it to its
   parent as a sub-issue. The factory never creates, edits or closes
   milestones; people do, and the bees inherit, or pick among them for the
   features of an agreed design. See
   [Features, sub-issues and milestones](workflow.md#features-sub-issues-and-milestones).

9. **File the factory errors.** With
   [`scheduler.report_factory_errors`](configuration.md#scheduler) on, the
   drafts sessions recorded with `report_factory_error`
   (`<state_dir>/feedback/`) are filed against `kpenfound/busybees`, the
   busybees project itself, whatever repository this factory builds. A draft
   is scored against every issue there, open and closed, by a local
   word-overlap check that needs no session and no search API: the best match
   gets a comment saying the problem was seen again, and a draft that matches
   nothing opens an issue. That issue carries no label and no assignee, so a
   factory building busybees does not pick its own bug report up as a work
   item; a person reads and labels it, as with any issue somebody files by
   hand. A draft is removed from the queue once its call succeeded, and one
   whose call failed stays for the next pass without holding up the drafts
   behind it. Off, the queue is not even read.

**Local passes.** A tick that is not due for a poll, and every wake, runs a
local pass: it classifies the issue and pull request lists cached from the
last successful poll again (reconcile's write-back and the refresh at the end
of every session keep that cache in step), then runs steps 5 and 6, dispatches
developers (never a requested review) and starts only the singletons that have
unread mail. It skips the poll, steps 2 to 4, step 9 and the product
manager's and QA's other has-work checks, all of which read GitHub; the label writes
reconcile and dispatch make still happen, because what a local pass protects
is the polling budget, not every API call. Until the first successful poll
there is nothing cached and a local pass does nothing.

The one read a local pass makes is a confirmation. Its snapshot can be stale
(an issue a worker has since finished, one a developer parked in
`bees:blocked`, one a person closed or relabelled), so before spending a
session on a candidate the pass fetches that one issue (`gh issue view`) and
drops it unless it is still open and in `bees:ready`, `bees:in-progress` or
`bees:review`, or in `bees:approved` for the interrupted checks stage of step
7. The fresh copy replaces the cached one, so the next local pass does not ask
again. That is one call immediately before a whole session, not one per pass.
The mailbox is not GitHub: the developer and reviewer loop, the checks stages
and mail-driven label transitions run at `poll_interval`, and sooner when a
wake asks, however the window is configured.

**Waking up.** Waiting out the poll interval for something that happened
locally is downtime, so the loop also listens on a wake channel and runs a
local pass for every signal. Three things signal it: a session finishing, a
developer worker returning its slot to the pool (a worker runs several
sessions before its slot comes free), and the two kinds of message the
scheduler sends itself (the merge-state notice of step 4 and the feedback of
steps 2 and 3). A wake is never a full pass, so the polling cadence stays
exactly what `poll_interval` and the window say. The channel holds one signal:
a burst of finished sessions costs one pass rather than one each, and a full
pass drops a pending wake because it does strictly more.

Mail written by another process (`bees mail send`, or the MCP server attached
to a session) cannot signal an in-process channel, and the mailbox is
deliberately not watched for changes. It does not need to be: the session that
wrote the mail signals when it finishes, and the local pass that follows
re-reads the mailbox from disk. Mail a person sends by hand while nothing is
running waits for the next tick.

A session's writes on GitHub cross the same boundary. The MCP server cannot
reach the cached issue lists either, so every tool that creates an issue or
changes one records its number in `<session>/touched-issues.txt`, and the
scheduler reads that list back when the session ends: one `gh issue view` per
issue on it, written into the cache before the wake is signalled. The local
pass that follows classifies from what the session did, so an issue the
project manager moved to `bees:ready` goes to a developer and a sub-issue it
filed counts as triage work, without waiting for the poll after the session. A
session that changed no issue records nothing and costs nothing, and an issue
that has since been closed, or that the filter does not match, is dropped
rather than cached: the cache holds what a poll would return. Pull requests
are not read back, because the developer and reviewer loop runs inside one
worker and finds its own pull request.

**The marker audit.** A comment posted through the `comment` tool always
carries the role's `<!-- bees:<role> -->` marker: the tool appends it. A
comment a session posts from its own shell with `gh` does not, unless the
session wrote the marker itself, and that path is outside the orchestrator
entirely. So the orchestrator looks afterwards: when a session ends it reads
the comments left since it started on its issue, on the pull request it was
given or reported opening, and on every issue it touched, and logs a warning
naming the item, the role and the comment for each one made by the factory's
login without a marker. It reports
and does not rewrite: the login already identifies the comment as the
factory's, and editing a comment after the fact would surprise more than the
missing marker costs. With `[github]` unset there is no login to go by, a
comment without a marker is indistinguishable from a person's, and the audit
reads nothing.

**API budget.** Every poll costs two `gh` calls. Everything else is gated on
what those lists report, so an idle factory stays at two calls per poll (and,
with `work_hours`, at two per `off_hours_poll_interval` outside the window).
Comments cost one call per issue whose `updatedAt` moved past its clock: the
four in-flight states, plus, once `[github]` names an account to mention,
`triage`, `ready` and the product manager's issues. Pull request feedback
costs three calls per pull request whose `updatedAt` moved; the product
manager's freshness check one `issue view` per feedback or feature issue
updated since its last run; QA's merged-PR query at most once per
`qa_interval`; the checks stages poll `gh pr checks` every
`roles.reviewer.checks_poll_interval` (default 2m), not every poll; the
visibility backstop makes two list calls after each session; the refresh after
each session one `issue view` per issue that session created or relabelled;
the marker audit, with `[github]` set, one comment read per issue and pull
request that session could have commented on, the one it opened included;
each queued factory-error report two calls, one to list the busybees
repository's issues and one to file or comment; and worker stage transitions
make a handful of `issue view`, `pr view` and `issue edit` calls. Sessions
call `gh` on their own on top of this, which busybees does not meter. See
[API budget](configuration.md#api-budget).

**Once mode.** `bees tick` and `bees run --once` perform a single pass and
then wait for everything it started. `--roles` restricts dispatch to the named
roles; a role with `enabled = false` in `bees.toml` is skipped regardless.

**`status.json`** is rewritten after every pass and whenever a worker or
singleton starts or stops; `bees status` reads it, the mailbox and the size of
each role's notes (from the backend [`notes.backend`](configuration.md#notes)
names, so with `backend = "neo4j"` it asks that service), and asks GitHub
nothing. Two of its queue counts carry their detail:
`needs_human` names each escalated issue and why, from the reason the
escalation recorded, and `approved` names each pull request waiting for a
person to merge, oldest first. Both are built from the snapshot the counts
came from plus one state-directory read per escalated issue, so neither costs
a GitHub call. `degraded` lists the operations that are failing
([Degraded operations](#degraded-operations)).

**The event stream** is the live half of the same picture, for a view running
in the same process. A subscriber gets a buffered channel of events: a session
started (with the model it runs on, whether that is the role's fallback, and
its directory, which is where its `transcript.jsonl` is and the one thing a
view cannot work out from the name), a session ended (with its outcome, turns,
cost and duration), a developer worker moved to another stage, a full pass
finished. Events are published beside `status.json`, never instead of it: the
event says something happened, `status.json` says what the factory looks like.
The poll event is published after the write, so a view that re-reads the file
when one arrives sees the pass that event is about, never the one before it.
No scheduler decision depends on whether anyone is subscribed, and publishing
never blocks: an event a subscriber has no room for is dropped, so a view that
stops reading loses events instead of slowing a pass down.

The [live view](cli.md#the-live-view) is the subscriber. Its Now and Recent
panels are built from the session and stage events; Needs human, Approved PRs
and Queues are `status.json`, re-read when an event says it changed. Two
things come from a session's own `transcript.jsonl`, in the directory the
started event named, because no event carries them: the transcript the session
view shows, and the turn count the Now panel shows for a session still running
(an agent reports its turn count in the event that ends its stream and nothing
before it). Beyond stopping the factory, its `k` key is the one thing it asks
the scheduler to do: stop one running session by the name the stream
published, through the same path `bees kill` uses, and escalate the issue it
was working on. The mark that leaves behind is what keeps the session's own
worker from retrying it or escalating the issue a second time. The view's one
write is the message a person types in the session view: an ordinary mailbox
entry from `human`, addressed to the role on screen and carrying its issue and
pull request, which reaches the *next* session on that work item. A headless
session works to the end of the prompt it was started with and ignores a
later turn written to its stdin.

## The developer worker

One worker owns one issue from claim to approval (or, with
`roles.reviewer.auto_merge`, to merge), or until the factory gives it up. It
is a small state machine with five stages:

```mermaid
stateDiagram-v2
    [*] --> develop
    [*] --> prereview: resumed with nothing recorded, an open PR and label bees:review
    [*] --> review: resumed in the stage the issue's bookkeeping recorded
    [*] --> stack_wait: resumed in the stage the issue's bookkeeping recorded
    [*] --> checks: resumed in the stage the issue's bookkeeping recorded
    develop --> prereview: pr-opened / pr-updated (PR found), before the first review
    develop --> review: a later review round, or pre_review_checks = false
    develop --> checks: pr-opened with the reviewer disabled and auto_merge on
    develop --> stack_wait: pr-opened with the reviewer disabled, on a stacked PR
    develop --> [*]: question (issue to blocked)
    develop --> [*]: failed / no PR (escalate)
    prereview --> review: checks pass / none reported / pending at the timeout / read failed
    prereview --> develop: a check failed, reviewer (checks mode) mailed a fix request
    prereview --> prereview: reviewer re-ran the check (approved)
    prereview --> [*]: fix rounds exhausted / reviewer failed (escalate)
    develop --> prereview: pr-updated while fixing them (returns to prereview)
    review --> [*]: approved, auto_merge off
    review --> checks: approved, auto_merge on
    review --> stack_wait: approved, stacked on another pull request
    stack_wait --> [*]: predecessor approved or merged, auto_merge off
    stack_wait --> checks: predecessor approved or merged, auto_merge on
    stack_wait --> [*]: predecessor closed unapproved (escalate)
    review --> develop: changes-requested, round under max_review_rounds
    review --> [*]: changes-requested at max_review_rounds (escalate)
    review --> [*]: failed (escalate)
    checks --> [*]: checks pass (or none are reported), then gh pr merge
    checks --> [*]: pending at checks_timeout / merge refused (escalate)
    checks --> develop: a check failed, reviewer (checks mode) mailed a fix request
    checks --> checks: reviewer re-ran the check (approved)
    checks --> [*]: fix rounds exhausted / reviewer failed (escalate)
    develop --> checks: pr-updated while fixing checks (returns to checks)
```

- **The stack.** With `scheduler.stacked_prs`, a work item blocked by another
  one under the same feature is cut from that one's branch while its pull
  request is open, and its own pull request targets that branch. Its review
  runs as any other, but approval waits for the stack: a verdict of `approved`
  on a stacked pull request takes the worker to `stack-wait` (`stack_wait` in
  the diagram), where it polls the predecessor's issue every
  `roles.reviewer.checks_poll_interval` and approves its own pull request,
  exactly as the direct path would have, once the predecessor carries
  `bees:approved` or its pull request has merged.
  Until then the pull request carries no approval label, is not in the
  Approved PRs panel and is not merged by `auto_merge`: merging the top of a
  stack merges it into its base, and a base still under review would take
  unreviewed content into the default branch when it merges in turn. The
  worker holds its `max_developers` slot for as long as that takes; there is
  no timeout. A predecessor whose issue closes without ever being approved,
  with its pull request open or closed unmerged, ends the wait with an
  escalation. A worker that starts on an issue whose open pull request still
  targets another work item's branch, while no open pull request under the
  same feature is there to stack on (the predecessor's closed unmerged while
  `bees run` was down; GitHub retargets a stacked pull request only when its
  base branch is deleted, which a merge does and a plain close does not),
  escalates before it runs any stage rather than building or approving
  against a branch nobody is going to merge. Without the key, or for a pull
  request built from the default branch, the stage is never entered.
- **Workspace.** `git fetch`, then one worktree for the issue on
  `<branch_prefix>issue-N`: created from `<project.remote>/<default_branch>`
  when the branch is new, checked out tracking the remote when it exists there
  (and fast-forwarded to it when a local branch was kept), or reused when it
  exists only locally. The same worktree serves the developer and reviewer
  sessions of that issue and is removed when the worker exits (unless
  `keep_workspaces`). Before each reviewer session it is fast-forwarded to the
  developer's latest push. Each workspace is a unique temporary directory
  under `workspace_root`, and the worktree inside it carries that same unique
  name: `git worktree add` derives its metadata id from the leaf name, and two
  concurrent adds sharing one would race for it.
- **Resume.** Before working each stage the worker records the stage it is in
  (`develop`, `fan-out`, `assembler`, `prereview`, `review`, `stack-wait` or
  `checks`), the gate a developer round returns to, and whether the
  pre-review checks have been read, in `<state_dir>/issues/<n>.json`. A
  worker that finds a recorded stage comes back to it, so a `bees run`
  killed in the checks stage or in the middle of a check-fix round carries
  on there instead of paying for a review that has already happened: a
  workflow label says an issue is in review, never whether its review has
  run. The labels stay the human-facing truth all the same. A recorded
  stage they contradict is dropped with a log line and the worker starts
  where the labels say: one of the four review-loop stages
  on an issue with no open pull request, or on one a person put back to
  `bees:ready`, and a stage name this build does not run. `develop` fits any
  label, so the loop state recorded with it is dropped on the same test: an
  issue whose labels have left the review loop starts a fresh round, whatever
  the last worker was doing. One develop record is exempt: the round the
  post-approval checks send back is recorded before the develop stage can
  relabel the issue `bees:in-progress`, so it sits under `bees:approved`
  legitimately and keeps the gate it returns to. The record also names the
  pull request it was written for, and one written for another pull request,
  or before the number was known, is dropped the same way: a person can close
  a pull request and open another on the same branch while nothing is running,
  and neither the labels nor the branch tell the two apart. With nothing
  recorded, the worker looks for an open pull request on the branch: when one
  exists and the issue is labelled `bees:review` it starts in prereview (in
  review, with `pre_review_checks = false` or the reviewer disabled),
  otherwise in develop. That is how work survives a restart of `bees run`.
- **An interrupted session.** The recorded stage says where the worker was,
  not what happened to the session that was running when the scheduler died:
  it left a transcript no `result.json` closed, and a branch that may carry
  commits, uncommitted edits or a pull request nobody reported. So the
  scheduler also records the session it is about to run in the issue's
  bookkeeping (`session`: role, name, directory, start time) and clears it
  when the session ends, however it ends; a record that outlives its session
  is the signal. The worker that takes the issue over reads it and asks the
  directory what happened: a pid file naming a live process means the session
  is still running under another scheduler, and nothing is reported; a
  `result.json` means it finished after all, and the stale record is cleared;
  anything else means it was interrupted. The first session of the role that
  was interrupted is then told, at the top of its task prompt, how far the
  previous one got (assistant messages counted in the transcript, an
  approximation of the turn count the missing final event would have
  carried), where the transcript is, and whether it was stopped on purpose
  (`bees kill`, the live view's `k` key and a hard stop write an `interrupted`
  marker into the directories they stop). A developer is told the branch may
  already carry the session's work; a reviewer that its round reported no
  verdict, and starts over. Another role's session is told nothing, and the
  report never outlives the worker that found it. `bees status` marks such a
  worker `resumed`. The record is not consumed by the worker that reads it,
  only overwritten by the next session as it starts, so a worker that returns
  before starting a session leaves it for the next one.
- **Later rounds.** Within one worker's loop, the developer's second and
  later sessions continue the conversation of its previous one: handed
  review feedback, it keeps what it learned in round 1 instead of relearning
  the codebase. The id is the one claude reports in `result.json`
  (`claude_session_id`); the next developer session is launched with
  `--resume <id>` and `--system-prompt-snapshot off`, because claude
  otherwise reuses the system prompt it recorded on the conversation's first
  request and the round's own system prompt would go unread. The task
  prompt is rebuilt for every round either way, from the issue, the pull
  request and the mailbox as they are then. The id lives in the worker and
  nowhere else: a worker started after a restart has a new worktree, whose
  paths the old conversation does not know, so its first session of each
  role starts fresh. A resumed launch that fails, as one with an id claude
  no longer has does, is retried like any infrastructure failure, without
  the id. Reviewer sessions of every kind start fresh: the judge session,
  because a later round is told the first review's findings and the commit
  that review read, which is all it verifies; checks mode; a requested
  review.
  Codex has no resume: every round of a codex role is a new thread. An
  opencode role's later round continues the session with `--session`.
- **Bookkeeping.** `<state_dir>/issues/<n>.json` records the review round,
  pull request number, branch, `check_fix_rounds`, the three resume fields,
  and the full review's artifact directory and the head commit it read
  (`review_artifact`, `reviewed_head`),
  plus the running session, the two human-comment clocks,
  `conflict_notified_sha`, the cost totals, the proposal observation, a
  feature's open children and, once the factory has given the issue up,
  `escalation` and `escalated_at`. The file has two writers. The worker holds
  one copy for the whole life of the issue and writes back only the first
  group of fields; the polling path writes each of the others through a method
  that reads the file, changes its own field and writes it back. Saving the
  worker's copy wholesale would put back what the worker loaded when it
  started: feedback already delivered would be delivered again, a head already
  mailed about again, an approval forgotten, a finished feature reported twice
  or not at all. The round increments on every `changes-requested` and is
  compared with `scheduler.max_review_rounds`; feedback from people does not
  count against it. `check_fix_rounds` increments each time the reviewer is
  asked to diagnose failing checks, is shared between the prereview and checks
  stages, and is compared with `roles.reviewer.max_check_fix_rounds` (default
  2); check-fix rounds do not count against `max_review_rounds` either.
- **The review** is `bees review`'s pipeline, run by the worker before the
  reviewer session (`review.go`, over `internal/review`'s `Runner`): the pull
  request's context is gathered, a distiller session briefs the change and
  sizes it, one session per angle `roles.reviewer.angles` gives that size
  looks for problems from that angle alone, and the judge, deterministic
  code, merges what they found into one list. The brief and the angle
  sessions are `internal/review`'s read-only sessions, run as the role's
  `agent` and `model` (`brief_model` and `angle_models` replacing the model
  for the brief and for one angle each), with no MCP server, no tool that
  writes, runs or fetches, and in a local clone of the worker's checkout
  under the review's artifact, which is kept under the state directory's
  `reviews/` (the brief, each angle's run, the judge's list; the clone is
  removed). What they cost is entered in the ledger under the round's name
  and charged to the issue. Then one reviewer session, the judge session,
  running `judge_model`, is told the list and posts every finding on the pull
  request with `submit_review`, untriaged: as a `comment` review on a
  developer's pull request, whose author the factory is, with the verdict
  going to the developer by mail and to the orchestrator as the outcome. An
  angle that fails is named in the session's task and the rest are judged; a
  review that could not run (no brief, every angle failing) escalates the
  issue with the reason. See [Review](workflow.md#review).
- **Prereview stage** (`pre_review_checks`, on by default, independent of
  `auto_merge`). Between the developer and the first review the worker waits
  for the pull request's checks with a deadline of
  `pre_review_checks_timeout`, so the reviewer starts from a green pull
  request. Passed, nothing reported, or still pending at the timeout: the
  review runs, with the checks in the reviewer's prompt, where the pending and
  the no-checks case say that nothing was verified. A read that errors is
  advisory too: the review runs without a checks section, and the failure is
  recorded as the `pre-review-checks` degraded operation so a reviewer quietly
  losing its checks section is visible. Failed: the same checks-mode reviewer
  and developer fix round the checks stage uses, and the developer's next
  `pr-updated` returns here. The read belongs to the first review: once it has
  happened, a later `pr-updated` goes straight to review, so an ordinary
  changes-requested round pays neither the read nor the wait and cannot spend
  a check-fix round. The checks section is handed to the review it was read
  for and cleared afterwards, so a later round is not told that a head the
  developer has since replaced is green. Whether the read happened is
  remembered (`pre_review_done`), so a restarted worker does not pay for it
  twice; what it read is not, so a review that resumes runs without a checks
  section, exactly like the second round of a loop nothing interrupted. `bees
  status` reports the stage as `pre-review checks`. See
  [Pre-review checks](roles.md#pre-review-checks-pre_review_checks-on-by-default).
- **Checks stage** (`auto_merge`). An approval only labels the pull request
  and the issue `bees:approved`, and requests a review from `scheduler.notify`
  when it is set; merging happens here. The worker sleeps `checks_wait`
  (default 1m), then polls every `checks_poll_interval` (default 2m) until the
  checks pass or fail, or `checks_timeout` (default 30m) elapses. Which
  **gate** is in force is decided on the first observation that reports
  anything and never changes afterwards: `required` when branch protection
  requires checks (`gh pr checks --required`; the second call is then never
  made), otherwise `reported`, every check the pull request reports (`gh pr
  checks` without `--required`), because a repository with no branch
  protection would otherwise merge with nothing green. Two consecutive empty
  observations mean `none`: no CI at all, which merges but is logged as an
  ungated merge, never as "checks passed". The gate is shown in the worker's
  stage (`checks (required)`, `checks (reported)`, `checks (none)`) so `bees
  status` says what a long wait is waiting for, and `bees doctor` warns once
  when `auto_merge` is on and the default branch requires no check. busybees
  never reads or writes branch protection to change it; that is a person's
  setting. Passed: `gh pr merge` with `merge_method` and `--delete-branch`; a
  refusal escalates. Failed: a reviewer session in checks mode
  (`BEES_REVIEW_MODE=checks`, given the failed checks), whose
  `changes-requested` sends the developer a fix request and sets `checks` as
  the gate the next `pr-updated` returns to, and whose `approved` means it
  re-ran the check itself, so the wait starts again. Pending at the timeout
  escalates. With the reviewer role disabled, a developer's `pr-opened` counts
  as approved, and with `auto_merge` the worker goes straight from develop to
  checks. See [Checks mode](roles.md#checks-mode-a-failing-check) and
  [Merging](workflow.md#merging).
- **Verification.** Every outcome that implies a side effect is checked before
  the worker acts on it, and a claim without its side effect is escalated
  rather than trusted. See
  [What the orchestrator checks](#what-the-orchestrator-checks).
- **Cost.** Between stages the worker compares what the issue has cost, every
  session included, with `scheduler.max_cost_per_issue`, and escalates when it
  is over. A running session is never interrupted on cost.
- **Escalation** sets `bees:needs-human`, posts a comment (mentioning
  `scheduler.notify` when it is set) and records the reason in the issue's
  bookkeeping, which is how `bees status` and the live view say what the
  factory is stuck on without asking GitHub. It is the only comment the
  orchestrator writes in this repository: the other one it writes goes to the
  busybees repository, on a factory-error report that duplicates an issue
  already there (step 9). Roles comment on GitHub to people (a
  developer replying on its pull request, the product manager on a feedback or
  feature issue), always ending with the `<!-- bees:<role> -->` marker. See
  [Escalation](workflow.md#escalation-beesneeds-human).

Singleton roles share one path: a detached worktree on the default branch, one
session, delivered mail marked read, `last_run` recorded in
`<state_dir>/<role>.json`.

## What the orchestrator checks

Every session ends by reporting an outcome, and an outcome is a claim: the
session says what it did. Where the claim implies a side effect somebody else
can look at, the orchestrator looks, and a claim it cannot see counts as a
failure rather than as work done. Where the claim is a judgment, it is the
session's to make and nothing re-derives it.

Checked, in Go, when the session ends:

- a developer's `pr-opened` or `pr-updated`: an open pull request exists on the
  issue's branch, looked up by the number the session reported, else by the
  branch. None escalates the issue.
- a developer's `question`: mail reached the project manager while the session
  ran. None escalates the issue.
- a reviewer's `changes-requested` in the review loop: mail reached the
  developer while the session ran. None escalates the issue.
- a requested review's `approved` or `changes-requested`: GitHub holds a review
  on the pull request, submitted since the session started, whose state matches
  the verdict. `approved` accepts an approval or a comment review, because
  GitHub refuses an approval from a pull request's own author and the reviewer
  is told to comment in its place. No matching review fails the review and
  backs the pull request off for five poll intervals; there is no issue to
  escalate.
- QA's outcome: mail reached the product manager while the session ran. QA owes
  a report every session, a clean pass included, and skips it only when it
  could not test at all, which is the `failed` outcome. A missing report fails
  the run and backs QA off for five poll intervals.
- every session's comments: the marker audit, under
  [The scheduler loop](#the-scheduler-loop).

A reviewer's `approved` in the review loop has nothing to check, because the
orchestrator performs the approval itself: it labels the pull request and the
issue, and requests a review from `scheduler.notify`. The findings the
session was to post on the pull request are looked for after either verdict,
and a session that posted none is the `review-post` degraded operation
rather than a failure: the verdict travels by outcome and mail, and the
review on the pull request is for the person who merges.

Not checked, because there is nothing to look at afterwards:

- a tool call itself. Each of `issue_create`, `issue_link`, `comment`,
  `issue_edit_body`, `issue_set_state`, `issue_question`, `submit_review`,
  `file_bug`, `report_factory_error` and
  `mail_send` does its work inside the call and returns its error to the
  session there, so the call is the ground truth at the moment it runs. What
  is checked above is the outcome claiming one was made, not the call.
- the product manager's and the project manager's outcomes. No particular write
  is owed: their work is issues that may or may not need writing, and `done`
  and `idle` are both honest with nothing changed on GitHub.
- what any role decided. Whether an issue is detailed enough to build, how big
  it is, what a feature breaks into, what verdict a review's findings call for:
  these are the judgment the session exists to make, and the label a prompt
  moves is that decision rather than a claim about one. The mechanical half of
  such a move is scheduler-owned where one exists: `bees:question` is removed
  by the pass that sees a person answer, not by the role that asked.

## Degraded operations

Most things the scheduler does are best-effort: a failed label edit,
assignment or mail update warns and the pass carries on. A warning nobody
reads is silence, though, so each of those sites reports under a short, stable
operation name (`poll`, `assign`, `label`, `reconcile`, `human-feedback`,
`check-prs`, `list-created`, `ledger`, `write-status`,
`project-prompts/<role>`, and so on). The record logs what the site logged
plus `op=<name>`, and keeps a per-operation streak of consecutive failures; a
success clears the streak. `status.json` carries the streaks as `degraded`, so
a broken operation is visible in `bees status` and the live view instead of
only in the log. A mutation whose caller reports the failure itself still
records its own streak: making an item visible is three independent calls
(label, assign, milestone) joined into one warning naming the item, and each
of the three keeps a streak of its own.

At three consecutive failures the streak emits one record at error level to
the summary stream a person watching the run sees, and only one: the streak is
marked so it is not repeated on every pass. Nothing else changes: no retry, no
backoff, no GitHub comment and no mail, because there is no issue to comment
on for a factory-wide operation and no role can fix a broken credential or a
missing label. Purely informational warnings (a declared dependency cycle,
worktree housekeeping, the rate-limit backoff, a session retry) are
deliberately not operations: they are already visible elsewhere, or are facts
rather than failures. See [Degraded operations](cli.md#degraded-operations)
for what `bees status` prints.

## Running a session

A session is one non-interactive run of the role's `agent`, executed inside
the worktree. With `agent = "claude"`, the default, it is one `claude -p`:

```
claude -p \
  --output-format stream-json --verbose \
  --dangerously-skip-permissions \
  --append-system-prompt-file <session>/system-prompt.md \
  --model <model> --max-turns <n> --name bees-<session name> \
  [--fallback-model <fallback>] [--effort <level>] \
  [--resume <session id> --system-prompt-snapshot off] \
  --add-dir <state_dir> \
  [--allowedTools ...] [--disallowedTools ...] \
  --mcp-config <session>/mcp.json --strict-mcp-config \
  [--plugin-dir <skill plugin dir> ...]
```

The task prompt is written to stdin. Each line of stream-json is appended to
`<session>/transcript.jsonl`; the final `result` event supplies the result
text, `is_error`, subtype, turn count, cost and claude session id. The
`--resume` pair is passed when the session continues an earlier one's
conversation, a later round of the developer or the reviewer
(see [Later rounds](#the-developer-worker)).

With `agent = "codex"` it is one `codex exec`:

```
codex exec --json \
  --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check \
  [--model <model>] \
  [-c model_reasoning_effort="<level>"] \
  -c mcp_servers.<name>.command="..." -c mcp_servers.<name>.args=[...] \
  -c mcp_servers.<name>.env.<VAR>="..." ... \
  -
```

Codex has no flag to append to its system prompt, so the system prompt is
written to stdin ahead of the task prompt, separated by a rule; it has no
`--mcp-config`, so every MCP server, the built-in one included, is passed as
configuration overrides, one per key; and it has no fallback model, turn
limit, tool allow-list or plugin directories, so those settings are not
passed (see [`agent`](configuration.md#global-and-rolesname)). Its stream is
appended to `transcript.jsonl` the same way: `thread.started` supplies the
session id, each `item.completed` is one turn, the last `agent_message` item
is the result text, and `turn.completed`, `turn.failed` or a bare `error`
event says how it ended. Codex reports tokens, never a cost, so a codex
session's cost is unknown rather than zero.

With `agent = "opencode"` it is one `opencode run`:

```
OPENCODE_CONFIG=<session>/opencode.json \
opencode run --format json --auto --title bees-<session name> \
  [--model <model>] [--session <session id>]
```

The task prompt is written to stdin. opencode has no flag to append to its
system prompt and no `--mcp-config`, but it reads one more configuration
file from the path `OPENCODE_CONFIG` names, so the session is given
`<session>/opencode.json`: its `instructions` entry is the rendered system
prompt file, which opencode appends to its own system prompt, and its `mcp`
table is every MCP server, the built-in one included, with the session's
`BEES_*` variables as that server's environment. The file lives in the
session directory, never in the worktree; a project's own `opencode.json` is
read as well. `--auto` approves what opencode would otherwise ask about (a
write outside the worktree, such as into the state directory), the
counterpart of `--dangerously-skip-permissions`; an explicit `deny` in the
project's configuration still holds. `--session` continues an earlier
session the way `--resume` does for claude, and there is no snapshot to
switch off: opencode reads the instruction files again on every request.
It has no fallback model, turn limit, tool allow-list or plugin
directories, and `effort` is not passed either (its `--variant` takes a
name the model defines, not a level), so those settings are not passed. Its
stream is appended to `transcript.jsonl` the same way: every event carries
the session id, each `step_finish` is one turn and carries what the step
cost, the last `text` event is the result text, and a `step_finish` whose
reason is `stop`, or an `error` event, says how it ended. The costs add up
to the session's, a known cost even when a local model makes it zero.

For every agent, stderr is saved to `stderr.log` when non-empty, and
`result.json` summarises the run. A session that ended without a final event
(a signalled process, most often) has no known cost: `bees status`, the live
view and the summary line say so rather than printing zero, and its turns are
counted from the transcript's assistant messages or completed items instead.

- **Sandbox.** The role's resolved
  [`sandbox`](configuration.md#sandboxing) says how much of the machine the
  session can reach. `none` is the command above. `claude` swaps
  `--dangerously-skip-permissions` for `--permission-mode acceptEdits
  --permission-prompts none` and adds `--settings <json>`: Claude Code's
  sandbox on, no retry outside it, refuse to start rather than run unboxed,
  the network limited to GitHub, and allow rules for `Bash`, `Read`,
  `WebFetch` on the same domains and every MCP server of the session, so the
  box decides what a command may do and anything the permission layer would
  ask a person about is refused. The block is passed inline rather than as a
  file, because the session directory is writable from inside the box and
  Claude Code applies an edit to a settings file it loaded to the running
  session; a copy is kept as `<session>/sandbox.json` for reading afterwards.
  [`container`](configuration.md#the-container-mode) is the same command as
  `none` inside `docker run`, as the user running bees, with the worktree,
  the repository's `.git` and the state directory bind-mounted at their host
  paths, an environment built from the session's variables alone, and the
  built-in MCP server started on the host as `bees mcp serve --listen` and
  reached over HTTP with a per-session token; `<session>/container-id`
  holds the container's id while it runs, and `<session>/mcp-server-pid`
  that server's pid, so a crash leaves both findable. The runner refuses a session whose
  role asks for a mode it cannot build, one its agent cannot run under, or a
  container mode missing its image or credentials, and `bees run` refuses to
  start at all while a role in the rotation does. See
  [Security](security.md) for what `claude` and `container` protect and what
  they do not.
- **Outcome.** The session ends by calling the `done` tool (or, outside a
  container, running `bees done <status>`), which writes
  `<session>/outcome.json` through one shared validation: the status must be
  one the role may report, and `pr-opened` and `pr-updated` need a pull
  request number. The runner reads the file after the agent exits; a missing
  one is reported as "no outcome" and the scheduler treats the session as
  `failed`. The process exits when the turn ends, so a
  session that ends its turn waiting on a background task's completion
  notification or a scheduled wakeup never receives one and never reports an
  outcome.
- **Retries.** Every session goes through the same retry loop. A failure is
  *infrastructure* (a timeout, an API error, exhausted turns, a rate limit,
  the agent exiting without a final event) or *behavioural* (the session
  reported an outcome, `failed` included, or exited cleanly without
  reporting). Only infrastructure failures are retried, `scheduler.retries`
  times (default 1), waiting `scheduler.retry_delay` (default 10m) between
  attempts and running with the role's fallback model when
  `scheduler.retry_with_fallback` is set (on by default). Each attempt has its
  own session directory (`<name>-retry<n>`), a retried developer session
  is told its previous attempt was interrupted so it continues from the
  branch, and a retry of a session that was launched resuming an earlier one
  runs fresh, without the id. The account-wide claude session limit is
  neither kind and never
  reaches the classification: a session that died on it returns to its worker
  at once (see step 6 of the loop). A session that cost more than
  `scheduler.max_cost_per_session` is treated as failed. One such session is
  retried like an infrastructure failure, with the fallback model when that is
  configured; a second in a row for the same work item (or the same singleton
  role) is reported as `failed`, which escalates a work item and backs a
  singleton off. See [Retries first](workflow.md#retries-first).
- **Environment.** Every inherited `BEES_*` variable is dropped first, so a
  session started from inside another session cannot pick up a stale issue,
  pull request or branch. Then, in order: the role's configured `env` entries
  (`$VAR`-expanded) and `SHELL` when `shell` is set; `BEES_ROLE`,
  `BEES_SESSION_DIR`, `BEES_STATE_DIR`, `BEES_CONFIG`, `BEES_REPO`,
  `BEES_LABEL` and `BEES_BIN`, plus `BEES_ISSUE`, `BEES_PR` and `BEES_BRANCH`
  when they apply and `BEES_REVIEW_MODE=checks` for the
  reviewer's checks-mode sessions; the directory holding the `bees` binary
  prepended to `PATH`, so `bees mail`, `bees issue` and `bees done` resolve
  inside the session (a container session gets neither `BEES_BIN` nor the
  `PATH` entry: the binary stays on the host); the factory's own
  [GitHub identity](configuration.md#github) when `[github]` configures one:
  `GH_TOKEN`, `GIT_AUTHOR_*` and `GIT_COMMITTER_*`, plus the variable a
  `"$VAR"` `github.token` names, holding the token bees resolved (a session
  loads `bees.toml` itself, and a reference that expands to nothing is a load
  error, so that one name survives the drop); and, unless `GIT_CONFIG_COUNT`
  is already set, the `GIT_CONFIG_*` entries below. The `BEES_*` variables are
  also written into the built-in MCP server's entry in `mcp.json` (for
  codex, its overrides) rather than left to inheritance; the token variable
  deliberately is not, because that file sits in the session directory on
  disk, and claude passes its own environment on to the servers it starts.
  Codex does not: a codex session's built-in server sees only the `BEES_*`
  variables its entry names. opencode lays the entry's environment over
  its own, as claude does. See
  [Exported into every session](configuration.md#exported-into-every-session).
- **Prompts.** The system prompt is `system/common.md` plus
  `system/<role>.md`, the role's custom `prompt` from `bees.toml`, and then
  the project's own prompt files; the task prompt is `task/<role>.md`
  (`task/reviewer_checks.md` for a checks-mode reviewer). Both are rendered
  from one data structure (project, filter, labels, workspace, inbox, issue,
  pull request, lists, round). The base prompts are compiled into the
  binary, so a running scheduler serves the prompts of the build it was
  started from: a prompt change merged to the default branch reaches no
  session until `bees` is rebuilt and `bees run` restarted. The scheduler
  records the build it runs, in its `scheduler started` log line and as
  `version` and `revision` in `status.json`, which is what `bees status` shows
  on its scheduler line, so the running prompts can be told from the
  repository's. `bees doctor`'s *scheduler build is current* check makes that
  comparison itself and warns when a running scheduler is behind `HEAD`.
- **Project prompt files.** `bees/prompts/common.md` and
  `bees/prompts/<role>.md` are read from the **worktree** the session runs in,
  so a branch's own instructions apply to the session working on that branch.
  They are read at session start rather than compiled in, so editing them
  takes effect on the next session with no rebuild and no restart. A missing
  directory is the normal case and is silent; a file that cannot be read, or
  is over 64 KiB, is skipped, never fatal, and recorded as the
  `project-prompts/<role>` degraded operation, one per role, so a file only
  one role reads is not cleared by the next session of another. `bees doctor`
  fails on the same files, and on one no role would read. See
  [Project prompt files](configuration.md#project-prompt-files).
- **Skills.** Each configured skill reference (`<url>[@ref][#subdir]`) is
  cloned under the cache directory (`BEES_CACHE_DIR`, else the user cache
  directory: `~/.cache/bees` on Linux, `~/Library/Caches/bees` on macOS) and
  becomes a `--plugin-dir`: the repository itself when it has
  `.claude-plugin/plugin.json`, otherwise a generated wrapper under
  `plugins/<name>/` in the cache whose `skills/` symlinks to the skill or
  skills collection. The project worktree is never modified. Sessions start
  concurrently and share one cache, so preparation is serialised and a wrapper
  that already points at the right target is left alone. Clones are refreshed
  according to `global.skills_refresh`; `bees skills` inspects the cache.
  Plugin directories are Claude Code's: a `codex` or `opencode` session is
  passed none, whatever its role configures. See
  [Skills](configuration.md#skills).
- **MCP.** A claude session gets `mcp.json`, always passed with
  `--strict-mcp-config`, so it sees exactly two things: the servers of the
  resolved role (`$VAR` in `env` and `headers` expanded from the bees
  process environment) and the built-in `bees` server, `<bees binary> mcp
  serve` over stdio with the session's `BEES_*` variables in its `env`. A
  codex session gets the same two things as `mcp_servers` overrides on its
  command line, next to whatever its own configuration file names, and an
  opencode session as the `mcp` table of the configuration file
  `OPENCODE_CONFIG` names, next to the project's own. That
  server serves the factory's own operations as tools backed by the same code
  the CLI uses, so a session calls a schema instead of composing a command
  line: `mail_send`, `mail_list`, `issue_create`, `issue_link`, `issue_view`,
  `pr_view`, `comment`, `report_factory_error`, `notes_read`, `notes_write`
  and `done` go to every role; `issue_edit_body` to the
  two managers, `issue_set_state` to the project manager, `issue_question`
  to the product manager, `submit_review` to the reviewer and `file_bug`,
  which refuses a bug the repository already reports, to QA. The schemas
  depend on `BEES_ROLE`: `done`'s `status`
  enum is the role's valid outcomes. The name `bees` is reserved in
  `bees.toml`. See [bees mcp serve](cli.md#bees-mcp-serve-sessions) and
  [MCP servers](configuration.md#mcp-servers).
- **Timeout.** The role's `timeout` (default 45m) bounds the command; the
  agent runs in its own process group, and on expiry the whole group is killed so
  MCP servers die with it. The result is marked timed out.
- **Stopping.** Sessions do not run under the loop's context: `bees run`
  derives a second one for them, so cancelling the loop (an interrupt, or the
  live view's stop key) stops polling and dispatch and lets every running
  session finish, each still bounded by its role's `timeout`. A developer
  worker runs under that context too, so an issue one already holds is carried
  through the stages it has left rather than dropped between two of them: the
  loop ends where it always ends, at an approval, an escalation,
  `max_review_rounds` or `max_cost_per_issue`. The gate that stops a *new*
  issue being taken, and every singleton, is the loop's context. That is the
  cool-down. The hard stop, the second interrupt or the second press in the
  view, cancels the sessions' context instead: each process group is killed
  exactly as a timeout kills it, but no result file is written and the
  running-session record in the issue's bookkeeping is kept, so the directory
  reads as an interrupted session and the next `bees run` resumes the issue
  and tells its next session what was stopped, through the same crash-recovery
  path a killed scheduler goes through, with a marker saying the stop was
  deliberate.

Unless `GIT_CONFIG_COUNT` is already set, the runner also exports git
configuration through `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n`, with
`GIT_CONFIG_COUNT` derived from the entries it built:
`push.autoSetupRemote=true` and `push.default=current`, so a session can run a
plain `git push` on a branch the workspace created with `git worktree add
--no-track -b`; and, when `[github]` carries a token, an empty
`credential.helper` followed by `credential.helper=!gh auth git-credential`,
so that an https push authenticates as the factory. The empty value comes
first because git asks helpers in configuration order and takes the first
answer, and `GIT_CONFIG_*` is read last: without it the machine owner's own
helper would answer and the push would be theirs. busybees never edits the
clone's git configuration.

## The mailbox

A message is one JSON file at `<state_dir>/mail/<to-role>/<id>.json`:

```json
{
  "id": "20260829T151201-9f3a2b1c",
  "from": "reviewer",
  "to": "developer",
  "subject": "Review round 1",
  "body": "...",
  "issue": 12,
  "pr": 34,
  "created_at": "2026-08-29T15:12:01Z",
  "read_at": null,
  "in_reply_to": ""
}
```

Messages are addressed to a **role**, not a session. Delivery rules:

- A developer session for issue N with pull request M receives the unread
  developer mail where `issue == N` or `pr == M`.
- A reviewer session receives the unread reviewer mail where `issue == N` or
  `pr == M`, in review mode and in checks mode alike, read afresh before each
  of those sessions. Its earlier feedback is not replayed: a later round is
  given the first review's findings to verify.
- A singleton session receives all unread mail addressed to its role.
- Mail is marked read (`read_at` set) after the session that received it
  finishes, so a session that crashed sees it again.
- Reconcile uses *unread* mail to relabel blocked issues; the verification of
  a session's claim to have sent mail uses creation time.
- Feedback on a pull request, a person's comments on an in-flight issue, and
  an `@`-mention of the factory's login on any other issue the filter reaches,
  enter the mailbox as messages from `human` (see the scheduler loop). People
  can also send mail by hand with `bees mail send --from human`, or by typing
  one in the live view's session view, which writes the same thing. The
  scheduler's own requests, to bring a pull request up to date with the branch
  it targets, come from `orchestrator`.

**Visibility backstop.** After every session the scheduler lists the issues
and pull requests created since the session started (`gh issue list` and `gh
pr list` with `created:>=<start>`, `--state all`, two calls), whatever their
labels and whoever opened them. Anything carrying the base label, or any
`<label>:*` label, but missing part of the filter is repaired the way the
developer worker repairs a pull request it opened: the base label, the
configured `filter.assignee` and, for pull requests only, the configured
`filter.milestone`. The search carries no author qualifier because the items
it exists for, a pull request a session opened with its own `gh pr create` and
an item a person opened by hand, are not reliably the account bees acts as, so
the label gate decides. Both halves of that gate are needed: a pull request a
session just opened carries only the base label, and earns its first
`<label>:*` label at approval. Items with no factory label at all are left
alone, and one that cannot be repaired is logged and skipped rather than
stopping the others.

A milestone is set on pull requests and never on issues: a milestone on an
issue is a person's decision, and an issue the factory creates gets one
through `bees issue create`, inherited or chosen among the existing ones,
while a milestone on a pull request is pure filter bookkeeping.

Writes are atomic (a temporary file, then a rename), ids embed a timestamp so
a listing sorts oldest first, and `bees mail` works from any directory because
sessions get `BEES_STATE_DIR`.

## State directory

```
<state_dir>/                     default .bees/ next to bees.toml
  README.md
  mail/<role>/*.json             the mailbox
  feedback/<id>.json             {id, role, session_dir, title, detail, created_at}: a
                                 draft report of an error the factory caused, written by
                                 report_factory_error with scheduler.report_factory_errors
                                 on, waiting to be filed against busybees
  notes/<role>.md                role memory (`bees notes show|edit|reset|add`)
  notes/archive/<role>-<ts>.md   notes replaced by `bees notes reset`
  reviews/<owner>/<name>/<pr>/<started>/
                                 one review of one pull request, as internal/review writes it:
                                 brief.json, angles/<angle>.json, findings.json
  sessions/<ts>-<name>-<rand>/   system-prompt.md, prompt.md, mcp.json (claude), transcript.jsonl,
                                 stderr.log, outcome.json, result.json, pid,
                                 touched-issues.txt (the issues the session changed on
                                 GitHub, one per line, read back into the cached poll
                                 when it ends), interrupted (written by `bees kill`,
                                 the live view's k key and a hard stop)
  issues/<n>.json                {number, round, pr, branch, check_fix_rounds, worker_stage,
                                 after_develop, pre_review_done, session, human_seen_at,
                                 issue_human_seen_at, conflict_notified_sha, cost, sessions,
                                 proposal, proposal_approved_at, open_children,
                                 complete_reported_at, escalation, escalated_at, updated_at}
  <role>.json                    per-role bookkeeping, one file per role that has run:
                                 {last_run, last_check, sessions, last_consolidated}
  status.json                    live scheduler status for `bees status` (queues, workers,
                                 singletons, pauses, degraded operations, last_poll, last_error)
  ledger.jsonl                   append-only, one JSON line per finished session
                                 {time, role, session, issue, pr, turns, cost_usd,
                                 duration_ms, outcome, error_subtype, timed_out}
  bees.log                       every record of the last scheduler runs as JSON, rotated
                                 at 10 MiB into bees.log.1 and bees.log.2
```

`ledger.jsonl` is the factory's accounting: one line for every session that
finishes, whatever it reported, and `bees cost` sums it. Lines are written
with a single append so concurrent workers cannot interleave, and a line that
does not parse is skipped on read rather than failing it.

`<role>.json` carries what the scheduler remembers about a role between runs:
when the singleton roles last ran (`last_run`) and last looked for work
(`last_check`), how many sessions of any kind the role has run (`sessions`)
and the count at which it was last asked to consolidate its notes
(`last_consolidated`). Developer workers share `developer.json`, so every
update to it is a read-modify-write under the scheduler's lock.

`bees.log` is written only by the commands that run sessions (`run`, `tick`,
`exec`) and always holds every record at debug level, whatever the console
flags say. `bees issue` and `bees mail` run inside sessions, concurrently with
the scheduler, so they never open it.

`bees init` makes sure the directory is ignored by git: when `git
check-ignore` does not already ignore it (and it lives inside the clone),
`/.bees/` is appended to the repository's `.gitignore`, which is a change to
commit. `bees.toml` itself is meant to be committed. Worktrees live under
`$TMPDIR/bees/` (or `scheduler.workspace_root`) and are removed after each
worker or singleton run; the skills cache lives outside the state directory
(see *Skills* under [Running a session](#running-a-session)).

## Crash recovery (`bees kill`)

The runner writes the session's pid to `<session dir>/pid` right after
starting the agent and removes it when the session ends. When bees dies,
those files, and a marker in every session's argv (the `--name
bees-<session>` a claude session is started with; for a codex session, the
`mcp_servers.bees.env.BEES_SESSION_DIR=` override that hands the built-in
MCP server its directory), let `bees kill` find the orphans: it merges the
pid files with a `ps` scan restricted to processes whose executable is
`claude` or `codex` (directly or through an interpreter), cross-checking pid
files against the scan so a reused pid is discarded rather than killed. An
opencode session is found through its pid file alone: its argv carries no
path of the state directory, so the scan does not know it. Both
sources are scoped to one factory: a scanned process counts only when its
command line also references this state directory's `sessions/` (a claude
session's argv carries `--append-system-prompt-file <sessions
dir>/<session>/system-prompt.md`, a codex session's the session directory in
that override, matched as a path prefix and also in its symlink-resolved
form). Sessions of
another project's factory are never reported, so `bees kill` run with one
project's config cannot strand another project's issues.

A session in the container sandbox is found through its container, because
its agent runs in the container's own pid namespace where neither source
reaches it. The engine is asked which of its running containers carry the
`bees.session` label (`docker ps --filter label=bees.session`), and the
label's value, the session directory, says which session each one is and
scopes it to this factory the same way. The container engine client the
process table does show counts as a session too, so the pid file naming it
is not discarded as a reused pid; a session directory recording a container
the engine no longer lists has its stale `container-id` file removed, as a
stale pid file is. On a machine with no engine to ask there are no container
sessions to find.

The `bees` binary is not in the container either, so the built-in MCP server
of such a session runs on the host, in a process group of its own that the
scheduler's own shutdown does not reach. The runner records its pid in
`<session dir>/mcp-server-pid` and removes the file when the session ends,
so a file left behind names a server a crash orphaned: `bees kill` stops it
along with the session's container and its engine client, and drops a file
naming a process that has gone, as it does a stale pid file.

Every session `bees kill` stops through a pid file or through its container
is marked: it writes `<session dir>/interrupted` naming the kill, so the next
session for that issue is told the session was stopped rather than left to
guess that the machine crashed (see *An interrupted session* under
[The developer worker](#the-developer-worker)). A process found only in the
process table names no directory and is killed unmarked.

The kill sends SIGTERM to the process group (sessions are started in a group
of their own, so MCP servers and shells belong to it), waits `--grace`
(default 5s), then SIGKILL. A container is removed first, with `docker rm
--force`: it outlives the engine client that started it, and the agent is
inside it. The built-in MCP server goes last, so the tools stay answerable
until what was using them is gone. Any of the three can be all there is
left to stop. The command then removes
every worktree of the
main clone that lives under the workspace root, prunes worktree metadata,
deletes leftover workspace directories and resets the worker list in
`status.json`. It refuses to run while the scheduler recorded in `status.json`
is alive unless `--scheduler` is given. See
[bees kill](cli.md#bees-kill---dry-run---scheduler---grace-5s).
