Watching the watcher
DraftHouse can replay completed design-review workspaces — load the workspace, parse all the response files, replay them as debate entries in the channel feed. That works. But a design review takes 10–30 minutes across multiple rounds, and until now there was no way to watch one in progress. You had to wait for it to finish and then load the result.
The gap was obvious once I started using the replay adapter regularly. I’d kick off a design review, switch to DraftHouse to watch it, and find… nothing. The review was running. DraftHouse couldn’t see it yet.
The shape of the problem
The replay adapter does everything in one pass: parse the workspace, dispatch all entries, push to WebSocket. For live watching, you need the opposite — sit on a directory, notice when a new file appears, parse just that file, and push the delta.
Two things made this non-trivial. First, the replay adapter’s replay() method was
monolithic — a single 200-line method that looped over rounds and dispatched six
different entry types inline. The watcher needed to call the same dispatch logic for
individual rounds as they arrived. Second, the watcher thread is a plain Java thread
with no CDI request context — and MessageService.dispatch() needs one.
What we built
WorkspaceWatcher sits between directory-watcher (Greg Methvin’s library — native
macOS FSEvents via JNA, no polling) and the existing dispatch infrastructure. When
load_workspace runs, it now checks whether the review is still in progress by
scanning the last 20 lines of progress.log for a terminal state. If there’s no
REVIEW DONE / PAUSED / FAILED, it replays what exists and hands off to the
watcher.
The watcher filters events to three paths: responses/reviewer-N.md (parse round,
dispatch RAISE entries), responses/implementor-N.md (dispatch response entries),
and progress.log (tail new lines, push metadata). A <workspace-status> element
in the topbar renders the metadata — agent activity, elapsed time, cost per round.
The refactoring landed naturally. I extracted dispatchIssues(), dispatchResponses(),
dispatchConfirmations(), and the rest from the monolithic replay() into
package-visible methods. Both replay and watcher now call the same methods. A
tenancyId field on the adapter handles the CDI context gap — the watcher captures
it from the channel at construction time and passes it explicitly on every
MessageDispatch, bypassing the CurrentPrincipal fallback that doesn’t exist on
the watcher thread.
The dedup bug
The one genuine bug was in the catch-up reconciliation. The watcher starts with a
scan to close the TOCTOU window between replay completion and watcher registration.
It iterates expected round numbers and calls processFile(stem) for each. The dedup
guard — processedFiles.add(stem) — fires before the file existence check. When the
implementor file doesn’t exist yet (the round hasn’t produced it), the add succeeds
but waitForFile() fails. The stem is now permanently in the dedup set. When the
file actually appears, the async handler sees it’s already “processed” and skips it.
The fix is one line: processedFiles.remove(stem) in the failure path. But the
failure mode is genuinely non-obvious — the dedup-first pattern is the standard
concurrent guard, and it works perfectly when both paths process actual items. It
breaks when one path processes expected items that might not exist yet.
What the design review caught
The adversarial review ran 8 rounds and drove significant improvements into the
spec before I wrote a line of implementation code. The biggest catches: CDI request
context activation on the watcher thread (Arc.container().requestContext()),
ReplayResult extension with raiseMessageIds and lastMessageId for incremental
WebSocket push, and the processedFiles dedup keyed by filename stem rather than
round number (each round has two independent files that arrive separately).
The review also caught that the dispatchRoundSnapshot method needed the repo path
and spec path passed through from the replay result, and that endDebate() needed
to stop the watcher before removing the session from the registry. Details that would
have surfaced during implementation — but surfacing them in the spec meant the
implementation was straightforward.
The directory-watcher library is a good fit here. Native FSEvents on macOS, no
polling, and the API is minimal — builder pattern, watchAsync(), close(). The
file appearance rate for design reviews is so low (one file every 2–8 minutes) that
any watching mechanism would work, but FSEvents means zero CPU cost between events.