Durable Background Jobs

Persist background jobs so in-flight work survives a process restart, with opt-in deliver-back replay

python
from praisonaiagents import Agent
from praisonaiagents.tools.subagent_tool import create_subagent_tool

subagent = create_subagent_tool()  # spawn_subagent + subagent_result

# Background subagent jobs are persisted out of the box to
# <runs_dir>/background_jobs.db — status survives a restart.
agent = Agent(
    name="Researcher",
    instructions="Spawn a background subagent for long-running research.",
    tools=[subagent["function"], subagent["result_tool"]["function"]],
)

agent.start("Summarise the last week of arXiv AI papers in the background")

Durable background jobs persist every state transition so a crash mid-job no longer silently drops the work — orphans become a queryable LOST state, and a manager that calls reconcile_on_start(redeliver=...) replays undelivered results.

Durability is on by default: the shared manager returned by get_job_manager() — the same one the chat /tasks command and background subagent deliver-backs use — automatically persists to <runs_dir>/background_jobs.db, so job status survives a restart. Replaying undelivered results requires a host that calls reconcile_on_start(redeliver=...) (see the warning below). Opt out with PRAISONAI_BACKGROUND_JOB_STORE=0 for the prior pure in-memory behaviour.

Diagram

Durable Background Jobs

🤖 start_job

💾 upsert PENDING/RUNNING

⚙️ run

💥 restart

🔁 reconcile_on_start

🟠 mark LOST

✅ redeliver result

Three ways to reach durability — pick the row that matches your setup.

Diagram

Yes ✅

No, custom manager

Yes

No

👤 Need background jobs?

Using
get_job_manager?

💾 Durable by default
SqliteBackgroundJobStore
at <runs_dir>/background_jobs.db

Need durability?

🧩 Pass store=SqliteBackgroundJobStore&(&)
or your own BackgroundJobStore

⚡ store=None — in-memory only

🔌 Opt out:
PRAISONAI_BACKGROUND_JOB_STORE=0

Quick Start

  1. You already have durability

    The shared manager returned by get_job_manager() is already backed by SqliteBackgroundJobStore and has already reconciled orphaned jobs on first construction — zero code, zero setup:

    python
    from praisonaiagents.background.job_manager import get_job_manager
    
    manager = get_job_manager()  # already backed by SqliteBackgroundJobStore
                                 # already ran reconcile_on_start() once
  2. Enable persistence on a custom manager

    Building your own BackgroundJobManager? Pass store=SqliteBackgroundJobStore() (or any store implementing the BackgroundJobStore protocol). Every job transition is now persisted:

    python
    from praisonaiagents.background import SqliteBackgroundJobStore
    from praisonaiagents.background.job_manager import BackgroundJobManager
    
    manager = BackgroundJobManager(store=SqliteBackgroundJobStore())
  3. Reconcile on startup

    get_job_manager() already ran this once. On a custom manager, call reconcile_on_start() once at boot, after wiring the deliver-back handler:

    python
    counts = manager.reconcile_on_start(redeliver=on_job_complete)
    # {"lost": 0, "redelivered": 0, "rehydrated": 0} on a clean start
    print(f"jobs reconciled: {counts}")
  4. Query a job after a restart

    Status lookups work after a restart — even before reconcile_on_start() runs, get_status falls through to the store:

    python
    from praisonaiagents.background.job_manager import JobStatus
    
    status = manager.get_status(job_id)  # e.g. JobStatus.LOST
  5. Handle LOST jobs

    Inspect orphaned jobs and decide whether to retry or surface them to the user — abandoned work is never auto-re-run:

    python
    from praisonaiagents.background.job_manager import JobStatus
    
    lost = manager.list_jobs(status=JobStatus.LOST)
    for job_id, info in lost.items():
        print(f"orphaned: {job_id} (origin={info.origin})")

How It Works

reconcile_on_start() reads store.list_unreconciled() and follows two paths: orphaned jobs become LOST; completed-but-undelivered jobs are replayed.

Diagram
BackgroundJobStoreBackgroundJobManagerAppBackgroundJobStoreBackgroundJobManagerAppalt[RUNNING/PENDING orphan][COMPLETED + origin + notdelivered]reconcile_on_start(redeliver)list_unreconciled()orphaned + undelivered jobsstatus = LOST (before it is visible)upsert(LOST)redeliver(job_info)okupsert(delivered=True)

Every persisted job is re-hydrated into the in-memory map so get_status(job_id) keeps working after a restart. redeliver=None is safe — undelivered jobs are re-hydrated but not delivered.

State machine

LOST is a new terminal state reached only via restart reconciliation.

Diagram

restart

restart

⏳ PENDING

⚙️ RUNNING

✅ COMPLETED

❌ FAILED

🚫 CANCELLED

🟠 LOST


The BackgroundJobStore Protocol

Implement this @runtime_checkable protocol to plug in your own durable store. A concrete SQLite implementation — SqliteBackgroundJobStore — now ships in core (praisonaiagents.background) and backs get_job_manager() by default; you only implement the protocol yourself for a non-SQLite backend.

python
from typing import Protocol, runtime_checkable, Optional, List
from praisonaiagents.background.job_manager import JobInfo

@runtime_checkable
class BackgroundJobStore(Protocol):
    def upsert(self, job: JobInfo) -> None: ...
    def get(self, job_id: str) -> Optional[JobInfo]: ...
    def list_unreconciled(self) -> List[JobInfo]: ...
MethodPurpose
upsert(job)Insert or update the persisted record, keyed by job_id
get(job_id)Return the persisted JobInfo, or None if unknown
list_unreconciled()Return orphaned (PENDING/RUNNING) jobs plus COMPLETED-with-origin jobs never marked delivered

An optional delete_older_than(ts) -> int method, when present on a store, is called best-effort by cleanup_completed() with the same max_age policy so the durable record stays bounded. It is not part of the protocol, so existing custom stores keep working unchanged — add it when you want pruning.


Built-in SqliteBackgroundJobStore

SqliteBackgroundJobStore is the concrete, stdlib-only store that backs get_job_manager() by default. Import it, or omit db_path to use the default location:

python
from praisonaiagents.background import SqliteBackgroundJobStore

# Default location: <runs_dir>/background_jobs.db (honours PRAISONAI_HOME)
store = SqliteBackgroundJobStore()

# In-memory database for tests
test_store = SqliteBackgroundJobStore(db_path=":memory:")
AspectDetail
Importfrom praisonaiagents.background import SqliteBackgroundJobStore (or from praisonaiagents.background.sqlite_store import SqliteBackgroundJobStore)
ConstructorSqliteBackgroundJobStore(db_path: Optional[str] = None)
Default DB path<runs_dir>/background_jobs.db — under PRAISONAI_HOME, alongside the run ledger and event log
TestsPass db_path=":memory:"
Extra methodclose() closes the underlying connection
Extra methoddelete_older_than(ts: float) -> int — deletes terminal jobs completed before ts; keeps COMPLETED jobs with an undelivered origin for restart re-delivery. Called by cleanup_completed()

Schema

A single background_jobs table with an index on status:

ColumnTypeNotes
job_idTEXTPrimary key
statusTEXTpending / running / completed / failed / cancelled / lost
created_atREALSpawn timestamp
started_atREALNullable
completed_atREALNullable
resultTEXTJSON-serialised job result
errorTEXTNullable error string
originTEXTJSON-serialised deliver-back context
deliveredINTEGER0/1 — deliver-back fired

Index: idx_background_jobs_status on status.

Thread-safety & durability posture

A single re-entrant lock guards one shared connection opened check_same_thread=False — safe to share across the runner’s worker threads. Durability matches its siblings SQLiteRunLedger and SqliteEventLog: WAL journal mode, busy_timeout=5000, synchronous=NORMAL.


Configuration

SymbolTypeDefaultDescription
storeOptional[BackgroundJobStore]NoneKeyword-only. Enable persistence when supplied; pure in-memory when None. get_job_manager() supplies a SqliteBackgroundJobStore automatically
JobStatus.LOSTenum value—Terminal state for RUNNING/PENDING jobs interrupted by a crash
JobInfo.deliveredboolFalseWhether the deliver-back to origin has fired
reconcile_on_startmethod—reconcile_on_start(redeliver=None) -> Dict[str, int]

Environment variable

Env varDefaultPurpose
PRAISONAI_BACKGROUND_JOB_STORE1 (on)Set to 0 / false to force the shared get_job_manager() back to pure in-memory (no store attached, no reconcile).

Reconciliation counts

reconcile_on_start() returns a counts dict — use it for a single startup log line:

python
counts = manager.reconcile_on_start(redeliver=on_job_complete)
# {"lost": 2, "redelivered": 1, "rehydrated": 3}
KeyMeaning
lostOrphaned RUNNING/PENDING jobs transitioned to LOST
redeliveredCOMPLETED-but-undelivered jobs whose deliver-back was replayed
rehydratedTotal jobs re-loaded into the in-memory map

Best Practices

Call reconcile_on_start() exactly once, at startup

Run it once at boot, after all deliver-back handlers are wired — mirroring how OutboundQueue.drain_pending() is wired. Calling it later risks a partially-wired redeliver.

Make redeliver idempotent

redeliver receives the persisted JobInfo. A transient failure inside it means “retry on the next boot” — the job is left undelivered, never silently lost. Design the handler so a double-fire is harmless.

Sweep LOST records with cleanup_completed()

cleanup_completed(max_age=...) evicts terminal records (including LOST) from both the in-memory map and SqliteBackgroundJobStore (best-effort). COMPLETED jobs with an origin that have not yet been delivered are kept, so restart re-delivery still works after a sweep. Run a periodic sweep (e.g. hourly) so the DB file stays bounded on long-lived gateways.

Keep the store thread-safe

upsert() is called from BackgroundJobManager‘s worker threads. Guard shared state (or use a per-thread connection) so concurrent transitions don’t corrupt the store.

Opt out with the env var if you truly want in-memory

For the shared get_job_manager() the escape hatch is the environment variable, not the constructor kwarg — set PRAISONAI_BACKGROUND_JOB_STORE=0 to get pure in-memory behaviour (no store, no reconcile). When you construct BackgroundJobManager yourself, omitting store= keeps that manager in-memory with zero overhead.


Related