Skip to main content

Durable Runs

Every agent.run(...) saves a run record as it goes: its status, its step, each tool call’s state, its approvals and its usage. By the end of this page you will have a run whose process was killed in the middle of charging a card, picked up by another process that knows the charge may already have happened.
Every output on this page is what the code printed when it was run. The model’s wording, the ids and the timestamps will differ on your run.

A run’s record

Nothing was configured: every agent keeps run records in its memory store. The record also holds the run’s own working context (the history it started from and the messages it added), its approvals and budget requests, and the tool calls’ arguments as a digest, not in full. A call’s outcome is success, error, denied (refused by the policy or a person), rejected (invalid arguments), timeout, cancelled, or unknown (interrupted mid-call, or begun and then past its time limit). run() returns the run_id; get_run(run_id) reads the record and list_runs(session_id=, status=, limit=100) lists records, oldest first. A run that ends failed says why on its record: error holds the error’s type and message (credentials removed, personal data redacted by your privacy settings), and termination_reason says whose fault it was. provider_error is an error from the model provider; internal_error is a fault inside the runtime or a component it uses (a store, a budget ledger); a run that stopped on a limit says max_steps, resource_limit and so on. The default store is in memory, so here the record dies with the process. To resume a run anywhere else, the store must be one both processes reach: SQLite, PostgreSQL, Redis or MongoDB (Stores and scale).

Survive a crash

A payment agent starts charging a card. The provider is slow, and the process is killed before the reply arrives. Did the charge happen? The run record knows the call started and never finished, and a second process takes the run over without charging twice.
1

One definition, imported by both processes

SQLite needs pip install "omnicoreagent[postgres]" (it brings SQLAlchemy).
2

Process one: start the charge, then die

3

Process two: take the run over

  • The dead process left the record running, with charge_card started.
  • Until its heartbeat was 15 seconds old (run_lease_seconds), resume refused: from outside, a dead process and a busy one look the same.
  • charge_card is not idempotent, so the runtime did not run it again, and never will: that is the guarantee. The model was told its outcome is unknown; on this run it checked with list_charges, found the charge and answered. One charge, not two. The call’s outcome is unknown.
  • list_charges is idempotent, so the runtime may run it again by design: a call whose result was saved is never repeated, but one whose result was lost (it finished while a sibling call was still running) runs again on resume, because running it twice is harmless. The second list_charges above is a new call; how many a run makes depends on the model.
  • The runtime never repeats a call whose outcome is unknown, but the model decides what to do next: told the outcome is unknown, a model can choose to call the tool again. That is a new call it made, recorded as one. Give tools that must not run twice a way to check (here list_charges), or make them idempotent (below).

How it works

1

Saved as it goes

The record is written when the run starts, at each step, before each tool call (started, a write-ahead) and after it (completed), at every approval or budget request, and when the run ends. The results of a turn’s calls are saved once every call of that turn has finished; a call whose result is saved is never called again.
2

A heartbeat, and a lease

While a run is live it refreshes heartbeat_at every third of run_lease_seconds (60 by default), even during a long model or tool call. A running record whose heartbeat is older than the lease belongs to a process that stopped; any process with the same store may take it over.
3

One owner at a time

Saves are versioned: a save names the version it read, and a stale one is refused. Two processes that both try to take a run over cannot both advance it; the one that lost stops writing (its save raises RunStateConflict).
4

The stopped step, finished carefully

On resume, the calls of the step that stopped are sorted out: a call whose result was saved is not run again; a call that never started runs; any other call runs again only if its tool is idempotent. That covers a call that started and never finished (or was cancelled or timed out), and one that finished while a sibling call was still running, so its result was lost. Otherwise the model receives an unknown_outcome error: “its outcome is unknown: it may or may not have taken effect. Check before calling it again.”, or, for a call that finished, “This call finished (it reported success), but its result was lost … It has taken effect: do not call it again without checking.”

Idempotent tools

Declare a tool idempotent when running it twice with the same arguments has no further effect, such as a read or an upsert by key:
Tools are not idempotent unless they say so. The built-in reads (workspace files, artifacts, read_skill_file) are. An MCP tool is when its server marks it readOnlyHint or idempotentHint. For a tool with side effects, give the model a way to check, as list_charges did above. The flag is recorded on each call when it starts, and a call runs again only if the tool was idempotent then and is now, so changing a tool’s flag in a deploy cannot rerun a call made under the old one.

Your idempotency key

A tool that calls a service can make itself safe to run twice: the call’s tool_call_id is the same on every resume of the run, so use it as the service’s idempotency key (Stripe’s Idempotency-Key, a database row’s unique id). Then a retry after a crash is a no-op at the service, and the tool can be declared idempotent=True. Tools get the id from omnicoreagent.governance.calls.current_tool_call().

A call that runs past its time limit

A plain (synchronous) tool runs in a worker thread, and a thread cannot be stopped. When tool_call_timeout fires on a call whose function had already begun, the thread carries on and its effect can land after the limit. For a tool that is not idempotent, that is the same situation as a crash in the middle of a call, and it is treated the same way: the call’s outcome in the run record is unknown, the trace’s tool_error event says may_still_complete: true, and the model receives an unknown_outcome error that says the call may still complete, and to check what it did before calling it again and before telling the user it failed. (The support desk chaos run of 2026-10-07 had a model tell a customer “The refund was not issued” about a call of this kind.) Two cases keep the plain timeout: a call that never began (it was waiting for a worker thread, so cancelling it stopped it), and a call whose tool is idempotent. An async tool is cancelled at its next await and also keeps timeout; its message still says to check before calling again.

What is guaranteed

What a run does after its process dies, stated exactly. “Resume” means resume(run_id), or run(..., run_id=) on a run that can resume.
  • One owner. Saves are versioned, so two processes cannot both advance a run; the one that lost gets RunStateConflict. A live run refreshes its heartbeat even during a long call. If a process stops refreshing it without dying (its store unreachable, say), another may take the run over while that process’s tool is still running.
  • Budgets. A model call’s worst case is held before it is sent and its real cost committed after. If the process stops in between, the hold stands until the run is resumed or ended, and is then released: that one call is counted as a call, but its tokens and cost are not. A tool call is counted each time it is dispatched, including once more when an approved call runs.
  • Usage in the record (get_run(...)["usage"]) is written when a run ends; a segment whose process stopped adds none. The spend itself is in the budget counters.
  • Sub-agents. A delegation is a tool call that is not idempotent, so the rows above apply to it. A child run left behind by a parent that stopped is not resumed, except a worker waiting for an approval.
  • Background retries. A retry after a timeout resumes the same run, so completed calls are not repeated. A retry after a failure starts over only if the run made no non-idempotent call; otherwise the run ends failed, is not retried, and lists its side_effects for a person. A worker that died is resumed.

What each store keeps when the machine loses power

The rows above hold when the process dies. When the machine loses power, what survives depends on the store’s own write settings: A save that did not survive leaves the run one step earlier than it was: the rows above then apply to that step, so a call made after the lost save is one the record never saw started, and it runs again. Give a store that holds payments the setting that keeps every save. These come from _pending_calls (core/agents/base.py), the call recording in core/agents/native_tools.py and core/runs.py, and RunBudgets.release_stale (core/budgets.py). The tests that hold them are in the repository, not the installed package: test_run_recovery.py, test_durable_runs_end_to_end.py, test_run_suspend.py and test_budget_stale_holds.py.

Pause and resume

A run can also stop on purpose, keep everything it has done, and continue with resume(run_id), in this process or any other with the same store: Each pause and each resume is its own trace segment, joined by get_run_trajectory(run_id) (below). A run waiting for a person holds no process and no lease: it can wait days, and it is never pruned while it waits.

Steer and interrupt a live run

  • steer(run_id, message, sender=None) queues a message on the record. It arrives as a user message at the run’s next step boundary, never in the middle of a model or tool call; here the model then fetched Q3 as well. A message for a run that is waiting or interrupted arrives when it resumes. It is user input: the injection guardrail checks it first, and a blocked message returns {"status": "blocked", ...} and is never queued. It is recorded in the run’s history and trace (run_steered). Only your application can steer a run; tool output cannot.
  • interrupt(run_id) asks a running run to stop at its next step boundary. The Q3 call already running finished first. The run returns status: "interrupted", keeps everything it did, and resume continues it. A run that finishes before it reaches another boundary simply finishes.
Both work from any process that shares the store: they write to the record, and the run reads it at each step.

Runs a dead process left behind

By itself, nothing resumes a run whose process died: an interactive run stays running, with a lapsed lease, until something calls resume(run_id) (background runs have a supervisor that does). A server does it for you: OmniServe sweeps for these runs on start and then every OMNICOREAGENT_SERVE_ORPHAN_SWEEP_INTERVAL_SECONDS (30 by default, each wait varying by up to a quarter, the first one a few seconds at most, so replicas do not sweep together). Without OmniServe, the same two calls are yours:
  • An orphan is a run of this agent that is running and whose heartbeat is older than its lease. A background run is left to its supervisor.
  • A decided run is one in awaiting_approval or awaiting_budget whose every approval is approved or denied (or whose budget request is granted or denied), that no live owner holds, and that nobody has resumed for OMNICOREAGENT_SERVE_ORPHAN_SWEEP_DECIDED_GRACE_SECONDS (30 by default) after the last decision. The sweep resumes it exactly as resume would, so a person’s decision leads somewhere even when the client that should have called resume was refused (a 503 from the admission limit) or is gone. The support desk ramp at 100 users left 119 approved runs waiting like this. A run with a decision still pending is left waiting, and so is a decided run inside its grace period. Pass decided_grace_seconds= to claim_orphaned_runs when you sweep yourself.
  • If a client’s resume and the sweep arrive together, the record’s version check lets exactly one win. The other is told the run was taken (409 over HTTP, ValueError from resume), and a call runs once.
  • A run is claimed through its record’s own version check, so two replicas never resume the same run, and a live run is never taken from its owner.
  • A claim keeps the run’s lease alive with heartbeats until the resumed run’s own heartbeat takes over, so a slow start (a cold model client can take over a minute) never lets a second sweeper claim the run. If you claim runs yourself and do not resume one, call agent.release_claim(claim).
  • A resume removes the sandboxes a dead attempt left only after it has saved the run as its own, so a process that lost the claim never removes the winner’s sandbox.
  • A recovered run continues under the rules above: completed calls never run again, and a call that did not finish becomes unknown_outcome unless its tool is idempotent.
  • Its trace says why: the run_resumed event has cause recovered_after_lapsed_lease, trigger orphan_sweep, the dead previous_owner, and orphaned_seconds. A decided run has the cause an explicit resume gives (approval, budget_grant or budget_denied), the same trigger, and decision (approved, denied, granted) with decided_at.
  • A run recovered OMNICOREAGENT_SERVE_ORPHAN_SWEEP_MAX_RECOVERIES times (3) and still not finished is ended failed: it is probably what stops its process. Resuming a decided run does not count as a recovery.
  • At most OMNICOREAGENT_SERVE_ORPHAN_SWEEP_MAX_CONCURRENT (2) recovered runs work at once. When a sweep fills every slot, the next one follows as soon as a slot frees, so a backlog (a hundred decided runs) is not resumed two per interval.
To keep the earlier behaviour, where a person or your own code resumes every orphan, set OMNICOREAGENT_SERVE_ORPHAN_SWEEP_ENABLED=false (OmniServeConfig(orphan_sweep_enabled=False)).

End a run nobody will resume

abandon_run(run_id, status=, reason=) closes a run from outside: one that waits for an approval nobody will give, an interrupted run, or a running run whose process died. status is cancelled, failed or timeout. The run’s own budget counters are released.
A run that has already ended is left as it is: abandon_run returns its record unchanged. Workers the run started with spawn_subagents are settled when the run ends, whether it finishes (after a resume, say), is cancelled or is abandoned. A worker still running whose own heartbeat is older than its lease has lost its process and its lead, so it becomes abandoned, with an error naming the lead’s run. A worker whose heartbeat is current is never touched.

Run again with the same run ID

Calling run(query, run_id=...) with the ID of a finished run (completed, failed, blocked, cancelled, timeout) starts a new attempt on the same record. The record keeps a summary of the earlier ones:
With the ID of a run that can resume (interrupted, running with a stale heartbeat, or waiting on decisions already made), run continues it as resume does; background recovery works this way. With a run that is still live or still waiting, it raises the same ValueError as resume.

Run statuses

The record’s status and the status that run() and resume() return name the same moments in two vocabularies:

A run that ends after side effects

A run that ends timeout or failed after it made calls that are not idempotent says so, on its record and in its error:
side_effects lists each call of a tool that is not idempotent whose outcome is success (it completed) or unknown (it started and did not finish, or ran past its time limit). It names the tool and the call, never the arguments, and reads are left out. The same list is in the run’s error and in everything OmniServe answers for the run (OmniServe). The support desk chaos run (2026-10-07) had a refund issued, then a deadline on the next model call; the client saw only the timeout. After a timeout or a failure, look at the side effects. To continue, resume the run: resume(run_id) (POST /runs/{run_id}/resume over HTTP) continues a timeout run as it does an interrupted one. Calls that completed are not run again, and a call whose outcome is unknown is handled by the rules above. A failed run cannot be resumed, and a new run starts over: it can repeat every call in the list. So run(query, run_id=...) on a finished, failed or timed-out run, which begins a new attempt, is not the way to continue; for a failed run, read the list and decide what the new run must not repeat.

One story per run

Each pause, resume, recovery or new attempt is its own trace segment. Read the whole run at once, here for the crashed payment run:
The first segment is interrupted: its process died before it could end its trace, so the resume closed it, ended at its last event. (Until a run resumes, the dead segment still reads running.) The two views count the interrupted call differently, on purpose: the run record says what is known of its effect (unknown: it started and no result came back), and the trajectory’s totals say how its span ended (cancelled, or timeout when a deadline stopped it). Each segment’s steps are in segment["trajectory"]["steps"]; totals are summed across segments, each tool call counted once. The traces live in the workspace directory (./workspace/telemetry/ by default), not in the memory store: a process that reads another’s run needs the same workspace directory, or a shared trace archive (Stores and scale). Reading a trajectory: Observability.

Sandboxes across a pause

A run that waits, is interrupted or loses its process does not keep its sandbox. Workspace files are safe: they are copied back after every command. When the run resumes, it gets a fresh sandbox with the workspace files, and the model is told the sandbox was reset (files outside the workspace, installed packages and running processes are gone).

Background runs

A background run that pauses for an approval or a budget goes to awaiting_approval or awaiting_budget, holds no worker lease, and keeps its task’s slot. Decide, then queue it again with await manager.resume_run(run_id) (over HTTP, POST /background/runs/{run_id}/resume); the next attempt continues the same run. A background task that fails, times out or is cancelled and is retried starts a new attempt from the first step (see Run again with the same run ID): the calls of the earlier attempt are not remembered, so its tools can run again. A background worker that dies is recovered with the same run ID once its worker lease (BackgroundAgentManager(lease_seconds=30)) lapses. The run record’s own lease must have lapsed by then too, or the recovery is refused as still running: for agents that run in the background, set run_lease_seconds to the worker lease or less (the defaults are 60 and 30).

How long a run’s evidence is kept

A run leaves two kinds of evidence, kept in different places for different lengths of time. Each window is yours to set, and None keeps everything: So with the defaults, from day 8 to day 30 you can still read what a run did in summary — get_run(run_id): its status, approvals, usage — but not step by step: its traces are gone. get_run_trajectory(run_id) then marks each segment trace_kept: False, counts them in traces_missing, and leaves totals empty rather than reporting zero; the record’s usage is still there. To keep the full step-by-step story as long as the record, set both:
A finished run’s record (completed, failed, blocked, cancelled, timeout) is removed that many days after the run started. A run still running, waiting for a person or interrupted is never removed, however old. Each window is applied once, when the agent starts its first run (the trace window also when a process first reads its traces, such as a first get_run_trajectory). To apply them now, and to see what was done:
telemetry_retention_status() has trace_store, payload_store and runs, each with its window and last cleanup. Housekeeping never fails a run: a failure shows in error. Nothing you exported elsewhere (OpenTelemetry, JSONL) is touched. An outcome recorded for a run whose record is gone raises LookupError: keep records as long as outcomes can arrive.

Options

In agent_config: And memory_router=MemoryRouter(...), the store that holds the records (Stores and scale). The trace windows are in the telemetry settings. Methods, each in the OmniCoreAgent reference: get_run, list_runs, resume, steer, interrupt, abandon_run, get_run_trajectory, prune_runs, prune_telemetry, telemetry_retention_status; for pauses, resolve_approval, grant_budget, deny_budget.

When things go wrong

resume, or run with that run_id, on a run whose heartbeat is younger than run_lease_seconds:
Either it really is running elsewhere, or its process died moments ago. Wait out the lease and try again, as recover.py does. A lower run_lease_seconds recovers sooner; too low, and a live run that misses a heartbeat could be taken over.
resume on a run that has ended:
To run it again, call run(query, run_id=...): a new attempt on the same record.
resume (or record_outcome) on a run id the store does not hold:
get_run and get_run_trajectory do not raise for such an id; they return None. The run is not in this agent’s memory store. In another process, build the agent with the same memory_router: the default in-memory store is not shared, and a run record kept there is gone when the process ends. It may also have been pruned (run_retention_days).
interrupt needs a running run. steer needs one that is running, awaiting_approval or interrupted.
abandon_run takes one of those three.
resume on a run whose request is on neither its record nor its history:
A run keeps the user’s request on its record from its first save, so a resume after the memory store failed mid-request still has it. A record written before that, whose message never reached the history, has nothing to answer, so the resume refuses and the run ends failed. Start the request again. A memory read that fails at the start of a run is tried again (twice, over about two seconds) before the run fails with the store’s error.
That call started and its process stopped before its result was saved, and its tool is not idempotent, so it was not run again. This is on purpose. If running it twice is harmless, register the tool with idempotent=True; if not, give the model a way to check, like list_charges above.
interrupt takes effect at the next step boundary. A run in its last model call finishes first, and returns success.

Next

Stores and scale

Which store keeps what, and one process versus several servers.

Approvals

A run that waits for a person, then resumes.

Background agents

Scheduled and queued runs, recovered when a worker dies.

OmniServe

Runs, approvals, resume and budgets over HTTP.

Agent in a container

Package the agent, its keys and its stores.

Budgets

The other pause: a run that needs more money.