Skip to content

[Feature]: Per-event turn-acceptance result for ack-first webhook channels (reply-lane admission / durable receive contract) #108411

Description

@edenfunf

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:

"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.

Impact

Evidence/examples

Additional information

Open questions:

  1. 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)?
  2. 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.

Related: #102141 (LINE webhook acceptance aggregation), #107732 / #107734 (post-ack work admission lifetime), #103695 (Telegram spooled turn adoption).

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:needs-maintainer-reviewClawSweeper marked this issue as needing maintainer review before automation.clawsweeper:needs-product-decisionClawSweeper marked this issue as needing a product or behavior decision.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.issue-rating: 🌊 off-meta tidepoolIssue quality rating does not apply to this item.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions