lazy documentationv0.22

v0.11 PR Walkthrough — The Daemon Release

6,388 lines added, 180 deleted across 71 files.

This is the narrative walkthrough for the v0.11 PR. It's organized by concept, not by file. Start at the top and read sequentially — each section builds on the previous one.


Where to start: the reconcile loop

The entire release orbits one function: the daemon's reconcile loop in src/daemon/server.ts. Before v0.11, the daemon was a simple RPC proxy — CLI commands routed through it for performance, but it didn't do anything on its own. Now it does. Every 5 seconds, the daemon:

  1. Snapshots all working tasks (status, branch tip, parent branch tip)
  2. Runs reconcileTasks() (existing — detects turn completions, crashes)
  3. Compares the snapshot to the new state
  4. Routes events based on what changed
  5. Auto-reacts to external signals (CI failures, PR comments)
  6. Pushes branches that changed

This is the heartbeat. Everything else in this PR is either feeding data into this loop or consuming signals that come out of it.

Read first: src/daemon/server.ts, starting from startDaemonServer(). Follow the reconcile loop body — it's the skeleton that the rest of the PR hangs from.


Layer 1: Dual-bind server and web dashboard

The daemon server was a unix-socket-only process. Now it dual-binds:

The TCP side reuses src/server/index.ts (the existing lazy server web UI) via createWebRequestHandler(). The key change in server.ts is tryBindTcpPort() — it tries the configured port, auto-increments on conflict, and logs the bound port. The web server is a separate Bun.serve() instance that shares storage with the daemon.

This is mostly plumbing. The interesting part is that lazy server (the standalone web command) and the daemon now share the same request handler code, which means features added to one appear in both. src/server/index.ts was refactored to export createWebRequestHandler() as a reusable function.

Mechanical changes: src/cli/commands/server.ts and src/cli/commands/daemon.ts updated to wire the new options through. Config type additions in src/config/types.ts (port field moved from 26024 to 26024 in lazy.toml.example).


Layer 2: Event routing via SSE

src/daemon/events.ts (430 lines, entirely new) is the nervous system's wiring.

The model: Events are transient signals, not durable messages. There's no event log, no replay, no persistence. The daemon maintains an in-memory routing table: Map<taskId, ReadableStreamDefaultController>. When a supervisor connects via GET /events/stream?task_id=<id>, it gets an SSE stream. When an event targets that task, the daemon writes it to the stream.

Event types:

Routing logic lives in routeStateChangeEvents(): Given a list of state changes (tasks that transitioned during reconciliation), it walks the task graph and sends events to connected supervisors. The graph walk is simple — one level up (parent) and one level across (siblings) — not recursive.

Catchup on reconnect: When a supervisor connects, sendCatchupEvents() re-derives the current state by checking git (merge-base for upstream drift, child task statuses for completions). This means a supervisor that disconnects and reconnects doesn't miss signals — it gets the current situation, not a replay of history.

The SSE connection lifecycle uses a heartbeat comment (: heartbeat\n\n) every 30 seconds. If the write fails, the connection is removed. The stopAllConnections() function is called during daemon shutdown.

On the supervisor side: src/supervisor/event-client.ts (238 lines, new) is the SSE consumer. It connects to the daemon, parses SSE frames, queues events, and exposes drainEvents() for the supervisor to call at turn boundaries. Events are never delivered mid-turn — the agent finishes its current work, then gets the event context in the next prompt.

Design decision worth noting: Events are transient by design. The system doesn't need event replay because the reconcile loop re-derives state every 5 seconds. If an event is missed (connection dropped), the next reconnect catches up from current state. This avoids the complexity of event storage, ordering, and delivery guarantees.


Layer 3: Auto-deliver — bridging events to blocked tasks

src/daemon/auto-deliver.ts (621 lines, the largest new file) answers: "What happens when an event targets a task that has no SSE connection?"

Working tasks have supervisors connected via SSE — events flow in real-time. But blocked tasks are dormant. No supervisor, no SSE. When a blocked task needs to react to an event, the daemon auto-unblocks it.

The core function is detectAndDeliverEvents(), called after each reconcile tick. It:

  1. Detects newly-accepted tasks (status changed to complete since last tick)
  2. Routes task.accepted → parent gets notified, siblings get upstream.updated
  3. Detects parent branch changes (tip moved since last tick)
  4. Routes upstream.updated → all non-terminal children
  5. Detects newly-completed/failed tasks
  6. Routes task.completed/task.failed → parent

For each event targeting a blocked task without an SSE connection: deliverToBlockedTask() auto-unblocks it. This is the most complex part of the file — it sets up the worktree, acquires a lock, writes an UnblockCommand to the protocol directory, and spawns a supervisor. It's essentially the same flow as lazy unblock, but triggered automatically.

Parent branch tracking: The function maintains previousParentTips — a Map<taskId, commitSha> that snapshots parent branch HEADs. On each tick, it compares current tips to previous tips. If a parent branch moved, all blocked children get upstream.updated with --sync-with-upstream context.

The rejected → abandoned fix (the TypeScript error fixed in this release) was in this file — two comparisons filtering terminal tasks used "rejected" which isn't a valid TaskStatus. The correct status for rejected tasks is "abandoned".


Layer 4: Auto-react — CI failures and PR comments

src/daemon/auto-react.ts (504 lines, new) is the external signal handler. While auto-deliver routes internal events (task graph changes), auto-react handles external signals from CI and code review.

runAutoReact() is called from the reconcile loop for each project. It iterates blocked tasks that have a remote branch, then:

  1. CI failures: Calls the remote driver to get check/pipeline results. Builds a "signature" (sorted job names + exit codes) for deduplication. If signature differs from the last-reacted one (stored in task metadata), formats structured feedback with job name, failure reason, truncated logs (last 3000 chars), and run URL. Spawns lazy unblock with this feedback.

  2. PR comments: Gets comments from the remote driver. Finds comments newer than the last-reacted comment ID (stored in task metadata). Formats feedback with author, file/line (if review comment), and body text. Spawns lazy unblock.

Fire-and-forget pattern: Auto-react spawns lazy unblock as a subprocess with LAZY_IS_DAEMON=1 to avoid routing back through the daemon (infinite loop). It doesn't wait for completion — it logs the PID and moves on. If the unblock fails (lock contention, worktree issue), the task stays blocked and the next reconcile tick won't re-trigger (dedup prevents it).

One trigger per tick per task: CI is checked first. If CI triggers an unblock, PR comments are skipped for that tick. This prevents double-unblocking a task in a single reconcile cycle.


Layer 5: Budget controls — the circuit breaker

src/daemon/auto-react-budget.ts (336 lines, new) prevents auto-react from burning through API credits.

Four gates, checked in order:

  1. Task not paused — a previously-exhausted task stays paused until human manually unblocks
  2. Daily budget — project-wide cap (default 50 auto-triggered turns per day), stored in .lazy/auto-react-budget.json
  3. Per-task per-trigger count — e.g., max 3 CI failure reactions before pausing (default auto_react_max_retries = 3)
  4. Backoff delay — exponential by default: 0s, 60s, 300s (capped at 5 min)

Counter storage: Per-task counters are stored in task metadata (auto_react_count_ci_failure, auto_react_last_ci_failure, etc.). Daily budget is a file with { date: "YYYY-MM-DD", used: N } — resets automatically at midnight.

Reset triggers: Counters reset only when a human manually unblocks or resumes the task — this is the signal that a human reviewed the situation and it's safe to reset. Budget counters are NOT reset on successful turn completion, because doing so would defeat the budget gate (counter resets to 0 after every turn, allowing unlimited auto-triggers).

Integration with reconcile: maybeAutoResume() in reconcile.ts (existing function for auto-restarting crashed tasks) now checks the budget before restarting. This means even crash recovery respects the circuit breaker — a task that crashes 3 times in a row gets paused instead of looping.


Layer 6: Branch push queue

src/daemon/push.ts (143 lines, new) is the simplest new module.

After the reconcile loop detects state changes (turn completion, acceptance), it calls pushBranchAfterStateChange(branchName, projectRoot). The push module maintains a Set<string> per project — branches waiting to be pushed. Pushes are serialized per-project to avoid concurrent git conflicts. Failed pushes stay in the set and retry on the next tick.

The push uses the remote driver (createDriver() from config), which handles GitHub/GitLab API differences. If no remote is configured, push is a no-op.


Layer 7: MCP proxy for containers

Two files: src/daemon/mcp-proxy.ts (132 lines) and src/daemon/mcp-routes.ts (79 lines).

The problem: Agents running in Docker containers need to call MCP tools (lazy_search, lazy_show, lazy_commit, etc.). Before v0.11, each builder session spun up its own TCP server inside the container. This was per-session overhead with port management and config file dance.

The solution: The daemon is already running. Agents proxy MCP calls through it.

mcp-routes.ts (daemon side): Handles POST /mcp/:taskId/:toolName. Creates a McpToolContext scoped to the task's worktree and executes the requested tool. The task ID is resolved from storage — if the task doesn't exist, it's a 404.

mcp-proxy.ts (agent side): Creates HTTP proxy handlers that forward tool calls to the daemon. Supports two transport modes: unix socket (host process) or TCP via host.docker.internal:26024 (container). The proxy reads a config file written by the host before container launch.

Docker runner integration: src/runner/docker-runner.ts was updated to:

  1. Detect if daemon config is available (LAZY_DAEMON_CONFIG env)
  2. Create a task-specific config file with the task ID baked in
  3. Mount the config into the container
  4. Pass --daemon-config to the agent entry point

This replaces the builder server pattern. The old pattern still works as fallback if daemon config isn't available.


Layer 8: Terminal driver and lazy watch

src/terminal/ (4 new files, ~270 lines total) adds a terminal abstraction.

interface.ts defines the TerminalDriver interface with two methods: setWindowTitle(title) and createWatchSession(taskId, command).

tmux.ts implements TmuxDriver: creates detached tmux sessions for agent observation, kills sessions on cleanup, auto-names windows.

vanilla.ts implements VanillaTerminalDriver: no-op for most operations, opens a new terminal window on macOS/Linux for watch.

index.ts exports the factory createTerminal() and helpers like tmuxSessionName() and killTmuxWatchSession().

src/cli/commands/watch.ts (103 lines): The lazy watch command. Finds the task, validates it's working, attaches to its tmux session in read-only mode (tmux attach-session -t <name> -r).

Reconcile integration: src/utils/reconcile.ts now calls killTmuxWatchSession() when a task's agent process exits (interrupted or completed). This cleans up the tmux session so lazy watch doesn't attach to a stale session.


Layer 9: Watchdog default

The smallest change in the release: src/supervisor/watchdog.ts now defaults to 2 hours (7,200,000ms) instead of disabled. lazy.toml.example updated to document the new default. That's it.

But this is a meaningful change — without it, a hung agent runs forever. With it, the worst case is a 2-hour burn before automatic cleanup.


The test suite

2,024 lines of new test code across 7 files:

Several existing unit test files got 8-line additions — these are beforeAll/afterAll hooks calling killTmuxWatchSession() to clean up tmux sessions that tests might create. Mechanical but necessary.


What to scrutinize in review

1. Auto-deliver worktree setup (auto-deliver.ts:380-520)

This is the riskiest code in the PR. When the daemon auto-unblocks a blocked task, it needs to set up the worktree, create a sandbox, configure the runner, and spawn a supervisor — all from the daemon process. This duplicates logic from lazy unblock (the CLI command) but runs in a long-lived process where failures can't just exit.

Concerns:

The fundamental question: should the daemon be spawning supervisors? It's the same work the CLI does, but the daemon is long-lived and handles many tasks. If a supervisor spawn hangs or leaks resources, it affects all tasks. The current code is cautious (pre-flight checks, lock-based exclusion, error boundaries) but it's the most complex new codepath.

2. Fire-and-forget lazy unblock in auto-react (auto-react.ts:364-429)

Auto-react spawns lazy unblock as a subprocess and doesn't wait for it. This is pragmatic — the daemon can't block its reconcile loop waiting for unblock to finish — but means failures are invisible unless you check logs.

What could go wrong:

The dedup signature is updated before spawning the unblock subprocess (in task metadata). If the subprocess fails, the daemon won't retry because it thinks it already reacted. This is a deliberate trade-off — better to miss one reaction than to spam-unblock — but reviewers should understand it.

3. Daily budget file race (auto-react-budget.ts)

The daily budget is stored in .lazy/auto-react-budget.json. If two daemon instances (shouldn't happen, but could in edge cases) write simultaneously, one write could be lost. The invariant "one daemon per project" prevents this, but there's no file locking — just read-modify-write.

4. Config and storage opened per MCP request (mcp-routes.ts)

Every MCP tool call from an agent opens storage and loads config. For a long-lived daemon handling many agents, this is wasteful. It works correctly but creates unnecessary I/O. A connection pool or config cache would help, but this is a performance concern, not a correctness concern.

5. Event client reconnect backoff (event-client.ts)

The supervisor's SSE client uses exponential backoff on reconnect (1s → 30s). If the daemon is flapping (rapid restart), the supervisor could be offline for up to 30 seconds between attempts. During that window, events are not delivered via SSE. This is mitigated by catchup-on-reconnect, but there's a latency gap.


Mechanical changes (safe to skim)


Suggested reading order

For a reviewer working through this PR:

  1. src/daemon/server.ts — the reconcile loop body. Understand the sequence: snapshot → reconcile → detect changes → route events → auto-deliver → auto-react → push.
  2. src/daemon/events.ts — the SSE routing table and event model. Understand transient-not-durable design.
  3. src/daemon/auto-deliver.ts — how events reach blocked tasks. Focus on deliverToBlockedTask() and the worktree setup.
  4. src/daemon/auto-react.ts — CI and comment detection. Focus on dedup signatures and the fire-and-forget pattern.
  5. src/daemon/auto-react-budget.ts — the four budget gates. Read shouldAutoReact() top to bottom.
  6. src/utils/reconcile.ts diff — integration points: budget check in maybeAutoResume(), counter reset in handleCompletedResponse(), tmux cleanup on task exit.
  7. src/supervisor/event-client.ts — the other end of SSE. Understand queue-and-drain pattern.
  8. src/daemon/mcp-proxy.ts + mcp-routes.ts — MCP proxy. Simple and self-contained.
  9. src/daemon/push.ts — push queue. Straightforward.
  10. src/terminal/ — terminal abstraction. Quick read.
  11. Tests — skim for coverage gaps, not correctness (they're extensive).

What was reworked during the release