ADR 0113 — An absorbed input is committed with the answer
Context
inputPolicy: 'inject' — hand a newly arrived input to the loop between tool
calls and let the run continue — shipped in 0.63.0 and was withdrawn in 0.65.0.
An adversarial read found four defects, and all four trace to one ordering
mistake: the absorption was committed durably at a step boundary, before the
answer existed.
absorbedbecame the only run state that was neither active, nor recoverable, nor terminal.listActiveandscanRecoverableexcluded it,recoverRunrefused it, and no terminal reason could produce it.close()between the absorb and the answer reportedsettled: truewhile leaving the input permanently unanswerable, invisible to recovery, and its idempotency key refused forever on retry — the exact case idempotency keys exist for.- The absorb re-projected the whole snapshot, so an unrelated queued input reached the model inside a run that never recorded it, and was then answered a second time by its own run.
injectwithcoalescePendingrefused a legitimate submission, because the reservation pointed at a run that had since becomeabsorbed.
The capability is worth having. It is right whenever the new input refines rather than redirects and the finished steps are still valuable — the second message in “summarise this thread… actually, in bullet points” should not throw away the reading the first one paid for.
Decision
Nothing durable happens until the run settles.
The loop may put a pending input into the prompt at a step boundary — that
half was right. What it may not do is write anything about it. The absorption is
a field on commitRunTerminal:
absorb?: { runId: string; inputMessageIds: string[] }[]
and it is applied in the same transaction as the terminal record. In that one
transaction the absorbed inputs join the absorbing run’s inputMessageIds, and
each absorbed run becomes terminal with terminalReason: 'absorbed' and
absorbedIntoRunId pointing at the run that answered it.
Everything follows from that ordering:
A run that ends any other way leaves an ordinary queued successor. A crash,
a close(), an interrupt, a failure — none of them commit an absorption,
because the commit is the terminal record itself. The successor is exactly what
every other policy already produces, and recovery already knows what to do with
it. There is no state in which an accepted input is unanswerable.
Only a completing run may absorb. An interrupted or failed run took the
input into its prompt and then stopped; it has not answered it. The executor
does not send absorb unless the terminal reason completes the run, and the
reducer refuses it if it arrives anyway.
No new run state. absorbed maps to the superseded state, which is
already in every enumeration — terminal, not active, not recoverable. The reason
says which of the two happened; the pointer says where the answer went. Minting
a state was the withdrawn design’s first mistake, and it is not repeated.
No assistant message for an absorbed run. It produced nothing. Writing an
empty message would be a record claiming this run answered when it did not; its
reserved assistant identity simply stays unused. The answer is reachable through
absorbedIntoRunId, and the store follows that pointer itself when a duplicate
submission arrives on the absorbed run’s idempotency key — which is what makes a
retry after a restart return the answer instead of an empty terminal record.
Only the taken admission’s own message reaches the prompt. Not a re-projection of the snapshot. An unrelated queued input cannot be carried in by an absorption it has nothing to do with.
Absorption is whole or not at all, and it is offered, not asserted. The registry that tells a run in flight what it may take on is process-local and may be lost to a crash, a close, or a restart without consequence: every entry in it is also a queued run in the store. At commit time an entry whose run is no longer queued, or whose input set no longer matches the run exactly, is dropped — the terminal record is not held hostage to a successor’s bookkeeping, and a dropped successor runs on its own.
Composing with coalescePending. That option lets a third submission join
the queued successor instead of stacking behind it, so one successor can carry
several inputs — and an absorption that covered only some of them would leave a
run that is terminal with inputs nobody answered. So the registry deduplicates
by input, not by run, and the executor re-takes at every boundary,
accumulating per run. A coalesced input that arrives before the absorbing run’s
last boundary joins the same absorption. One that arrives after it cancels the
absorption, and the successor answers all of its inputs itself — the absorbing
run has then answered one of them too, which is a duplicate answer and never a
missing one.
Consequences
inject is back, with queue semantics in the coordinator: it never ends the
run in flight, and the successor is an ordinary queued run until the absorbing
run’s terminal commit says otherwise.
The store reducer gained plural effects — runRecords, historyMutations —
because one mutation now settles two runs. They must be written in one
transaction; a driver that persists one record of the pair fails
runAgentStoreConformance.
The executor installs a prepareStep hook when injection is possible, and
composes with an application’s own. When it is not possible — the configured
inputPolicy can never return 'inject' — nothing is installed and no boundary
does any work, so a runtime that does not use this pays nothing for it.
An absorbed run’s ticket resolves through the same path in-process and after a
restart: the coordinator eventually starts it, the executor reads the durable
record, sees absorbed, and returns the absorbing run’s result. There is one
resolution path, not two, and it is reached from the store rather than from
anything held in memory.