You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A typed per-event turn-acceptance result (adopted / rejected / completed-without-dispatch) exposed through the Plugin SDK, so ack-first webhook channels like LINE can gate their webhook response on the canonical outcome instead of inferring it channel-side.
Problem to solve
steipete's note on #102141 describes where LINE's webhook acknowledgement needs to end up:
"A correct fix needs a canonical reply-lane admission/durable receive contract exposed through the Plugin SDK, then LINE can aggregate that explicit result for every event in the webhook batch. The acceptance signal must occur only after the lane owns the turn; failures before that point must reject the webhook."
I've been working in the same area on #107732/#107734 (post-ack work admission), so I went through current main to see how much of that contract already exists. The gap is smaller than I expected, but the missing piece is exactly the one a deadline-bounded webhook waiter needs: a per-event result it can aggregate. The pre-adoption outcomes are scattered across surfaces that don't compose:
a recordInboundSession failure fires onPreDispatchFailure and rejects the whole inbound.run promise (kernel.ts:498-537);
a dispatch failure only rejects inbound.run, and that settlement can arrive long after a 2-second webhook deadline;
reply-lane admission ending skipped/lifecycle-invalidated just logs and returns busy (src/auto-reply/reply/dispatch-from-config.lifecycle.ts:247-275, reply-turn-admission.ts:176-238), so inbound.run resolves as if the turn succeeded;
observe-only and drop admissions also resolve with nothing to distinguish them from a dispatched turn.
So a waiter can see "adopted", but it can't tell "rejected, return non-2xx so the platform redelivers" from "completed legitimately without dispatch, ack now" from "still working". For LINE that ambiguity is the remaining silent-loss window: every failure after the 200 is invisible to the platform's redelivery.
Proposed solution
Most of the rails already exist on main:
onTurnAdopted landed as a core reply option in ef10320 (2026-07-10): declared at src/auto-reply/get-reply-options.types.ts:99 and threaded through the turn kernel (src/channels/turn/kernel.ts:194-216). It only fires once the turn is durably adopted: after the transcript commit on the steer path (src/auto-reply/reply/agent-runner.ts:1303, with waitForTranscriptCommit forced at :1289), and after the restart-recovery delivery claim persists at run start (:1707). If I read it right, those firing points are "the lane owns the turn".
The kernel also already exposes a failure hook for one pre-adoption stage: onPreDispatchFailure (src/channels/turn/types.ts:296), fired when recordInboundSession throws (kernel.ts:532).
Telegram consumes the adoption signal end to end: it ships after_agent_dispatch as its default receive ack policy (extensions/telegram/src/channel.ts:263-264), gates its update watermark on adoption (bot-core.ts:170, bot-update-tracker.ts:198), and wires it from the spooled dispatch (bot-message-dispatch-turn.ts:114, hardened in fix(telegram): harden spooled turn adoption #103695). Because core only exposes the adopted half plus that one failure hook, Telegram hand-rolls the rest of the lifecycle extension-side — onTurnDeferred / onTurnAbandoned are Telegram-local in bot-message-dispatch-fence.ts:19.
LINE, by contrast, still declares only after_receive_record (extensions/line/src/outbound.ts:449-450); after_durable_send is declared in the policy union but not implemented by any channel yet.
What's missing is the unification. One possible shape (very much up for discussion): a typed per-event acceptance result surfaced alongside channel.inbound.run, settling as one of
adopted — where onTurnAdopted fires today;
rejected(reason, retryable) — any pre-adoption failure, including admission skipped/lifecycle-invalidated;
completedWithoutDispatch — observe/handled/drop admissions, so verification-only or mention-gated events can ack immediately.
This is close to what Telegram already runs in production. Its fence vocabulary is adopted / deferred / abandoned, and its spool drain settles every row through a per-event outcome (complete, release-for-retry, or dead-letter — webhook.ts:569-666), threaded through a private processing-outcome module (bot-processing-outcome.ts). Two notes from reading that code: core currently folds "durably queued into an active turn" into adoption (the steer path fires onTurnAdopted only after the transcript commit), so a webhook waiter can treat queued-and-committed as ack-safe. And the result would need to be a first-class turn-level parameter rather than a late reply option, since the prepared-dispatch path ignores those (kernel.ts:713).
To be clear about scope: the LINE-side work here is already done, in #102141. The machinery built there — per-batch aggregation with a response deadline, the replay-claim commit-on-acceptance/release-on-failure lifecycle, the delayed-failure/multi-event regressions, the redelivery operator docs — looks like it would carry over onto a canonical result nearly unchanged, and that PR is where it should land. Its current head already consumes onTurnAdopted for the adoption half; the canonical result would mainly replace the part it currently has to infer channel-side — deciding when a non-adopted event is safely acked versus retried. It would also make it natural to keep after_receive_record as the default policy with after_agent_dispatch opt-in. Telegram could later shed its local deferred/abandoned plumbing if it wanted, but nothing would force that.
Alternatives considered
Keep inferring channel-side (what Fix LINE webhook retryable acknowledgement #102141's head has to do today): works for the adoption half via onTurnAdopted, but a channel can't observe core-internal outcomes like an admission ending busy-skip — inbound.run resolves as if the turn succeeded — so the inference can never be complete, and every ack-first channel has to re-solve it.
Jump straight to a Telegram-style durable spool for LINE without a canonical result: the spool drain still needs the same per-event settlement to decide complete / release-for-retry / dead-letter, so the contract looks like a prerequisite rather than an alternative.
Severity: silent message loss — the sender's platform believes delivery succeeded, so nothing retries.
Frequency: any pre-adoption failure after the 200 (admission busy-skips, session-record failures, gateway drain windows).
Consequence: inbound messages disappear with no error surfaced anywhere, and each channel grows private workarounds (Telegram's fence + processing-outcome module) instead of one contract.
Evidence/examples
Telegram's production machinery as prior art: spool settlement in extensions/telegram/src/webhook.ts:569-666, outcome threading in bot-processing-outcome.ts, fence lifecycle in bot-message-dispatch-fence.ts.
Is acceptance-gated ack (dependent on operators enabling LINE webhook redelivery) the intended end state for LINE, or a stepping stone toward spool-before-ack like Telegram's webhook path (durable receive record before the 200, no redelivery dependency)?
If the direction is right, where should the seam live — a result from channel.inbound.run, or an extension of the MessageReceiveContext contract? (Telegram threads its outcomes through dispatch options rather than the receive context, for what that precedent is worth.)
Happy to attempt a prototype of the core-side primitive if this direction (or a corrected version of it) makes sense — as a base #102141 can stack its LINE-side aggregation onto, not as a replacement for it.
Summary
A typed per-event turn-acceptance result (adopted / rejected / completed-without-dispatch) exposed through the Plugin SDK, so ack-first webhook channels like LINE can gate their webhook response on the canonical outcome instead of inferring it channel-side.
Problem to solve
steipete's note on #102141 describes where LINE's webhook acknowledgement needs to end up:
I've been working in the same area on #107732/#107734 (post-ack work admission), so I went through current main to see how much of that contract already exists. The gap is smaller than I expected, but the missing piece is exactly the one a deadline-bounded webhook waiter needs: a per-event result it can aggregate. The pre-adoption outcomes are scattered across surfaces that don't compose:
recordInboundSessionfailure firesonPreDispatchFailureand rejects the wholeinbound.runpromise (kernel.ts:498-537);inbound.run, and that settlement can arrive long after a 2-second webhook deadline;skipped/lifecycle-invalidatedjust logs and returns busy (src/auto-reply/reply/dispatch-from-config.lifecycle.ts:247-275,reply-turn-admission.ts:176-238), soinbound.runresolves as if the turn succeeded;So a waiter can see "adopted", but it can't tell "rejected, return non-2xx so the platform redelivers" from "completed legitimately without dispatch, ack now" from "still working". For LINE that ambiguity is the remaining silent-loss window: every failure after the 200 is invisible to the platform's redelivery.
Proposed solution
Most of the rails already exist on main:
onTurnAdoptedlanded as a core reply option in ef10320 (2026-07-10): declared atsrc/auto-reply/get-reply-options.types.ts:99and threaded through the turn kernel (src/channels/turn/kernel.ts:194-216). It only fires once the turn is durably adopted: after the transcript commit on the steer path (src/auto-reply/reply/agent-runner.ts:1303, withwaitForTranscriptCommitforced at:1289), and after the restart-recovery delivery claim persists at run start (:1707). If I read it right, those firing points are "the lane owns the turn".onPreDispatchFailure(src/channels/turn/types.ts:296), fired whenrecordInboundSessionthrows (kernel.ts:532).after_agent_dispatchas its default receive ack policy (extensions/telegram/src/channel.ts:263-264), gates its update watermark on adoption (bot-core.ts:170,bot-update-tracker.ts:198), and wires it from the spooled dispatch (bot-message-dispatch-turn.ts:114, hardened in fix(telegram): harden spooled turn adoption #103695). Because core only exposes the adopted half plus that one failure hook, Telegram hand-rolls the rest of the lifecycle extension-side —onTurnDeferred/onTurnAbandonedare Telegram-local inbot-message-dispatch-fence.ts:19.after_receive_record(extensions/line/src/outbound.ts:449-450);after_durable_sendis declared in the policy union but not implemented by any channel yet.What's missing is the unification. One possible shape (very much up for discussion): a typed per-event acceptance result surfaced alongside
channel.inbound.run, settling as one ofadopted— whereonTurnAdoptedfires today;rejected(reason, retryable)— any pre-adoption failure, including admissionskipped/lifecycle-invalidated;completedWithoutDispatch— observe/handled/drop admissions, so verification-only or mention-gated events can ack immediately.This is close to what Telegram already runs in production. Its fence vocabulary is adopted / deferred / abandoned, and its spool drain settles every row through a per-event outcome (complete, release-for-retry, or dead-letter —
webhook.ts:569-666), threaded through a private processing-outcome module (bot-processing-outcome.ts). Two notes from reading that code: core currently folds "durably queued into an active turn" into adoption (the steer path firesonTurnAdoptedonly after the transcript commit), so a webhook waiter can treat queued-and-committed as ack-safe. And the result would need to be a first-class turn-level parameter rather than a late reply option, since the prepared-dispatch path ignores those (kernel.ts:713).To be clear about scope: the LINE-side work here is already done, in #102141. The machinery built there — per-batch aggregation with a response deadline, the replay-claim commit-on-acceptance/release-on-failure lifecycle, the delayed-failure/multi-event regressions, the redelivery operator docs — looks like it would carry over onto a canonical result nearly unchanged, and that PR is where it should land. Its current head already consumes
onTurnAdoptedfor the adoption half; the canonical result would mainly replace the part it currently has to infer channel-side — deciding when a non-adopted event is safely acked versus retried. It would also make it natural to keepafter_receive_recordas the default policy withafter_agent_dispatchopt-in. Telegram could later shed its local deferred/abandoned plumbing if it wanted, but nothing would force that.Alternatives considered
onTurnAdopted, but a channel can't observe core-internal outcomes like an admission ending busy-skip —inbound.runresolves as if the turn succeeded — so the inference can never be complete, and every ack-first channel has to re-solve it.Impact
Evidence/examples
extensions/telegram/src/webhook.ts:569-666, outcome threading inbot-processing-outcome.ts, fence lifecycle inbot-message-dispatch-fence.ts.Additional information
Open questions:
channel.inbound.run, or an extension of theMessageReceiveContextcontract? (Telegram threads its outcomes through dispatch options rather than the receive context, for what that precedent is worth.)Happy to attempt a prototype of the core-side primitive if this direction (or a corrected version of it) makes sense — as a base #102141 can stack its LINE-side aggregation onto, not as a replacement for it.
Related: #102141 (LINE webhook acceptance aggregation), #107732 / #107734 (post-ack work admission lifetime), #103695 (Telegram spooled turn adoption).