From 4240cf94fc20fba3e6d0e260c1949c8b84f7d25d Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Fri, 25 Sep 2026 13:51:02 -0700 Subject: [PATCH] fix(dispatcher): wake idle units on inferred read binds --- docs/reference/specs/thread-admission.md | 3 +- src/core/dispatch/operator.ts | 8 +++- src/core/dispatcher.test.ts | 49 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/reference/specs/thread-admission.md b/docs/reference/specs/thread-admission.md index 54b786cfc..0d7c82bc3 100644 --- a/docs/reference/specs/thread-admission.md +++ b/docs/reference/specs/thread-admission.md @@ -25,7 +25,7 @@ Before this, a follow-up in a thread with a run in flight dispatched a second, f 6. **A child run is its own thread** ([agent-conductor.md](agent-conductor.md); [routing-and-config.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. Four channels open one: Slack posts the lead top-level in the parent's channel and builds the thread IO from the posted `ts` (the key `slack::`; [slack-channel.md](slack-channel.md) item 11); the CLI harness derives `/child-` 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::`, 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](web-chat.md) item 11; [record 0060](../../decisions/0060-a-ship-pipeline-is-a-live-run-for-its-whole-life-and-runs-on-every-channel-that-can-open-a-thread.md)); the null channel of a resumed run derives the CLI's key shape and logs, so a resumed parent's spawn is a run with a record rather than a refusal. A channel without `openThread` — HTTP, MCP, single-shot surfaces with no thread to open — 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](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](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](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](http-ingress.md) item 9; [run-history.md](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](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](../../decisions/0055-a-unit-has-one-thread-and-a-round-reads-the-checks-at-its-head.md)), 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](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](../../decisions/0051-a-thread-has-one-owner-for-its-life-a-message-is-one-event-in-a-chosen-mode-and-a-pipeline-idles-instead-of-ending.md) the owner, reply-as-event, fold and gone-instance rules; [routing-and-config.md](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 plus at most one read of the page's instance's unit rows (`instanceOf`: the ship run's own `ship_handoff` projection or a child's `parentInstanceId`), in this order: the live run; the unfinished unit whose row names this thread; an ended generated pipeline whose same-thread unit has not merged; the newest continuable session a person addressed (a coordinator's child never owns); none. The ended pipeline reads its original task from the ship parent's durable first `input` and re-issues that stable generated plan, so the next attempt keeps the plan id, unit branch, recorded pull request and only the durable wall-clock and review-round budgets the prior attempt left; the reply's fragment is never a new task. Continuation first requires one ended owner, the original requester (or a `runs:write` holder), matching durable thread/repository/generated-plan/branch identity, remaining wall-clock/round budget, a recorded pull request and its persisted expected head; it then reads GitHub's current open same-repository branch and head before the runner chooses its next operation, using one commit-ref read for existence and tip; only exact agreement between the PR object's head and that ref yields an attributable `{ repo, ref, sha }` receipt, whose repository, branch and full SHA must equal the durable repository, unit branch and persisted `lastPush`. A missing recorded pull request or expected head, a missing, unknown, unreadable or malformed ref, a missing or stale PR head, PR/ref disagreement, moved durable head, ambiguous owner, unauthorized requester, exhausted budget or contradictory fact names that blocker and starts nothing. 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](http-ingress.md) item 9), the sender is acked with where the message went, and the router never runs. In a UNIT's own thread 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](routing-and-config.md) item 29), so a preset bind lands as the plain reply's unit event. The SEED thread of a live pipeline runner is the runner's for its life ([record 0051](../../decisions/0051-a-thread-has-one-owner-for-its-life-a-message-is-one-event-in-a-chosen-mode-and-a-pipeline-idles-instead-of-ending.md)'s owner rule): a hosted runner holds no admission slot, so `threadLive` is false there, yet the page names a live run carrying an instance (`ownerOf` kind `live` with `instanceId`) — and a reply there, plain or directive, is 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 beside the pipeline and never folded, since the runner takes no inbox. 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, ends the unit row `terminated` — the thread is unowned from then on — and the message routes fresh with a line saying so; any other failure keeps the event appended and acks it as queued for the pipeline's next step. An append the store cannot hold — the null store's `unavailable` or the Worker store's throw on a failed call — routes the message fresh, never unacked and unrouted. 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](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, the read commands and a question, and a decision that is none of those — a refusal's prose, a preset bind, a write — is the steer of the whole message, so the executor answers `fold` and the dispatch runs on to this item's append (an idle unit) or admission's fold (a live run), no operator prose posted. An ended generated pipeline is the deterministic exception: its durable owner is resolved before operator execution, and every continuation-shaped reply bypasses the operator and its commands so authorization, ambiguity, budget, task, exact-head and duplicate fences alone decide whether the original task is re-issued. A pending question's free-text answer otherwise folds JOINED onto the ask the question kept ([routing-and-config.md](routing-and-config.md) item 29, issue 2046: ` — : `), 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 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](../../decisions/0071-a-ship-unit-owns-its-pull-request-until-it-is-merged-merge-ready-waits-on-facts-and-a-dirty-head-buys-a-rebase-round.md), mechanism three; [agent-ship.md](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, `merge_ready` remains an ended machine kind, but a generated pipeline's same thread still owns that unmerged unit for re-issue; merge is the fact that releases it to ordinary routing. +9. **A thread has one owner, and a unit-owned thread's reply is a thread event** ([record 0051](../../decisions/0051-a-thread-has-one-owner-for-its-life-a-message-is-one-event-in-a-chosen-mode-and-a-pipeline-idles-instead-of-ending.md) the owner, reply-as-event, fold and gone-instance rules; [routing-and-config.md](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 plus at most one read of the page's instance's unit rows (`instanceOf`: the ship run's own `ship_handoff` projection or a child's `parentInstanceId`), in this order: the live run; the unfinished unit whose row names this thread; an ended generated pipeline whose same-thread unit has not merged; the newest continuable session a person addressed (a coordinator's child never owns); none. The ended pipeline reads its original task from the ship parent's durable first `input` and re-issues that stable generated plan, so the next attempt keeps the plan id, unit branch, recorded pull request and only the durable wall-clock and review-round budgets the prior attempt left; the reply's fragment is never a new task. Continuation first requires one ended owner, the original requester (or a `runs:write` holder), matching durable thread/repository/generated-plan/branch identity, remaining wall-clock/round budget, a recorded pull request and its persisted expected head; it then reads GitHub's current open same-repository branch and head before the runner chooses its next operation, using one commit-ref read for existence and tip; only exact agreement between the PR object's head and that ref yields an attributable `{ repo, ref, sha }` receipt, whose repository, branch and full SHA must equal the durable repository, unit branch and persisted `lastPush`. A missing recorded pull request or expected head, a missing, unknown, unreadable or malformed ref, a missing or stale PR head, PR/ref disagreement, moved durable head, ambiguous owner, unauthorized requester, exhausted budget or contradictory fact names that blocker and starts nothing. 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](http-ingress.md) item 9), the sender is acked with where the message went, and the router never runs. In a UNIT's own thread 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](routing-and-config.md) item 29), so a preset bind lands as the plain reply's unit event. The SEED thread of a live pipeline runner is the runner's for its life ([record 0051](../../decisions/0051-a-thread-has-one-owner-for-its-life-a-message-is-one-event-in-a-chosen-mode-and-a-pipeline-idles-instead-of-ending.md)'s owner rule): a hosted runner holds no admission slot, so `threadLive` is false there, yet the page names a live run carrying an instance (`ownerOf` kind `live` with `instanceId`) — and a reply there, plain or directive, is 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 beside the pipeline and never folded, since the runner takes no inbox. 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, ends the unit row `terminated` — the thread is unowned from then on — and the message routes fresh with a line saying so; any other failure keeps the event appended and acks it as queued for the pipeline's next step. An append the store cannot hold — the null store's `unavailable` or the Worker store's throw on a failed call — routes the message fresh, never unacked and unrouted. 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](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 explicitly typed read command still takes 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 is the deterministic exception: its durable owner is resolved before operator execution, and every continuation-shaped reply bypasses the operator and its commands so authorization, ambiguity, budget, task, exact-head and duplicate fences alone decide whether the original task is re-issued. A pending question's free-text answer otherwise folds JOINED onto the ask the question kept ([routing-and-config.md](routing-and-config.md) item 29, issue 2046: ` — : `), 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 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](../../decisions/0071-a-ship-unit-owns-its-pull-request-until-it-is-merged-merge-ready-waits-on-facts-and-a-dirty-head-buys-a-rebase-round.md), mechanism three; [agent-ship.md](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, `merge_ready` remains an ended machine kind, but a generated pipeline's same thread still owns that unmerged unit for re-issue; merge is the fact that releases it to ordinary routing. ## Validation criteria @@ -40,6 +40,7 @@ Before this, a follow-up in a thread with a run in flight dispatched a second, f | Item 9: a directive reply in a live pipeline's seed thread — the page's live run carries an instance and holds no admission slot — is refused `pipeline_thread_owned` naming the open unit and its thread, a plain reply the same way, and no rival run starts | `[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 (` — : `) 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 store whose append throws routes the message fresh; over-cap attachments recorded dropped; `agent:` runs today's path; a merged unit routes fresh while every other ended generated unit remains pipeline-owned and re-issues its original task; the relay's no-instance ends the row `terminated` and routes fresh; 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` | diff --git a/src/core/dispatch/operator.ts b/src/core/dispatch/operator.ts index 5c332ff0c..a9a6b2eb3 100644 --- a/src/core/dispatch/operator.ts +++ b/src/core/dispatch/operator.ts @@ -303,6 +303,8 @@ export function ownerNote(owner: OperatorThreadOwner): string { : ""; if (owner.kind === "pipeline") return `This thread is owned by ${who}: the request below is a continuation of that durable task. Read tools may ground the decision, but every action folds into the owner so the current pull request is re-read and the pipeline resumes; an informational command or question is not fulfillment.`; + if (owner.kind === "unit") + return `This thread is owned by ${who}: the request below is a follow-up for that unit. An inferred read command cannot answer it; fold the whole message into the unit unchanged. Ask a question only when a required detail is missing.`; return `This thread is owned by ${who}: the request below is a follow-up for that owner. To act on it,${steer} bind a read command, or ask a question. Any other decision — a refusal, a preset, a write — folds the whole message into the owner unchanged and posts no answer.`; } @@ -1012,7 +1014,11 @@ function ownedDecisionRuns(event: OperatorEventFields, owner: OperatorThreadOwne // has no live steer target either: folding reaches the dispatcher's durable // task re-issue path instead of letting a transcript's stale run id answer. if (def.id === "steer.run") return !(owner.kind === "live" && owner.runId === undefined); - return boundBlastRadius(def as CommandDef, parsed.input) === "read"; + // An idle unit owns this thread even when the operator infers a read from + // an action request. A plain reply reaches the unit's durable event path; + // a person's explicitly typed command is still handled by the command + // fast path in dispatch, without relying on this model decision. + return owner.kind !== "unit" && boundBlastRadius(def as CommandDef, parsed.input) === "read"; }); } diff --git a/src/core/dispatcher.test.ts b/src/core/dispatcher.test.ts index 648e28373..b00265815 100644 --- a/src/core/dispatcher.test.ts +++ b/src/core/dispatcher.test.ts @@ -19507,6 +19507,55 @@ describe("the operator behind routing.operator (record 0057; routing-and-config expect(registry.snapshotById("r2")).toBeNull(); }); + it("on: an inferred read bind cannot answer an action request in an idle unit's thread", async () => { + const instanceId = "plan-fix-the-login-6435ec"; + const { deps, registry, provider } = operatorDeps(ON_YAML); + wireCommands(deps); + const instances = new InMemoryCoordinatorInstanceStore(); + await instances.putUnits([ + { + instanceId, + unit: "U12", + slug: "u12", + branch: "plan/fix-the-login-6435ec/u12", + dependsOn: [], + rounds: [], + threadKey: "slack:CX:1.0", + idle: { why: "held", at: 1, renewalsLeft: 1, spendUsd: 1, wakes: 0 }, + }, + ]); + deps.coordinatorInstances = instances; + const sends: string[] = []; + deps.workflow = { get: async (id) => ({ sendEvent: async () => void sends.push(id) }) }; + const thread = [ + { id: "c1", startedAt: 0, finished: true, eventCount: 1, agent: "coding", parentInstanceId: instanceId }, + ] as RunView[]; + deps.operatorModel = decides({ + reason: "inspect the unit before acting", + binds: [{ line: "plane show", reason: "inspect the plane first" }], + }); + const request = "Address the current PR review comments in this unit and request another review"; + const { io, replies } = fakeIO(); + + await dispatch(deps, msg(request, "slack:UADMIN"), io, { thread }); + + expect(deps.invoked).toEqual([]); + expect(await instances.listEvents({ instanceId, unit: "U12" })).toEqual([ + expect.objectContaining({ sender: "slack:UADMIN", text: request, mode: "wake" }), + ]); + expect(sends).toEqual([instanceId]); + expect(replies.some((reply) => reply.includes("Noted for unit U12"))).toBe(true); + expect(provider.requests).toHaveLength(0); + expect(registry.snapshotById("r1")!.events.find((event) => event.type === "operator")).toMatchObject({ + mode: "on", + outcome: "binds", + }); + + await dispatch(deps, msg("plane show", "slack:UADMIN"), fakeIO().io, { thread }); + expect(deps.invoked).toEqual(["plane.show"]); + expect(await instances.listEvents({ instanceId, unit: "U12" })).toHaveLength(1); + }); + it("on: a preset bind into a live pipeline's seed thread is refused by the owner rule — the decision still lands on a door record, under shadow too", async () => { const INSTANCE = "plan-fix-the-login-6435ec"; const seedThread = () =>