Repository navigation
tower: state.db builders rows persist as ghosts after PR merge (no porch-done → row transition; no startup reconcile) #1083
Description
Problem
Tower's builders table in .agent-farm/state.db does not get cleaned up automatically when a builder's PR merges. The row stays forever in its last-recorded state (typically status: implementing, phase: init) until someone manually runs afx cleanup --project <id>. If post-merge cleanup is skipped or forgotten, afx status and any sidebar / dashboard derived from the same DB show the dead entry as if it were a live, in-progress builder.
On 2026-06-20 the architect surfaced 5 stale rows whose underlying issues had been closed via merged PRs as far back as 2026-05-27:
| Row id | Issue | PR state | DB state |
|---|---|---|---|
builder-bugfix-880 |
#880 closed | shipped (separate route, see issue) | implementing/init, port=0, pid=0, age 24d |
builder-pir-882 |
#882 closed | PR #884 MERGED | implementing/init, port=0, pid=0, age 24d |
builder-pir-791 |
#791 closed | PR #955 MERGED | implementing/init, port=0, pid=0, age 24d |
builder-bugfix-974 |
#974 closed | PR #977 MERGED | implementing/init, port=0, pid=0, age 24d |
builder-bugfix-1024 |
#1024 closed | PR #1026 MERGED (shipped in v3.2.0) | implementing/init, port=0, pid=0, age 24d |
The port=0, pid=0, phase=init signature looks like a failed spawn at first glance, but the underlying work was completed in every case (issues all closed, PRs all merged). The bug is the lifecycle, not the spawn.
Root cause
packages/codev/src/commands/porch/index.ts and the porch state-machine that drives builder lifecycle do not communicate completion back to Tower's state.db builders row. porch done <id> advances the project status to complete, but the builder row's status column (spawning | implementing | blocked | pr | complete) stays at whatever the last spawn-time write set it to. Tower has no listener for PR-merge events and no scheduled reconcile pass to detect "the worktree at builders.worktree no longer exists on disk, the row should be dropped or marked stale."
afx cleanup --project <id> does the right thing (it deletes the row, as well as killing terminals / shellpers / optionally the worktree). But it's a manual step. If the architect forgets it post-merge, or if multiple cleanups got batched and one was dropped, the row persists.
Two reasonable fixes (pick one or compose)
A. porch done writes status=complete to state.db (preferred)
When the porch protocol-complete transition fires for a builder, also update the matching builders row to status=complete. afx status filters out complete rows by default (with a --include-complete flag for inspection). The DB row stays for audit / history but is no longer noise in the live view.
Pros: surgical, deterministic, fires exactly when completion happens, no polling. Connects two state machines that should already have been connected.
Cons: requires porch to know how to find the corresponding builders row (the issue_number column makes this straightforward).
B. Periodic Tower reconcile of worktree paths against disk
On Tower startup and at a low cadence afterwards (e.g. once per hour), scan builders.worktree paths: any row where the path no longer exists on disk gets transitioned to status=stale (or auto-deleted after a grace period). afx status filters out stale.
Pros: self-healing; catches the case where someone removes a worktree directly via git worktree remove (bypassing afx cleanup); requires no porch coupling.
Cons: scans against disk, adds startup work; a brief window after a git worktree remove shows stale before reconcile catches it; doesn't distinguish "spawn never finished" from "PR merged and worktree cleaned up."
Recommendation
Ship A as the primary path (it fires at the exactly-right moment and matches the existing porch state-machine ownership). Ship B as a backstop for the cases A misses (manual git worktree remove, Tower restart while a row is mid-transition, etc.). The two compose without conflict: B becomes a no-op once A is correct, but stays useful as a safety net.
Acceptance criteria
- When
porch done <id>runs, the matchingbuildersrow instate.dbis updated tostatus=completeif the project's protocol completed successfully. (Lookup key:builders.issue_number = <id>orbuilders.id = builder-<protocol>-<id>, whichever is more reliable.) -
afx statusfilters outstatus=completerows by default. A new--include-complete(or--all) flag shows them. - When Tower starts up, it scans
builders.worktreepaths and transitions rows with missing paths tostatus=stale.afx statusfilters outstaletoo. - A grace period for
stalerows: after 7 days ofstatus=stale, the row is auto-deleted on the next startup reconcile pass. (Configurable via env if anyone needs different retention.) - The transition from any non-
completestatus tocompleteis idempotent: re-runningporch donedoes not error if the row is alreadycomplete. - Unit tests cover: the porch → state.db transition; the startup reconcile; the filter behavior in
afx status. - An integration test exercises the full happy path: spawn → builder runs → PR merges →
porch done→ row iscomplete→ nextafx statusdoes not show the row by default. - CHANGELOG entry under
Other fixesdocumenting that completed builders no longer appear inafx status.
Out of scope
- Changing the schema's status enum beyond adding
stale. The existing values (spawning | implementing | blocked | pr | complete) are the right vocabulary. - Reconciling DB rows against open PRs (e.g. "PR is open but builder is in
complete" detection). The single direction (worktree gone → mark stale) is enough. - Touching how
afx cleanupworks today. It continues to be the manual escape hatch for the case where someone wants to delete a row immediately. The state-machine fixes are about not having to think about cleanup in the common case. - Listening for GitHub PR-merge webhooks. Codev's coupling stays local;
porch doneis the trigger. - Migrating existing stale rows (those will get caught by the reconcile pass on the first Tower startup after this ships).
Why BUGFIX
Small, well-scoped, mechanical. One new code path in porch done, one new method on the Tower API that updates the row, one new filter in afx status rendering, one startup reconcile pass. ~150-200 LOC across packages/codev, with tests. No design ambiguity beyond pick-A-or-B-or-both (resolved above: both).
PIR is overkill (no visual surface, no dev-approval-worthy "feels right" UX call). AIR fits if the architect wants single-pass autonomy.
Related
packages/codev/src/agent-farm/commands/cleanup.ts(or whereverafx cleanuplives): the existing manual escape hatch this issue makes less-needed.packages/codev/src/commands/porch/index.ts: the porch state-machine entry point that needs to notify Tower ondone..agent-farm/state.dbschema:builderstable (seeCREATE TABLE buildersin the migration files for the canonical shape).- afx workspace recover: revives builders that were previously cleaned up via afx cleanup #915 (open): afx workspace recover revives builders that were previously cleaned up via afx cleanup. Adjacent lifecycle bug, possibly related cause.
- afx cleanup: add a flag (--full) to also remove worktree + delete branch #852 (open): afx cleanup: add a flag (--full) to also remove worktree + delete branch. Sibling cleanup-UX improvement.
- Spec 755 / Spec 786 / Spec 823: established
builders.spawned_by_architectand the architects-updated event surface. Same lifecycle code area. - vscode: sync Builders sidebar selection with the active builder-diff file #1066 / PR vscode: sync Builders sidebar selection with the active builder-diff file (#1066) #1075 (shipped): set the precedent for "Tower's state.db is the source of truth for the sidebar." A row that lies in state.db lies everywhere in the IDE.
Activity