The todo tool's plan lives in its own PostgreSQL table, one row per (user_id, session_id). It used to be an opaque JSON string inside session_store (scope='todo'); this page covers why it moved and what the row now carries.
The old row already survived restarts — the problem was that it could not tell a live plan from one a crashed run abandoned. Reads were untyped JSON parsed on every call, and there was no timestamp the recovery path could trust. The dedicated row adds a revision counter and an updated_at that the agent can reason about, plus room for per-step provenance that protocols will use.
CREATE TABLE session_todos (
user_id TEXT NOT NULL DEFAULT '',
session_id TEXT NOT NULL,
tasks JSONB NOT NULL DEFAULT '[]'::jsonb,
revision INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, session_id)
);
tasks is an array of objects, one per step:
| Field | Meaning | ||||
|---|---|---|---|---|---|
text |
The step as shown to the model and the client. | ||||
status |
pending \ |
in_progress \ |
done \ |
failed \ |
skipped. |
validation |
How the result was verified. Required for done. |
||||
milestone |
Grouping label from the plan ("A", "B", …). |
||||
source |
"plan" for the agent's own plan, "protocol" for injected protocol steps. |
||||
step_id |
Stable id of the step inside the protocol definition. | ||||
protocol_run_id |
Which protocol run injected the step. |
Rows written before these fields existed load with their defaults, so no backfill is needed.
revision starts at 0 and increments on every save. It is not a concurrency control yet — it exists so the recovery note can fire once per plan version.
PgTodoStore creates the table and runs the migration lazily on first use (same pattern as KvStore). The migration copies every session_store row with scope='todo' into session_todos and then deletes the source rows:
ON CONFLICT DO NOTHING makes the copy idempotent, so a plan written to the new table is never overwritten by a stale KV copy;If session_store does not exist yet, the migration is logged and skipped — the table simply starts empty. Running the old code after this migration will not see existing plans; that is the cost of the cut-over and the reason it is done in one release.
When the plan reminder is built (get_progress_message), a plan with a step still in_progress whose row was last updated before this process started is flagged once:
Recovery: this plan was last changed at … and step N is still 'in_progress'. If no run is working on it right now, a previous run stopped mid-step — check the actual state … before continuing.
The note is keyed by (session_id, revision), so it appears once per plan version and not on every iteration. A plan updated by the current process gets no note. The wording is deliberately conditional ("if no run is working on it right now") because a multi-worker deployment can legitimately have another process writing the row.
PgTodoStore(pool) is created in navi/core/container.py, stored on AppContainer.todo_store, passed into build_default_registries() and onward to TodoTool. todo.set_store() makes it the module-global backend. If no store is wired, TodoTool falls back to KvTodoStore over the shared KvStore — that path is what unit tests use and what an instance that has not migrated yet would still read.
load_tasks_for(session_id, user_id, kv=None, store=None) resolves in order: explicit store, explicit kv, module-global store. The REST endpoint GET /sessions/{id}/todos passes both (the container's store, and the KV store as a fallback), so its payload shape is unchanged.
navi/tools/todo.py exposes the plan surgery the protocol tool builds on (see skills.md):
insert_steps(session_id, steps, after=None, source="protocol", protocol_run_id=…, step_ids=…) splices a block into the existing plan after the last step the agent reached (or after an explicit index) and returns the new 1-based indexes. Existing steps and statuses are preserved — no re-issue of the whole plan via set.protocol_step_states(session_id, protocol_run_id) lists the steps a run injected, with their 1-based plan indexes and statuses — what finish validates against.close_protocol_steps(session_id, protocol_run_id, note=…) marks a run's still-open steps skipped (used by cancel/fail) and returns their indexes.todo_session_id(explicit=None) is the public form of _sid — any tool that touches the plan must resolve the session id the same way, or a sub-agent's call would edit the parent's plan._current_position(tasks) finds that insertion point (last done / in_progress / failed step).