Skip to main content
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.
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")

Quick Start

1

Simple Usage

Open a run, append a couple of events, then close it.
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.
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. The journal, run ledger, checkpoints, and inbound journal are four adjacent persistence primitives — pick correctly with this map.

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_idstrOwning run.
seqintStep number. With kind forms the natural key (run_id, seq, kind).
kindstrOne 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_idstrOwning 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_metarun_meta(run_id) -> RunMeta | NoneRead back the metadata row (or None).
interrupted_runsinterrupted_runs() -> List[str]Ids of runs still running — resume candidates on restart, ordered oldest-first.
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:
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.
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.
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
Discover interrupted runs on gateway startup. Any run still running after a crash is a resume candidate.
journal = RunJournal()
for run_id in journal.interrupted_runs():
    # gateway resumes each run left `running` after the last crash
    resume_run(run_id)

Best Practices

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.
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.
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.
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.

Run Ledger

Tracks run status (queued/running/done) — the journal tracks the per-event cursor.

Checkpoints

Workspace file snapshots bound via checkpoint_id for restore-before-replay.

Durable Approvals

Approval decisions journalled as KIND_APPROVAL events for pause-for-approval HITL.

Inbound Journal

Webhook dedup and in-flight recovery — a different scope from run-state replay.