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, for each role roles.md, for every
bees.toml key configuration.md and for the commands
cli.md. The package layout and the test-suite are contributor
material and live in
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) 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). 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.
A full pass is:
- Poll.
gh issue listandgh pr listwith the filter’s query (label, assignee, milestone, creator): two calls, or four under afilter.creatorthat is not the account the factory acts as, because--authortakes 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 carryingbees:feedbackorbees:featureis set aside for the product manager instead, so it never gets a state label. The queue counts (the buckets, plusfeedback,features,proposalsandopen_prs) go intostatus.json. A ready issue that declares a blocker still open is listed there as waiting on it and is not dispatched (see Dependencies), unlessscheduler.stacked_prslets it build on the blocker: a blocker under the same feature whose pull request is open holds nothing back. - Comments on an in-flight issue. For every issue in
in-progress,review,approvedorblockedwhoseupdatedAtmoved past the issue’sissue_human_seen_atclock, the comments written since that clock are fetched (one call) and what people wrote goes out as one message fromhuman(issue == N) to the role that can act on it: the developer forin-progressandapproved; the developer and a copy to the reviewer forreview, so the round in flight sees it; and forblocked, 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 toready; 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 intriageorreadyhas 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. Abees:featureorbees:feedbackissue 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. - Feedback on a pull request. For every open pull request whose closing
issue is visible, when its
updatedAtis later than the issue’shuman_seen_atclock (or the pull request’s creation time), its reviews, inline review comments and conversation comments are fetched withgh api --paginate(three calls). Bee comments and empty approvals are dropped by the rule above. The rest go to the developer as one message fromhuman(issue == N,pr == M) whose body carries each item’s id and theghcommand to reply to it, and the clock advances to the newest item. Anapprovedissue that received feedback goes back toreadyand its pull request losesbees: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. - Merge state.
gh pr listalready reportsmergeable,mergeStateStatusand the head commit, so this costs no call. For an issue inrevieworapproved, aCONFLICTINGpull request (withscheduler.pr_fix_conflicts) or aBEHINDone (withscheduler.pr_keep_updated) gets the developer one message fromorchestrator(issue == N,pr == M) asking it to merge the default branch, resolve, test, push and reportpr-updated. The head commit is recorded asconflict_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 toreadyas in step 3. AnUNKNOWNor 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. - Reconcile. Label transitions driven by local state, in this order:
- an issue with no state label and neither
bees:featurenorbees:feedbackis a person handing the factory an idea, not a spec: when the product manager is enabled it getsbees: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 itbees:triageorbees:readythemselves; - a
bees:blockedissue with unread developer mail about it becomesbees:ready; one with unread project manager mail about it becomesbees:triage. Mail from a person counts as an answer too; - a
bees:readyissue with no size label getsbees:size/m, the default size (see Sizing); - a
bees:readyissue sized aboveroles.developer.max_size(defaultl, so normally abees:size/xlone) goes back tobees:triagewithout 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.
- an issue with no state label and neither
- 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 statusand 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_dayset, 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 undermax_cost_per_day_resume_percentof 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_issuebetween a developer worker’s stages,max_cost_per_sessionafter a session ends. See Cost budgets. - Claude session limit. Recorded from a finished session rather than
computed here: a session whose last
rate_limit_eventwas 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_backoffwhen 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.
- Daily cost budget. With
- Dispatch developers. The candidates, in order: issues in
in-progressandreviewthat no worker owns (resumed after a restart, never reordered); anapprovedissue 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;readyissues 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 ofready:bees:priorityfirst, thenscheduler.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. Withscheduler.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. Abees:size/lissue that is new work waits whilescheduler.max_large_in_flightof 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 ofmax_developers(default 1), or one slot per attempt, all of them or none, when its size fans out underbest_of_n_by_sizeormoe_experts_by_size; when the pool cannot supply it the pass stops dispatching. A goroutine runs the 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 whatbees statusshows. 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. Then the requested reviews: every pull request in the poll carryingbees:review-requestedthat 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. Withscheduler.review_assigned_prsa pull request whose head branch does not start withproject.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 inissues/<pr>.jsonbefore 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. - 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 (onegh issue vieweach) 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 carryingbees:questionhas that label removed on the spot. A proposal (bees:proposal), and an issue in planning mode, 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:plannedwakes 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 aslast_checkin<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:proposalstays a proposal); every open feature with its sub-issue progress (onegh api repos/../issues/Nper 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 createandissue_linkrefuse abees:planningissue as a parent, as they refuse a proposal whilescheduler.feature_proposalsis 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_createtool (orbees 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, elsefilter.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. - the project manager: triage issues (it takes the first
- File the factory errors. With
scheduler.report_factory_errorson, the drafts sessions recorded withreport_factory_error(<state_dir>/feedback/) are filed againstkpenfound/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
- 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.
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
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).
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 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:
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 ofapprovedon a stacked pull request takes the worker tostack-wait(stack_waitin the diagram), where it polls the predecessor’s issue everyroles.reviewer.checks_poll_intervaland approves its own pull request, exactly as the direct path would have, once the predecessor carriesbees:approvedor 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 byauto_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 itsmax_developersslot 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 whilebees runwas 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 (unlesskeep_workspaces). Before each reviewer session it is fast-forwarded to the developer’s latest push. Each workspace is a unique temporary directory underworkspace_root, and the worktree inside it carries that same unique name:git worktree addderives 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-waitorchecks), 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 abees runkilled 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 tobees:ready, and a stage name this build does not run.developfits 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 issuebees:in-progress, so it sits underbees:approvedlegitimately 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 labelledbees:reviewit starts in prereview (in review, withpre_review_checks = falseor the reviewer disabled), otherwise in develop. That is how work survives a restart ofbees 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.jsonclosed, 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; aresult.jsonmeans 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’skkey and a hard stop write aninterruptedmarker 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 statusmarks such a workerresumed. 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 every round’s review runs again on the head as it stands and the session posts that round’s list; 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>.jsonrecords the review round, pull request number, branch,check_fix_roundsand the three resume fields, 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,escalationandescalated_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 everychanges-requestedand is compared withscheduler.max_review_rounds; feedback from people does not count against it.check_fix_roundsincrements each time the reviewer is asked to diagnose failing checks, is shared between the prereview and checks stages, and is compared withroles.reviewer.max_check_fix_rounds(default 2); check-fix rounds do not count againstmax_review_roundseither. - The review is
bees review’s pipeline, run by the worker before the reviewer session (review.go, overinternal/review’sRunner): the pull request’s context is gathered, a distiller session briefs the change and sizes it, one session per angleroles.reviewer.anglesgives 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 areinternal/review’s read-only sessions, run as the role’sagentandmodel(brief_modelandangle_modelsreplacing 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’sreviews/(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, runningjudge_model, is told the list and posts every finding on the pull request withsubmit_review, untriaged: as acommentreview 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. - Prereview stage (
pre_review_checks, on by default, independent ofauto_merge). Between the developer and the first review the worker waits for the pull request’s checks with a deadline ofpre_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 thepre-review-checksdegraded 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 nextpr-updatedreturns here. The read belongs to the first review: once it has happened, a laterpr-updatedgoes 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 statusreports the stage aspre-review checks. See Pre-review checks. - Checks stage (
auto_merge). An approval only labels the pull request and the issuebees:approved, and requests a review fromscheduler.notifywhen it is set; merging happens here. The worker sleepschecks_wait(default 1m), then polls everychecks_poll_interval(default 2m) until the checks pass or fail, orchecks_timeout(default 30m) elapses. Which gate is in force is decided on the first observation that reports anything and never changes afterwards:requiredwhen branch protection requires checks (gh pr checks --required; the second call is then never made), otherwisereported, every check the pull request reports (gh pr checkswithout--required), because a repository with no branch protection would otherwise merge with nothing green. Two consecutive empty observations meannone: 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)) sobees statussays what a long wait is waiting for, andbees doctorwarns once whenauto_mergeis 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 mergewithmerge_methodand--delete-branch; a refusal escalates. Failed: a reviewer session in checks mode (BEES_REVIEW_MODE=checks, given the failed checks), whosechanges-requestedsends the developer a fix request and setschecksas the gate the nextpr-updatedreturns to, and whoseapprovedmeans it re-ran the check itself, so the wait starts again. Pending at the timeout escalates. With the reviewer role disabled, a developer’spr-openedcounts as approved, and withauto_mergethe worker goes straight from develop to checks. See Checks mode and 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.
- 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 (mentioningscheduler.notifywhen it is set) and records the reason in the issue’s bookkeeping, which is howbees statusand 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.
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-openedorpr-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-requestedin the review loop: mail reached the developer while the session ran. None escalates the issue. - a requested review’s
approvedorchanges-requested: GitHub holds a review on the pull request, submitted since the session started, whose state matches the verdict.approvedaccepts 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
failedoutcome. 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.
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_errorandmail_senddoes 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
doneandidleare 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:questionis 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
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).
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). 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
sandboxsays how much of the machine the session can reach.noneis the command above.claudeswaps--dangerously-skip-permissionsfor--permission-mode acceptEdits --permission-prompts noneand 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 forBash,Read,WebFetchon 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.jsonfor reading afterwards.containeris the same command asnoneinsidedocker run, as the user running bees, with the worktree, the repository’s.gitand 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 asbees mcp serve --listenand reached over HTTP with a per-session token;<session>/container-idholds the container’s id while it runs, and<session>/mcp-server-pidthat 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, andbees runrefuses to start at all while a role in the rotation does. See Security for whatclaudeandcontainerprotect and what they do not. - Outcome. The session ends by calling the
donetool (or, outside a container, runningbees done <status>), which writes<session>/outcome.jsonthrough one shared validation: the status must be one the role may report, andpr-openedandpr-updatedneed 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 asfailed. 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,
failedincluded, or exited cleanly without reporting). Only infrastructure failures are retried,scheduler.retriestimes (default 1), waitingscheduler.retry_delay(default 10m) between attempts and running with the role’s fallback model whenscheduler.retry_with_fallbackis 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 thanscheduler.max_cost_per_sessionis 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 asfailed, which escalates a work item and backs a singleton off. See 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 configuredenventries ($VAR-expanded) andSHELLwhenshellis set;BEES_ROLE,BEES_SESSION_DIR,BEES_STATE_DIR,BEES_CONFIG,BEES_REPO,BEES_LABELandBEES_BIN, plusBEES_ISSUE,BEES_PRandBEES_BRANCHwhen they apply andBEES_REVIEW_MODE=checksfor the reviewer’s checks-mode sessions; the directory holding thebeesbinary prepended toPATH, sobees mail,bees issueandbees doneresolve inside the session (a container session gets neitherBEES_BINnor thePATHentry: the binary stays on the host); the factory’s own GitHub identity when[github]configures one:GH_TOKEN,GIT_AUTHOR_*andGIT_COMMITTER_*, plus the variable a"$VAR"github.tokennames, holding the token bees resolved (a session loadsbees.tomlitself, and a reference that expands to nothing is a load error, so that one name survives the drop); and, unlessGIT_CONFIG_COUNTis already set, theGIT_CONFIG_*entries below. TheBEES_*variables are also written into the built-in MCP server’s entry inmcp.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 theBEES_*variables its entry names. opencode lays the entry’s environment over its own, as claude does. See Exported into every session. - Prompts. The system prompt is
system/common.mdplussystem/<role>.md, the role’s custompromptfrombees.toml, and then the project’s own prompt files; the task prompt istask/<role>.md(task/reviewer_checks.mdfor 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 untilbeesis rebuilt andbees runrestarted. The scheduler records the build it runs, in itsscheduler startedlog line and asversionandrevisioninstatus.json, which is whatbees statusshows 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 behindHEAD. - Project prompt files.
bees/prompts/common.mdandbees/prompts/<role>.mdare 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 theproject-prompts/<role>degraded operation, one per role, so a file only one role reads is not cleared by the next session of another.bees doctorfails on the same files, and on one no role would read. See 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/beeson Linux,~/Library/Caches/beeson macOS) and becomes a--plugin-dir: the repository itself when it has.claude-plugin/plugin.json, otherwise a generated wrapper underplugins/<name>/in the cache whoseskills/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 toglobal.skills_refresh;bees skillsinspects the cache. Plugin directories are Claude Code’s: acodexoropencodesession is passed none, whatever its role configures. See 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 ($VARinenvandheadersexpanded from the bees process environment) and the built-inbeesserver,<bees binary> mcp serveover stdio with the session’sBEES_*variables in itsenv. A codex session gets the same two things asmcp_serversoverrides on its command line, next to whatever its own configuration file names, and an opencode session as themcptable of the configuration fileOPENCODE_CONFIGnames, 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_writeanddonego to every role;issue_edit_bodyto the two managers,issue_set_stateto the project manager,issue_questionto the product manager,submit_reviewto the reviewer andfile_bug, which refuses a bug the repository already reports, to QA. The schemas depend onBEES_ROLE:done’sstatusenum is the role’s valid outcomes. The namebeesis reserved inbees.toml. See bees mcp serve and 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 runderives 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’stimeout. 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_roundsormax_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 nextbees runresumes 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:
{
"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 == Norpr == M. - A reviewer session receives the unread reviewer mail where
issue == Norpr == M, in review mode and in checks mode alike, read afresh before each of those sessions. Its earlier feedback is not replayed: each round’s review runs again on the head as it stands. - A singleton session receives all unread mail addressed to its role.
- Mail is marked read (
read_atset) 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 fromhuman(see the scheduler loop). People can also send mail by hand withbees 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 fromorchestrator.
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).
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). 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.