The user closes the tab. The agent keeps running. It finishes the web search, calls a tool, sends an email, and writes the result to a CRM record. From the user’s point of view, the task was cancelled. From the system’s point of view, only the browser stopped listening.
Cancelling an AI agent is not a UI event; it is a distributed workflow transition. Model calls, tools, queues, child agents, and external APIs may all be doing work at once. Some work can be stopped. Some cannot be recalled. A safe design propagates cancellation, stops starting new side effects, reconciles actions already in flight, and reports the final state honestly.
This article covers cancellation semantics, state machines, deadlines, external tool calls, queue workers, streaming clients, cleanup, and the tests needed to prove that a cancelled run does not leave orphaned work behind.
A disconnect is not a cancellation
Several events can look like “the user cancelled” while meaning different things:
- the browser tab closed;
- the network connection dropped;
- the frontend called an abort API;
- the application marked a task cancelled;
- the model provider acknowledged cancellation;
- a tool or downstream service stopped processing;
- the workflow reached a terminal state.
Do not collapse those into one boolean. Closing an HTTP stream usually proves only that the client is no longer receiving bytes. A request may continue at the application server, provider, worker, or downstream service. Even if a provider supports generation cancellation, it cannot undo a tool call that already committed.
Define the product promise precisely. “Stop showing the answer” is different from “stop spending tokens,” and both differ from “undo the action.” A button labeled “Cancel task” should not claim the strongest behavior unless the system can guarantee it.
Model cancellation as a state machine
A durable operation can use states such as:
queued → running → cancel_requested → cancelling → cancelled
↓ ↓
completed unknown
↓
compensated
The cancel_requested state is important. It records intent while workers and tools reach a safe checkpoint. cancelled means no further work will be started and all cancellable work has stopped or been reconciled. unknown means a side effect may have completed and needs reconciliation. completed may win a race if the task finished just before the cancellation request was committed.
Store the task state in a durable database or workflow engine. In-memory flags disappear on process restart, and an AI run can last longer than the HTTP request that started it. Every worker should consult the durable state before beginning a new model call or side effect.
1. Give cancellation a stable task identity
Create a task_id when the user starts the agent run. Propagate it through model calls, tool calls, child agents, queue jobs, approval requests, and notifications. A cancel request should refer to that ID, not the browser connection or a model response ID.
Store at least:
- task and tenant IDs;
- requesting actor;
- current state and state version;
- cancellation timestamp and requester;
- active child operation IDs;
- tool operations and their outcomes;
- task deadline and budget;
- final result or reason for stopping.
The server must authorize the cancellation request. A user can cancel their own task; an administrator or operator may have broader powers, but those should be explicit. A guessed task ID should not let another tenant stop someone else’s workflow.
2. Propagate cancellation cooperatively
Cancellation works best as a signal that each stage checks at safe boundaries. A workflow can inspect the task state before starting retrieval, after a model response, before each tool call, and before committing a write.
async def run_task(task_id, model, tools, repository):
task = repository.get_task(task_id)
try:
context = await load_context(task)
if repository.cancel_requested(task_id):
return repository.mark_cancelled(task_id)
plan = await model.plan(task.prompt, context)
repository.save_plan(task_id, plan)
for action in plan.actions:
if repository.cancel_requested(task_id):
return await reconcile_and_cancel(task_id, repository)
await tools.execute(action, operation_id=action.operation_id)
return repository.mark_completed(task_id)
except Exception as exc:
repository.record_failure(task_id, safe_error_code(exc))
raise
This sketch assumes the model client and tool wrapper expose their own cancellation or timeout behavior. A cancellation check between steps cannot interrupt a blocking call already in progress. Use the provider’s supported cancellation mechanism when available, and configure timeouts so the workflow eventually regains control.
Cooperative cancellation is not enough for safety-critical writes. The tool boundary itself should check task state and authorization immediately before executing. That closes the race where the workflow checks “not cancelled,” then a user cancels, then the worker commits a write.
3. Separate cancellation from compensation
Before a side effect starts, cancellation can often stop the workflow cleanly. After an external action commits, cancellation cannot erase history.
Examples:
- a model generation can often be aborted, but generated tokens already consumed may still be billable;
- a database transaction can be rolled back before commit;
- an email already sent cannot be unsent;
- a payment may require a refund or void, which is a new operation;
- a provisioning action may require a compensating delete, which can also fail.
Use separate states for cancellation and compensation. If the user cancels after a payment succeeds, record the payment as succeeded and create a refund workflow if policy permits. Do not rewrite the original operation as “cancelled” and lose the fact that money moved.
For each write tool, define the cancellation boundary: before request sent, request in flight, response received, local state committed. At each point specify whether the action can stop, needs reconciliation, or needs compensation.
4. Resolve the race between cancel and completion
Cancellation and completion can occur at nearly the same time. Use a conditional state transition so only one terminal outcome wins.
update agent_tasks
set status = 'cancel_requested',
cancel_requested_at = now(),
version = version + 1
where id = :task_id
and status in ('queued', 'running')
returning id, status, version;
If the update returns no row, read the current state. The task may already be completed, already cancelled, or in a non-cancellable state. Return that reality to the client rather than claiming cancellation succeeded.
For a write, use a separate operation state and a transactional check. A task-level cancellation flag should prevent new operations, while the tool operation record determines whether an existing side effect is pending, succeeded, failed, or unknown. This prevents a late worker from treating a task cancellation as proof that an external payment did not happen.
5. Propagate cancellation to child agents and tools
An orchestrator that cancels itself but leaves child agents running has not cancelled the task. Maintain a parent-child relationship and propagate the signal to every child. A child should stop launching new tools, release temporary resources, and report its state to the parent.
For fan-out work, cancel siblings that are no longer needed, but do not discard a result that has already committed a side effect. The parent should await child cancellation acknowledgments or mark their outcomes unknown if they time out.
Third-party tools need explicit contracts. A tool that cannot cancel an in-flight request should report that limitation, and the workflow should switch to reconciliation. Do not assume that aborting a local HTTP request guarantees the remote service stopped processing.
OpenAI’s Agents SDK includes streaming-run cancellation APIs, but the exact behavior depends on the SDK version and run state; provider or tool side effects still need their own controls. Treat SDK cancellation as a signal within the agent runtime, not as a universal rollback mechanism. (OpenAI Agents SDK, streaming)
6. Make queue workers cancellation-aware
Queued tasks create a common orphan case: the user cancels, but the message remains in the queue and a worker later starts it. Before processing a message, the worker must reload the task and confirm it is still eligible to run.
For long-running jobs:
- store cancellation state outside the message payload;
- check it before every expensive model call and side effect;
- use leases or heartbeats so abandoned workers are detected;
- stop retrying a task after cancellation;
- handle redelivery by loading the durable task state;
- clean up temporary files and memory only when safe.
Queue cancellation may be best-effort. If a message cannot be removed, the worker can still acknowledge and discard it after checking the task state. The user-facing status should reflect whether the work is stopped, pending cancellation, or uncertain.
Do not swallow cancellation inside application code
Cancellation APIs usually signal through exceptions, abort signals, or task state. A broad exception handler that catches everything and retries can accidentally convert cancellation into more work.
In Python, handle asyncio.CancelledError specifically, perform bounded cleanup, and re-raise it so the task runtime can mark the coroutine cancelled:
import asyncio
async def run_step(client, operation):
try:
return await client.execute(operation)
except asyncio.CancelledError:
await record_cancel_requested(operation.id)
raise
finally:
await release_local_resources(operation.id)
The cleanup itself should have a timeout and should not start a new model request. If it cannot finish, persist the needed cleanup state for a separate worker. Apply the same principle in JavaScript: aborting a fetch can stop local waiting, but code must not catch the abort and transparently issue the same write again.
Review SDK and framework cancellation semantics for the exact versions you deploy. Some runtimes cancel a task cooperatively at an await point; some APIs only stop receiving data; some downstream services have no cancellation mechanism. Make that boundary explicit in the wrapper and in the user-facing state.
Keep finalizers and reconciliation alive when needed
Cancellation should stop the main agent loop, but some bounded cleanup must continue: release a lock, persist cancel_requested, flush a required audit event, or reconcile a potentially completed write. Cancelling every child task indiscriminately can orphan precisely the work needed to know what happened.
Separate work cancellation from recovery work. Once a side effect may have been submitted, create a durable reconciliation job that is not tied to the browser session. It can query the payment or order system, update the operation record, and notify the user. The agent should remain stopped while that deterministic recovery runs.
Use leases or durable workflow timers for cleanup. Do not keep an in-memory finalizer alive indefinitely after the worker is shut down. If the process dies, a recovery worker should discover operations stuck in cancel_requested, in_progress, or unknown and continue safely.
Make cleanup idempotent as well. Releasing the same reservation twice should be safe or detected; deleting a temporary object should not remove a user-owned artifact that was attached to another task.
7. Preserve partial results and clean up resources
Cancellation can leave useful completed work: a retrieved document list, a draft, or a partially assembled report. Decide which results are retained and how they are labeled. A partial artifact should not be mistaken for a complete one.
Temporary resources also need ownership. Cancelled tasks may leave uploaded files, browser sessions, locks, sandbox containers, or reserved capacity. Add cleanup jobs with idempotent deletion and retention deadlines. Do not delete an artifact that is still referenced by an in-flight operation or required for an audit record.
For streaming responses, stop sending deltas after cancellation is accepted. If partial text is retained, mark it incomplete and do not feed it back into a later turn as a completed assistant answer without an explicit status.
8. Design the cancellation UX honestly
The user should see a distinction between:
- Cancel requested: the system received the request but has not stopped all work;
- Cancelled: no further work is running and cancellable steps are stopped;
- Completed: the task finished before cancellation took effect;
- Checking outcome: an external side effect may have completed and is being reconciled;
- Compensation pending: the original action completed and a reversal is underway.
For long-running tasks, a confirmation may be needed before cancelling if the user would lose significant progress. For potentially irreversible writes, the UI should explain that cancellation may not undo an action already submitted.
Do not use a generic “Cancelled” toast immediately after aborting the browser request. The server should return the durable task state, and the UI should update from that state. Preserve the task reference so the user can check what happened after navigating away.
9. Bound the cancellation path itself
Cancellation should not trigger another unbounded agent run to summarize why the task stopped. Write a compact status from deterministic state: which steps completed, which were cancelled, and which require reconciliation. If a model-generated summary is useful, make it optional and run it only after side effects are safe.
Set a maximum time for cancellation acknowledgment. If a tool does not respond, transition the operation to unknown, alert the recovery process, and stop starting additional writes. Record the last known state and the external request reference so an operator can reconcile it.
Budgets should include cancellation overhead. Aborting a generation may not refund tokens already consumed. Tool cancellation may require a status lookup. Compensation may incur an additional charge. The product should monitor these costs instead of treating cancellation as free.
Distinguish a deadline from a user cancellation
A deadline is a system policy: the task exceeded its allowed time. A user cancellation is an explicit request to stop. Both can signal the same worker machinery, but they may have different consequences.
For example, when a user cancels a draft, the application may preserve the partial text for later editing. When a deadline expires on a payment workflow, the system may need to reconcile provider state even though the user did not ask to stop. When a human approval expires, the policy may close the proposal rather than resume it.
Record the stop reason separately: user_cancelled, deadline_exceeded, policy_blocked, or operator_paused. This distinction helps support explain what happened and helps the product measure whether deadlines are too short or users are actively abandoning the task. It also prevents an automatic timeout from being reported as though the user cancelled.
Apply deadlines to the whole operation, not independently to each step. If retrieval consumes most of the budget, the agent should not begin a long generation that cannot finish in time. Check remaining time before retries and before irreversible writes. If a result can still be useful asynchronously, transition to a queued task instead of letting an HTTP timeout determine the workflow state.
Protect the cancellation endpoint
Cancellation is an action on a task and should be authorized like any other action. Check that the caller owns the task or has an administrative role; do not rely on an opaque task ID being unguessable. Make the endpoint idempotent so repeated clicks return the current status rather than creating duplicate cleanup work.
Record who requested cancellation, when it was accepted, and which operations were already in flight. Avoid exposing another tenant’s task state through error messages. If the caller no longer has permission to see the underlying resource, they may still have permission to stop their own task, but the response should disclose only the minimum status needed.
Rate-limit cancellation and status polling. A malicious client should not be able to flood workers with repeated stop signals or force expensive reconciliation for tasks it cannot access. Keep an operator override separate from the user path and audit its use.
10. Test cancellation at every boundary
Inject cancellation:
- Before the task enters the queue.
- While waiting for a worker.
- During retrieval.
- During model streaming.
- After a model proposes a tool call but before execution.
- During a tool call that can be cancelled.
- After a remote write succeeds but before local state commits.
- During a multi-agent handoff.
- While waiting for human approval.
- After the operation has completed.
For each test, assert the task state, child state, side-effect count, retained artifacts, and user-visible status. A test that only checks the browser stopped receiving bytes does not prove the worker or provider stopped.
Include duplicate cancel requests and queue redelivery. Cancellation should be idempotent: sending it twice should not create two compensation operations or corrupt the task state.
Guard the last millisecond before a write
The hardest race is cancellation arriving after the worker checked the task but just before it sends a side-effecting request. A task-level check at the top of a loop is not sufficient. The tool wrapper should re-check task state and authorization immediately before execution, then atomically register the operation as in progress with its stable idempotency key.
There is still a boundary between your database and the external service. A cancellation can arrive after the provider received the request. At that point, mark the outcome as unknown and reconcile; do not pretend the local cancel_requested flag undid the remote action. If the write is internal to the same database, use a transaction or conditional update to make the state transition atomic.
For queued writes, a transactional outbox helps ensure a cancelled task does not leave an untracked message waiting to execute. The worker must re-read the durable task state before processing the event. If the action was already claimed, it follows the operation’s cancellation/reconciliation policy rather than blindly ignoring the new state. Test the race under concurrent workers.
A cancellation contract
For every AI workflow, define:
- who can cancel and which task they can cancel;
- what “cancelled” means at each stage;
- how signals propagate to child agents, queues, and tools;
- which side effects cannot be recalled;
- how unknown outcomes are reconciled;
- whether compensation is available and who can authorize it;
- which partial outputs and temporary resources are retained;
- what the user sees while cancellation is pending;
- how cancellation is measured and tested.
The reliable pattern is to stop new work first, propagate cancellation to active components, and then reconcile anything that may already have committed. A browser abort is only a transport event. A real cancellation is a durable workflow decision with an explicit outcome for every side effect.