Skip to content

Durable blackboard

durable_blackboard_agent is the blackboard planner for agents that stay up: the to-do list lives in a Store instead of a closure, so it survives the run, the process, and the crash.

Reach for it when an agent wakes on a schedule, does one thing, and has to know where it is next time. For a one-shot planner inside a single call, the flat blackboard is simpler — it resets on every invocation, which is exactly what this one must not do.

Source

"""Always-on worker: a plan that survives the process that runs it.

The ephemeral blackboard (``blackboard_planner.py``) resets its to-do list on
every invocation — right for a one-shot planner, wrong for an agent that wakes
up, does one thing, and has to know where it is next time.

Here the plan lives in a ``Store(db=...)``. Each wake-up claims **one** task,
does it, ticks it, and stops. Kill the process at any point and the next start
picks up exactly where it left off — including a task that was claimed but
never finished, which returns to the queue once its lease expires.

Run it twice to see the resume: the second run continues the same plan.

    python examples/patterns/durable_blackboard.py
"""

from __future__ import annotations

from lazybridge import Agent, Store
from lazybridge.ext.planners import DURABLE_BLACKBOARD_GUIDANCE, DurableBlackboard, durable_blackboard_agent

PLAN_DB = "durable_blackboard.sqlite"
PLAN_ID = "quarterly-memo"

TASKS = [
    "collect the three latest filings",
    "extract revenue and margin",
    "write a two-paragraph memo",
]


def show(text: str) -> None:
    """Print board state without dying on a legacy console.

    ``render()`` uses ``→`` for results; a default Windows console is cp1252
    and raises ``UnicodeEncodeError`` on it, which would crash the example
    rather than the thing it is demonstrating.
    """
    try:
        print(text)
    except UnicodeEncodeError:
        print(text.encode("ascii", "replace").decode("ascii"))


def build_worker() -> Agent:
    """The specialist that actually does each task."""
    return Agent(
        name="analyst",
        description="Performs one research or writing task and reports the result.",
        model="claude-opus-4-7",
    )


def tick(store: Store) -> str:
    """One wake-up: plan if needed, otherwise advance the plan by one task."""
    planner = durable_blackboard_agent(
        [build_worker()],
        store=store,
        plan_id=PLAN_ID,
        model="claude-opus-4-7",
        system=DURABLE_BLACKBOARD_GUIDANCE,
        lease_seconds=600,
        max_attempts=3,
    )
    board = DurableBlackboard(store, PLAN_ID)
    if not board.snapshot().tasks:
        return planner(f"Create a plan for this job: {'; '.join(TASKS)}").text()
    return planner("Continue the plan: claim the next task, do it, and tick it off.").text()


def main() -> None:
    # A file-backed Store is what makes this survive restarts; the default
    # in-memory Store would put us right back to a per-process plan.
    with Store(db=PLAN_DB) as store:
        board = DurableBlackboard(store, PLAN_ID)
        show("state at start:\n" + board.render() + "\n")

        if board.snapshot().complete:
            show(f"plan already complete — delete {PLAN_DB} to start a new one")
            return

        show(tick(store))
        show("\nstate after this run:\n" + board.render())


if __name__ == "__main__":
    main()

The five verbs

Tool What it does
set_plan(reasoning, tasks) Creates the plan. Refuses to discard one still in progress.
get_plan() The whole board: what is done, claimed, failed, and what is next.
claim_next() Takes exactly one task, atomically.
mark_done(index, summary) Closes a task with its result.
mark_failed(index, error) Hands a task back after a genuine failure.

Closing requires an active claim: a task that was never claimed, or one already closed, is refused. Each planner instance also claims under its own identity, so a run whose lease expired cannot come back and overwrite the result of the run that replaced it.

claim_next is the difference from the ephemeral version. A plan you can only read is not resumable: two workers would take the same task, and a worker that dies mid-task would leave it "in progress" forever.

What makes it survive a restart

  • Store-backed state. Pass Store(db="planner.sqlite"); the default in-memory Store puts you back to a per-process plan.
  • A stable plan_id. That string is the resume handle — same id, same plan.
  • Leases. A claimed task carries an owner and a timestamp. If nobody closes it within lease_seconds, a later run may take it over: that is how work interrupted by a crash comes back instead of being lost.
  • Attempts. Each claim counts. After max_attempts the task is parked as failed rather than handed out forever — a task that kills its worker must not stall the whole plan.
  • Compare-and-swap. Every mutation is a CAS on the document, so two workers sharing one Store serialise instead of overwriting each other.

Driving it

One wake-up should be one task. Keep each run short — that also keeps it inside the per-run timeouts of the CLI-backed engines:

with Store(db="planner.sqlite") as store:
    planner = durable_blackboard_agent([worker], store=store, plan_id="quarterly-memo")
    planner("Continue the plan: claim the next task, do it, and tick it off.")

Call that from a scheduler, a loop, or a LazyPulse tick. Nothing is held open between runs, so the agent can be restarted at any moment — a scheduler that rebuilds the whole agent on every firing needs nothing else from you, as long as it hands the same Store back in.

Size lease_seconds above the real task duration. The lease exists to recover work from a dead worker, and it cannot tell "dead" from "slow": if a task takes longer than its lease, the next firing will reclaim it and two workers will run the same item. Rule of thumb: longer than the slowest task, and longer than the interval between firings.

Using it without an agent

DurableBlackboard is a plain object — useful for a queue you drive yourself, or for inspecting a plan from outside the agent:

board = DurableBlackboard(store, "quarterly-memo", lease_seconds=600, max_attempts=3)
board.set_plan("quarterly review", ["gather", "extract", "write"])

claimed = board.claim_next(owner="worker-1")   # (0, "gather") or None
board.mark_done(0, "pulled 4 filings", owner="worker-1")

board.snapshot().complete                       # bool
print(board.render())                           # the same text the agent sees

See also