Durable Runs
Everyagent.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.
A run’s record
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.One definition, imported by both processes
pip install "omnicoreagent[postgres]" (it brings SQLAlchemy).Process one: start the charge, then die
Process two: take the run over
- The dead process left the record
running, withcharge_cardstarted. - Until its heartbeat was 15 seconds old (
run_lease_seconds),resumerefused: from outside, a dead process and a busy one look the same. charge_cardis 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 withlist_charges, found the charge and answered. One charge, not two. The call’soutcomeisunknown.list_chargesis 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 secondlist_chargesabove 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
Saved as it goes
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.A heartbeat, and a lease
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.One owner at a time
RunStateConflict).The stopped step, finished carefully
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: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’stool_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. Whentool_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” meansresume(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 itsside_effectsfor 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:_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 withresume(run_id), in this process or any other with the same store:
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 arunningrun to stop at its next step boundary. The Q3 call already running finished first. The run returnsstatus: "interrupted", keeps everything it did, andresumecontinues it. A run that finishes before it reaches another boundary simply finishes.
Runs a dead process left behind
By itself, nothing resumes a run whose process died: an interactive run staysrunning, 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
runningand whose heartbeat is older than its lease. A background run is left to its supervisor. - A decided run is one in
awaiting_approvalorawaiting_budgetwhose every approval isapprovedordenied(or whose budget request is granted or denied), that no live owner holds, and that nobody has resumed forOMNICOREAGENT_SERVE_ORPHAN_SWEEP_DECIDED_GRACE_SECONDS(30 by default) after the last decision. The sweep resumes it exactly asresumewould, so a person’s decision leads somewhere even when the client that should have calledresumewas refused (a503from 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. Passdecided_grace_seconds=toclaim_orphaned_runswhen you sweep yourself. - If a client’s
resumeand the sweep arrive together, the record’s version check lets exactly one win. The other is told the run was taken (409over HTTP,ValueErrorfromresume), 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_outcomeunless its tool is idempotent. - Its trace says why: the
run_resumedevent hascauserecovered_after_lapsed_lease,triggerorphan_sweep, the deadprevious_owner, andorphaned_seconds. A decided run has thecausean explicit resume gives (approval,budget_grantorbudget_denied), the sametrigger, anddecision(approved,denied,granted) withdecided_at. - A run recovered
OMNICOREAGENT_SERVE_ORPHAN_SWEEP_MAX_RECOVERIEStimes (3) and still not finished is endedfailed: 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.
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.
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
Callingrun(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:
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’sstatus 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 endstimeout 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: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 toawaiting_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, andNone keeps everything:
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:
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
Inagent_config:
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
ValueError: Run … is running in another process (its heartbeat is current)
ValueError: Run … is running in another process (its heartbeat is current)
resume, or run with that run_id, on a run whose heartbeat is younger
than run_lease_seconds:recover.py does. A lower
run_lease_seconds recovers sooner; too low, and a live run that misses
a heartbeat could be taken over.ValueError: Run … is completed; only a waiting or stopped run can resume
ValueError: Run … is completed; only a waiting or stopped run can resume
resume on a run that has ended:run(query, run_id=...): a new attempt on the same
record.LookupError: No run …
LookupError: No run …
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).ValueError: … only a running run can be interrupted / it cannot be steered
ValueError: … only a running run can be interrupted / it cannot be steered
interrupt needs a running run. steer needs one that is running,
awaiting_approval or interrupted.ValueError: status must be cancelled, failed or timeout
ValueError: status must be cancelled, failed or timeout
abandon_run takes one of those three.RunRequestLost: … its request was never stored
RunRequestLost: … its request was never stored
resume on a run whose request is on neither its record nor its history: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.The model says a call's outcome is unknown
The model says a call's outcome is unknown
idempotent=True;
if not, give the model a way to check, like list_charges above.The interrupt did nothing
The interrupt did nothing
interrupt takes effect at the next step boundary. A run in its last
model call finishes first, and returns success.