Skip to content

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; FollowUpInput.from, the run that sent a follow-up — item 7), src/core/threadEvents.ts (the one rendering of another sender's words — the attributed line and the attributed join, shared by the steer prompt, the fresh turn's merge and the unit-thread fold — items 3, 4 and 9), src/core/dispatch/lineage.ts (item 6: a reply in a spawned thread is the child's, and the live parent hears it), 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; adoptCarriedRun and foldCarriedInbox for a resumed or restarted run's row and durable inbox; steerRun, a parent run's steer through the same inbox — item 7), src/core/runLedger/inboxMessage.ts (fromRunId on the durable row), src/core/dispatch/settle.ts (the settle stage: settleThread frees the slot in dispatch()'s outer finally and decides — quiet, dropped after a stop, handed on, a run's steer set aside; tellDropped sends each dropped sender the ⛔ note; prepareFreshTurn builds 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/core/harness/pi/harness.ts (drains the inbox on every event and tick and steers it into pi), src/core/shipPipeline.ts (hands the thread's inbox to every child round), src/core/types.ts (ChannelIO.openThread, OpenedThread — item 6), src/core/dispatch/spawn.ts (a child dispatched on the thread its parent's channel opened), src/channels/slack.ts (SlackIO.openThread, outbound source ids and the relay-source fence), src/channels/slack/requester.ts (the relay footer's source message id), src/channels/slack/dedupe.ts (bounded bot-source memory), src/cli.ts (ConsoleIO.openThread), src/channels/web.ts (WebIO.openThread — item 6), src/core/nullChannelIo.ts
  • Docs: How a request flows
  • Tests: src/core/threadAdmission.test.ts, src/core/threadEvents.test.ts, src/core/runLedger/threadsElsewhere.test.ts, src/core/harness/pi/harness.test.ts (a follow-up steered into pi), src/core/dispatch/admission.test.ts (one outcome per branch of admit; the carried run's row and inbox; steerRun — item 7), src/core/dispatch/settle.test.ts (one settlement per branch; the fresh turn's request; a run's steer set aside), src/tools/runs.test.ts (send_to_run), src/core/dispatcher.test.ts (thread admission, run ledger write-through — what the thread sees on each branch, end to end; agent:conductor — a child on its own thread), src/core/dispatch/spawn.test.ts, src/channels/slack.test.ts, src/cli.test.ts, src/channels/web.test.ts (item 6: WebIO.openThread), src/core/nullChannelIo.test.ts (item 6: the null channel's openThread)

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 ​

  1. One live run per thread, decided before anything slow. On Slack, an unmentioned plain reply reaches admission at all only when the intake gate admitted it as addressed (slack-channel.md item 15, record 0058); a silent verdict never dispatches, so it never claims. 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. With routing.operator: off or shadow, the chat-command fast path (help, runs stop, config …) runs before the claim and answers inline during a live run. Under on, the operator binds the command; a live owner may fold that decision into its run before the registry can execute it. 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, at verbose and above (routing-and-config.md item 28 — an acknowledgement, sent through replyAck; at quiet, the default, the steer is silent and the run's answer is what the thread hears), 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 run-page URL stays bare so every channel can render the same acknowledgement; Slack auto-links it. The Slack adapter also supports valid native HTTP(S) <url|label> links through URL encoding and label escaping (slack-channel.md item 4). 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:review in 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 hears agent: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's children are runs of their own, steered like any run. The plan runner's children are ordinary dispatch() runs in a thread of the unit's own (agent-ship.md item 16; a task string's in the requesting thread), so a reply in that thread while a child is live is steered into that child through this item — a "make sure X" during a review round becomes a finding the next fix round answers — and a plain reply between rounds is the unfinished same-thread unit's event; only a thread with no remaining owner routes as a fresh request. The ship run itself stays live for the pipeline's whole life once the hand-off is taken (record 0060) while occupying no thread: its ledger row sits under the host key and the coordinator's spawn check (liveOnThread) skips hosted views, so a one-unit task's child is admitted and its ledger claim accepted in the requesting thread beside the live parent; the dispatch's own admission slot is released once the hand-off answered as for any run, and a reply in the requesting thread belongs to a live child there, then an unfinished unit naming that thread, or otherwise meets the hosted parent's seed-thread guard while the parent remains live.
    • 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. MCP follow-ups also carry the exact approval connection, request hash and consent mode through admission and durable replay. An admitted follow-up carries its sender whole — the id, the display name, and the credential behind them (authenticatedAs, and postedBy for a relayed app — authorization.md items 14 and 15) — on the in-memory item exactly as on the durable row, so whatever the follow-up later becomes (the fresh turn of item 4) is gated on the actor they make, never the bare user id.
    • Under the operator the decision, not the slot, says what a reply is (routing-and-config.md item 29; the one-door plan's admission unit). With routing.operator: on the dispatcher executes the operator's decision before any fold. A plain reply's steer bind resolves its target from the thread's live owner at execution — the current child, never an ended run id copied from the transcript — while an explicitly typed steer run <id> … keeps the id the person named and may target another thread. steer.run's wired sender (createSteerSender) applies the steer owner rule (authorization.md item 16a) and the same live-agent allowlist, with no confirm-class hand-back; an ended target fails the command run once with run <id> ended at <time>; nothing to steer, and emits no redispatch or inbox item. A named steer into a run held at a deferred checkpoint is saved under the same owner, but its receipt says at every chat verbosity that storage recovery is pending and the message does not resume the run. The sender carries a typed recovery-hold result into the command renderer; ordinary steer acknowledgements retain an empty quiet projection. A retained live row alone does not prove an active producer. Every other bind runs as its own work beside the live run, so a new ask while units are live opens work of its own instead of folding into the wrong run. decideFollowUp reads the decision over the directive where one accompanies a follow-up: a steer that names the live run folds in whatever agent token the message carried. A Slack post records its native source id; if a configured relay loops that bot-authored post back with a new event id, slack.receive drops it by the footer's source before intake or acknowledgement. Thread occupancy itself is untouched — one live run per thread, a plan's units in threads of their own (record 0055), the pipeline's parent under record 0060's host key — and under shadow and off the fold is exactly the steer above.
  2. Steering lands at pi's next turn boundary, never mid-turn. The harness drains the inbox on every event and tick while the loop is live and steers what it drained into pi as ONE follow-up prompt (followUpPrompt: ↪ Follow-up from the thread, sent while you were working … with the texts, each attributed to its sender (item 3's renderer), bulleted when several, the inputs' images as pi's image blocks; a follow-up carrying staged files is staged first and the steer's text ends with the attachments line — execution.md item 20, harness-pi.md item 6), and pi reads a steer at its next turn boundary — after the tool results of the turn in flight, before the next model call. A NAMED steer (steer.run, the operator's bind or the typed line) rides the same inbox and lands at the same boundary: one drain, one item shape, whichever thread the words were typed in. Each drained input is recorded on the run's event stream as an input event (text, sender, message link) and a follow_up run note (follow-up folded in: <snippet>), so the run page and the card show it; the steer's text is the next step's user turn on the ledger (harness-pi.md item 8). On the run page it is ONE run with several inputs: the first input is the REQUEST block; every later input is its own ↪ FOLLOW-UP block 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. The follow_up note 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 model call already in flight is never aborted for a follow-up: retries over side effects that already happened are the failure mode this avoids. The plane's steers ride this same inbox (record 0064; run-history.md item 40): the checkpoint steer and a parked turn's reissue are run_inbox rows the ledger object writes itself, sender plane, read at the same boundary as any follow-up — which is exactly why a stalled run hears the plane without anything aborting its call in flight.
  3. A follow-up that lands while pi writes its final answer is pi's to read. A steer sent while pi is still processing is delivered at its next turn boundary, so a follow-up that lands during the model's last turn becomes the next user turn and the loop goes on — pi's own rule, the steer's header the plain one. Either header's batch names its senders: followUpPrompt renders every drained follow-up as the attributed line <sender>: <text> (the display name first, the id as the fallback) through the one renderer (attributedText, src/core/threadEvents.ts), so a superseded batch from two people says who asked for what. Whatever the harness had not drained when pi settled stays in the inbox unconsumed for item 4, as does a follow-up that lands after a budget or a stop ended the loop (the harness steers nothing once the write-up is on: the write-up is the loop's last turn). Without an inbox, or with an empty one, pi is sent exactly the prompts it would be sent otherwise.
  4. 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: each text attributed to its sender and joined in arrival order (foldThreadEvents, the fold's renderer), attachments concatenated, run on the FIRST sender's channel handle and under their identity, credential included — the fresh turn's requester (record 0062: the first unconsumed sender asked for what runs next, and a later sender's wider grants admit nothing) — an ordinary dispatch() that claims the thread itself and gates on that requester, refusing a first sender the addressed agent's allowlist excludes with the allowlist refusal. When settlement combines more than one follow-up, mergedFollowUps marks the combined text as context without shared registry command authority. The marker survives durable replay. A single follow-up retains its own command authority and approval binding. Inputs with an original durable owner are kept separate. Original organization-owned inputs retain their admitted identities, exact input and channel handle. Each continuation rechecks current source proof; unavailable proof holds its unread input without consuming it or creating another owner. An original-unit recovery child also keeps its original owner: an unconsumed follow-up is appended durably to that original unit and no fresh turn runs, because its follow-up belongs to that existing action and must not become a distinct recovery request. A run an operator stopped (soft or hard) does not restart — the stop during the run, or the one that ended the run's attach before its loop started, the request ending stopped either way (execution.md item 9); a stop relayed during an attach that then refused stopped nothing, and the follow-ups run fresh — : 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 run the harness failed by name asked for no stop — it ended its loop through its own connection — so its unconsumed follow-ups are handed on as the fresh turn like those of a run that ended by itself; only the control's recorded stop, an operator's, drops them. 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 with receivedAt = now, and it carries queuedBehindMs — now minus the earliest follow-up's arrival — on its root and, from a minute, as the card's queued 4m 10s behind the previous run caption. A steered follow-up's ack and a superseded resume's close are dispatch.admission spans; a refused follow-up is a dispatch.refuse.
  5. 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 under DURABLE_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 records attachmentsDropped, 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's seq rides 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 confirmed missing target can retain the existing in-memory fallback. Storage failure, an invalid message, an unclear acknowledgement or an older server's unqualified rejection stops delivery; it cannot select the in-memory fallback. A resumed run inherits what lies past its last record: the reclaim hands the resume the inbox items with seq > 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 (FollowUpInbox ignores 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). Only an explicit not-live rejection confirms target absence: the map is stale, so the thread is forgotten and the request can run fresh through existing routing. A failed or unclear write retains the known owner and does not run fresh. A run this generation adopted is not "elsewhere": the moment a resume or restart takes up its row (adoptCarriedRun) the thread is forgotten on the map — in-process admission holds the thread from the claim on, and nothing else would take the entry out, since a sweep with nothing to reclaim never replaces the map; before this the entry outlived the run, and a request on the thread after the run closed — its own restart from its request, after its container was replaced (harness-pi.md item 16) — was steered into the closing row's inbox and vanished with it (seen live on 1.233.0). And a restart never steers into the row it closed: the restart from the request (run-history item 54; harness-pi item 16) names the run it restarts (DispatchOptions.restartOf, carried by prepareRestartTurn and through a redispatch), and an entry naming that run is not a live run — the dispatch that hands the request on has closed it, its finish in flight through the writer while the ledger would still take a push — so the entry is dropped and the request runs fresh in the thread, under the run's own id (run-history item 54: the restart keeps the run's identity); an entry naming another run is a live run as for any request. Staleness the other way (a row claimed after the last pass) starts a fresh run as before; the old row is then closed interrupted when 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 (the run-step-fresh resume) — the model sees the same text again, which is harmless. A unit-owned idle thread's sibling event list has the stronger indexed rule: unit-wake stores the answer, all consumed marks and any segment row in one durable write keyed by the wait id, so a reclaim reads the answer and each event enters at most one segment.
  6. A child run is its own thread (agent-conductor.md; routing-and-config.md item 20). A run spawned by another never shares its parent's thread: spawnChild() asks the parent's channel for a thread of its own — ChannelIO.openThread(lead) → { thread: { threadKey, sourceUrl? }, io } — and dispatches the child on that key, so item 1's one live run per thread holds for both: the parent keeps its slot, the child claims its own, and a reply in either thread steers into that thread's run. Every request channel opens one: Slack posts the lead top-level in the parent's channel and builds the thread IO from the posted ts (the key slack:<channel>:<ts>; slack-channel.md item 11); the CLI harness derives <parentThreadKey>/child-<n> and prefixes the child's output so two runs on one stream stay legible; the web mints a conversation id in the session's own lane (WebIO.openThread → web:<sub>:<id>, the lead's length logged — the conversation renders from the run records) and binds the child's handle to it through the same conversation reader (web-chat.md item 11; record 0060); HTTP and MCP use a job thread under the request key, with a stable hash of the coordinator unit key across retries; its replies and progress live in run records. A resumed handle rebuilds the same null channel, while unrelated child threads get random ids. A handle without openThread refuses the spawn by name (spawn_unsupported); a child never falls back to its parent's thread. And the thread stays the child's (agent-conductor.md item 10): a person's reply there steers the child while it runs, as item 1 says, and after it ended starts a new run that the dispatcher's lineage stage (src/core/dispatch/lineage.ts) makes a run of the same child — the spawning run's id on its record at depth 1, no clock inherited — and either way the parent, while it lives, hears the reply as a follow-up from that child through item 7's path (steerRun as the person who replied, from the child, the reply's link), so the parent's next wait ends follow_up and its rows for the child follow the thread's newest run. And a follow-up continues the thread's session (session-log.md item 9): after a run ended, a reply in its thread whose agent runs on the pi harness starts from that agent's session log — the log's tail within the seed budget, the lines a person wrote since that run ended, the reply — so a thread, a child's or anyone's, reads to its next run as one conversation; on the native loop the thread's history is the seed, as before.
  7. A parent steers its child through the same inbox (agent-conductor.md item 8). send_to_run is a steer by a run rather than a thread reply — steerRun (src/core/dispatch/admission.ts), beside admit: the sender is the requesting user (the parent's userId, userName, channel and thread link) and the parent run is from; the same allowlist gate as item 1 (the sender must be allowed to run the live agent — being heard by an agent counts as running it), no agent-switch gate (a steer names no agent), the durable copy first (item 5; the row carries fromRunId, so a resumed child reads the item back with from), then the slot that holds the target run NOW, matched by run id — a newer run on the same thread is never handed another run's steer; with no slot here and the ledger's copy taken, the durable inbox alone (the child lives on another generation); no slot and a refused push means the run is not live, and the caller says so by name. No ack: nothing was said in the child's thread to answer. The runner drains it exactly as item 2 says — the follow-up header, an input event whose source.run names the parent beside the requester and the link — one inbox, one drain, one item shape (followUpOf) for a person's reply and a run's steer. A run's steer is never run fresh: FollowUpInput.from marks it and it carries no channel handle (DispatchFollowUp.io is a person's), so the settle (item 4) sets it aside with a log line — neither handed on as a fresh turn nor answered with the ⛔ note after a stop — and the parent reads the child's end through await_runs; a person's follow-ups beside it settle exactly as before.
  8. A coordinator's spawn never steers (http-ingress.md item 9; run-history.md item 48). A request the coordinator's spawn route dispatches carries DispatchOptions.coordinator and is the bot's own, never a person's reply: a run in flight on the unit's thread means the step's child is already running (its row carries the same key) or another run holds the thread, and in either case the spawn's text must never land in an inbox — a retried spawn would otherwise steer the very child it meant to start, or a person's run. So admit refuses it by name, coordinator_thread_live, with nothing said in the thread (the thread has a person's run or the child itself in it, and a retry is not news), whether the live run is here or on another generation's ledger row (the slot taken for the check released, no durable push made); the refusal is the dispatch's named outcome, and the spawn route answers the coordinator from the live run's own key. A free thread is claimed for the child as for any request. The plan runner's findings step is such a spawn (agent-ship.md item 7): the review's findings dispatched into the unit thread as agent:coding meet a live run there as a person's run (the runner awaited its own child's end), are refused coordinator_thread_live the same way, and the runner asks the step again under the unit's wall clock; a review child's spawn is judged against the unit's thread too, the thread it runs in (record 0055), so a person's run there holds up the review as it holds up the findings step. Item 1 holds unchanged for people: a person's reply on the same thread still steers. And the refusal is as it is under the operator: a coordinator's spawn re-enters a decided request, which the operator never reads (routing-and-config.md item 29), so no decision can steer or displace it — coordinator_thread_live stands exactly as written here.
  9. A thread has one owner, and a unit-owned thread's reply is a thread event (record 0051 the owner, reply-as-event, fold and gone-instance rules; routing-and-config.md item 21). The dispatcher computes the thread's owner where it already decides threadLive (ownerOf, src/core/dispatch/thread.ts), from the runs page it already read and the page's instance's unit rows (an exact accepted-message retry may reread retained rows) (instanceOf: the ship run's own ship_handoff projection or a child's parentInstanceId), in this order: a non-hosted live child in the thread; the unfinished unit whose row names this thread; a hosted Ship parent only as the seed-thread guard when no unit claims this thread; an ended generated pipeline whose same-thread unit remains unfinished or needs legacy merge-ready recovery; the newest continuable session a person addressed (a coordinator's child never owns); none. An ended-thread continuation selects one original durable unit and delegates to its existing recovery controller, carrying the adapter message ID, requester and thread. The controller checks its original budget, grants, evidence, current owner and exact publication; the dispatcher neither reconstructs a task nor transfers ownership to a replacement instance. Closed refusal or uncertainty starts no substitute work. Exact recover unit and renew unit commands retain their named identity and operation with the operator on or off. A plain reply into a thread owned by an unfinished unit with no live run passes the agent gate for coding first — the event is read by the unit's next coding child, a write-identity run, so its sender must be allowed to run that agent, refused by the allowlist's own sentence before anything is appended, exactly as a live steer is — and is then appended to the unit's event list (ThreadEvent {seq, id?, sender, text, attachments?, mode, at, consumedBy?} on the state Worker's coordinator_unit_events table, a sibling of the unit rows — one row per event with a store-assigned sequence, the durable inbox's 400 KiB cap per event — attachments over it dropped whole and counted, a text still over it alone cut from the end to fit and the cut counted — and the delivery mode read off the owner's state — steer between rounds — never off the text), the instance is sent a payload-free nudge (unit-nudge-…, http-ingress.md item 9), the sender is acked with where the message went, and the router never runs. A native channel or DM reply to a pending human question, including an agent: directive, carries the complete captured question into the original unit's guarded append. The first guarded source owns that question; stale, ended or unavailable admission starts no fresh work. An exact accepted native message retry keeps its original event after consumption, clearing or ending, and cannot promote an old unguarded event. A recovered human hold records the answer without nudging or reopening execution. In a UNIT's own thread without a pending question, a directive naming an agent falls through to today's path; an operator preset bind is never a directive here — it rides the resolution as its own field (routing-and-config.md item 29), so a preset bind lands as the plain reply's unit event. The hosted parent guards its SEED thread only when no unfinished unit claims that same thread (record 0051's owner rule). It holds no admission slot, so threadLive is false there, yet ownerOf returns the hosted live parent after checking units; a plain or directive reply is then refused pipeline_thread_owned, naming the owner and the open units' threads to reply in (REFUSAL_SENTENCES.pipeline_thread_owned), never run as a rival or folded into the parent's nonexistent inbox. When the seed thread is also an unfinished unit's thread, the unit wins between child runs: a plain reply is appended as its event, while an explicit agent: directive follows the unit-thread directive rule. A nudge whose send fails asks the instance's status route (the injected reader, else the process's own shim at PUBLIC_BASE_URL with the coordinator bearer — the same address the ship branch creates instances at, so production reaches it): absent, or a status of complete, errored or terminated, can end only the exact checked unit row terminated through compareAndReplaceUnit. The message routes fresh only after that replacement is acknowledged; a changed row or an unconfirmed write keeps the saved reply with its original task and starts no replacement work; any other failure keeps the event appended and acks it as queued for the pipeline's next step. A failed or rejected append retains the original owner and returns the system refusal unit_delivery_unconfirmed. It starts no replacement work and makes no delivery claim. A lost acknowledgment may follow a committed append; this path does not retry it. Every appended event has a reader in the same release. A generated hosted ship task's accepted inline images/documents enter this same list as one internal steer seed after the generated unit row is durable and before the Workflow starts; its stable id makes append retries return the original sequence, and a task without accepted media appends no seed. The coordinator's spawn folds the unit's unconsumed events into every coding child's request, in arrival order with each text attributed to its sender (foldThreadEvents, src/core/threadEvents.ts — the ONE renderer of another sender's words, shared with the live steer's prompt and the fresh turn's merge, so no second rendering drifts) and the events' stored attachments as the child's own images and documents (foldThreadAttachments); an accepted Slack attachment's by-reference twin is promoted to the child's staging list so the artifact path writes and catalogues the workspace file. The spawn marks the events consumed by its step only after the child registers; a failure before registration leaves them for the retry, a review spawn leaves them, and a later coding spawn sees no successfully consumed seed. Events still unconsumed when the unit ends run as ONE fresh turn in the unit's thread (item 4's shape, their attachments carried the same way) and are marked consumed under the ending's identity before the dispatch, so a replayed unit-end runs nothing twice; with no channel handle to run the turn in they stay unconsumed and the log says how many were left on which unit. Under routing.operator: on the reply first passes the operator's one turn (routing-and-config.md item 29, issue 2027) when a live run or idle unit owns the thread: the turn's projection narrows to the steer, read commands and a question. An inferred read command may run beside a live run, but with an idle-unit owner it folds the whole message into that unit; a person's typed read command is bound by the operator and executed through the registry; off and shadow retain the command fast path. A decision that does not run — a refusal's prose, a preset bind, a write, or that idle-unit inferred read — folds the whole message, so the dispatch runs on to this item's append (an idle unit) or admission's fold (a live run), with no operator prose posted. An ended generated pipeline keeps its durable owner while the operator classifies the reply as continuation, an exact-PR review, or new work (routing-and-config item 29a). A typed Ship continuation delegates only to that original unit's recovery action; a review starts independently on the verified PR; an unrelated bind stops. Original-unit recovery owns authorization, historical budgets, exact evidence and duplicate action checks. A pending question's free-text answer otherwise folds JOINED onto the ask the question kept (routing-and-config.md item 29, issue 2046: <request> — <question>: <answer>), so the owner receives the whole ask, never the answer's bare words, while the owner order itself is untouched by the join (the live run, then the unfinished unit, then the ended generated pipeline, then the sticky session, as ever) (src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::on: a pending question's answer into a unit-owned idle thread folds the JOINED ask as the unit's event — the owner order unchanged, never the answer's bare words (issue 2046; thread-admission item 9)); a hosted runner's seed thread with no unfinished same-thread unit names its live owner WITHOUT a run id — no steer is offered, a steer bind folds too, and the fold runs on to this item's pipeline_thread_owned refusal, so no reply queues in the inbox the runner never drains (src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::on: a hosted pipeline runner's seed thread offers no steer and folds a steer bind…) (src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::on: the operator's prose answer into a unit-owned idle thread ends as one unit event…). A watching unit is such an owner (record 0071, mechanism three; agent-ship.md item 21): with watch-until-merge on for its repository, a unit whose pull request reached merge-ready waits registered in the merge-ready book and its row keeps no ending, so this item's owner order gives it the thread — an addressed reply is the unit's turn, appended as its event, never the router's. With the watch off, a publication-bound merge_ready unit is complete and releases the thread to ordinary routing; a legacy row without that binding retains guarded recovery.

Validation criteria ​

| Criterion | Proof | | Item 9: a hosted seed-thread refusal identifies the actual unit-thread destination, using a dashboard link when its public base is usable and retaining the known thread key when a link is unavailable. A unit with no saved thread is marked unavailable. | [unit] src/core/dispatch/reply.test.ts::unit thread destinations in refusal replies::*; src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::a directive reply in a live pipeline's seed thread is refused…, src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::on: a preset bind into a live pipeline's seed thread is refused…, ::on: a hosted pipeline runner's seed thread offers no steer and folds a steer bind… | | Until exact durable cancellation is recorded, a thread held after an unknown initial claim has no active producer. Later messages receive an explicit hold and unsaved response at every verbosity and never enter its memory-only inbox. Earlier queued messages receive the same correction. No replacement runs or durable writes start. | [unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::holds an unknown native claim through setup and follow-up* |

A new user request can release an unknown local claim only after its exact thread and channel have a canonical nonprovisional hard-stop cancellation record. Missing, provisional or foreign records keep the hold. This does not replay input, resume a coordinator, or release retained source evidence.[unit] src/core/dispatch/admission.test.ts::admit — the thread admission claim::a new request releases an unknown claim only after exact durable cancellation: %s
Item 1: a plain reply's operator steer is retargeted to the thread's live owner; a named run that has ended fails one command run with its id and end time, pushes and redispatches nothing; a configured relay looping a bot-authored Slack source is dropped before acknowledgement[unit] src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::on at verbose: a plain reply's steer resolves to the live owner, keeps its receipt, and never uses the finished transcript id, ::on: a steer to a run that ended fails its command run once…; src/core/dispatch/admission.test.ts::createSteerSender — the wired sender behind \steer.run` (the one-door plan's admission unit)::a steer whose named run ended fails once…; src/channels/slack.test.ts::receiveSlackMessage — the intake gate (docs/reference/specs/slack-channel.md item 15)::admits the source message once when a relay loops the bot's reply back into the same thread`
Item 9: an ended pipeline continuation selects one original durable unit and invokes its recovery action with the original requester, thread and adapter event identity. It does not mint a replacement task, instance, plan ID, owner or budget. The controller verifies retained evidence, exact publication and historical caps; refusal and uncertainty preserve the original owner. Exact recover/renew grammar bypasses operator reinterpretation. Unreadable history and ambiguous owners start nothing; concurrent repeats start no rival pipeline; completed publication releases the thread[unit] src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::plain continuation delegates to exact original-unit recovery without replacing its owner, ::repeated concurrent replies cannot start a second pipeline while the first verified continuation owns the thread, ::an ended thread claimed by multiple units names the ambiguity and starts no substitute work; src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::on: an ended abort card followed by plain continue binds the original durable continuation; src/core/dispatch/thread.test.ts::ownerOf and instanceOf — the thread's owner (record 0051's owner rule)::an ended ship pipeline still owns an aborted unit's thread, but multiple ended units in one thread are ambiguous
Item 9: native channel and DM question answers reserve the canonical first-winner slot; an earlier browser answer refuses a new native answer; changing the complete action, assignment, ending or stop at append stores nothing; an exact consumed and cleared retry preserves the original event without restarting an ended owner, while a human recovery hold remains held; multiple released units claiming the same accepted message refuse acknowledgement[unit] src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::reserves a native…; ::keeps a browser winner…; ::refuses a native answer…; ::acknowledges only the exact native answer…; ::stores a native answer in its recovered human hold without reopening execution; ::refuses an ambiguous accepted native message across released historical units
Item 9: answer retry identity includes an ordered digest of accepted media before the durable event cap. Dropping oversized bytes cannot make different attachments an identical retry. A staged-only digest records the reference metadata, not downloaded bytes.[unit] src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::distinguishes uncapped native answer media when the durable question event drops oversized bytes
Item 9: a plain reply naming PR #N or a PR URL cannot continue an ended pipeline bound to a different PR; it names both targets and starts nothing.[unit] src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::a plain reply naming another PR cannot reissue an ended pipeline on its old PR, ::a plain PR URL naming another PR cannot reissue an ended pipeline on its old PR
Item 9: an explicit recover unit <instanceId>:<unit> request, optionally prefixed with agent:ship, uses the same original-unit recovery action as implicit ended-thread continuation. It must come from the original requester in the original thread and reopens only that durable row at an unchanged head through its named recovery Workflow; it creates no replacement attempt, unit, branch, grant or budget[unit] src/core/dispatch/ship.test.ts::runShipBranch — the agent:ship fork hands every admitted request to the plan runner::runs explicit recovery before ordinary preflight and hands the resolved requester and thread to the deterministic coordinator operation; src/channels/adminCoordinator.test.ts::POST /admin/coordinator/recover-unit — unchanged-head original-unit recovery::refuses a different requester or thread before reading evidence or claiming ownership
Item 9: while that durable claim exists, a plain reply remains an event of the original unit but nudges and checks liveness against the claim's separate recovery Workflow id; it never mistakes the terminal original Workflow for the active continuation[unit] src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::*
Item 9 (record 0071, mechanism three): a watching unit — merge-ready, watch on, its row without an ending — owns its thread under the unfinished-unit clause; with the watch off a publication-bound merge_ready unit releases the thread, while a legacy row without that binding remains owned for guarded recovery[unit] src/core/dispatch/thread.test.ts::ownerOf and instanceOf — the thread's owner (record 0051's owner rule)::a watching unit owns its thread (record 0071, mechanism three): a merge-ready wait leaves the row without an ending, so an addressed reply is the unit's turn and never the router's
Item 9: a completed publication-bound unit releases ownership but contributes its verified pull request as the thread's target, even when the hosted run record has no PR; inconsistent binding contributes none, an older cited PR cannot displace the published one, and a newer completed run's PR supersedes it[unit] src/core/dispatch/thread.test.ts::ownerOf and instanceOf — the thread's owner (record 0051's owner rule)::a watching unit owns its thread (record 0071, mechanism three): a merge-ready wait leaves the row without an ending, so an addressed reply is the unit's turn and never the router's; src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::a task after a completed merge-ready pipeline routes through the operator as fresh ship work on its PR, ::a newer completed run's PR supersedes an older released unit PR
Item 9: the owner in order — a non-hosted live child; the unfinished unit named by the hosted ship parent's instance or a child's parentInstanceId; a hosted parent guards its seed thread when no unit claims it; an ended generated pipeline whose same-thread unit needs continuation; the sticky session after completion; none on an empty page; a coordinator's child never the owner; another thread's unit row not the owner; an unreadable unit-owner row is unavailable, never none[unit] src/core/dispatch/thread.test.ts::ownerOf and instanceOf — the thread's owner (record 0051's owner rule)::*
Item 9: when no unfinished unit claims a live pipeline's seed thread, its hosted parent holds no admission slot but guards that thread: a directive or plain reply is refused pipeline_thread_owned naming the open unit and its thread, and no rival run starts; a shared seed/unit thread follows the unfinished-unit rule instead[unit] src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::a directive reply in a live pipeline's seed thread is refused naming the unit thread — never a rival run beside the runner (issue 2010)
Item 9 (issue 2010): the same directive reply into a seed thread whose runner finished and whose every unit ended binds as today — the directive is honoured only in a thread nobody owns[unit] src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::the same directive reply into a seed thread whose runner finished binds as today — the directive is honoured only in a thread nobody owns (issue 2010)
Item 9: an operator preset bind into a unit-owned idle thread is one unit event and zero runs, and one into a live thread folds under the owner rule — the preset is the resolution's own field, never the typed intent admission reads[unit] src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::on: a preset bind into a unit-owned idle thread is one unit event and zero runs — the owner rule reads typed intent, not the operator's preset, src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::on: a preset bind into a live thread folds into the run in flight — the owner rule reads typed intent, never an agent_mismatch refusal
Item 9 (issue 2265): an inferred read bind in an idle unit's thread folds the original words into one unit wake event; a person's explicitly typed read command still runs as a command.[unit] src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::on: an inferred read bind cannot answer an action request in an idle unit's thread
Item 9 (issue 2046): a pending question's free-text answer into a unit-owned idle thread folds the JOINED ask (<request> — <question>: <answer>) as the unit's one event — the owner order unchanged, never the answer's bare words[unit] src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::on: a pending question's answer into a unit-owned idle thread folds the JOINED ask as the unit's event — the owner order unchanged, never the answer's bare words (issue 2046; thread-admission item 9)
Item 9: a plain reply into a unit-owned thread appends one steer event, nudges once, acks, calls no router and starts no run; a sender who may not run coding is refused by the allowlist before anything is appended; a failed or rejected append retains the owner and refuses delivery without starting replacement work; over-cap attachments recorded dropped; agent:<preset> runs today's path; a merged or publication-bound merge-ready unit routes fresh while other ended generated units remain pipeline-owned and re-issue their original task; the relay's no-instance ends the exact checked row terminated and routes fresh only after the replacement is acknowledged; without an injected reader the status is read off the process's own shim; a 502 keeps the event and acks it as queued[unit] src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::*
Item 9: the fold — a coding spawn carries two senders' events attributed in arrival order with their attachments as its images and documents and promotes an accepted Slack file's source into staging; the staged file, prompt line and artifact catalogue occur once on replay; events are marked consumed by the step only after registration, so a pre-registration failure leaves the hosted seed for retry and round two sees none after consumption; a review spawn leaves events unconsumed; leftovers at a final ending run once as one fresh turn, or stay unconsumed and logged by count when no channel handle exists[unit] src/channels/adminCoordinator.test.ts::the fold — a unit's thread events reach the pipeline's next step (record 0051's fold rule)::*, src/core/coordinator/handOff.test.ts::handOffToCoordinator — the ship request as a plan runner instance (item 16)::a generated task persists its accepted inline media as one retry-stable seed event after the unit row and before the Workflow starts; an over-cap seed says what was dropped
Item 9: the attributed join — the shared renderer, its senderName ?? sender fallback and its dropped-attachments note[unit] src/core/threadEvents.test.ts::foldThreadEvents — the attributed join of a unit's thread events::*, src/core/threadEvents.test.ts::attributedText — the one rendering of another sender's words::*
Item 9: stored attachments become one message's images and documents in arrival order, with an accepted file's by-reference source promoted to its staging list[unit] src/core/dispatch/admission.test.ts::foldThreadAttachments — the stored attachments as one message's images and documents::*
Items 1 and 9 (record 0069, as amended): the owner rule and the pending question are state read before the operator's loop — a reply into a thread a live run holds folds through admission's inbox and never starts a rival, and a parked question's answer rebinds the original request joined with the question, never a bare fragment[unit] src/core/dispatch/admission.test.ts::the owner rule and the pending question are read before the loop::*
Item 9: the event store — append assigns sequences and caps per event (attachments dropped whole, a text alone over the cap cut and counted, a caller's consumedBy never stored), a repeated stable id returns its original sequence without a duplicate row, list filters unconsumed, mark-consumed is idempotent, a put of the unit row leaves the events untouched, both stores agree[unit] src/core/coordinator/instanceStore.test.ts::*::appendEvent assigns sequences in order and caps per event; listEvents filters unconsumed; markConsumed is idempotent; a put of the unit row leaves the events untouched, deploy/cloudflare-memory/runLedger.test.ts::run ledger — the coordinator's unit events (record 0051's reply-as-event rule)::append assigns sequences in order and caps per event; list filters unconsumed; mark-consumed is idempotent; a put of the unit row leaves the events untouched
Item 1: a hosted ship parent occupies no thread — the coordinator's spawn admits a one-unit task's coding child into the requesting thread beside the live hosted parent (no busy), and the child's own ledger claim on the thread is accepted[unit] src/channels/adminCoordinator.test.ts::POST /admin/coordinator/spawn — the child as the parent record's requester (item 9)::a hosted parent live in the requesting thread…
Item 1: under on the decision runs before admission — an explicit bind of steer typed in an unowned thread folds into the named run's thread; a plain reply in an owned thread resolves to that live owner; a member's steer is refused by the owner rule; an ended target fails once and never redispatches[unit] src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::on at quiet: a steer bind folds into the named run without a routine receipt or hand-back, and the plan thread starts no rival run, ::on at verbose: a plain reply's steer resolves to the live owner, keeps its receipt, and never uses the finished transcript id, ::on: a member's steer bind into the requester's run is refused…, ::on: a steer to a run that ended fails its command run once…
Item 1: decideFollowUp reads the decision over the directive — a steer naming the live run folds in whatever agent token rode the message; one naming another run leaves the directive gate to decide[unit] src/core/threadAdmission.test.ts::decideFollowUp::under the operator the decision outranks the directive…, ::a decision that steers ANOTHER run never folds here by its say-so…
Item 1: the wired sender folds by run id into another thread's run, refuses a non-requester with nothing pushed, and fails an ended target once with its id and end time without pushing or redispatching[unit] src/core/dispatch/admission.test.ts::createSteerSender — the wired sender behind…::*
Item 1: a named steer into a checkpoint-held run reports the recovery hold after its inbox write, with or without a confirmed handoff; a recorded resume restores the normal receipt[unit] src/core/dispatch/admission.test.ts::createSteerSender — the wired sender behind \steer.run` (the one-door plan's admission unit)::reports a checkpoint hold after saving a named steer (handoff confirmed: %s)`
Item 1: a successful typed or operator-bound steer at quiet verbosity displays the checkpoint-recovery warning, saves the message under the original owner and starts no model work; routine steer acknowledgements stay silent[unit] src/core/dispatcher.test.ts::the operator behind routing.operator (record 0057; routing-and-config item 29)::at quiet: %s steering reports a saved message awaiting checkpoint recovery; ::on at quiet: a steer bind folds into the named run without a routine receipt or hand-back, and the plan thread starts no rival run
Item 1: an operator-bound steer from the verified requester in their own DM retains its direct-audience stamp through command invocation and the durable inbox; indirect or cross-thread steers cannot manufacture that stamp, and publication still rechecks Slack[unit] src/core/dispatch/admission.test.ts::createSteerSender — the wired sender behind…::an operator-bound same-requester DM steer keeps direct provenance through the command and durable inbox, src/core/dispatch/privateAudience.test.ts::private audience publication gate::keeps only a direct follow-up from the same requester eligible for private reads
Item 7: a run's steer passes only where its lineage names the target — its instance's run in another thread, its own child, its own parent — and is refused fail-closed outside them with nothing pushed[unit] src/core/dispatch/admission.test.ts::steerRun — the owner rule for a run's steer (authorization item 16a; the one-door plan's admission unit)::*
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, each follow-up attributed to its sender (item 3); unconsumed inputs merge into one request keyed to the FIRST sender, credential included, each text attributed[unit] src/core/threadAdmission.test.ts::replies::*, ::followUpPrompt / followUpSnippet::*, ::mergeFollowUps…::*
Item 1: an admitted follow-up carries its sender's credential and relay (authenticatedAs, postedBy) on the in-memory item as on the durable row[unit] src/core/dispatch/admission.test.ts::steerRun — a run steers a live run through the inbox a thread reply takes::the in-memory item carries the sender's bound credential (authenticatedAs)…, ::a relayed sender (postedBy) naming an admin is refused the restricted live agent…
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…
The harness: a follow-up is steered into pi with the loop's follow-up prompt, recorded as input + follow_up note; several drain as one steer; images ride as image blocks; a steer's text is the next step's user turn on the ledger[unit] src/core/harness/pi/harness.test.ts::runPiHarness — a run on pi from the first file to the answer::a thread follow-up is steered into pi with the loop's follow-up prompt, recorded as an input and a follow_up note; the ledger counts it consumed once pi echoes the steer, not before, src/core/harness/pi/harness.test.ts::runPiHarness — after a bot restart::a steer's text the dead generation read but never wrote is the next step's user turn, not mistaken for the seed's echo; the continue prompt's own echo is a turn the model was told
A follow-up's staged files (execution.md item 20): the reference rides the input, the fresh turn concatenates them, and a steer into the live run is staged over the run's executor before the steer is sent, whose text ends with the attachments line[unit] src/core/threadAdmission.test.ts::mergeFollowUps — unconsumed inputs become ONE fresh request::texts join in order; attachments and staged references concatenate…, src/core/dispatch/runLoop.test.ts::the pi harness — every preset's runs, in the run's container::a steered follow-up's staged file is copied and pulled before pi reads the steer…
A follow-up during the run reaches pi at its next turn boundary and the run goes on with it; what the run never consumed runs as a fresh turn after the run ends by itself; after an operator stop it is not run and its sender is told[unit] src/core/dispatcher.test.ts::thread admission (docs/reference/specs/thread-admission.md)::a thread reply while a run is in flight is steered: no second run, the follow-up reaches the live run at its next step, the reply says where it went, ::what the run never consumed runs as a fresh turn when the run ends by itself (here: it failed before its next step), ::after an operator stop, an unconsumed follow-up is not run — its sender is told the run was stopped before reading it, src/core/dispatcher.test.ts::executor provisioning by agent resources::a follow-up queued during the first attach's wait is dropped with the ⛔ note when the stop ends the attach…; src/core/threadAdmission.test.ts::followUpPrompt / followUpSnippet::superseded…
An original-unit recovery child's unconsumed follow-up is retained as an event on that original unit and never dispatched as a fresh replacement turn[unit] src/core/dispatcher.test.ts::thread admission (docs/reference/specs/thread-admission.md)::an unconsumed recovery follow-up stays on the original unit and never becomes a fresh replacement turn
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: 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 FIRST sender's handle and identity, each text attributed (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: a fresh turn whose FIRST sender may not run the addressed agent is refused with the allowlist refusal — a later sender's grants admit nothing[unit] src/core/dispatcher.test.ts::thread admission…::a fresh turn whose FIRST sender may not run the addressed agent is refused with the allowlist refusal — the second sender's grants do not admit it
Item 4: a setup ownership fence leaves sequenced follow-ups in the successor's durable inbox and tells each sender whose follow-up was only local that it was not saved; neither copy starts fresh work from the old owner[unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::a review fenced during … leaves the card and row to the new owner, ::a review fenced after a pre-reservation follow-up tells its sender it was not saved
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: an idle unit's indexed wake folds every unconsumed event before deciding sender authority, stores its typed answer and event marks together; replay writes nothing and the attributed events enter one segment[unit] src/channels/adminCoordinator.test.ts::the plan runner's steps — plan, unit-start, branch, round, unit-end, finish (item 9)::unit-wake folds every sender before deciding…
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: a retry pause stays mapped only after a fresh ledger read confirms its row is still owned; a concurrent hard stop that sealed it clears only that run's mapping, leaving any newer thread owner intact[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)::retains a paused retry but forgets its owner when the pause sealed a hard stop
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 run this generation adopted is not elsewhere — the adopt of a resumed or restarted row forgets the thread on the map; a fresh request leaves the map alone[unit] src/core/dispatch/admission.test.ts::adoptCarriedRun — taking up a resumed or restarted run's row::a run this generation adopted is not elsewhere…
Item 5: a restart from the request never steers into the row it closed — an entry naming the run it restarts is dropped and the request proceeds fresh with nothing pushed, the same message without restartOf still steers, an entry naming another run steers as for any request; prepareRestartTurn names the run on the dispatch options[unit] src/core/dispatch/admission.test.ts::admit — the thread admission claim::a restart from the request is never steered into the row it closed…, ::an entry naming ANOTHER run than the one a restart restarts…, src/core/dispatch/reattach.test.ts::prepareRestartTurn…::names the run it restarts on the dispatch options…
Item 5: end to end through dispatch() — a relaunch refused by name restarts from the request, and with the map naming the replaced run and the row's finish landing after the restart began, the request runs under the same run id, nothing is pushed into the closed row and the map forgets it; and the roll order every deploy has — the row handed to the next generation, resumed there with pi alive, the map forgetting the thread at the adopt, the container then replaced under the living generation and pi relaunched there, one run from the request to the answer[unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::a relaunch whose re-attach the resident refuses…, ::the roll order every deploy has…
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 mirror'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/core/harness/pi/harness.test.ts::runPiHarness — a run on pi from the first file to the answer::a thread follow-up is steered into pi with the loop's follow-up prompt, recorded as an input and a follow_up note; the ledger counts it consumed once pi echoes the steer, not before, 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…
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.
Item 6: openThread on the request channels — Slack posts the lead top-level (no thread_ts) and keys the thread by the posted ts, the returned IO replying under it, the permalink as sourceUrl; the CLI derives <thread>/child-<n> and prefixes the child's lines; the web mints a conversation in the session's lane (web:<sub>:<id>, two calls two ids) and binds the child's handle to it; the null channel uses random ids or a stable coordinator key and logs the lead's length[unit] src/channels/slack.test.ts::SlackIO.openThread (docs/reference/specs/slack-channel.md item 11)::*, src/cli.test.ts::askExitCode — what the \ask` process exits with (the ConsoleIO channel's receipt)::ConsoleIO.openThread derives the child's key…, src/channels/web.test.ts::the web handle rebuilt from a bare thread key — threadIoFor's web: arm (record 0060; web-chat item 11)::openThread(lead) mints a conversation in the same lane…, src/core/nullChannelIo.test.ts::nullChannelIO::openThread hands back distinct keys…, ::opens a stable, separate job thread for each coordinator unit across rebuilt handles, src/channels/http.test.ts::HttpIO (single-shot ChannelIO)::opens stable coordinator job threads when bound to a request thread`
Item 6: a child is dispatched on the opened thread's key and channel — its replies land there, never in the parent's thread — and a channel whose IO cannot open a thread refuses spawn_unsupported naming the channel[unit] src/core/dispatch/spawn.test.ts::spawnChild — the one path a child run is born through::the child's channel is the opened thread's…, ::a channel whose IO cannot open a thread refuses \spawn_unsupported`…`
Item 6: end to end — the parent holds its own slot while the child runs on a slot of its own, both freed when each ends[unit] src/core/dispatcher.test.ts::agent:conductor — a run that spawns child runs through dispatch()::spawns a research child as the requester in a thread of its own…
Item 6: a person's reply in a child's thread after the child ended is a run of the same child (the parent's id on its record, no parent clock) and the live parent hears it as a follow-up from that child through item 7's path; an ended parent is told nothing[unit] src/core/dispatcher.test.ts::agent:conductor — a run that spawns child runs through dispatch()::a person's reply in a spawned thread after the child ended…, ::a person's reply in a child's thread while the parent awaits…
Item 7: steerRun — the same allowlist gate, the durable copy first with the sending run on the row, then the slot that holds the target run now (matched by run id; another run's slot on the thread is never handed it), the item as the requester with from and no channel handle, no reply anywhere; no slot and the ledger's copy taken is elsewhere; no slot and a refused push is not_live; a sender the live agent excludes is refused before any push[unit] src/core/dispatch/admission.test.ts::steerRun — a run steers a live run through the inbox a thread reply takes::*
Item 7: a run's steer survives the durable round trip with from; a person's row reads back without it[unit] src/core/dispatch/admission.test.ts::followUpFromInbox — a durable inbox item back as a follow-up::a steer a run sent survives the round trip…
Item 7: a run's steer unconsumed at the run's end is set aside — never handed on as a fresh turn, never answered with the ⛔ note after a stop; a person's follow-ups beside it settle as before[unit] src/core/dispatch/settle.test.ts::settleThread — the thread when the request is over::a steer a run sent…
Item 7: the harness drains a run's steer like any follow-up, and the input event's source names the sending run[unit] src/core/dispatcher.test.ts::agent:conductor — a run that spawns child runs through dispatch()::send_to_run steers a live child…
Item 7: end to end — a conductor's send_to_run reaches its child's next step as the requester's follow-up (the follow-up header, the record's second input naming the parent run), nothing is acked in the child's thread, and the parent learns the child's end through await_runs[unit] src/core/dispatcher.test.ts::agent:conductor — a run that spawns child runs through dispatch()::send_to_run steers a live child…
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.
Item 8: a coordinator's spawn onto a thread with a run in flight — here, or on another generation's ledger row — is refused coordinator_thread_live with nothing pushed and nothing said, the refusal named as the dispatch's outcome and the slot taken for the check released; a free thread is claimed for the child as for any request[unit] src/core/dispatch/admission.test.ts::admit — the thread admission claim::a coordinator's spawn onto a thread with a run in flight (item 8)::*, src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::a coordinator's child (DispatchOptions.coordinator) carries parentInstanceId and idempotencyKey on the reserved row…
Item 8: the findings step's dispatch is answered busy while a run is live in the unit thread and dispatched when none is, a run that took the thread meanwhile answered from that run; a review child's spawn is judged against the unit's thread; the runner waits a chunk on a busy answer and asks the same step again under the unit's clock[unit] src/channels/adminCoordinator.test.ts::the plan runner's steps — plan, unit-start, branch, round, unit-end, finish (item 9)::spawn with a findings brief is refused busy…, ::spawn with a review brief dispatches the review child into the unit's thread…, src/core/ship/coordinator.test.ts::the unit pipeline — every ending the ship pipeline has, on step returns::the findings step waits out a person's run…
CriterionProof
Live-reply admission propagates inbox storage failure instead of queuing only in memory or routing fresh. Invalid-message and legacy unqualified rejections also stop delivery. A successful append still reaches the exact target; only explicit target absence permits the existing fresh-routing branch. This does not create a pending-delivery queue or authorize a retry of an unknown write.[unit] src/core/dispatch/admission.test.ts::admit — the thread admission claim::holds a…; src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::an unavailable inbox keeps the remote work owner without starting another agent
CriterionProof
A unit reply whose append fails, is rejected, or has an unknown acknowledgment retains the original unit. It starts no replacement and returns unit_delivery_unconfirmed, a system refusal. A reply saved before acknowledgment loss remains in the original unit event log. This does not create an automatic retry or pending-delivery queue.[unit] src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::an unconfirmed unit append…; ::a lost unit append acknowledgment keeps the saved reply without starting replacement work
A failed unit nudge followed by confirmed Workflow absence can close only the exact unit snapshot that was checked. If the unit changes during the status lookup, its newer state and saved reply remain with the original task; no replacement work starts.[unit] src/core/dispatcher.test.ts::a unit-owned thread (record 0051's reply-as-event and gone-instance rules)::a gone Workflow cannot replace a unit…; ::an unconfirmed gone-Workflow settlement…