Skip to content

Proposal: Beads (bd) as task lifecycle backend #518

@patrick-motard

Description

@patrick-motard

The Problem

TaskPlane hand-rolled a dependency-aware task queue across ~8,000 lines of TypeScript:

File Lines What it does
discovery.ts 1,818 Parse PROMPT.md, extract deps, scan directories
waves.ts 1,548 Build dependency graph, validate, compute wave order
persistence.ts 2,087 Serialize/deserialize batch-state.json
resume.ts 2,878 Reconcile state after crash, re-resolve blockers
types.ts 4,278 ParsedTask, LaneTaskStatus, dependency graph types

This is solid engineering, but it's reimplementing primitives that beads provides natively.

The Mapping

TaskPlane concept Current implementation Beads equivalent
Task definition Directory + PROMPT.md + STATUS.md bd create with description/metadata
Dependencies dependencies.json + PROMPT.md parsing --deps blocks:bd-123 (native)
"What's ready to run?" computeWaves() — topological sort, 200+ lines bd ready --json — one call
Task status batch-state.json field per task bd update --status / bd close
Dependency graph validation validateGraph() — cycle detection, orphan check Built into beads' blocker resolution
Resume after crash resume.ts — 2,878 lines reconciling stale state bd ready --json — just ask again
Task metadata Parsed from PROMPT.md headers (size, review level, repo) --metadata '{"size":"M","reviewLevel":2}'
Discovered work during execution Not supported bd create --deps discovered-from:bd-123

What Changes

Phase 1: Beads as task registry (additive, non-breaking)

TaskPlane keeps PROMPT.md files for the rich task spec (steps, checkboxes, file scope, context docs). But instead of scanning directories and parsing headers for the task lifecycle data, it reads from beads:

# Discovery becomes:
bd ready --json  # → [{id, title, deps, priority, metadata: {size, reviewLevel, taskFolder}}]

# Wave planning becomes:
# beads already computed the unblocked set. TaskPlane just needs to
# bin them into lanes based on size/repo — the graph work is done.

# Status updates become:
bd update $TASK_ID --status in_progress   # worker starts
bd close $TASK_ID --reason "All steps complete"  # worker finishes
bd update $TASK_ID --status blocked --note "merge conflict"  # failure

Phase 2: Replace persistence layer

batch-state.json shrinks dramatically. Runtime state (lane assignments, worktree paths, telemetry) stays in batch-state. But task status, dependency resolution, and completion tracking move to beads. The 2,878-line resume.ts simplifies to "ask beads what's still open."

Phase 3: Agent-driven task creation

Workers that discover new work during execution can:

bd create "Found missing API migration" \
  --deps discovered-from:$CURRENT_TASK \
  -p 1 --metadata '{"size":"S","reviewLevel":1}' \
  --json

This slots into the dependency graph automatically. Next bd ready reflects it. No file scaffolding needed.

What Stays the Same

  • PROMPT.md — the rich task specification with steps, checkboxes, file scope, context docs. Beads doesn't replace this; it just tracks the lifecycle around it.
  • STATUS.md — intra-task progress tracking (step checkpoints). Beads tracks task-level status; STATUS.md tracks step-level progress.
  • Lane assignment & worktrees — that's execution infrastructure, not task management.
  • Dashboard — reads from beads instead of batch-state.json for task status. Gets task titles for free (solving Dashboard: show task title under task ID in lane view #485).
  • Merge agent — unchanged, it works on git branches not task records.

What Gets Deleted

  • dependencies.json files and the cache layer
  • computeWaves() graph construction (1,548 lines of waves.ts significantly simplified)
  • Most of resume.ts — crash recovery becomes "query beads for current state"
  • PROMPT.md header parsing for lifecycle fields (deps, status) — only content parsing remains
  • Half of persistence.ts — task state serialization moves to beads

Why Dolt

Beads uses Dolt, a MySQL-compatible database with git-style version control. This is unusually well-suited for TaskPlane's needs:

Time-travel debugging

Every beads write auto-commits to Dolt history. When a batch crashes at 2:30 AM and you're debugging at 9 AM, you can query the exact state at crash time:

-- What was the task state when wave 2 started?
SELECT id, title, status FROM issues AS OF 'abc123def' WHERE status = 'open';

Or via the CLI:

bd show TP-004 --as-of abc123    # Show task as it was at that commit
bd diff HEAD~5 HEAD              # What changed in the last 5 operations?
bd history TP-004                # Full audit trail for one task

TaskPlane currently has no equivalent. When batch-state.json gets corrupted or has a stale write, the state is gone. The 2,878-line resume.ts exists largely to reconstruct what should have been recorded. With Dolt, the history is immutable.

Crash recovery becomes a non-problem

The current resume.ts does heroic work reconciling .DONE files, STATUS.md checkboxes, lane snapshots, and batch-state.json after a crash. It has to because the source of truth is a single JSON file that can be mid-write when the process dies.

Dolt is ACID. Writes either commit or they don't. After a crash, bd ready --json returns the correct unblocked set without reconciliation. The entire category of "stale state" bugs disappears.

Branching for speculative planning

Dolt supports branches. TaskPlane could use this for dry-run wave planning:

bd dolt branch planning-run
# Simulate task completions, test wave ordering
bd dolt branch -d planning-run   # Throw away if not needed

Multi-repo sync

Dolt has native push/pull to remotes. In polyrepo workspaces (TaskPlane's active development focus), task state can be shared across repos without file-based coordination:

bd dolt push    # Push task state to remote
bd dolt pull    # Pull task state from another machine

This is more robust than the current approach of committing batch-state.json to git branches and hoping merge conflicts don't corrupt it.

SQL as the query layer

Dolt exposes a MySQL wire protocol server. The ready-work computation that TaskPlane reimplemented in 1,548 lines of TypeScript is a SQL query in Dolt:

-- Tasks with no unresolved blockers (simplified)
SELECT i.id, i.title, i.metadata
FROM issues i
LEFT JOIN dependencies d ON d.issue_id = i.id
LEFT JOIN issues blocker ON d.depends_on_id = blocker.id AND blocker.status != 'closed'
WHERE i.status = 'open' AND blocker.id IS NULL;

The ready_issues materialized view in beads already does this. It's tested, fast, and handles edge cases that TaskPlane's graph code has had repeated bugs with (look at #462, #479, #508 — all dependency/state resolution bugs).

Built-in audit trail

Every mutation is recorded:

$ bd history TP-004
commit abc123  2026-04-24 16:02  bd: create TP-004
commit def456  2026-04-24 16:05  bd: update TP-004 (status: in_progress)
commit ghi789  2026-04-24 16:22  bd: close TP-004 (reason: "All steps complete")

TaskPlane currently stores this partially in STATUS.md (if the worker bothers to update it — see #510) and partially in batch-state.json (which gets cleaned up after integration). With Dolt, the full lifecycle is preserved and queryable.

Integration Challenges: Go CLI ↔ TypeScript

This is the real tension. Beads is a Go binary (bd). TaskPlane is TypeScript. There are three integration paths, each with tradeoffs:

Path A: Shell out to bd CLI (simplest, proven pattern)

TaskPlane already shells out to git via execFileSync — 85 call sites across the codebase using a runGit() wrapper. The same pattern works for beads:

// Analogous to the existing runGit() pattern in git.ts
function runBd(args: string[], cwd?: string): { ok: boolean; data: unknown; stderr: string } {
  try {
    const stdout = execFileSync("bd", [...args, "--json"], {
      encoding: "utf-8",
      timeout: 10_000,
      cwd: cwd || process.cwd(),
      stdio: ["pipe", "pipe", "pipe"],
    }).trim();
    return { ok: true, data: JSON.parse(stdout), stderr: "" };
  } catch (err) { /* same error handling as runGit */ }
}

// Discovery:
const { data: ready } = runBd(["ready"]);

// Status update:
runBd(["update", taskId, "--status", "in_progress"]);

// Close:
runBd(["close", taskId, "--reason", reason]);

Pros:

  • Identical to the existing git integration pattern — no new paradigm
  • bd --json returns structured data — no stdout parsing needed
  • Beads handles its own Dolt server lifecycle (auto-start, connection pooling)
  • Zero new dependencies in package.json

Cons:

  • Process spawn overhead per call (~400ms for bd ready, ~1.4s for bd create — measured on this machine)
  • Synchronous execFileSync blocks the event loop (but TaskPlane already does this for git)
  • Error handling is string-based (stderr parsing)

Mitigation: Batch operations help. Instead of calling bd update per task, beads supports bulk operations and file-based creation (bd create --file batch.md). The hot path (bd ready) is ~400ms — comparable to the git operations TaskPlane already runs.

Path B: Direct MySQL connection to Dolt (fastest reads)

Dolt runs a MySQL-compatible server (already running on the machine — port auto-assigned). TypeScript can connect via mysql2:

import mysql from 'mysql2/promise';

const conn = await mysql.createConnection({
  host: '127.0.0.1',
  port: parseInt(readFileSync('.beads/dolt-server.port', 'utf-8')),
  user: 'root',
  database: 'wiki',  // or project-specific DB name
});

// Direct query — microseconds, not milliseconds
const [ready] = await conn.execute(
  'SELECT * FROM ready_issues ORDER BY priority'
);

// Time-travel query
const [snapshot] = await conn.execute(
  'SELECT * FROM issues AS OF ? WHERE id = ?',
  [commitHash, taskId]
);

Pros:

  • Sub-millisecond reads — no process spawn overhead
  • Full SQL query power (joins, aggregations, time-travel)
  • Async/non-blocking — doesn't hold up the event loop
  • Can use connection pooling for concurrent lane operations

Cons:

  • Adds mysql2 dependency to TaskPlane's package.json (currently only depends on yaml)
  • Must handle Dolt server lifecycle (what if it's not running? beads auto-starts it, but direct connections skip that)
  • Writes should still go through bd CLI to preserve beads' business logic (validation, audit events, auto-commit policies)
  • Two integration paths to maintain (SQL for reads, CLI for writes)

Mitigation: Use Path B for hot-path reads (discovery, status checks, dashboard polling) and Path A for writes. This is a common pattern — read replica via SQL, writes via the application layer.

Path C: Hybrid with JSONL interchange (most portable)

Beads exports to .beads/backup/issues.jsonl and .beads/backup/dependencies.jsonl. TaskPlane could read these directly as a lightweight integration:

// Read the JSONL backup — no bd process, no SQL connection
const issues = readFileSync('.beads/backup/issues.jsonl', 'utf-8')
  .split('\n')
  .filter(Boolean)
  .map(JSON.parse);

Pros:

  • Zero runtime dependencies — just file reads
  • Portable across machines (JSONL files can be committed to git)
  • Works even if Dolt server is down

Cons:

  • Stale reads — backup is periodic, not real-time
  • No write path — still need CLI for mutations
  • Loses the dependency resolution logic (must reimplement blocker checks)
  • Defeats the purpose of the integration

Verdict: Path C is a non-starter for the main integration but useful as a fallback/export format.

Recommended approach: Path A with selective Path B

Start with Path A (CLI shelling) because:

  1. It mirrors TaskPlane's existing runGit() pattern — minimal paradigm shift
  2. The performance is adequate for batch orchestration (tasks run for minutes; 400ms overhead is noise)
  3. Beads handles all its own complexity (server lifecycle, migrations, validation)
  4. It's the smallest diff and easiest to review/merge

Graduate to Path B (direct SQL) only for the dashboard server, which polls every 5 seconds and would benefit from sub-millisecond reads. The dashboard already runs a Node.js HTTP server (dashboard/server.cjs) — adding a mysql2 connection there is natural.

Other integration considerations

Beads as a dependency: Users would need bd installed. This is a Go binary distributed via Homebrew (brew install beads) or direct download. TaskPlane could:

  • Make it optional (feature flag: taskplane.backend: "beads" | "files")
  • Auto-detect (which bd succeeds → use beads; fails → fall back to files)
  • Add a taskplane doctor check for bd availability

Dolt server lifecycle: Beads auto-starts the Dolt server on first use and auto-stops it after inactivity. TaskPlane doesn't need to manage this. But in long-running batches, the server must stay alive. Beads' .beads/dolt-server.activity heartbeat file handles this — TaskPlane would touch it periodically during batch execution.

Concurrent access: Multiple lanes writing status updates simultaneously. The CLI approach serializes through beads' own locking. The SQL approach needs transaction isolation. Dolt supports SERIALIZABLE isolation, so this works but needs explicit transaction boundaries for multi-statement updates.

ID scheme: Beads uses content-addressed short IDs (e.g., wiki-fle). TaskPlane uses sequential IDs (e.g., TP-004). Beads supports --prefix to control the prefix and --id for explicit IDs, so TaskPlane's existing naming convention can be preserved:

bd create "Port RPI commands" --prefix TP --id TP-004 --json

Why It's Worth It

  1. ~4,000+ lines deleted from the most bug-prone parts of the codebase (look at the issue tracker — most bugs are in dependency resolution, resume, and state persistence)
  2. Crash recovery becomes trivial — Dolt is ACID, beads is the source of truth, not a JSON file that can get corrupted mid-write
  3. bd ready replaces the wave planner's dependency resolution — tested, proven, fast
  4. Agent-discovered work is a new capability that's hard to add with the file-based system
  5. Dolt time-travel gives you historical state queries for free — "what was happening when it crashed?" becomes a one-liner instead of log archaeology
  6. Dashboard Dashboard: show task title under task ID in lane view #485 is solved as a side effect — beads has titles natively
  7. Audit trail — every state transition is recorded and queryable, unlike STATUS.md which depends on worker compliance (Lane-runner should enforce STATUS.md checkpoint discipline at step boundaries #510)

Migration Path

Non-breaking. Add beads as an optional backend behind a config flag. Existing file-based system stays as default. Users opt in with taskplane.backend: "beads" in settings. Once proven, flip the default.

Step 1: bd adapter module

Create extensions/taskplane/beads.ts — thin wrapper around bd CLI (modeled on git.ts). Exports: bdReady(), bdCreate(), bdUpdate(), bdClose(), bdQuery().

Step 2: Discovery adapter

In discovery.ts, add a code path: if beads backend is enabled, call bdReady() instead of scanning directories. Map beads issue fields to ParsedTask interface. PROMPT.md content is still read from the file path stored in beads metadata.

Step 3: Engine integration

In engine.ts and execution.ts, status updates write to beads in addition to (or instead of) batch-state.json. Resume reads from beads instead of reconciling stale JSON.

Step 4: Dashboard integration

In dashboard/server.cjs, optionally read task titles and status from beads (solving #485 immediately). Consider direct SQL connection here for polling performance.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions