Run-State Journal

Resume an agent run where it crashed β€” no re-executed tools, no re-billed LLM calls

The run-state journal records the execution cursor of an agent run so a crash mid tool-loop can resume without re-executing side-effecting tools or re-billing LLM calls.

python
from praisonaiagents.runtime import RunJournal, JournalEvent

journal = RunJournal()  # ~/.praisonai/runs/journal.db
journal.open_run("r1", agent="coder", task="migrate module")

# Record each boundary as it happens
journal.append(JournalEvent("r1", 0, "model_decision", {"text": "call tool X"}))
journal.append(JournalEvent("r1", 0, "tool_result", {"result": "done"}))

# On resume, journalled steps return recorded results β€” no re-billing, no re-running
replay = journal.replay_index("r1")
cached = replay.get((0, "tool_result"))  # instant recorded result

journal.close_run("r1", "succeeded")
Diagram

Run-State Journal

πŸ“ Model/Tool boundary

πŸ“’ append event

πŸ—„οΈ journal.db

πŸ’₯ Crash

▢️ resume run

⚑ memoised replay

πŸ” restart live at first un-journalled step

Quick Start

  1. Simple Usage

    Open a run, append a couple of events, then close it.

    python
    from praisonaiagents.runtime import RunJournal, JournalEvent
    
    journal = RunJournal(":memory:")
    journal.open_run("r1", agent="coder", task="migrate module")
    
    seq = 0
    for i in range(3):
        journal.append(JournalEvent("r1", seq, "model_decision", {"text": f"call {i}"}))
        journal.append(JournalEvent("r1", seq, "tool_call", {"name": f"tool{i}", "args": {}, "idempotency_key": f"k{i}"}))
        journal.append(JournalEvent("r1", seq, "tool_result", {"result": i}))
        journal.append(JournalEvent("r1", seq, "iteration", {"index": i}))
        seq += 1
    
    journal.close_run("r1", "succeeded")
  2. Resume after a crash

    Compute replay_index() and drive the loop β€” journalled model calls and tools return their recorded results instantly, so the LLM is not re-billed and side-effecting tools are not re-run.

    python
    replay = journal.replay_index("r1")
    
    def call_model(seq):
        rec = replay.get((seq, "model_decision"))
        if rec is not None:
            return rec["text"]        # cached β€” no LLM call, no re-bill
        # …live LLM call here, then journal.append(...) the new decision
    
    def run_tool(seq):
        rec = replay.get((seq, "tool_result"))
        if rec is not None:
            return rec["result"]      # cached β€” no re-execution of side effects
        # …live tool call here, then journal.append(...) the new result

How It Works

The loop is re-driven from the top on resume; recorded steps return cached results while real work restarts at the first un-journalled step.

Diagram
LLMToolRunJournalAgentUserLLMToolRunJournalAgentUserπŸ’₯ CRASH before step 1seq 0 recorded β€” return cached, do NOT re-call LLM/toolagent.start(“…”)open_run(run_id, agent, task)model decision (0)append(model_decision, seq=0)run tool (0)append(tool_call, seq=0)append(tool_result, seq=0)append(iteration, index=0)resume(run_id)replay_index(run_id){(0, “model_decision”): …, (0, “tool_result”): …}model decision (1) [live β€” first un-journalled step]run tool (1)append(…)close_run(run_id, “succeeded”)final result

The journal, run ledger, checkpoints, and inbound journal are four adjacent persistence primitives β€” pick correctly with this map.

Diagram

Per-event execution cursor of ONE run

Run status
queued/running/done

Workspace file snapshots

Webhook/inbound message dedup

Finished conversation messages

What do you need to persist?

Run-State Journal
this page

Run Ledger

Checkpoints

Inbound Journal

Session Store


Configuration Options

The journal is a zero-dependency SQLite primitive with a single constructor argument.

RunJournal(db_path=None) constructor

ArgumentTypeDefaultDescription
db_pathOptional[str]None β†’ ~/.praisonai/runs/journal.dbPath to the SQLite journal DB. Use ":memory:" for tests.

Event kinds β€” the five boundaries the journal records:

Kind constantStringPayload shapePurpose
KIND_MODEL_DECISION"model_decision"{"text": ...}Memoised on replay so the LLM is not re-called (and not re-billed).
KIND_TOOL_CALL"tool_call"{"name", "args", "idempotency_key"}Records that a tool was issued.
KIND_TOOL_RESULT"tool_result"{"result": ...}Memoised on replay so side-effecting tools are not re-run.
KIND_APPROVAL"approval"{"decision": ...}Human/policy approval decision β€” enables durable pause-for-approval HITL.
KIND_ITERATION"iteration"{"index": int}Loop cursor β€” restored on resume via last_iteration().

JournalEvent dataclass

FieldTypeDefaultDescription
run_idstrβ€”Owning run.
seqintβ€”Step number. With kind forms the natural key (run_id, seq, kind).
kindstrβ€”One of the 5 event kinds above. Anything else raises ValueError.
payloadDict[str, Any]{}Any JSON-serialisable dict; non-JSON values fall back to str(...).
created_atfloattime.time()Wall-clock, set at construction.

RunMeta dataclass

FieldTypeDefaultDescription
run_idstrβ€”Owning run.
agentstr""Agent identifier at open time.
taskstr""Task label at open time.
statusstr"running"One of running, done, succeeded, failed, cancelled.
outcomeOptional[str]NoneTerminal outcome string (as passed to close_run).
checkpoint_idOptional[str]NoneBinds the files store β€” a resume restores this checkpoint before replaying.
created_atfloattime.time()Wall-clock at first open. Preserved on reopen.
updated_atfloattime.time()Wall-clock at latest write.
metadataDict[str, Any]{}Free-form JSON dict.

RunJournal public methods

MethodSignatureWhat it does
open_runopen_run(run_id, *, agent="", task="", checkpoint_id=None, metadata=None)Register run_id as running (idempotent). On reopen: preserves the journal, keeps created_at, COALESCEs checkpoint_id (a None reopen preserves the existing binding; a supplied one binds it).
set_checkpointset_checkpoint(run_id, checkpoint_id)Bind the latest files checkpoint to run_id.
close_runclose_run(run_id, outcome="done")Mark terminal so it is no longer resumed. Outcome coerced to {done, succeeded, failed, cancelled} (anything else β†’ "done").
run_meta`run_meta(run_id) -> RunMeta \None`Read back the metadata row (or None).
interrupted_runsinterrupted_runs() -> List[str]Ids of runs still running β€” resume candidates on restart, ordered oldest-first. Nothing resumes them automatically: resume each by run id with ExecutionConfig(durable=True, resume_run_id=..., journal_path=...) using the recorded task from run_meta(run_id).task.
appendappend(JournalEvent)Append an event. Idempotent on (run_id, seq, kind): re-appending updates the payload rather than raising. Unknown kinds raise ValueError.
eventsevents(run_id) -> List[JournalEvent]All events ordered by (seq, rowid) β€” tool_call always precedes its tool_result within a step.
replay_indexreplay_index(run_id) -> Dict[(seq, kind), payload]Memoised lookup used during resume: a hit returns the recorded result instantly; a miss = real work restarts there.
last_iterationlast_iteration(run_id) -> Optional[int]Highest journalled iteration index β€” restores the loop cursor exactly on resume.
assert_replay_orderassert_replay_order(run_id, expected)Determinism guardrail. expected accepts either (seq, kind) tuples (preferred, position-aware) or bare kind strings (kind-only compare). Raises RuntimeError on mismatch.
closeclose()Close the underlying SQLite connection.

Import the primitive and its event kind constants directly:

python
from praisonaiagents.runtime import RunJournal, JournalEvent, RunMeta
from praisonaiagents.runtime.journal import (
    KIND_MODEL_DECISION,
    KIND_TOOL_CALL,
    KIND_TOOL_RESULT,
    KIND_APPROVAL,
    KIND_ITERATION,
)

Common Patterns

Three patterns cover the full open β†’ journal β†’ resume lifecycle.

Journal every boundary as it happens. Record a model_decision, tool_call, tool_result, and iteration at each step.

python
from praisonaiagents.runtime import RunJournal, JournalEvent

journal = RunJournal(":memory:")
journal.open_run("r1", agent="coder", task="migrate module")

seq = 0
for i in range(3):
    journal.append(JournalEvent("r1", seq, "model_decision", {"text": f"call {i}"}))
    journal.append(JournalEvent("r1", seq, "tool_call", {"name": f"tool{i}", "args": {}, "idempotency_key": f"k{i}"}))
    journal.append(JournalEvent("r1", seq, "tool_result", {"result": i}))
    journal.append(JournalEvent("r1", seq, "iteration", {"index": i}))
    seq += 1

journal.close_run("r1", "succeeded")

Resume with memoised replay. Look up each step in replay_index(); a hit skips re-execution.

python
replay = journal.replay_index("r1")

def run_tool(seq):
    rec = replay.get((seq, "tool_result"))
    if rec is not None:
        return rec["result"]      # cached β€” no re-execution of side effects
    # …live tool call here, then journal.append(...) the new result

Resume interrupted runs after a restart. Any run still running after a crash is a resume candidate β€” nothing resumes it for you.

python
from praisonaiagents import Agent, ExecutionConfig
from praisonaiagents.runtime import RunJournal

journal = RunJournal()
for run_id in journal.interrupted_runs():
    meta = journal.run_meta(run_id)
    if meta is None:
        continue
    agent = Agent(
        name=meta.agent,
        execution=ExecutionConfig(durable=True, resume_run_id=run_id),
    )
    agent.chat(meta.task)  # must be the recorded task

Best Practices

Journal at every meaningful boundary

The five event kinds cover every point where re-execution would be either wasteful (model_decision) or unsafe (tool_call/tool_result, approval). Omitting a boundary means that boundary re-runs on resume.

Always append the iteration event alongside model_decision/tool_result

last_iteration() reads this to restore the loop cursor exactly on resume; without it the resumed loop must scan the journal to find its position.

Bind a checkpoint_id before appending tool events

open_run(..., checkpoint_id=...) (or a later set_checkpoint) is what lets a resume restore the workspace to a consistent state before replaying β€” otherwise replay may run against a workspace that has drifted.

Use assert_replay_order() in tests, not in production hot loops

The full (seq, kind) position-aware compare is the determinism guardrail β€” perfect for test coverage of your loop wiring, unnecessary on every replay step in production.


Related