Thread admission — one live run per thread
A thread reply that arrives while a run is already in flight in that thread is folded into the live run or refused with a pointer to it. It never starts a second run in the same thread.
- Code:
src/core/threadAdmission.ts(pure: the live-thread map, the inbox, the steer/refuse decision, the replies),src/core/runLedger/threadsElsewhere.ts(the threads live on the ledger but not here, item 5),src/core/dispatch/admission.ts(the stage:admit— the claim after the agent gate, the steer, the refusal, the boot-gap steer to a run live on another generation — and its outcome;adoptCarriedRunandfoldCarriedInboxfor a resumed or restarted run's row and durable inbox),src/core/dispatch/settle.ts(the settle stage:settleThreadfrees the slot indispatch()'s outer finally and decides — quiet, dropped after a stop, handed on;tellDroppedsends each dropped sender the ⛔ note;prepareFreshTurnbuilds the fresh turn's request),src/core/dispatcher.ts(builds the stage's context after the agent gate, ends the root with the settlement, runs the fresh turn),src/runner.ts(drains the inbox at step boundaries),src/core/shipPipeline.ts(hands the thread's inbox to every child round) - Docs: How a request flows
- Tests:
src/core/threadAdmission.test.ts,src/core/runLedger/threadsElsewhere.test.ts,src/runner.test.ts(follow-up inbox),src/core/dispatch/admission.test.ts(one outcome per branch ofadmit; the carried run's row and inbox),src/core/dispatch/settle.test.ts(one settlement per branch; the fresh turn's request),src/core/dispatcher.test.ts(thread admission,run ledger write-through— what the thread sees on each branch, end to end)
Why
Before this, a follow-up in a thread with a run in flight dispatched a second, fully independent run. Both runs share the thread's workspace (the sandbox and the resident worktree are keyed by thread), so two agents edited one checkout at once: the second run's git checkout -b moved the first run's branch under it, and the first run's PR post looked up a branch it never pushed and reported "branch not found on the remote". The design is non-blocking threads (one live run per thread): admit every input, steer at the next model step, never interrupt in-flight work, never drop an input.
Behavior
- One live run per thread, decided before anything slow.
dispatch()claims the thread's slot right after the agent permission gate and before the setup card, repo resolution or any executor attach, so no window exists in which two runs can attach the same per-thread workspace. The chat-command fast path (help,runs stop,config …) runs BEFORE the claim and is unaffected: commands never start a run, so they answer inline during a live run. A claim on an occupied thread ends the dispatch with one of two replies and no card, no run, no workspace:- Steer (every agent): the message is pushed to the live run's inbox and the sender gets
↪ Folded into the *<agent>* run already in flight in this thread (<N>s in) — it picks this up at its next step. · <bare run-page URL>. The URL is bare, never mrkdwn<url|label>—ChannelIO.replyescapes</>, so the label form arrives as literal text; Slack auto-links the bare form. The link appears once the live run is registered; a follow-up that lands during setup gets the ack without it (and without the·separator). - Refuse (one case only): the follow-up named a DIFFERENT agent than the one running —
agent:reviewin a live coding thread is a new request, not a nudge — and the sender gets⏳ A *<agent>* run is already in flight in this thread (<N>s in). …plus "one run per thread, wait or start a new thread". No agent refuses a follow-up for being mid-flight: a review hearsagent:review <pr> — re-review: …or "also check the migration" on its next step (the reviewed-head settle, agent-review.md items 8 + 12, still reconciles a head that moved), and a ship pipeline hands the reply to the child round in flight (below). There is no per-agent follow-up policy to configure. - Ship steers through its children. The orchestrator has no model turn of its own, so the dispatcher's one per-thread inbox is handed to EVERY child round's runner (
ShipPipelineInput.inbox): a reply during a coding round is read by that coding child at its next step; a reply during a review round by that review child (a "make sure X" there becomes a finding the next fix round answers); a reply that lands between rounds waits in the inbox and is read by the next child's first step boundary. The ack names theshiprun. What no child consumed before the pipeline ended follows item 4. - The follow-up's sender must be allowed to run the LIVE agent (invariant 3): the gate ran against the follow-up's own resolved agent, so a second check against the live one precedes the steer; a sender the allowlist excludes gets the allowlist refusal naming the live agent, and nothing is folded in.
- Steer (every agent): the message is pushed to the live run's inbox and the sender gets
- Steering lands at the next step boundary, never mid-step. The runner drains the inbox only when it is about to take another step: after a tool turn's results are appended (the follow-up rides on that user turn as ONE text part after the results —
↪ Follow-up from the thread, sent while you were working …with the texts, bulleted when several — followed by the inputs' image/document parts), and only if the loop would continue (turn and time budget left, no stop requested, sandbox alive). Each drained input is recorded on the run's event stream as aninputevent (text, sender, message link) and afollow_uprun note (follow-up folded in: <snippet>), so the run page and the card show it. On the run page it is ONE run with several inputs: the firstinputis the REQUEST block; every laterinputis its own↪ FOLLOW-UPblock IN THE TIMELINE — the Request's visual treatment (header with the time and the sender linked to the message, the full text rendered as markdown), placed where the run read it, between the step that was in flight and the one that heard it. Thefollow_upnote is not a row on the page (the block is the marker; before, the page showed only the note's 80-char snippet as an amber warning line and listed the full text under the request, away from the moment it landed). A follow-up never replaces the request in the header. A tool call or provider call already in flight is never aborted for a follow-up: retries over side effects that already happened are the failure mode this avoids. - A follow-up that lands while the model wrote its final answer supersedes the answer. When a text-only completion returns with the inbox non-empty and another step is allowed, the answer becomes an
assistantnarration event, the follow-up the next user turn, and the loop continues (this counts as a turn). The superseded answer is never lost to the thread: the follow-up's text part uses the superseded header —↪ Follow-up from the thread, sent while you were writing your answer. That answer was NOT delivered — the thread has not seen it, and it will not be sent. Write ONE complete answer now that covers the original request AND this follow-up:— because without it the model writes an increment ("Perfect addition. Let me add that detail…") and the channel never receives the original answer, which then exists only as run-page narration. A follow-up riding a tool turn keeps the plain header (… sent while you were working …). When no further step is allowed (budget reached, stop requested) the answer stands and the follow-up stays unconsumed for item 4. Without an inbox, or with an empty one, every provider request is byte-identical to the plain loop. - Nothing is dropped silently. When the run ends, the dispatcher releases the thread and settles what the run never consumed. A run that ended by itself (answer, budget, failure, dead sandbox, setup refusal) hands its unconsumed follow-ups on as ONE fresh turn: their texts joined in arrival order, attachments concatenated, run on the most recent sender's channel handle and under their identity — an ordinary
dispatch()that claims the thread itself. A run an operator stopped (soft or hard) does not restart: each sender is told⛔ The run this was folded into was stopped before it read this follow-up, so it was not run. Re-send it to run it fresh.A release by a dispatch that no longer owns the slot is a no-op. The fresh turn is a request of its own (tracing.md item 18): the previous request's root has ended, the fresh one starts now withreceivedAt = now, and it carriesqueuedBehindMs— now minus the earliest follow-up's arrival — on its root and, from a minute, as the card'squeued 4m 10s behind the previous runcaption. A steered follow-up's ack and a superseded resume's close aredispatch.admissionspans; a refused follow-up is adispatch.refuse. - The inbox is durable, and admission spans generations (run-history item 40; durable-runs plan D5). With the run ledger on, every steer also writes the follow-up to the live run's durable inbox (
POST /runs/inbox: the message — text, sender, link, thread, arrival time — with its images and documents when the row stays underDURABLE_INBOX_MAX_BYTES(400 KiB; the state Worker caps the route's body at 512 KiB); over that the bytes stay with the in-memory copy and the row recordsattachmentsDropped, so the resumed run's follow-up ends with(N attachment(s) from this reply could not be carried across the bot's restart …)instead of losing them silently) BEFORE it is pushed in memory, so the ledger'sseqrides on the in-memory item and the run's next step record says how far the durable inbox has been consumed (inboxConsumedSeq). A run has its row from the reservation before the workspace attach (run-history item 42), so a steer during the attach gets its durable copy too and a kill there restarts the run with the follow-up folded in; only a steer in the moment between admission and the reservation (the ack card, the repo resolution) rides in memory alone, and a push the ledger refuses or cannot take is a warning: the follow-up rides in memory only. A resumed run inherits what lies past its last record: the reclaim hands the resume the inbox items withseq > inboxConsumedSeq, and at adopt time — right after the resume's own in-process claim — the dispatcher re-reads the durable inbox past the highest seq it knows (POST /runs/inbox/read), so a steer that landed between the reclaim's snapshot and the launch is not lost (from the claim on, steers reach the run in-process; a state Worker without the route is a warning and the snapshot alone). The dispatcher pushes them into the resumed run's inbox on the resume's channel handle, and the runner folds them in at its first boundary exactly like item 2 (or item 3 when they supersede its first answer) — no second ack: the admitting generation already sent it. An item whose stored shape this build cannot read is skipped with a warning, never fatal. The boot gap: a follow-up on a thread whose live run is on the ledger but not in this process — a row another generation still holds (a rollout overlap, a dead generation whose lease has not expired), or one this generation reclaimed and has not yet launched — must not start a rival run. The reclaim sweep keeps a map of those threads (ThreadsElsewhere, replaced whole on every pass: rows live elsewhere plus the rows just taken for the launcher); when in-process admission finds the thread free, the dispatcher consults it and, under the same gates as item 1 (the live agent's allowlist, no agent switch), releases the slot it had just claimed before any round trip (a resume launching that instant must find the thread free, not a rival that closes its row), writes the follow-up to that run's durable inbox, and acks with item 1's steer reply without a run-page link (the link's token is the other generation's). If the run was launched here during the round trip, the item is handed to it in memory as well, under its ledger seq (the resume's dispatch names its run id on the slot at the claim, BEFORE the re-read — the registry row is created at the reservation, after the re-read — so a push landing after the re-read finds the run it belongs to); the inbox folds one seq in once (FollowUpInboxignores a seq it has seen, pending or drained), so the re-read and the hand-off cannot double it. A row whose meta names no agent is not steered into (the no-agent-switch gate cannot be judged): the message runs fresh. The in-process steer has the same care: after its own durable push it re-checks that the run still holds the thread — a run that finished during the round trip would leave the item on a dead slot with its durable copy gone — and runs the follow-up fresh instead (item 4's rule, taken early). A push the ledger refuses means the row is gone — the map is stale by at most one sweep interval — so the thread is forgotten and the message runs fresh. Staleness the other way (a row claimed after the last pass) starts a fresh run as before; the old row is then closedinterruptedwhen its resume finds the newer run (run-history item 38). At most one follow-up can be folded in twice: one that rode a step whose turns landed but whose record did not (therun-step-freshresume) — the model sees the same text again, which is harmless.
Validation criteria
| Criterion | Proof |
|---|---|
| Claim/release: first claim starts, a second on the same thread sees the SAME live slot; other threads independent; release frees the slot and returns the unconsumed inputs; a stale release is a no-op | [unit] src/core/threadAdmission.test.ts::ThreadAdmission — claim and release::* |
Decision: a bare follow-up steers whatever agent is live (review and ship included); a same-agent follow-up steers; a different agent is the ONLY refusal (agent_mismatch, carrying the requested agent) | [unit] src/core/threadAdmission.test.ts::decideFollowUp::* |
Replies name the agent, elapsed time and the live-run URL bare (no </>, no dangling separator or link before registration); the prompt block and snippet shapes; unconsumed inputs merge into one request keyed to the most recent sender | [unit] src/core/threadAdmission.test.ts::replies::*, ::followUpPrompt / followUpSnippet::*, ::mergeFollowUps…::* |
Run page: the first input is the Request; every later input is a ↪ Follow-up block in the timeline where the run read it (after the step in flight), with the sender linked to the message, the time and the markdown text; the follow_up note is not a row; every other note still is; never a second request block and no copy of the follow-up under the request | [unit] web/src/lib/runPageModel.test.ts::a later input is a steered follow-up: its own timeline block where the run read it…, ::every other run note is still a row…, web/src/pages/runPage.test.ts::RunPage — history mode::keeps the first input as the Request and renders later inputs as follow-up blocks in the timeline… |
Runner: a follow-up pushed during a tool step rides on that step's results turn after the results with the header; several drain as one text part; images ride as image parts; each input recorded as input + follow_up note; consumed exactly once | [unit] src/runner.test.ts::follow-up inbox…::a follow-up pushed during a tool step…, ::two follow-ups drained together…, ::a follow-up's images… |
Runner: a follow-up during the final answer supersedes it (answer → assistant event, follow-up → next user turn with the superseded header — "NOT delivered", "ONE complete answer" — loop continues); a tool-turn drain keeps the plain header; at the budget the answer stands and the inbox is left unconsumed; a soft stop leaves it unconsumed; no inbox ⇒ byte-identical requests | [unit] src/runner.test.ts::follow-up inbox…::a follow-up that lands while the model wrote its final answer…, ::a follow-up riding a tool turn does NOT carry the superseded wording…, ::when the budget allows no further step…, ::a soft stop leaves a pending follow-up unconsumed…, ::without an inbox…; src/core/threadAdmission.test.ts::followUpPrompt / followUpSnippet::superseded… |
Dispatcher: a thread reply during a live run → no second run, no card, the steer ack with the run link, the follow-up in the live run's next request, input + follow_up on the record, slot released at the end | [unit] src/core/dispatcher.test.ts::thread admission…::a thread reply while a run is in flight is steered… |
| Dispatcher: a different-agent follow-up is refused with the pointer and nothing is folded in | [unit] src/core/dispatcher.test.ts::thread admission…::a follow-up naming a DIFFERENT agent is refused… |
Dispatcher: a re-review sent into a LIVE review run is steered — one run, no second card, the ack names review, the follow-up rides the next model turn, one review posted carrying the answer that heard it | [unit] src/core/dispatcher.test.ts::thread admission…::a re-review sent into a LIVE review run is folded in… |
Dispatcher: a thread reply during a ship run is steered into the child round in flight — the ack names ship, no second card, the coding child reads it on its next turn, both child rounds run on the same inbox object, the follow-up is an input on the one run record | [unit] src/core/dispatcher.test.ts::agent:ship (pipeline)::a thread reply during a ship run is folded into the live child round… |
Dispatcher: registry commands (help) answer inline during a live run, never steered | [unit] src/core/dispatcher.test.ts::thread admission…::registry commands still answer inline… |
| Dispatcher: a follow-up from a sender not allowed to run the live agent gets the allowlist refusal and is not folded in | [unit] src/core/dispatcher.test.ts::thread admission…::the follow-up's sender must be allowed to run the live agent… |
| Dispatcher: unconsumed follow-ups after a run that ended by itself run as ONE fresh turn on the most recent sender's handle and identity (two follow-ups merge); after an operator stop they are not run and each sender gets the ⛔ note | [unit] src/core/dispatcher.test.ts::thread admission…::what the run never consumed runs as a fresh turn…, ::two follow-ups during one run…, ::after an operator stop… |
Item 4: the fresh turn's root starts after the first request's ended and carries queuedBehindMs; the steer ack is a dispatch.admission span | [unit] src/core/dispatcher.test.ts::no gaps: every awaited step runs inside a span (docs/reference/specs/tracing.md)::a fresh turn for unconsumed follow-ups is a request of its own… |
Item 5: a steered follow-up is written to the durable inbox with its seq before the in-memory push, and the next step record's inboxConsumedSeq says the run consumed it; the record's events carry the input | [unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::a steered follow-up is written to the run's durable inbox with its seq, and the next step record says the run consumed it (thread-admission item 5, run-history item 40) |
| Item 5: a follow-up on a thread live elsewhere is steered into that run's durable inbox and acked without a link, no card and no run here; a refused push forgets the thread and runs the message fresh | [unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::a follow-up on a thread whose live run is on the ledger but not in this process (the boot gap) is steered into that run's durable inbox and acked — no card, no second run; a row the ledger no longer has means a fresh run and the thread is forgotten |
| Item 5: a steer whose run finished during the durable push runs the follow-up fresh — no ack, no dead-slot item | [unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::a steer whose run finished during the durable push does not land on a dead slot: the follow-up runs fresh instead (review F2) |
| Item 5: the boot-gap steer holds no slot across the round trip; a resume that claims the thread meanwhile names its run on the slot at the claim, so a push landing after its re-read is handed to it in memory and folded in once | [unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::the boot-gap steer holds no slot across the ledger round trip; a resume that claims the thread meanwhile names its run id at the claim, so a push landing after its re-read reaches it in memory — folded in once (review F1's window at the launch edge) |
| Item 5: a follow-up steered into a run that was killed while attaching its workspace (run-history item 42) is carried by the row and folded into the restarted run with no second ack | [unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::a run reserved at admission whose owner died is restarted under its own id (item 42): the request is dispatched again from the row, the card is the row's, the follow-ups steered in meanwhile are folded in with no second ack, this generation promotes the row and the finish closes it — the record keeps the original start |
| Item 5: a row live elsewhere with no agent is not steered into | [unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::a row live elsewhere whose meta names no agent is not steered into — the no-agent-switch gate cannot be judged, so the message runs fresh (review F3) |
| Item 5: one durable seq folds in once — a second push with a seen seq (pending or drained) is ignored; seq-less items never are | [unit] src/core/threadAdmission.test.ts::FollowUpInbox — one durable follow-up folds in once (thread-admission item 5)::a second push with a ledger seq the inbox has seen — pending or already drained — is ignored; items without a seq are never deduped |
Item 5: the map is replaced whole each sweep; a listed thread answers with its run, a dropped one is forgotten, forget takes one out early | [unit] src/core/runLedger/threadsElsewhere.test.ts::ThreadsElsewhere — the threads whose live run is on the ledger but not in this process (thread-admission item 5)::replace() is the whole truth each sweep: a listed thread answers with its run, a dropped one is forgotten, forget() takes one out early |
| Item 5: a resumed run inherits the inbox past its last record, folds it in at its first boundary as inputs, and sends no second ack; an unreadable item is skipped with a warning | [unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::a resumed run inherits the follow-ups the durable inbox holds past its last record (item 40) — the reclaim's snapshot plus what landed before the launch, re-read at adopt: folded in at its first boundary, recorded as inputs; the ack was the admitting generation's, none is sent again |
| Item 5: the runner's step report carries the highest ledger seq drained so far; 0 before any; a follow-up without a seq leaves it; a resume starts at its last record's | [unit] src/runner.test.ts::follow-up inbox (docs/reference/specs/thread-admission.md)::the step report carries the highest ledger seq drained so far (run-history item 40): 0 before any, unchanged by a follow-up the ledger never saw, src/runner.test.ts::follow-up inbox (docs/reference/specs/thread-admission.md)::a resumed run's counter starts at its last record's inbox seq |
| Item 5: live — a follow-up posted in the boot gap (between a kill and the resume) is folded into the resumed run, not run afresh | [agent] Kill the bot mid-run (POST /admin/crash), post a plain reply in the thread before the new generation's /healthz answers, then check the resumed run's page shows the reply as a ↪ FOLLOW-UP block and the thread got ONE answer. |
Live: in a Slack thread, @switchboard agent:coding in <repo>: <task>, then a plain reply while the card spins → exactly one card, the reply gets ↪ Folded into the *coding* run…, the run page shows the follow-up as its own block in the timeline (no follow-up folded in row), and the final answer/PR reflects both messages | [agent] Post the two messages ~30 s apart against a warm resident repo; capture the thread (one card), /runs/<id> (two input events), and the PR diff. Negative path: reply @switchboard agent:review <pr> in the same live thread → the ⏳ … one run per thread refusal and still one card. Receipt → the receipts issue. |
Live, review: @switchboard agent:review <pr>, then while the review card spins @switchboard agent:review <pr> — re-review: <what changed> (or a plain "also check X") → the reply gets ↪ Folded into the *review* run…, still one card, the run page shows the follow-up as a block in the timeline, and the ONE posted review reflects it (a moved head is settled at the new head per agent-review item 12) | [agent] Post both against a warm resident repo; capture the thread (one card, the ack), /runs/<id> (two input events), and the PR's single review comment. Receipt → the receipts issue. |
Live, spam: five plain replies posted as fast as possible (≤2 s apart) into a live run's thread → five ↪ Folded into… acks, still ONE card and one run, the run page shows five ↪ Follow-up blocks in the timeline in arrival order, and the thread's single final answer addresses every one of the five (including any that landed on the final answer — the superseded header, item 3) | [agent] Start an agent:research run with a question that takes several steps, then post five distinct follow-ups within ~10 s; capture the thread (acks, one card, one answer covering all five), /runs/<id> (five input events as blocks, no follow-up folded in rows, no second request). Receipt → the receipts issue. |