AI & ML / Agents & orchestration / 14_long_running_agent_jobs.md

Long-running agent jobs

Updated 5 interview angles 5 min read source
On this page8
  1. Three shapes, and when each is right
  2. The job shape
  3. Progress is not optional
  4. Resumption, and what “resume” actually means
  5. Cancellation
  6. What to store
  7. Related
  8. Interview angle

Long-running agent jobs

An agent run takes seconds to minutes, occasionally longer. That does not fit a request/response endpoint, and the shape you choose for it is a system design question an interviewer can go a long way into.

Three shapes, and when each is right

Shape Client sees Use when
Synchronous one response, at the end under ~10s, single call
Streaming tokens as they arrive a user is watching
Job an id, then a result minutes, or no one waiting

Synchronous is fine for a single LLM call and stops being fine the moment a tool loop is involved. A load balancer with a 30-second idle timeout will kill your agent mid-run and return a 502 that tells the user nothing.

Streaming solves the perception problem, not the duration problem — see The chat API surface. It also pins a connection open for the whole run, which caps your concurrency at however many sockets you can hold.

Job is the honest answer past a minute or so.

The job shape

python
@app.post("/runs", status_code=202)
async def start(req: RunRequest) -> RunAccepted:
    run_id = uuid7()
    await queue.enqueue(
        "agent.run", run_id=run_id, payload=req
    )
    return RunAccepted(
        run_id=run_id, status_url=f"/runs/{run_id}"
    )

@app.get("/runs/{run_id}")
async def status(run_id: UUID) -> RunStatus:
    return await store.get(run_id)

202 Accepted plus a status URL is the standard contract. The client polls, subscribes, or gets a webhook — and the API stays fast regardless of how long the agent thinks.

Telling the client it finished

Mechanism Cost Use when
Polling simple, wasteful internal tools, low volume
Webhook client needs an endpoint server-to-server
SSE / WebSocket connection held a UI is open

Polling with sensible backoff is underrated and is usually where to start. Webhooks are the right answer between services, and they bring their own requirements — signature verification, retries, and an idempotent consumer, because you will deliver twice eventually.

Gotcha: a webhook that fires before the run’s state is committed gives the client a callback for a run your own API then reports as pending. Commit first, then publish — or use the outbox pattern. Same bug as any write-then-notify system.

Progress is not optional

A run with no visible progress is indistinguishable from a hung one, and the first support ticket will be “it’s stuck”.

Emit a step event per loop iteration — which tool, how long, what it returned in summary form. That gives you three things at once: a progress bar, a debug trail, and the spans your observability needs anyway. See LLM observability.

Resumption, and what “resume” actually means

If the worker dies at step 7, you want to continue rather than restart — partly for latency, mostly because the first six steps had side effects and cost money.

That requires the state to be outside the process: checkpointed after each step, keyed by run id. LangGraph’s checkpointers do this for the agent graph; a workflow engine does it for the whole process. The trade-off is covered in Durable execution and human-in-the-loop.

The part people miss: resuming replays tool calls unless they are idempotent. A crash between “charge the card” and “write the checkpoint” means the resumed run charges again. That is the same problem as any at-least-once system, and the same fix — idempotency keys, deduped by the tool, not by the agent.

Cancellation

Users close tabs. A run nobody is waiting for still burns tokens.

python
for step in range(MAX_STEPS):
    if await store.is_cancelled(run_id):
        await store.mark(run_id, "cancelled")
        return
    ...

Check between steps rather than trying to interrupt an in-flight provider call. Cancelling mid-call is possible but leaves you unsure whether the provider billed you, so a step boundary is the clean seam.

Pair it with a wall-clock deadline for the whole run, not just per call. An agent that takes twelve minutes has usually gone wrong, and the step limit alone will not catch a run where each step is slow rather than numerous.

What to store

A run record earns its keep when something goes wrong:

text
run_id, tenant_id, status
created_at, finished_at
input, output, error
steps[], model
tokens_in, tokens_out, cost
trace_id

trace_id is the one to insist on — it is what turns “this run was wrong” into an actual investigation rather than a re-run and a shrug.

Interview angle 5

  • “How do you expose an agent that takes two minutes?” - as a job, not a request. 202 Accepted with a run id and a status URL, work on a queue, and a webhook or poll for completion. A synchronous endpoint dies at the load balancer’s idle timeout and returns a 502 that tells the user nothing.
  • “Streaming or a job?” - streaming fixes perceived latency and holds a connection for the whole run, so it caps concurrency at your socket count. It is right when a user is watching and wrong as a way to handle duration.
  • “What does resuming a failed run require?” - state outside the process, checkpointed per step and keyed by run id. And idempotent tools, because a crash between the side effect and the checkpoint means the resumed run repeats it — the classic at-least-once problem.
  • “How do you cancel one?” - a flag checked at step boundaries, plus a wall-clock deadline for the whole run. Interrupting an in-flight provider call leaves you unsure whether you were billed, so the step boundary is the clean seam.
  • “How do you keep a webhook honest?” - commit the run state before publishing, or use an outbox. Firing the callback first gives the client a completion notice for a run your own API still reports as pending.