#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:
- Snapshots all working tasks (status, branch tip, parent branch tip)
- Runs
reconcileTasks()(existing — detects turn completions, crashes) - Compares the snapshot to the new state
- Routes events based on what changed
- Auto-reacts to external signals (CI failures, PR comments)
- 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:
- Unix socket — CLI and agent RPC, bearer token auth (unchanged)
- TCP port 26024 — web dashboard for browsers, no auth (localhost only)
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:
upstream.updated— parent branch has new commits (flows down to children)task.completed— child turn finished (flows up to parent)task.accepted— child merged (flows to parent + siblings)task.failed— child crashed (flows up to parent)
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:
- Detects newly-accepted tasks (status changed to
completesince last tick) - Routes
task.accepted→ parent gets notified, siblings getupstream.updated - Detects parent branch changes (tip moved since last tick)
- Routes
upstream.updated→ all non-terminal children - Detects newly-completed/failed tasks
- 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:
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 unblockwith this feedback.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:
- Task not paused — a previously-exhausted task stays paused until human manually unblocks
- Daily budget — project-wide cap (default 50 auto-triggered turns per
day), stored in
.lazy/auto-react-budget.json - Per-task per-trigger count — e.g., max 3 CI failure reactions before
pausing (default
auto_react_max_retries = 3) - 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:
- Detect if daemon config is available (
LAZY_DAEMON_CONFIGenv) - Create a task-specific config file with the task ID baked in
- Mount the config into the container
- Pass
--daemon-configto 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:
test/e2e/daemon-events.test.ts(625 lines) — SSE connection, event routing, heartbeat, catchup on reconnecttest/e2e/auto-deliver.test.ts(475 lines) — event delivery to blocked tasks, parent branch detection, task graph cascadestest/e2e/daemon-auto-react.test.ts(359 lines) — CI failure detection, PR comment reactions, deduplicationtest/e2e/auto-react-budget.test.ts(316 lines) — budget gates, backoff, daily limits, counter resettest/unit/terminal.test.ts(108 lines) — tmux session naming, driver factory, window titletest/e2e/daemon-push.test.ts(58 lines) — push queue, serializationtest/e2e/watch.test.ts(41 lines) — command validation, task resolution
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:
- Lock acquisition: if a worktree lock is held by a dead process, auto-deliver skips the task. This is safe but means the task stays blocked until the lock times out or is manually cleaned.
- Sandbox setup: copies
.gitconfig, creates protocol directory, writes command file — same sequence as CLI but in daemon context. If any step fails, the task stays blocked with a logged warning. - Runner creation:
createRunner()could fail (Docker not available, config invalid). Failure is caught and logged.
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:
- Lock contention: unblock needs the worktree lock, which another process might hold
- Storage lock: unblock needs storage access, which the daemon also uses
- Stale dedup: if unblock fails but the dedup signature was already updated, the signal won't re-trigger
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)
- Config type additions (
src/config/types.ts): New fields for daemon auto-react settings (auto_react,auto_react_ci,auto_react_comments,auto_react_max_retries,auto_react_backoff,auto_react_daily_budget). All have defaults. - CLI command registrations (
src/cli/commands/index.ts,shared.ts): Addingwatchto the command list, adding--daemon-configflags. lazy.toml.example: Port changed from 26024 to 26024, watchdog default documented.src/daemon/index.ts: Re-exports new modules.src/daemon/lifecycle.ts: Minor — added web port to status output.- Unit test boilerplate: 6 existing test files got identical 8-line
beforeAll/afterAllblocks for tmux cleanup. src/cli/commands/list.ts,loop.ts,pair.ts,show.ts,start.ts,unblock.ts,upgrade.ts,wait.ts: Small additions to pass daemon config or terminal driver through existing command flows.- Release artifacts:
CHANGELOG.md,docs/release/v0.11-presentation.md, and SVG diagrams are documentation — no code review needed.
#Suggested reading order
For a reviewer working through this PR:
src/daemon/server.ts— the reconcile loop body. Understand the sequence: snapshot → reconcile → detect changes → route events → auto-deliver → auto-react → push.src/daemon/events.ts— the SSE routing table and event model. Understand transient-not-durable design.src/daemon/auto-deliver.ts— how events reach blocked tasks. Focus ondeliverToBlockedTask()and the worktree setup.src/daemon/auto-react.ts— CI and comment detection. Focus on dedup signatures and the fire-and-forget pattern.src/daemon/auto-react-budget.ts— the four budget gates. ReadshouldAutoReact()top to bottom.src/utils/reconcile.tsdiff — integration points: budget check inmaybeAutoResume(), counter reset inhandleCompletedResponse(), tmux cleanup on task exit.src/supervisor/event-client.ts— the other end of SSE. Understand queue-and-drain pattern.src/daemon/mcp-proxy.ts+mcp-routes.ts— MCP proxy. Simple and self-contained.src/daemon/push.ts— push queue. Straightforward.src/terminal/— terminal abstraction. Quick read.- Tests — skim for coverage gaps, not correctness (they're extensive).
#What was reworked during the release
rejected→abandonedinauto-deliver.ts: Two comparisons filtered terminal tasks using"rejected", which isn't a validTaskStatus. TypeScript caught this — the correct status is"abandoned"(whatlazy rejecttransitions to). Fixed as the first commit of the release process.Web dashboard port: Changed from 26024 (which was a placeholder) to 26024 in
lazy.toml.example. This is the "lazy" port (L=1, A=3, Z=2, Y=1 → 26024 by some creative mapping... or just a nice number).Watchdog default: Changed from "disabled by default" to "2 hours by default". The
lazy.toml.examplecomment was updated to reflect this. The code change inwatchdog.tsis one line, but the behavior change is significant — every agent now has a timeout unless explicitly disabled.