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 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. |
add_tasks(tasks) |
Appends newly-discovered tasks without touching any existing task's index or status. |
cancel_task(index, expected_text, reason) |
Drops a task nobody is actively working on when it is no longer needed: either todo or parked as failed after exhausting its attempts. expected_text must match the task's current text. |
set_task_schedule(index, expected_text, planned_start_at, due_at, reason) |
Sets (or clears, by passing None) a todo/claimed task's planned start and due timestamps. Same expected_text stale-index guard; each call also appends an entry to an audit trail. |
claim_next() |
Takes exactly one task — whichever is earliest eligible — atomically. |
claim_task(index, expected_text, renew=False) |
Takes a SPECIFIC task instead of "next", for a caller that already knows which one it needs and doesn't want to claim-and-close every earlier task just to reach it. Same expected_text stale-index guard as cancel_task. |
mark_done(index, summary) |
Closes a task with its result. |
mark_failed(index, error) |
Hands a task back after a genuine failure. |
Re-entering a claim you already hold (renew=True)¶
By default claim_task refuses a task that is currently claimed, even by
the caller that claimed it: two callers are only ever "the same" if one of
them says so. Passing renew=True lets the same identified owner re-enter
a claim it already holds -- the shape of a flow that claims a task, does some
preparation, and claims the same index again to attach the job that will do
the work.
Renewal is deliberately narrow. It succeeds only when all of these hold, and is refused otherwise:
owner=was given and equals the current holder (owner=Nonenever renews: two anonymous callers are not the same caller);renew=Truewas passed. An owner is usually process-level, so equality alone cannot tell "the holder re-entering" from "a second, genuinely new delegation for a task that already has one running" -- inferring renewal would let one batch start two writers on one task;- the lease is still alive. A holder whose lease lapsed did not finish in time, and that is exactly what an attempt counts; renewing it would let a stable owner identity dodge the attempt limit forever;
- the claim has not already been renewed. A renewal attaches a worker to a task; a second one would attach a second worker to the same checkout. The check happens inside the compare-and-swap, so two concurrent callers cannot both win.
A fresh claim always starts un-renewed, whichever way it was taken
(claim_next included).
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.
Scheduling, completed_at, and OVERDUE¶
Every task carries planned_start_at and due_at (both None until set) and
a completed_at timestamp, stored on the task record but not shown by
render() — a consumer that reads the plan document directly (not just
get_plan()'s text) uses it to place a closed task in time. Only
set_task_schedule writes the first two — set_plan/add_tasks always
create a task with both None — and only a task actually reaching a
terminal state (done, an exhausted failed, or cancelled) gets
completed_at set; a mark_failed that sends a task back to todo for a
retry is not a close and leaves it None.
set_task_schedule requires reason, rejects a non-finite or bool value
for either timestamp, and rejects planned_start_at > due_at when both are
given. It only accepts todo/claimed tasks — a terminal task can't be
(re)scheduled. Every call, including one that only clears a timestamp with
None, appends one entry to the plan's own append-only schedule_events
list — {task_index, planned_start_at, due_at, reason, at} — written in the
same compare-and-swap as the task update itself, so a concurrent close of
that task either wins the race first (and this edit is rejected on retry
against the now-terminal task) or the schedule and its audit entry land
together.
render() (what get_plan()/the CLI show) prints a task's planned interval
under its line when set, and appends OVERDUE next to due_at when it's in
the past and the task is still todo or claimed — never for a task
that already closed, on time or not.
What makes it survive a restart¶
- Store-backed state. Pass
Store(db="planner.sqlite"); the default in-memoryStoreputs 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_attemptsthe task is parked asfailedrather 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
Storeserialise 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¶
- Blackboard planner — the ephemeral sibling.
- Checkpoint & resume — durable state for a
static
PlanDAG, when the structure is known up front. - Store — what backs the board.