Blog · · 10 min read

Long-running AI agents: build an async task contract

Long-running AI agents: build an async task contract

Long-running AI agents break an existing product when a request ends before the work does. The worker may keep running, but the user cannot reconnect, inspect progress, provide missing input, or cancel the job. Retrying can start the same work twice. A late result can overwrite newer product state.

A longer HTTP timeout only delays the failure. Put the agent behind a durable asynchronous task contract owned by your product. This guide defines task identity, states, progress, cancellation, result retention, callbacks, failure recovery, and verification. Clients can then follow background work through the same production interfaces they use for other product jobs.

The async task contract in one view

Represent an agent run as a durable product task. The connection is only a way to submit or inspect it. The initial request should create or recover the task record and return quickly. Workers advance that record, while clients observe and control it through ordinary application APIs.

Use this sequence:

  1. Accept the request with an idempotency key and authenticated product context.
  2. Create one durable task record before dispatching work.
  3. Return 202 Accepted with a stable task URL.
  4. Run the agent in a worker that can checkpoint and resume.
  5. Expose status, progress, required input, and cancellation through the product API.
  6. Commit consequential side effects through existing authorization and idempotency controls.
  7. Store the terminal result long enough for a disconnected client to retrieve it.

The experimental MCP Tasks extension uses the same core idea: a server returns a durable task handle instead of blocking, and the client can inspect state, provide input, reconnect, and retrieve the result. Your product should use that lifecycle even when the agent runtime or client does not speak MCP.

Why blocking long-running AI agents fails

A synchronous tool call couples four lifecycles with different deadlines:

  • the browser, mobile app, or API request;
  • the agent's reasoning and tool loop;
  • external work such as imports, reports, provisioning, or batch updates;
  • the product record that users expect to remain queryable.

Holding the connection open does not make those lifecycles reliable. Transport intermediaries impose timeouts, clients disconnect, and workers restart. The MCP Tasks documentation explicitly identifies connection duration, crash recovery, progress visibility, and mid-flight input as reasons to use a task handle.

Model-driven polling is also the wrong control plane. The accepted MCP Tasks proposal, SEP-1686, records a concrete failure in earlier patterns: an agent may forget to poll, poll inconsistently, or spend model calls checking whether work is done. Status retrieval belongs in deterministic application code.

Cancellation exposes another gap. An agent SDK user requested an active-run handle because a run-to-completion API gave the application no clean way to interrupt work or inject updated information. That OpenAI Agents SDK lifecycle request is a practitioner report, not a guarantee about every runtime, but it captures the product requirement: users need control after a run starts.

Implement long-running AI agents behind a task API

Persist the task before dispatch

Create the task record in the same transaction that accepts the request. Do not enqueue first and hope the database write succeeds afterward. The record is the source of truth for whether the product accepted the work.

A minimal record can look like this:

AgentTask {
  id: UUID
  tenant_id: UUID
  requested_by: UUID
  workflow_type: string
  input_ref: string
  idempotency_key: string
  status: queued | working | input_required | completed | failed | cancelled
  progress_current: integer | null
  progress_total: integer | null
  progress_message: string | null
  checkpoint_ref: string | null
  required_input: object | null
  cancel_requested_at: timestamp | null
  result_ref: string | null
  error_code: string | null
  created_at: timestamp
  updated_at: timestamp
  terminal_at: timestamp | null
  version: integer

  UNIQUE (tenant_id, workflow_type, idempotency_key)
}

Keep large prompts, files, and results out of the row. Store references with explicit access checks and retention rules. The task row should remain small enough for frequent status updates and conditional writes.

The idempotency constraint answers a common acceptance question: "Did the server start my task before the connection dropped?" A retry with the same tenant, workflow, and key returns the existing task instead of dispatching another worker.

Specify every state transition

For each state, define who can move the task, where it can move, and what must be true first.

Current stateAllowed next stateOwnerRequired condition
queuedworking, cancelled, failedDispatcherLease acquired, cancellation observed, or dispatch exhausted
workinginput_required, completed, failed, cancelledWorkerCheckpoint saved before pausing or terminating
input_requiredworking, cancelled, failedProduct APIAuthenticated input matches the task version and request schema
completednoneNoneResult and committed effects are durable
failednoneNoneTyped terminal error and recovery owner are recorded
cancellednoneNoneCancellation outcome and any surviving effects are recorded

Use conditional updates such as WHERE id = ? AND version = ? AND status = ?. They prevent a stale worker from completing a task after a newer worker, cancellation request, or operator action changed it.

Google's guide to long-running agents that pause and resume uses explicit durable workflow state rather than conversation history to track progress. In an existing product, chat history can explain what was said, but it should not decide whether a production task already provisioned an account or applied a data change.

Return a stable resource immediately

The create endpoint should return the task representation and a stable status URL:

POST /v1/agent-tasks
Idempotency-Key: import-customers-2026-07-27-acme

202 Accepted
Location: /v1/agent-tasks/task_123

{
  "id": "task_123",
  "status": "queued",
  "status_url": "/v1/agent-tasks/task_123",
  "cancel_url": "/v1/agent-tasks/task_123/cancellation",
  "version": 1
}

Authenticate every read and mutation using the product's user and tenant model. Possession of a task ID only locates the record; it does not authorize access. Avoid embedding raw credentials or sensitive input in the task representation.

For progress, return facts the worker can measure: records processed, steps completed, current phase, or an explicit unknown total. Do not turn model confidence into a fake percentage. The exact-fit Fast.io guide to long-running agent tasks covers queues, checkpoints, retry policies, and progress reporting. Your task API still needs to make those mechanisms behave like the product's other jobs.

Separate polling, callbacks, and agent reasoning

Polling should happen in the client or application service, not inside an LLM loop. Return cache validators or a recommended interval, and add jitter so many clients do not poll in lockstep.

Use callbacks or webhooks when another backend needs completion events. Sign each delivery, include an event ID, and make redelivery safe. The receiver should deduplicate by event ID and fetch the current task before acting. Treat the callback as notice that state may have changed, and keep the result in the task store.

Microsoft's example of long-running MCP tools with Durable Functions describes an asynchronous task handle with status retrieval, input updates, and cancellation while broader client support develops. Keep the responsibilities separate: durable execution owns the work, and the task interface owns client interaction.

Make cancellation a request with a defined outcome

Keep a cancelled task instead of deleting it. Record cancel_requested_at, notify the worker, and return the current state. The worker should check for cancellation between bounded units of work and before each consequential side effect.

Choose and document answers for these races:

  • If completion commits before cancellation, the task stays completed.
  • If cancellation wins before the final commit, the task becomes cancelled.
  • If external work cannot be stopped, the task records cancel_requested internally until the outcome is known.
  • If partial effects already happened, cancellation reports them and starts compensation when the product supports it.

Never claim that cancelled means "nothing happened" unless the system can prove it. For payments, messages, provisioning, and external updates, store action IDs and reconcile unknown outcomes before retrying.

Resume from explicit checkpoints

Checkpoint after meaningful units of work. Avoid writing one after every token or waiting until the whole task finishes. A useful checkpoint includes the workflow step, immutable input version, completed action IDs, pending work, and the product records that must be revalidated on resume.

When a worker resumes, it should reload current authorization and product state. A checkpoint proves where execution stopped. It does not grant permanent permission, and it does not freeze mutable business data.

A realistic example: an account migration task

Suppose an admin asks an agent to classify and migrate 8,000 customer records. The request creates one AgentTask and returns immediately. A worker processes records in batches of 100, checkpoints after each batch, and reports measured progress.

Ambiguous records move the task to input_required with a structured question and a deadline. The admin's answer includes the task version, so an old browser tab cannot resume a newer task accidentally. Each write carries a stable action ID derived from the task and record. If the worker crashes after a database commit but before checkpointing, replay finds the existing action instead of applying the migration twice.

A cancellation request stops the next batch. The final task response reports how many records changed, how many remained untouched, which items need review, and whether compensation is available. A message that only says "the agent stopped" gives the user nothing to verify.

Handle failure and recovery

Recovery has to cover failures at the product boundary as well as model errors:

FailureRecovery behavior
The acceptance result is lostRetry with the same idempotency key and return the existing task.
Queue dispatch is lostLet a reconciler find old queued tasks and dispatch them with a bounded attempt count.
A worker lease expiresFence the stale worker, then resume from the last durable checkpoint.
A status update failsRetry the conditional state write or stop. Do not infer success from worker memory.
Input arrives lateReject it when the task version, state, schema, or deadline no longer matches.
Callback delivery failsRedeliver with the same event ID and keep the result available through the task API.
Cancellation races with completionResolve the race through one conditional terminal transition and report the winning state.
The result expiresReturn an explicit expiry state or tombstone instead of a 404 that looks like the task never existed.

Give failures stable error codes and an owner. Some failures are retryable by the worker, some require user input, and some need operator repair. Do not hide all three behind failed plus a free-text model message.

Verify the task lifecycle

Test the contract without a model first. A deterministic fake worker is enough to exercise lifecycle rules.

Run these checks:

  1. Send the same create request twice and prove that only one task and one dispatch exist.
  2. Drop the create response, retry, and recover the same task ID.
  3. Kill a worker after an external commit but before checkpointing; prove replay does not repeat the effect.
  4. Pause at input_required, restart every process, submit valid input, and resume once.
  5. Submit stale input with an old version and confirm rejection.
  6. Cancel before work starts, during a batch, and during the final commit race.
  7. Deliver each completion callback more than once and prove the receiver acts once.
  8. Disconnect the client, reconnect later, and retrieve progress and the terminal result.
  9. Attempt every endpoint as another user and tenant and confirm denial.
  10. Run a reconciler against stuck queued and working tasks and verify bounded recovery.

Add one invariant to monitoring: every nonterminal task must have a recent lease, a future wake-up time, or a named input deadline. A task with none of those is stranded even if its status still says working.

Common mistakes

A longer timeout postpones the same disconnect and restart failures while tying up connections. Model-managed polling adds cost and makes status retrieval nondeterministic, so keep polling in application code.

A queue message should not double as the task record. Queue retention and delivery semantics do not provide a user-facing source of truth. Keep cancelled tasks too; deleting them removes the evidence needed to explain partial work, reconcile side effects, and prevent replay.

If the total is unknown, report the current phase and completed units instead of inventing a percentage. Before resuming work, recheck identity, tenant, policy, and mutable product state rather than relying on the authority captured at the original checkpoint.

What to do next

Pick one workflow that regularly outlives an HTTP request. Write its state-transition table and the cancellation race rules before choosing a queue or agent framework. Then build the create, status, input, and cancellation endpoints around a fake worker. Once those invariants pass, connect the real agent runtime.

References

Be first in line.

Join the waitlist and we'll email you the moment it's ready. No sales call.

Try the demo →
or talk to us