Durable execution
An agent run is a loop that calls a model, calls tools, and decides whether it is done. AgentArea runs that loop as a Temporal workflow, which means the loop’s state survives the process executing it. If the worker is killed mid-iteration, another worker picks the run up at the same point with the same conversation history, the same budget counter, and the same pending approvals.The problem
An agent loop is long, expensive, and externally interactive. It can run for minutes to hours, each iteration costs money that must not be spent twice, and users need to pause it, approve a tool call, change its model, or send it a new message while it runs. Run that loop in a web process and every one of those breaks. A deploy kills it with no record of where it was. A retry re-runs LLM calls already paid for. There is no way to address a running loop from outside, so approval and follow-up have to be faked with polling and a shared cache. And the failure mode is silent: the user sees a spinner that never resolves.How AgentArea approaches it
AgentExecutionWorkflow is the loop. Everything with a side effect — calling
the model, calling a tool, writing task status, publishing events, materializing
skill files, validating artifacts — is a Temporal activity. The workflow itself
only decides and remembers.
The workflow id is task-{task_id} and the task queue is agent-tasks. That id
is deterministic, so any surface that knows the task id can address the running
workflow without a lookup.
What replay buys
Temporal records every activity result in the workflow’s history and replays that history to rebuild in-memory state after a crash. The practical consequence is that a completed LLM call is never re-issued: on replay, the workflow reads the recorded result instead of calling the model again. The budget counter, the message list, and the iteration number come back exactly as they were.Retries are per activity and typed
Each activity carries its own timeout and retry policy:
Retries are not blanket.
make_retry_policy derives Temporal’s
non_retryable_error_types from the PermanentError class hierarchy, so an
activity that raises AgentNotFoundError or ModelInstanceNotFoundError fails
immediately rather than burning three attempts on a failure that cannot change.
Adding a permanent failure is a new subclass in the domain; the policy picks it
up with no change at the Temporal layer.
The LLM path draws the same line inside the provider taxonomy. Authentication
failures, quota exhaustion, and unknown-model errors are permanent. Rate limits
are explicitly not — a 429 is retried with backoff, and the check for it runs
before the quota check so that “rate limit exceeded” is not swallowed by a
substring match on “exceeded”.
Signals, updates, and queries
External control is not polling. Five signals reach a running workflow:pause_execution/resume_execution— the loop parks on a wait condition.resolve_escalation— approve or deny a specific tool escalation. The workflow re-checks the caller against the task’s approver list and ignores an unauthorized signal, so the API boundary cannot bypass policy.handle_a2ui_action— a UI interaction, queued and injected as a user message.workflow_command— a dispatcher forchange_model,update_budget,continue_execution,queue_message,submit_user_input,remove_message.
continue_execution also exists as a Temporal update, which is what the
continuation endpoint uses: a signal is fire-and-forget, and granting resources
needs a validated accept/reject answer in the response.
Three queries read state without touching it: get_current_state,
get_workflow_events, get_latest_events. get_current_state is how the
governance API serves the effective policy a task is actually running under —
the workflow carries the resolved snapshot, so the answer is the one being
enforced rather than a re-resolution that might differ.
Durable timers
Waiting is free. The 30-minute follow-up window, the 30-minute user-input window, and the 24-hour continuation window are all Temporal timers. The workflow consumes no worker slot while parked; Temporal persists the state and wakes it on signal or expiry. A task can sit inwaiting_for_continuation
overnight at no runtime cost.
Continue-as-new
Temporal history has a size limit, and a long agent run will reach it. Whenis_continue_as_new_suggested() returns true, the workflow compacts its message
history, packs its state into a ContinueAsNewState, emits an event, and
restarts with a fresh history. The task id stays the same, so nothing outside
notices.
Why not run the loop in the API process
That option exists in the codebase. SettingWORKFLOW__EXECUTION_ENGINE=direct
swaps in DirectTaskManager, which runs the loop in-process. It is a useful
comparison because it shows precisely what durability is worth.
DirectTaskManager runs the whole loop inside the submitting request, so the
caller blocks until the agent finishes. It has a hard-coded ceiling of 10
iterations. cancel_task logs a warning and returns False. It supports two
tools — skill activation and completion — and answers any other tool call with
“Unknown tool”. It emits no workflow events, so nothing streams. A restart loses
the run with no record beyond a failed row.
Why not a job queue
Celery, Arq, or a Redis work queue give you retry and at-least-once delivery, which covers the crash case. They do not give you the other three. There is no replayable history, so a retry re-runs the whole job including LLM calls already paid for — you have to build your own checkpointing and make every step idempotent. There is no addressable handle on a running job, so approval, model change, and follow-up messages need a side channel and a polling loop. And there are no durable timers, so a 24-hour wait is either a held worker or a scheduled re-enqueue you have to write and monitor. The honest version of the tradeoff: you can build this on a queue, and what you end up with is a worse Temporal.Limits
- Determinism constrains the workflow file. Workflow code cannot read the
clock, generate randomness, or perform I/O directly. Imports that touch those
go through
workflow.unsafe.imports_passed_through. Every side effect must be an activity, which is why the file is long and indirect. - There is no workflow versioning in use. The codebase uses no
workflow.patchedorget_versioncalls. A change to workflow control flow can therefore break replay for in-flight workflows started against the older code. Deploys that change the loop should drain first. ContinueAsNewStateis a manual enumeration. Every field of workflow state that must survive a continue-as-new is listed by hand. A new field added to the workflow and not added there is silently lost at the next restart.- The immediate event-publish path can drop events.
_publish_events_immediatelyclears its pending buffer before it publishes, with a 5-second timeout and a single attempt. If the publish activity fails, those events are gone from the workflow’s buffer, and the failure surfaces as a workflow error rather than a retry. - Temporal orchestrates within a task, not between contexts. Cross-context reactions — an agent deletion deactivating its triggers, for example — are the event bus’s job, not the workflow’s. Do not reach for a child workflow to do choreography.
- Cancellation is a Temporal-side fact. See the cancel limit in Tasks.
- Operating this is real work. A Temporal server, its datastore, and at least one worker are load-bearing infrastructure. If the worker fleet is down, tasks are created and never dispatched.