Skip to content

feat(core): WarmTransferTask cooperative cancellation via abortSignal, teardown-complete run() - #2292

Draft
dtran26 wants to merge 2 commits into
livekit:mainfrom
dtran26:feat/warm-transfer-abort-signal
Draft

feat(core): WarmTransferTask cooperative cancellation via abortSignal, teardown-complete run()#2292
dtran26 wants to merge 2 commits into
livekit:mainfrom
dtran26:feat/warm-transfer-abort-signal

Conversation

@dtran26

@dtran26 dtran26 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Implements #2288, using the API shape agreed in the issue thread: abortSignal passed in the task options, with no separate closed promise — run() is the single lifecycle boundary.

const result = await new WarmTransferTask({
  ...options,
  abortSignal: AbortSignal.timeout(120_000),
}).run();

What's included

  • abortSignal option on WarmTransferTaskOptions. Aborting cooperatively stops dialing, ringing, or consulting: a pending SIP dial is torn down, and a human agent who already answered hears a brief built-in cancellation notice before their call is ended.
  • Observable cancellation reason. run() rejects with signal.reason when it is an Error (so AbortSignal.timeout() surfaces its TimeoutError directly); non-Error reasons are wrapped in a ToolError.
  • A committed move wins over a concurrent abort. An abort that lands while moveParticipant is in flight is deferred: if the move commits, the transfer completes successfully and the abort is dropped; if the move fails, the deferred abort owns the result. Cancellation never turns a bridged transfer into a failure.
  • Failed runs settle at a bounded teardown boundary; successful runs settle immediately. On success, run() resolves as soon as the move commits, with caller I/O restored — identical timing to today — and consult-room teardown finishes detached (it never touches the caller room). On failure or cancellation, run() rejects only after teardown has finished: any human-agent notification spoken, the human agent session (including deleteRoomOnClose room cleanup) shut down, and caller I/O restored last, so the resumed agent cannot hear and answer the caller before the application has the outcome. The wait is bounded (30s, logged with context) so a stuck step cannot block the caller session; several completion paths execute inside the human agent session's own tool call, so teardown steps are collected and awaited in the run() wrapper rather than in place.
  • Late-answer compensation for cancelled dials. CreateSIPParticipant initiates the call immediately and cannot be aborted server-side; cleanup deletes the consult room (which ends a pending dial), but a dial that answers in the deletion race can auto-recreate the room. The task now watches the abandoned request and deletes the room again if it settles successfully.
  • AgentSession.shutdown() now returns the closing promise (previously void). See the design note below.

Design note: why touch AgentSession

The change is small and only exposes the promise the class already creates: shutdown() returns _closeSoon()'s closing task, and _closeSoon() returns the in-flight closingTask on re-entry instead of undefined. Dedupe, drain semantics, and close ordering are unchanged; callers that ignore the return value are unaffected, and widening voidPromise<void> is non-breaking for TypeScript consumers. The doc comment explicitly warns that awaiting the promise from inside the session's own tool call or lifecycle hook deadlocks (the drain waits for that work).

It is needed because the failure-path boundary must cover SDK-owned room cleanup, and the alternatives fall short:

  • The Close event fires too early. closeImplInner emits it before await this._roomIO?.close(), which is where the deleteRoomOnClose room deletion is awaited — gating on Close would let run() settle while the room delete is still in flight. It also has an attach-after-close race: a session that already finished closing never re-emits it.
  • await session.close() is wrong twice over. It bypasses the closingTask dedupe, so calling it while a shutdown()-initiated close is draining runs closeImplInner concurrently; and it force-interrupts current speech (drain: false), which would cut off the human-agent notification mid-sentence.
  • Nothing can be awaited at the completion site. Completion runs inside the human agent session's own tool call, so the warm-transfer task needs a promise handle it can collect and await later from the caller side — exactly what the new return value provides.

If you'd rather keep AgentSession untouched, the fallback is gating on the Close event and accepting that room deletion falls outside the boundary — a strictly weaker contract than the issue proposed. An awaitable shutdown also seems independently useful for app code (e.g. end-call flows that want to know when teardown actually finished). Happy to split this into its own PR if preferred.

Behavior notes

  • With no signal provided, the success path is byte-for-byte the existing timing; only failure paths settle later (after bounded teardown). This is the trade discussed in the issue — one clear boundary instead of a second closed promise.
  • Because the failure boundary includes the human-agent notification, an abort that lands mid-briefing keeps run() pending for the duration of the (capped) notice, with caller I/O disabled throughout — so the agent never replies to the caller without knowing the transfer failed. If a silent teardown is preferable for some destinations (e.g. cancelling out of a hold queue where nobody hears the notice), a follow-up could make the abort notice configurable/skippable.
  • A deeper alternative to the run()-wrapper boundary is a finalizing state inside the task that defers task.complete() until teardown ends, so the caller agent is not resumed at all until cleanup is done. That reworks the task.done re-entrancy guards and the completion/ALS machinery, so it isn't attempted here — but I'm open to it if you'd prefer that architecture.

Coordination

Testing

  • warm_transfer_abort.test.ts runs the real AgentSession/AgentTask machinery with only LiveKit/SIP network calls stubbed: abort during dial (rejects with the exact reason), pre-aborted signal (completes without dialing), committed-move-wins, abort-wins-on-failed-move, success resolving without waiting for consult teardown, a failed run held (with caller I/O disabled) until the human-agent session shutdown finishes, and late-answer room-deletion compensation for a cancelled dial.
  • Full agents package suite passes (2144 tests), plus typecheck, eslint, throws:check, and prettier.
  • pnpm api:check fails identically on unmodified main (export * as unsupported by the bundled api-extractor), so it isn't affected by this change.

🤖 Generated with Claude Code

…own-complete run()

WarmTransferTask represents a long-running workflow but had no supported
cancellation or deadline API, and task settlement was not a
teardown-complete boundary (livekit#2288).

- Add `abortSignal` to WarmTransferTaskOptions. Aborting cooperatively
  stops dialing, ringing, or consulting: a pending SIP dial is torn
  down, a human agent who already answered is told the transfer ended
  before their call is ended, and the task completes with the signal's
  reason (e.g. the TimeoutError from AbortSignal.timeout()).
- Arbitrate abort against an in-flight participant move: a move that
  already committed wins over a concurrent abort, so cancellation never
  turns a bridged transfer into a failure; if the move fails, the
  deferred abort owns the result.
- Make run() the single lifecycle boundary: it settles only after
  teardown has finished — hold audio stopped, caller I/O restored, any
  human-agent notification spoken, and the human agent session
  (including deleteRoomOnClose room cleanup) shut down. The wait is
  capped so a stuck teardown step cannot block the caller session
  indefinitely.
- Return the closing promise from AgentSession.shutdown() so callers
  can observe when the session has fully closed.

Behavior is unchanged when no signal is provided, apart from run()
settling at the stronger teardown-complete boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 414344b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Major
@livekit/agents-plugin-anam Major
@livekit/agents-plugin-anthropic Major
@livekit/agents-plugin-assemblyai Major
@livekit/agents-plugin-azure Major
@livekit/agents-plugin-baseten Major
@livekit/agents-plugin-bey Major
@livekit/agents-plugin-cartesia Major
@livekit/agents-plugin-cerebras Major
@livekit/agents-plugin-deepgram Major
@livekit/agents-plugin-did Major
@livekit/agents-plugin-elevenlabs Major
@livekit/agents-plugin-fishaudio Major
@livekit/agents-plugin-google Major
@livekit/agents-plugin-hedra Major
@livekit/agents-plugin-hume Major
@livekit/agents-plugin-inworld Major
@livekit/agents-plugin-krisp Major
@livekit/agents-plugin-lemonslice Major
@livekit/agents-plugin-liveavatar Major
@livekit/agents-plugin-livekit Major
@livekit/agents-plugin-minimax Major
@livekit/agents-plugin-mistral Major
@livekit/agents-plugin-mistralai Major
@livekit/agents-plugin-neuphonic Major
@livekit/agents-plugin-openai Major
@livekit/agents-plugin-perplexity Major
@livekit/agents-plugin-phonic Major
@livekit/agents-plugin-protoface Major
@livekit/agents-plugin-resemble Major
@livekit/agents-plugin-rime Major
@livekit/agents-plugin-runway Major
@livekit/agents-plugin-sarvam Major
@livekit/agents-plugin-silero Major
@livekit/agents-plugin-soniox Major
@livekit/agents-plugin-tavus Major
@livekit/agents-plugins-test Major
@livekit/agents-plugin-trugen Major
@livekit/agents-plugin-xai Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

- Settle a successful run() as soon as the participant move commits,
  with caller I/O restored immediately; only failed or cancelled runs
  wait for the (bounded) teardown boundary. Holding a successful run()
  open left the resumed caller agent live for seconds before the
  application could act on the bridged call.
- Keep caller I/O disabled on failure paths until the boundary, so the
  resumed agent cannot react to the caller before the application has
  observed the outcome, then restore it as the last step.
- Compensate for a cancelled SIP dial that answers late: the server
  request cannot be aborted, and a dial that answers after cleanup can
  recreate the consult room — delete the room again once the abandoned
  request settles.
- Word the boundary honestly as bounded (deadline logged with context)
  and document that awaiting AgentSession.shutdown() from the session's
  own tool call or lifecycle hook deadlocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant