# 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, unr…

A persistent task graph the model maintains across turns — different from the in-turn [`todos`](/docs/kin/guide/tools/#planning-interaction) list, different from the chat history, and different from the similarly-named [`task`](/docs/kin/guide/subagents/#the-task-tool) tool that spawns [subagents](/docs/kin/concepts/agents/) (same word, unrelated tool — see [Tasks vs todos](#tasks-vs-todos) below for the actual distinction). The DAG survives the turn, the journal replay, and the session resume.

<!-- SOURCE: src/kin/harness/tools/tasks.py, src/kin/harness/dag.py, src/kin/harness/events.py -->

## 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](/docs/kin/concepts/agents/) — see [Subagents](/docs/kin/guide/subagents/) — and to the `workflow` tool's model-authored fan-out runs — see [Workflows](/docs/kin/guide/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:

```json
{
  "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")`:

1. `X` becomes `status="completed"` with `completed_at=now`.
2. The store walks every remaining task whose `blockedBy` mentions `X`.
3. **No mutation**: the dependent's `status` stays as-is. The store only re-checks readiness on the next `blocked()` query (a completed blocker no longer counts).
4. The `tasks_changed` event 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](/docs/kin/guide/sessions/) (`<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 `--resume` of 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 `todos` list 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](/docs/kin/concepts/agents/#the-agent-panel-ctrlo)** (++ctrl+o++ → `tasks` pane) — every entry as a one-line row `id · 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 with `status`, `content`, `blockedBy`, `blocks` so 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

```json
// 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.
