feat(core): WarmTransferTask cooperative cancellation via abortSignal, teardown-complete run() - #2292
Draft
dtran26 wants to merge 2 commits into
Draft
feat(core): WarmTransferTask cooperative cancellation via abortSignal, teardown-complete run()#2292dtran26 wants to merge 2 commits into
dtran26 wants to merge 2 commits into
Conversation
…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 detectedLatest commit: 414344b The changes in this PR will be included in the next version bump. This PR includes changesets to release 39 packages
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements #2288, using the API shape agreed in the issue thread:
abortSignalpassed in the task options, with no separateclosedpromise —run()is the single lifecycle boundary.What's included
abortSignaloption onWarmTransferTaskOptions. 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.run()rejects withsignal.reasonwhen it is anError(soAbortSignal.timeout()surfaces itsTimeoutErrordirectly); non-Error reasons are wrapped in aToolError.moveParticipantis 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.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 (includingdeleteRoomOnCloseroom 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 therun()wrapper rather than in place.CreateSIPParticipantinitiates 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 (previouslyvoid). See the design note below.Design note: why touch
AgentSessionThe change is small and only exposes the promise the class already creates:
shutdown()returns_closeSoon()'s closing task, and_closeSoon()returns the in-flightclosingTaskon re-entry instead ofundefined. Dedupe, drain semantics, and close ordering are unchanged; callers that ignore the return value are unaffected, and wideningvoid→Promise<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:
Closeevent fires too early.closeImplInneremits it beforeawait this._roomIO?.close(), which is where thedeleteRoomOnCloseroom deletion is awaited — gating onClosewould letrun()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 theclosingTaskdedupe, so calling it while ashutdown()-initiated close is draining runscloseImplInnerconcurrently; and it force-interrupts current speech (drain: false), which would cut off the human-agent notification mid-sentence.If you'd rather keep
AgentSessionuntouched, the fallback is gating on theCloseevent 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
closedpromise.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.run()-wrapper boundary is a finalizing state inside the task that deferstask.complete()until teardown ends, so the caller agent is not resumed at all until cleanup is done. That reworks thetask.donere-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
AgentSessionshutdown behavior (activity race during close) — happy to rebase/sequence whichever lands first.Testing
warm_transfer_abort.test.tsruns the realAgentSession/AgentTaskmachinery 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.agentspackage suite passes (2144 tests), plus typecheck, eslint,throws:check, and prettier.pnpm api:checkfails identically on unmodifiedmain(export * asunsupported by the bundled api-extractor), so it isn't affected by this change.🤖 Generated with Claude Code