Tasks DAG¶
A persistent task graph the model maintains across turns — different from the in-turn todos list, different from the chat history, and different from the similarly-named task tool that spawns subagents (same word, unrelated tool — see Tasks vs todos below for the actual distinction). The DAG survives the turn, the journal replay, and the session resume.
Tasks vs todos¶
todos |
tasks |
|
|---|---|---|
| Scope | A single turn — drives the in-turn pinned panel above the composer | A session-long graph of work items, possibly across many turns |
| Lifecycle | Replaced wholesale on every todos call |
Edits add / update / complete / remove entries; the DAG grows |
| Persistence | Lost on turn end | Written through to a sidecar JSON file (the journal AND the sidecar) |
| Dependency | None — a flat ordered list | blockedBy edges between entries; cycle-checked on every mutation |
Use todos for "here's what I'll do this turn." Use tasks for "here's the work for this session, including stuff I'll come back to later."
Both are unrelated to the task tool (singular, no "s") that spawns subagents — see Subagents — and to the workflow tool's model-authored fan-out runs — see Workflows. All four ("todos", "tasks", "task", "workflow") show up in the same tool palette and are easy to conflate by name alone; the DAG this page describes is the one persistent, dependency-aware graph among them.
The tasks tool¶
One tool, six actions. The tool returns the post-mutation DAG snapshot as its tool_result (so the model has the authoritative shape after each call) and emits a tasks_changed event so the UI updates.
The action enum¶
| Action | Args | What it does |
|---|---|---|
add |
content, blockedBy (optional), active_form (optional) |
Insert a new task. The id is auto-minted by the store (t_<unix_ms>_<rand>) and returned in the snapshot — the model never picks an id, which sidesteps id-collision concerns on retries |
update |
id, content (optional), status (optional), blockedBy (optional) |
Mutate fields on an existing task. Cycle-checks blockedBy before applying |
complete |
id |
Mark a task done. Dependents whose blockedBy are now all completed (or deleted) are NOT mutated — they stay pending; the blocked() query computes readiness at read time |
remove |
id |
Soft-delete: marks the task status="deleted". The row stays in the store so historical blockedBy chains still resolve — blocked() treats deleted ids as inactive blockers |
list |
status_filter (optional) |
Returns the current DAG, no mutation. The filter is one of pending / in_progress / completed / deleted; omitted, it returns every task regardless of status (deleted rows included) |
blocked |
— | Returns the tasks whose blockedBy is non-empty AND has at least one still-active blocker (a blocker that isn't completed and isn't deleted) — useful for "what can't I start yet?" |
Returned shape¶
The shape differs slightly by action, but every mutating call (add / update / complete / remove) carries both the one row it touched and the full post-mutation DAG:
{
"action": "add",
"task": {
"id": "t_1720600000000_a1b2",
"content": "Seed dev fixtures",
"status": "pending",
"blockedBy": ["t_1720599990000_9f3c"]
},
"snapshot": {
"tasks": [
{
"id": "t_1720599990000_9f3c",
"content": "Provision the dev DB",
"status": "pending",
"blocks": ["t_1720600000000_a1b2"]
},
{
"id": "t_1720600000000_a1b2",
"content": "Seed dev fixtures",
"status": "pending",
"blockedBy": ["t_1720599990000_9f3c"]
}
]
}
}
list returns {"action": "list", "tasks": [...filtered rows...], "snapshot": {...the full unfiltered DAG...}}; blocked returns just {"action": "blocked", "tasks": [...]} (no snapshot — it's a pure query, nothing changed). Note the wire shape is compact: an empty blockedBy, blocks, or active_form, and a null completed_at, are dropped from a row entirely rather than sent as [] / "" / null — a task with no dependents simply has no blocks key.
The blocks field is derived (every task whose blockedBy mentions this id) — the model doesn't write it; the store computes it on every mutation. Keeping it derived is what makes the auto-unblock on complete correct: as soon as the DB-provisioning task becomes completed, the fixtures task sees an empty unmet blockedBy and flips to actionable.
Cycle detection¶
Adding a blockedBy edge that would form a cycle is refused with an error result and no mutation. The store runs a DFS with a visited set on every add / update — A blockedBy B blockedBy A and longer loops both fail. The model reads the error and rewrites the dependency.
This is a hard invariant: a cycle in the DAG would mean "task X is blocked by task Y is blocked by task X" and neither would ever complete. The DFS catches the whole transitive closure.
Auto-unblock on complete¶
When you call tasks(action="complete", id="X"):
Xbecomesstatus="completed"withcompleted_at=now.- The store walks every remaining task whose
blockedBymentionsX. - No mutation: the dependent's
statusstays as-is. The store only re-checks readiness on the nextblocked()query (a completed blocker no longer counts). - The
tasks_changedevent fires with the post-cascade snapshot.
This keeps the model simple — complete is a one-row write, not a cascade. The blocked() query is the single source of truth for "what can't I start?" — any completed-or-deleted id in blockedBy is invisible to the readiness check.
Sidecar persistence¶
The DAG is written through to a sidecar file, <session_id>.tasks.json, sitting right next to that session's journal (<session_id>.jsonl) — same directory, same KIN_SESSION_DIR / session_dir resolution. The journal still records the chat history; the sidecar carries the DAG state. Both are needed because:
- the DAG is a model-managed data structure, not a chat turn — it doesn't fit the journal's append-only message shape,
- a
--resumeof the same session id loads the sidecar and replays the DAG into the live store.
Atomic write¶
Every mutation writes through a temp file (<session_id>.tasks.json.tmp) then renames into place — a crash mid-write cannot leave a torn sidecar (a torn file would either fail to parse on load, or the rename never happens and the prior good copy stays). No debounce: every mutation writes immediately, so a session kill at any point leaves a recoverable DAG.
Crash recovery¶
On load, any task with status="in_progress" whose updated_at is older than 5 minutes (the recover_stale_in_progress grace window) is reaped back to status="pending". A session that crashed mid-task doesn't strand the work item — the next launch sees it actionable.
A task that was actually running and then resumed by hand is update(status="in_progress") again; the reaper treats you as authoritative as soon as you write in_progress a second time.
The tool_result is authoritative¶
The sidecar is the bootstrap (load on session start, save on every mutation). The tool_result from each tasks call is the authoritative view the model sees — if the sidecar and the tool_result ever disagreed, the tool_result wins. The store runs every mutation, then returns the post-mutation snapshot; the model can trust that shape without re-reading the sidecar.
The UI surface¶
Three renderers:
- The pinned panel above the composer — the same collapsible widget the in-turn
todoslist uses, in "tasks" mode. It's a lite view, not the full DAG: the in-progress task first, then up to 5 pending tasks, each tagged(blocked)if it still has an active blocker; completed and deleted entries are omitted, and the panel hides itself entirely when nothing is pending or in progress. This is the live, at-a-glance surface — no keypress needed. - The agent panel (Ctrl+O →
taskspane) — every entry as a one-line rowid · status · content. Full DAG render, including completed and deleted rows. - Press
o/ Enter on a row in the agent panel — opens a read-only modal withstatus,content,blockedBy,blocksso you can inspect a dependency at a glance.
Both panel renderers update on the tasks_changed event (full snapshot, not a delta — simpler than tracking which task id changed).
Worked example¶
// turn 1
// (the store mints ids; capture them from each add's snapshot)
const setupDb = tasks(action="add", content="Provision the dev DB")
const seed = tasks(action="add", content="Seed fixtures", blockedBy=[setupDb.task.id])
const smoke = tasks(action="add", content="Smoke test", blockedBy=[seed.task.id])
// turn 2
tasks(action="complete", id=setupDb.task.id)
// → seed.task.blockedBy still includes setupDb, but blocked() no longer counts it (completed)
tasks(action="update", id=seed.task.id, status="in_progress")
// turn 3
tasks(action="complete", id=seed.task.id)
// → blocked() now returns [] (smoke has no active blockers; smoke.task.status is still "pending")
tasks(action="remove", id=smoke.task.id)
// → soft-deleted; sidecar rewritten; smoke still appears in the snapshot with status="deleted"
tasks(action="list")
// → current shape, with smoke showing status="deleted"
Limits¶
There is no hard cap on DAG size, but the model should treat it as a working set — the DAG is rendered in the panel every refresh, and the tasks_changed event carries the full snapshot. Dozens of entries are fine; hundreds are not.