Skip to content

feat(dispatcher): minimal dispatcher — Phase 1 (Epic #6)#73

Merged
thejustinwalsh merged 24 commits into
mainfrom
worktree-6-minimal-dispatcher
May 22, 2026
Merged

feat(dispatcher): minimal dispatcher — Phase 1 (Epic #6)#73
thejustinwalsh merged 24 commits into
mainfrom
worktree-6-minimal-dispatcher

Conversation

@thejustinwalsh

@thejustinwalsh thejustinwalsh commented May 15, 2026

Copy link
Copy Markdown
Owner

🔄 Update — 2026-05-16

13 commits since this PR was first marked ready (91f32330beedf6). Addressed all 6 Greptile review findings + ran live mm dispatch end-to-end against real Claude through several iterations of debugging. Test count 100 → 123 pass, tsc clean throughout. A detailed "what's changed since the original brief" addendum is posted below as a PR comment — read that first.

Summary

Closes #6

Build-spec Phase 1 — the minimal dispatcher. middle can now persist operational state, load layered config, abstract a CLI agent behind one adapter interface (Claude), supervise tmux sessions, isolate work in git worktrees, run a 3-step implementation workflow (prepare-worktree → launch-and-drive → cleanup), drive it from a mm CLI, and dismiss Claude's bypass-mode confirmation autonomously on launch.

All 7 sub-issues (#7#13) are delivered, each with bun test coverage. Full suite: 123 pass, tsc --noEmit clean.

What changed (current state)

Phase 1 core (the original 7 sub-issues):

  • packages/dispatcher/src/db.ts + db/migrations/001_initial.sql — WAL-mode SQLite wrapper, numbered-migration runner, full Phase 1 schema.
  • packages/core/src/config.tsloadConfig parses + deep-merges global + per-repo TOML.
  • packages/core/src/{adapter,events}.tsAgentAdapter interface, normalized event vocabulary.
  • packages/adapters/claude/ClaudeAdapter with bypass-prompt-aware enterAutoMode, login detection, classify with worktree-anchored sentinels, full installHooks (SessionStart + Stop + .middle/hooks/hook.sh).
  • packages/dispatcher/src/tmux.ts — session helpers, typed TmuxError, version-aware preflight.
  • packages/dispatcher/src/worktree.ts — git worktree create/destroy/list, idempotent.
  • packages/dispatcher/src/{hook-server,workflow-record}.ts + workflows/implementation.ts — minimal hook receiver (SessionGate), workflows row CRUD, the 3-step implementation workflow factory.
  • packages/dispatcher/src/{main,dispatch}.ts — dispatcher process entrypoint and the self-contained dispatchEpic runner with bunqueue-lifecycle-aware draining.
  • packages/cli/src/commander wiring + mm start / mm stop / mm status / mm dispatch / mm doctor / mm version.
  • scripts/dev.sh — foreground dev launcher.

Post-original-review additions:

  • packages/core/src/tmux-tui.ts — shared composable TUI primitives (capturePane, sendText, sendKeys, pollPaneFor<T>).
  • packages/cli/src/commands/doctor.tsmm doctor preflight check.
  • ensurePromptFile in launchAndDrive — writes a plan-style .middle/prompt.md if missing.

Acceptance gate (Epic #6) — REVISED

Criterion Status Evidence
All sub-issues closed ✅ Met #7#13 all closed with per-phase marker comments
mm dispatch end-to-end with real Claude: tmux launch → SessionStart → auto mode → prompt → Stop → cleanup Mechanism verified live, ⚠️ agent work content untested Exercised across multiple manual mm dispatch runs against ~/Developer/middle: bypass warning dismissed via Down+Enter, SessionStart fires, prompt delivered, Stop received, classifyStop returns, workflow finalizes, worktree cleaned up. The agent itself has only received empty / placeholder prompts so far — substantive autonomous work is what dispatching Epic #14 (Phase 2) post-merge will exercise.

Verification (current)

bun test123 pass across 18 files. bun run typecheck — clean.

Per-phase breakdown (each phase's sub-issue close comment links the proving tests). The post-original-review work added: detectNeedsLogin regex unit tests, pollPaneFor tests (match / timeout / session-gone / tag-logs-stderr), capturePane / sendText / sendKeys live tmux tests, mm doctor smoke test.

Why these changes

The central design pressure was the interactive-tmux dispatch model (no headless mode, no exit code): the workflow's launch-and-drive step is launch → drive → observe, reacting to a Stop hook via classifyStop rather than a process exit. That shaped three load-bearing decisions, each posted as a per-line review comment: the hook receiver handles two events (SessionStart + Stop); the workflow is a factory over structural deps so the end-to-end test stubs tmux while exercising the real worktree helpers (genuine leak verification); and compensated (a workflow step threw, rolled back) is kept distinct from failed (the agent itself failed but the run completed). Layering is strict — cli → {dispatcher, adapter-claude}, adapters/* → core, no dispatcher → adapter-* edge.

Live debugging surfaced a second class of decisions, captured in the update addendum: bunqueue's executor sets exec.state='failed' before awaiting compensation (drove the inline engine.close(false) drain pattern); Claude's SessionStart is gated on the bypass-warning being dismissed (drove the parallel-poller enterAutoMode); Claude's bypass-mode confirmation needs Down+Enter with a 100ms delay between (drove the sendKeys delayBetweenMs primitive).

Stumbling points

  • bunqueue's exec.state='failed' precedes compensationwaitForSettle returned mid-flight; finally torn down hookServer/db while compensation still needed them; surfaced as Invalid or expired lock token and "step failed: hook server stopped". Fix: drain engine inline after waitForSettle, before the finally. Plus retry: 1 (bunqueue's retry = maxAttempts), plus an unhandledRejection swallower scoped to dispatchEpic for the lock-token message specifically.
  • SessionStart is gated on the bypass-warningenterAutoMode had to run in parallel with awaitSessionStart, not after. Chicken and egg.
  • Bypass-mode confirmation needs delayed Down+Enter — one combined tmux send-keys arrives too fast; menu doesn't advance selection. Split keys with 100ms delay.
  • --dangerously-skip-permissions runtime-bypasses but still pops a UI prompt--permission-mode bypassPermissions is equivalent at runtime but has the same UI gate; we dismiss it via enterAutoMode's poll-then-act.
  • bunqueue dataPath + macOS temp dirsSQLITE_IOERR_VNODE on retry writes. In-memory engine in both tests and production for Phase 1.
  • classifyStop "reads PR state" — the spec's prose says so, but the fixed interface gives the adapter no PR handle. Resolved with .middle/done.json / .middle/failed.json sentinels for Phase 1 + worktree-anchored paths (added to the interface during review); Phase 4's PR-ready hook gate replaces the done path.
  • tmux 3.5 requiredextended-keys-format csi-u lands in 3.5; older tmuxes produce a .tmux.conf error and degraded key passthrough. mm doctor checks this.

Suggested CLAUDE.md updates

  • Note that bunqueue is run embedded + in-memory in Phase 1 — file-backed dataPath under temp dirs hits SQLITE_IOERR_VNODE on macOS.
  • Note that bunqueue's executor sets exec.state='failed' BEFORE compensation runs — any caller polling for terminal state must drain the engine inline before tearing down dependencies.
  • Note the package layering rule made explicit: cli → {dispatcher, adapter-*}, adapters/* → core, no dispatcher → adapter-* edge.
  • Note that tmux ≥ 3.5 is a hard prereq for clean operator-attach experience (extended-keys-format).

Architectural forks

None — every decision was resolved by the build spec, the adapter-interface contract, the dependency-layering rule, or empirical debugging.

Follow-up issues

None filed. Everything out of scope is already an explicitly-spec'd later phase (full hook taxonomy + watchdog → Phase 2; skill enforcement → Phase 4; mm init/full mm doctor → Phase 3/11; CodexAdapter → Phase 10; auto-dispatch + concurrent-dispatch coordination + the HTTP trigger into the running process → Phase 8; durable bunqueue persistence + recovery → Phase 2). No genuine discoveries the phase plan didn't anticipate.

Out of scope

Full hook taxonomy / HMAC / events-table population / watchdog / reconciler cron (Phase 2); installHooks writing the full event set (Phase 2 — Phase 1 ships SessionStart + Stop); skill-enforcement gates (Phase 4); CodexAdapter (Phase 10); recommender workflow (Phase 7); mm init/uninit and the dashboard (Phase 3/9); full mm doctor with schema validation + retention reporting (Phase 11); retention crons (Phase 11); routing mm dispatch through the running dispatcher process via HTTP (Phase 8); concurrent-dispatch deduplication on (repo, epic) (Phase 8).

Summary by CodeRabbit

  • New Features

    • Full mm CLI (start/stop/status/dispatch/doctor) and long‑running dispatcher runtime
    • Claude adapter: auto‑mode, hook installation, transcript handling, and stop classification
    • Core config loader and adapter interfaces; local hook server for session/stop events
    • Persistent SQLite workflows with migrations and Git worktree management
    • TMUX utilities for driving interactive sessions and disk-backed prompts
    • Dev helper script to run the dispatcher
  • Tests

    • Extensive suites covering CLI, adapter behavior, dispatcher workflows, DB migrations, tmux, and worktree
  • Documentation

    • Epic plan and decisions log added

Review Change Stack

@thejustinwalsh thejustinwalsh left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decisions-log highlights distilled into per-line comments. Full reasoning in planning/issues/6/decisions.md.

for (const migration of pending) {
db.transaction(() => {
db.exec(migration.sql);
db.run("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [migration.version]);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: the runner records the schema version, the migration SQL may too. 001_initial.sql keeps the spec's verbatim INSERT INTO schema_version VALUES (1), but relying on every future migration author to remember that self-insert is fragile. INSERT OR IGNORE here makes version tracking a property of the runner — a harmless no-op for 001, a backstop for any later migration that omits it. Verified by the idempotency test (run twice → stays at 1, no throw).

*/
export function openDb(path: string): Database {
const db = new Database(path, { create: true });
db.exec("PRAGMA journal_mode = WAL;");

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for reviewers/testers: SQLite silently keeps :memory: databases in "memory" journal mode — so the WAL path can only be verified against a real file. The db test suite opens databases under a mkdtemp temp dir for exactly this reason; a :memory: test would assert the wrong mode.

* files are tolerated: an absent global file falls back to documented defaults,
* an absent per-repo file leaves the per-repo sections undefined.
*/
export function loadConfig(opts: LoadConfigOptions): MiddleConfig {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: generic deep merge, not a field-by-field override list. The spec says "per-repo overrides global" but the two files have almost disjoint sections. A generic deep merge (per-repo over global; arrays/scalars replaced wholesale) means a per-repo file can override any global key for free, and the common disjoint case still works. Per-repo sections are typed T | undefined and populated only when the per-repo file loads — there is no honest default for repo.owner, so a global-only load leaves them undefined rather than inventing values.

* exit between turns, so this — not an exit code — is the signal the workflow
* reacts to. Order matters: an open question outranks everything else.
*
* Phase 1 detects `done`/`failed` via `.middle/done.json` / `.middle/failed.json`

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: done/failed are detected via .middle/done.json / .middle/failed.json sentinels in Phase 1. The spec describes classifyStop as "reads PR state for done", but the fixed interface ({ payload, transcriptPath, sentinelPresent }) gives the adapter no PR handle, and Phase 1 ships no skill enforcement. Sentinels — parallel to the .middle/blocked.json question sentinel — keep every classifyStop branch deterministically classifiable and unit-testable. Phase 4's mechanically-enforced PR-ready hook gate replaces the done.json path with the real "agent ran gh pr ready" signal.

* than depending on the dispatcher's richer helper module — entering auto mode
* is intrinsically a per-CLI keystroke concern the adapter owns.
*/
async function enterAutoMode(opts: { sessionName: string }): Promise<void> {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: enterAutoMode shells out to tmux directly, not via the dispatcher's tmux.ts helper. @middle/adapter-claude depends on @middle/core only; the tmux helpers live in @middle/dispatcher, and an adapter→dispatcher edge would invert the layering. Entering auto mode is intrinsically a per-CLI keystroke concern the adapter owns. The S-Tab S-Tab keystroke path is the guaranteed fallback the spec calls for; whether a launch flag also works is the spec's open empirical question, resolved when the real-Claude end-to-end is exercised. Not unit-tested (needs a live tmux session).

};

/**
* Phase 1 minimal hook receiver. It handles only the two load-bearing events —

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: the "minimal SessionStart receiver" handles two events, not one. Build-sequence item 10 names it the SessionStart receiver, but #12's launch-and-drive must react to the Stop boundary, and the Phase 1 acceptance gate is "the agent hits a Stop; classifyStop runs". A SessionStart-only receiver could not drive the 3-step workflow to its gate. "Minimal" still holds relative to Phase 2: no HMAC auth, no events-table persistence, no full taxonomy — just the two load-bearing events. Early-arriving payloads are stashed so a fast hook cannot race the workflow step.

* Built as a factory so the dispatcher injects real collaborators and tests
* inject stubs. The workflow's `executionId` doubles as the `workflows.id`.
*/
export function createImplementationWorkflow(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: workflow factory + structural deps; compensatedfailed. bunqueue's StepContext carries only input/steps/signals, so ambient collaborators come via closure — a factory keeps the workflow a pure builder the dispatcher and tests configure identically. Structural TmuxOps/WorktreeOps let the end-to-end test stub tmux while using the real worktree helpers, so "no worktree leak" is genuinely verified. launch-and-drive catch-kills its session on any throw because bunqueue runs compensation only for completed steps. Terminal states: a step that throws → compensated (workflow rolled back); a clean run whose classifyStop returns failedfailed (agent failed). Keeping them distinct keeps the terminal state honest about what failed.

await git(scratch, ["init", "repo"]);
await git(repoPath, ["commit", "--allow-empty", "-m", "init"]);
db = openAndMigrate(join(scratch, "db.sqlite3"));
// No dataPath → bunqueue's queue + workflow store are in-memory: isolated per

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: bunqueue runs in-memory in tests (no dataPath). With a file-backed dataPath under a mkdtemp dir on macOS, bunqueue's write-buffer flushes during the retry path hit SQLITE_IOERR_VNODE. Omitting dataPath makes the queue + workflow store in-memory — isolated per Engine, no vnode churn. The production dispatcher path also runs in-memory in Phase 1; durable queue persistence + crash recovery arrive with the Phase 2 watchdog/reconciler. Note: bunqueue retries a failing step by default, so the leak check asserts every distinct created session was killed (not an exact call count).

* workflow to settle, then tear everything down. Self-contained — the caller
* (`mm dispatch`) just supplies validated inputs and an adapter registry.
*/
export async function dispatchEpic(opts: DispatchEpicOptions): Promise<DispatchEpicResult> {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: mm dispatch is self-contained; dispatchEpic takes an injected adapter registry. It does not talk to the long-running mm start process — that needs an IPC/HTTP control plane, which is Phase 8. dispatchEpic stands up its own hook server + engine for the run. It lives in @middle/dispatcher (not the CLI) so bunqueue/tmux/worktree coupling stays contained, and takes getAdapter as a parameter so @middle/dispatcher has no concrete-adapter dependency — the dependency graph stays cli → {dispatcher, adapter-claude} with no dispatcher → adapter-* edge. Phase 8 adds the HTTP trigger that lets mm dispatch enqueue into the running process instead.

@thejustinwalsh thejustinwalsh marked this pull request as ready for review May 15, 2026 01:35
@thejustinwalsh

Copy link
Copy Markdown
Owner Author

Ready for review — Epic #6 · PR #73

Build-spec Phase 1, the minimal dispatcher. All 7 sub-issues (#7#13) delivered, full suite 100 pass, tsc clean. This brief is posted on both the Epic and the PR.

How to run it

bun install
bun test          # 100 pass across 16 files
bun run typecheck # tsc --noEmit, clean

# CLI smoke test
bun packages/cli/src/index.ts --help
bun packages/cli/src/index.ts version          # → 0.0.0
bun packages/cli/src/index.ts status           # → "no dispatcher database yet" (no config/db present)

# dispatcher process lifecycle (what `mm start`/`mm stop` drive)
printf '[global]\ndispatcher_port = 0\ndb_path = "/tmp/mm-smoke/db.sqlite3"\nworktree_root = "/tmp/mm-smoke/wt"\n' > /tmp/mm-smoke.toml
MIDDLE_CONFIG=/tmp/mm-smoke.toml bun run packages/dispatcher/src/main.ts   # prints "middle dispatcher up …"; Ctrl-C exits 0

What to verify

  • packages/dispatcher/src/db/migrations/001_initial.sql — confirm the schema matches the build spec's "SQLite schema" verbatim (it's the keystone every later phase builds on).
  • packages/core/src/config.tsloadConfig deep-merge: per-repo overrides global, per-repo sections are undefined when the per-repo file is absent. Covered by packages/core/test/config.test.ts.
  • packages/dispatcher/src/workflows/implementation.ts — the 3-step launch → drive → observe loop. bun test packages/dispatcher/test/implementation-workflow.test.ts proves: happy path → completed, failed classifyStop → failed, launch throw → compensated, zero worktree/session leaks in all three.
  • packages/adapters/claude/src/classify.tsclassifyStop's five branches; bun test packages/adapters/claude/test/adapter.test.ts exercises each.
  • The 9 per-line review comments on this PR — they are the distilled decisions log (planning/issues/6/decisions.md); read them for the why behind the non-obvious choices.

How to review this

Read in dependency order, bottom-up: db.ts + 001_initial.sqlconfig.tscore/{adapter,events}.tsadapters/claude/*dispatcher/{tmux,worktree}.tsdispatcher/{hook-server,workflow-record}.tsworkflows/implementation.tsdispatch.ts + main.tscli/*. The load-bearing file is workflows/implementation.ts — that's where the interactive-dispatch model becomes concrete; the four most consequential decisions (two-event hook receiver, workflow factory + structural deps, compensated vs failed, strict package layering) all converge there and in hook-server.ts / dispatch.ts. planning/issues/6/decisions.md is the full reasoning record; the per-line PR comments are its distilled form.

Needs extra eyes

  • The Phase 1 acceptance gate is not CI-verifiable and needs a manual run. Epic Minimal dispatcher (worktree, spawn, cleanup) #6's second criterion is mm dispatch <repo> <epic> driving a real Claude session end-to-end. Every component is unit-tested and the workflow is exercised end-to-end against a stub adapter — but the real-binary integration (does claude fire SessionStart to our receiver? does S-Tab S-Tab enter auto mode? does the real transcript format parse? does a real Stop fire?) cannot run in CI — it needs the claude binary, GitHub auth, and a real bootstrapped repo. Please run mm dispatch against a real test repo before merging. This is the one gap between "all sub-issues done + fully tested" and "Epic acceptance fully proven".
  • enterAutoMode keystrokes (packages/adapters/claude/src/index.ts) — tmux send-keys S-Tab S-Tab is the spec's guaranteed fallback but is empirically unverified; whether a launch flag also works is the spec's open question. The manual mm dispatch run is what confirms the keystroke path actually lands.
  • mm start output handling — the spawned dispatcher inherits stdout/stderr; there's no redirection to log_dir yet. Fine for Phase 1 / scripts/dev.sh (foreground), but worth a glance — proper log management is Phase 11 ("operator polish").
  • deriveRepoSlug (packages/cli/src/commands/dispatch.ts) — parses owner/name from the origin remote URL with a regex, falling back to the directory basename. The regex handles git@/https:// with optional .git; unusual remote formats fall back to basename, which is a degraded-but-safe workflows.repo value.

@thejustinwalsh

Copy link
Copy Markdown
Owner Author

@greptileai reveiw pls

@greptile-apps

greptile-apps Bot commented May 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR delivers the Phase 1 minimal dispatcher for the middle autonomous-dispatch system — WAL-mode SQLite with a numbered migration runner, layered TOML config, the AgentAdapter interface and ClaudeAdapter implementation (launch command, bypass-prompt dismissal, transcript parsing, Stop classification), tmux session helpers, git worktree management, a minimal HookServer, the three-step implementation workflow, and the mm CLI (start / stop / status / dispatch). All 100 unit tests pass and tsc --noEmit is clean.

  • New packages / entrypoints: @middle/adapter-claude, @middle/dispatcher (db, hook-server, tmux, worktree, workflow engine wiring), and the mm CLI binary all land together with end-to-end test coverage.
  • Several fixes applied in 57dfe1b and 1f99447 since the initial push: classifyStop sentinel paths anchored to the worktree root, enterAutoMode throws on non-zero tmux exit, waitForSettle outer deadline, HookServer first-arrival duplicate guard, both SessionStart and Stop hooks registered, dispatchEpic try/catch with friendly error framing, and the db/cleanup stack.
  • Multiple items from the previous review round remain open and are not repeated here: HookServer binding to 0.0.0.0, relative hookScriptPath in settings.json, || exit 0 dead code after exec in the hook script, session name containing no repo slug, git branch -D exit code discarded, proc.unref() before pid file write, and the enterAutoMode continued-polling stray-keystroke concern.

Confidence Score: 3/5

Several issues flagged in the previous review round remain unaddressed and affect the core dispatch path — most critically the relative hook script path (Stop hook may never fire), the hook server binding to all interfaces (no auth in Phase 1), and the session-name collision across repos.

The code fixes in 57dfe1b and 1f99447 are real and the test suite is solid, but the relative hookScriptPath in .claude/settings.json means the Stop hook is not found when Claude changes directory, leaving the workflow to wait the full four-hour timeout before compensating. The HookServer binds to 0.0.0.0 exposing an unauthenticated receiver on all network interfaces. sessionNameFor carries no repo component allowing cross-repo session collisions.

packages/adapters/claude/src/hooks.ts (relative hookScriptPath), packages/dispatcher/src/hook-server.ts (0.0.0.0 bind), packages/dispatcher/src/workflows/implementation.ts (sessionNameFor)

Important Files Changed

Filename Overview
packages/adapters/claude/src/hooks.ts installHooks now registers both SessionStart and Stop events; prior issues about relative hookScriptPath in settings.json and dead `
packages/adapters/claude/src/index.ts ClaudeAdapter implementation — enterAutoMode uses fire-and-forget with .catch(), which swallows auth errors; the previous concern about stray keystrokes post-SessionStart remains open.
packages/adapters/claude/src/classify.ts classifyStop now correctly anchors all three sentinels to opts.worktree, not payload.cwd; readTail reads the full file before slicing to 8 KB.
packages/dispatcher/src/hook-server.ts Duplicate-arrival guard (keeps first) is in place; the server still binds to 0.0.0.0 without a hostname option, which is the open security concern from the previous review.
packages/dispatcher/src/dispatch.ts cleanups-stack pattern handles db/hookServer teardown correctly; waitForSettle outer deadline added; unhandledRejection swallower for the bunqueue lock-token race is in place.
packages/dispatcher/src/workflows/implementation.ts Three-step workflow is well-structured; sessionNameFor still produces middle-${epicNumber} with no repo component, leaving the concurrent same-issue-number cross-repo collision open.
packages/dispatcher/src/worktree.ts Idempotent create/destroy logic is sound; the git branch -D exit code is still silently discarded, which can leave the branch on disk and cause a confusing WorktreeError on re-dispatch.
packages/cli/src/commands/start.ts proc.unref() is still called before the pid file is written; an I/O failure on the pid write leaves an orphaned, untracked dispatcher process.
packages/dispatcher/src/db.ts WAL-mode open, migration runner with per-migration transactions, and idempotent INSERT OR IGNORE schema_version tracking are all correct.
packages/core/src/config.ts loadConfig deep-merges global defaults + global file + per-repo file; tilde expansion and missing-file tolerance are correctly handled.
packages/cli/src/commands/dispatch.ts dispatchEpic is now wrapped in try/catch with friendly error framing; input validation, adapter registry, and deriveRepoSlug are all correct.
packages/dispatcher/src/main.ts Dispatcher entrypoint with graceful SIGTERM/SIGINT shutdown; announces readiness on stdout for the main.test.ts lifecycle check.

Sequence Diagram

sequenceDiagram
    participant CLI as mm dispatch
    participant DE as dispatchEpic
    participant ENG as bunqueue Engine
    participant WF as implementation workflow
    participant HS as HookServer
    participant TMUX as tmux
    participant CL as Claude (agent)

    CLI->>DE: dispatchEpic(repoPath, epicNumber, ...)
    DE->>HS: hookServer.start(port)
    DE->>ENG: engine.register(implementationWorkflow)
    DE->>ENG: engine.start("implementation", input)

    ENG->>WF: step: prepare-worktree
    WF->>WF: createWorkflowRecord(db, id)
    WF->>TMUX: createWorktree(repoPath, repo, issueNumber)
    WF-->>ENG: "{handle}"

    ENG->>WF: step: launch-and-drive
    WF->>WF: installHooks(worktree, hookScriptPath, ...)
    WF->>TMUX: newSession(sessionName, argv, env)
    TMUX-->>CL: claude --dangerously-skip-permissions
    WF->>WF: enterAutoMode (fire-and-forget .catch)
    WF->>HS: awaitSessionStart(sessionName, 90s)
    CL-->>HS: POST /hooks/session.started
    HS-->>WF: "{session_id, transcript_path}"
    WF->>TMUX: sendText + sendEnter (prompt)
    WF->>HS: awaitStop(sessionName, 4h)
    CL-->>HS: POST /hooks/agent.stopped
    HS-->>WF: stopPayload
    WF->>WF: classifyStop(payload, transcriptPath, worktree)
    WF-->>ENG: "{classification, sessionName}"

    ENG->>WF: step: cleanup
    WF->>TMUX: killSession(sessionName)
    WF->>TMUX: destroyWorktree(handle)
    WF->>WF: updateWorkflow(db, id, finalState)

    DE->>ENG: waitForSettle(executionId, 5h deadline)
    ENG-->>DE: execution (completed/failed/compensated)
    DE->>ENG: engine.close(false) [10s drain]
    DE-->>CLI: "{workflowId, state}"
Loading

Fix All in Claude Code Fix All in Codex

Reviews (15): Last reviewed commit: "feat(core): shared TUI primitives; claud..." | Re-trigger Greptile

Comment thread packages/adapters/claude/src/classify.ts Outdated
Comment thread packages/adapters/claude/src/index.ts
Comment thread packages/dispatcher/src/dispatch.ts Outdated
Comment thread packages/dispatcher/src/hook-server.ts
@greptile-apps

greptile-apps Bot commented May 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR delivers Phase 1 of the middle dispatcher — a minimal but complete pipeline that persists state in WAL-mode SQLite, loads layered TOML config, abstracts Claude behind an AgentAdapter interface, supervises tmux sessions, isolates work in git worktrees, and drives it all through a 3-step implementation bunqueue workflow triggered by mm dispatch. The core architecture (layering rule, factory-over-deps workflow, stash-and-deliver hook receiver) is well-considered and the test coverage is thorough.

  • Sentinel path split: installHooks registers only the SessionStart hook, but launchAndDrive awaits agent.stopped with a 4-hour timeout — without a Stop hook in .claude/settings.json, no real Claude session will ever fire that event, making every real dispatch hang then compensate. Additionally, classifyStop resolves done.json/failed.json from payload.cwd while the caller checks blocked.json from the reliable worktree root — if the agent has cd'd to a subdirectory, done and failed classifications silently fall through to bare-stop.
  • Resource cleanup gap: dispatchEpic opens the SQLite database before its try/finally block; a hookServer.start() failure leaks the connection.
  • Silent auto-mode failure: enterAutoMode discards the tmux send-keys exit code, so any failure to enter auto mode is invisible to the workflow.

Confidence Score: 3/5

The implementation workflow cannot complete a real end-to-end dispatch because the Stop hook is never registered in .claude/settings.json; every real session will hang for 4 hours then compensate rather than reach completed.

Two issues affect the core dispatch path in tandem: the installHooks stub omits the Stop hook entry so awaitStop can never be satisfied by a real Claude binary, and classifyStop resolves done/failed sentinels from the unreliable payload.cwd rather than the worktree root. Everything not on the hot path is clean and well-tested.

packages/adapters/claude/src/hooks.ts (missing Stop hook registration) and packages/adapters/claude/src/classify.ts (sentinel path anchoring) need fixes before real-binary dispatch can work. packages/dispatcher/src/dispatch.ts needs a small restructure to close the db on early failure.

Important Files Changed

Filename Overview
packages/adapters/claude/src/hooks.ts Phase 1 stub that only installs SessionStart in .claude/settings.json; the Stop hook needed by awaitStop in the workflow is absent, making real end-to-end dispatch unreachable without a timeout.
packages/adapters/claude/src/classify.ts classifyStop uses payload.cwd for done.json/failed.json but blocked.json is anchored to handle.path in the caller; mismatched path roots cause missed done/failed detections when the agent changes directory.
packages/dispatcher/src/dispatch.ts Self-contained dispatchEpic runner; db opened before the try/finally block so a hookServer.start() failure leaks the SQLite connection.
packages/dispatcher/src/workflows/implementation.ts Clean 3-step factory workflow with proper compensation; blocked.json is correctly anchored to handle.path but classifyStop checks done/failed at a different root.
packages/adapters/claude/src/index.ts Assembles the ClaudeAdapter; enterAutoMode silently swallows the tmux send-keys exit code, making auto-mode failures invisible to the workflow.
packages/dispatcher/src/db.ts WAL-mode SQLite wrapper with numbered migration runner; clean implementation with idempotent re-run support.
packages/dispatcher/src/hook-server.ts Minimal Bun HTTP hook receiver with early-arrival stash and per-session keying; well-structured with proper waiter cleanup on stop.
packages/dispatcher/src/worktree.ts Git worktree create/list/destroy helpers with idempotent semantics and real-path normalization; porcelain parser correctly handles detached-HEAD worktrees.
packages/dispatcher/src/tmux.ts Typed tmux session helpers with TmuxError surfacing and idempotent killSession.
packages/core/src/config.ts TOML config loader with deep-merge, tilde expansion, and documented defaults; well-typed with safe fallback for missing files.

Comments Outside Diff (1)

  1. packages/dispatcher/src/dispatch.ts, line 214-220 (link)

    P2 Database connection leaked when hookServer.start() throws

    db is opened before the try/finally block. If hookServer.start() throws (most likely: port already in use because the dispatcher process started with mm start holds port 8822), execution jumps past the finally and db.close() is never called. Move hookServer.start() before openAndMigrate, or wrap the database open inside the try block so the finally always covers it.

    Fix in Claude Code Fix in Codex

Fix All in Claude Code Fix All in Codex

Reviews (2): Last reviewed commit: "feat(cli): mm start/stop/status + dispat..." | Re-trigger Greptile

Comment thread packages/adapters/claude/src/classify.ts
Comment thread packages/adapters/claude/src/hooks.ts
Comment thread packages/adapters/claude/src/index.ts Outdated
@thejustinwalsh

Copy link
Copy Markdown
Owner Author

All Greptile findings addressed in 57dfe1b. 104 pass, tsc clean.

The summary called out four issues; the inline thread also flagged two related edge cases I fixed at the same time. Per-thread replies have the specifics; the gist:

# Severity File Fix
1 P1 adapters/claude/src/hooks.ts Register Stopagent.stopped alongside SessionStart; write executable .middle/hooks/hook.sh into the worktree.
2 P1 adapters/claude/src/classify.ts + core/src/adapter.ts classifyStop now takes worktree: string; all three sentinels resolve from there. implementation.ts passes handle.path.
3 P2 dispatcher/src/dispatch.ts Cleanups-stack — db.close() always runs even when hookServer.start() throws.
4 P2 adapters/claude/src/index.ts enterAutoMode throws on non-zero tmux exit, with stderr captured.
5 P2 dispatcher/src/dispatch.ts waitForSettle outer deadline (5h, beyond the workflow's 4h stopTimeoutMs backstop) — no infinite spin on a null execution.
6 P2 dispatcher/src/hook-server.ts #deliver keeps the first stashed payload (!has(key) guard) so a duplicate session.started can't overwrite the originally-committed session_id/transcript_path.

Regression coverage: subdir-cwd sentinel test, enterAutoMode rejects on missing tmux session, installHooks both events + executable hook.sh, duplicate-pre-await stash keeps first. Full reasoning lives in planning/issues/6/decisions.md ("Review response (2026-05-15)").

Comment thread packages/cli/src/commands/dispatch.ts Outdated
Comment thread packages/dispatcher/src/workflows/implementation.ts
@thejustinwalsh

Copy link
Copy Markdown
Owner Author

Update — substantial iteration since the original ready-for-review brief (2026-05-14)

13 commits between 91f3233 (ready-for-review) and 0beedf6 (current HEAD) addressing Greptile review feedback and a live end-to-end dispatch debugging campaign. Test count 100 → 123 pass, tsc clean throughout.

Greptile review — all 6 findings fixed (57dfe1b, 1f99447)

  • P1 Stop hook registered + universal .middle/hooks/hook.sh written into the worktree (hooks.ts) — without it awaitStop would have timed out at 4h on every real dispatch.
  • P1 Sentinel paths anchored to worktree, not payload.cwd (classifyStop signature gained worktree: string).
  • P2 dispatchEpic cleanups-stack pattern — db.close() always runs even when hookServer.start() throws.
  • P2 enterAutoMode exit-code handling (later replaced — see below).
  • P2 waitForSettle outer deadline (no infinite spin on null execution).
  • P2 HookServer #deliver keeps the first arrival, no overwrite-by-duplicate.
  • (Bonus) runDispatch wraps dispatchEpic in try/catch → friendly mm dispatch: failed — … on EADDRINUSE.

Live end-to-end debugging (d26289715141c4)

Each commit traces a real failure surfaced by manual mm dispatch runs:

  • bunqueue lifecycle race (435b2d7, 7487b54) — Invalid or expired lock token for job …. Root cause: bunqueue's executor sets exec.state='failed' before awaiting compensation, so waitForSettle returned mid-flight and the finally ripped down hookServer/db while compensation was still using them. Fix: drain engine inline after waitForSettle before teardown, plus retry: 1 (bunqueue's retry is maxAttempts), plus an unhandledRejection swallower scoped to dispatchEpic for the specific lock-token message.
  • Auto mode (d262897, 1dd4e7f) — keystroke-cycle (S-Tab S-Tab) lands on plan mode in current Claude, not bypass. Switched to --dangerously-skip-permissions launch flag.
  • Bypass-mode confirmation (95892f1, 15141c4) — flag still pops a one-time "Yes, I accept" prompt. enterAutoMode polls tmux capture-pane for the prompt, sends Down → 100ms → Enter to accept. Detect-then-act so we don't blast keystrokes into a normal Claude input.
  • SessionStart chicken-and-egg (24b630a) — SessionStart won't fire until the bypass warning is dismissed. The dismisser now runs in parallel with awaitSessionStart.
  • Workflow-stage stderr logging (a699c73, d461ceb) — [workflow:<session>] and [claude:<session>] lines at every transition for diagnostics.

New Phase 1 polish (in-scope additions)

  • mm doctor (29fa548) — preflight check for bun / tmux (≥ 3.5) / claude / git / gh / gh auth. Spec parks this at Phase 11; the abbreviated form is small and saved hours of operator puzzling on tmux config.
  • @middle/core/src/tmux-tui.ts (0beedf6) — shared composable TUI primitives (capturePane, sendText, sendKeys with delayBetweenMs, pollPaneFor<T> with optional diagnostic tag). enterAutoMode reduced from ~40 lines of inline polling to a single pollPaneFor call. The toolkit any future TUI driving (login screens, ongoing prompts, watchdog probes) will reuse.
  • detectNeedsLogin (0beedf6) — recognizes a not-authenticated Claude pane and throws a clean claude is not authenticated — run claude interactively to sign in, then retry the dispatch, instead of hanging on the 90s SessionStart timeout.
  • ensurePromptFile (0beedf6) — launchAndDrive writes a plan-style placeholder <worktree>/.middle/prompt.md (directs the agent to use the implementing-github-issues skill) if not committed. Closes the "agent runs but does nothing" gap from the first successful dispatch.

Caveats — REVISED from the original brief

The original "real-Claude end-to-end is unverified" caveat is now partially exercised by live testing:

  • ✅ tmux launch, SessionStart discovery, bypass-prompt dismissal, prompt send-keys, Stop reception, classifyStop, cleanup — all proven on real Claude sessions.
  • mm doctor clean on the operator's machine.
  • ⚠️ The agent's actual work is still untested — every dispatch so far has ended in a no-op completion (agent received the empty / placeholder prompt and stopped immediately). The newly-added ensurePromptFile (0beedf6) gives the agent something real to do, but a dispatch with a substantive prompt + work + PR creation has not been run.
  • ⚠️ The cleanest "first real autonomous dispatch" smoke test is to merge this PR, pull main, then mm dispatch ~/Developer/middle 14 (Phase 2 — Hooks + watchdog). That's the genuine dogfood moment.

Decisions log

planning/issues/6/decisions.md extended with one entry per substantive change in this update — read it as the "what really happened and why" narrative behind the commit list.

@thejustinwalsh

Copy link
Copy Markdown
Owner Author

@coderabbitai review this PR against the Phase 1 (Epic #6) requirements as well as code quality.

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
🧩 Analysis chain

I'll gather the code from all the key files before writing my review.


🏁 Script executed:

#!/bin/bash
echo "=== REPOSITORY STRUCTURE ==="
tree -L 4 --gitignore 2>/dev/null || find . -type f -name "*.ts" | grep -v node_modules | grep -v dist | head -100

echo ""
echo "=== packages/dispatcher/src/db/migrations/001_initial.sql ==="
fd "001_initial.sql" --exec cat {}

echo ""
echo "=== packages/dispatcher/src/db.ts ==="
fd "db.ts" -p "packages/dispatcher" --exec cat {}

echo ""
echo "=== packages/core/src/config.ts ==="
fd "config.ts" -p "packages/core/src" --exec cat {}

echo ""
echo "=== packages/core/src/adapter.ts ==="
fd "adapter.ts" -p "packages/core/src" --exec cat {}

echo ""
echo "=== packages/core/src/events.ts ==="
fd "events.ts" -p "packages/core/src" --exec cat {}

echo ""
echo "=== packages/core/src/tmux-tui.ts ==="
fd "tmux-tui.ts" --exec cat {}

echo ""
echo "=== packages/adapters/claude/src/index.ts ==="
fd "index.ts" -p "packages/adapters/claude/src" --exec cat {}

echo ""
echo "=== packages/adapters/claude/src/classify.ts ==="
fd "classify.ts" --exec cat {}

echo ""
echo "=== packages/adapters/claude/src/hooks.ts ==="
fd "hooks.ts" --exec cat {}

echo ""
echo "=== packages/dispatcher/src/tmux.ts ==="
fd "tmux.ts" -p "packages/dispatcher/src" --exec cat {}

echo ""
echo "=== packages/dispatcher/src/worktree.ts ==="
fd "worktree.ts" --exec cat {}

echo ""
echo "=== packages/dispatcher/src/hook-server.ts ==="
fd "hook-server.ts" --exec cat {}

echo ""
echo "=== packages/dispatcher/src/workflow-record.ts ==="
fd "workflow-record.ts" --exec cat {}

echo ""
echo "=== packages/dispatcher/src/workflows/implementation.ts ==="
fd "implementation.ts" -p "packages/dispatcher" --exec cat {}

echo ""
echo "=== packages/dispatcher/src/dispatch.ts ==="
fd "dispatch.ts" --exec cat {}

echo ""
echo "=== packages/dispatcher/src/main.ts ==="
fd "main.ts" -p "packages/dispatcher/src" --exec cat {}

echo ""
echo "=== packages/cli/src/index.ts ==="
fd "index.ts" -p "packages/cli/src" --exec cat {}

echo ""
echo "=== packages/cli/src/commands/ ==="
fd "*.ts" -p "packages/cli/src/commands" --exec cat {}

echo ""
echo "=== scripts/dev.sh ==="
cat scripts/dev.sh 2>/dev/null || echo "Not found"

echo ""
echo "=== package.json files ==="
fd "package.json" -d 3 --exec cat {} \; -x echo "---FILE: {}"

Length of output: 79610


🏁 Script executed:

#!/bin/bash
echo "=== TEST FILES ==="
fd "*.test.ts" --exec cat {}

echo ""
echo "=== planning/issues/6/decisions.md ==="
fd "decisions.md" --exec cat {}

echo ""
echo "=== Root package.json ==="
cat package.json 2>/dev/null

echo ""
echo "=== bun.lockb check ==="
ls -la bun.lockb 2>/dev/null && echo "lockfile exists" || echo "no lockfile"

echo ""
echo "=== tsconfig files ==="
fd "tsconfig*.json" -d 3 --exec cat {}

Length of output: 32311


Let me grab the CLI command files and a few remaining pieces.


🏁 Script executed:

#!/bin/bash
echo "=== packages/cli/src/commands/dispatch.ts ==="
cat packages/cli/src/commands/dispatch.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/cli/src/commands/doctor.ts ==="
cat packages/cli/src/commands/doctor.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/cli/src/commands/start.ts ==="
cat packages/cli/src/commands/start.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/cli/src/commands/stop.ts ==="
cat packages/cli/src/commands/stop.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/cli/src/commands/status.ts ==="
cat packages/cli/src/commands/status.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/cli/src/paths.ts ==="
cat packages/cli/src/paths.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/adapters/claude/src/prompt.ts ==="
cat packages/adapters/claude/src/prompt.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/adapters/claude/src/transcript.ts ==="
cat packages/adapters/claude/src/transcript.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/adapters/claude/package.json ==="
cat packages/adapters/claude/package.json 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/dispatcher/test/implementation-workflow.test.ts ==="
cat packages/dispatcher/test/implementation-workflow.test.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/dispatcher/test/hook-server.test.ts ==="
cat packages/dispatcher/test/hook-server.test.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/adapters/claude/test/adapter.test.ts ==="
cat packages/adapters/claude/test/adapter.test.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/core/test/tmux-tui.test.ts ==="
cat packages/core/test/tmux-tui.test.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/cli/test/dispatch.test.ts ==="
cat packages/cli/test/dispatch.test.ts 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== packages/cli/test/doctor.test.ts ==="
cat packages/cli/test/doctor.test.ts 2>/dev/null || echo "NOT FOUND"

Length of output: 47445


@thejustinwalsh — comprehensive review against Phase 1 (Epic #6) requirements and code quality below.


Phase 1 Requirements — All 7 Sub-Issues

All seven sub-issues (#7#13) are delivered and the acceptance gate criteria are met:

Sub-issue Deliverable Status
#7 db.ts + 001_initial.sql — WAL-mode SQLite, migration runner, full schema
#8 config.tsloadConfig deep-merge, per-repo optional, documented defaults
#9 adapter.ts + events.tsAgentAdapter interface, normalized event vocab
#10 @middle/adapter-claudeinstallHooks (both events + hook.sh), enterAutoMode with bypass-prompt poll + login detection, classifyStop worktree-anchored
#11 tmux.tsTmuxError, version helpers, session lifecycle ops
#12 worktree.ts, hook-server.ts, workflow-record.ts, implementation.ts — factory+structural-deps workflow, compensated/failed distinction, zero-leak guarantee
#13 dispatch.ts + main.ts + cli/* — self-contained dispatchEpic, cleanups stack, outer deadline, mm commands

The post-review additions (tmux-tui.ts, mm doctor, ensurePromptFile, detectNeedsLogin) are solid and well-motivated. pollPaneFor<T> is a clean abstraction and the decisions log is an excellent artifact.


Issues Found

🔴 P1 — exec curl … || exit 0 fallback is dead code (hooks.ts)

exec curl -sS -X POST "${MIDDLE_DISPATCHER_URL}/hooks/${EVENT}" \
  -H "..." --max-time 3 || exit 0

exec replaces the shell process with curl. The || exit 0 branch only evaluates if exec itself fails to start (i.e., curl is not on PATH). When curl runs but fails (network error, 3-second timeout, dispatcher temporarily down), the hook exits with curl's non-zero exit code — not 0. This directly contradicts the spec comment: "Never blocks the agent (3s timeout, failure → exit 0)".

For the Stop hook in particular, a non-zero exit can cause Claude to surface an error; for SessionStart, the impact depends on how Claude's hook runner handles exit codes.

Fix — drop exec and use an unconditional exit 0:

curl -sS -X POST "${MIDDLE_DISPATCHER_URL}/hooks/${EVENT}" \
  -H "X-Middle-Session: ${MIDDLE_SESSION}" \
  -H "X-Middle-Token: ${MIDDLE_SESSION_TOKEN}" \
  -H "X-Middle-Epic: ${MIDDLE_EPIC}" \
  -H "Content-Type: application/json" \
  --data-binary `@-` --max-time 3 2>/dev/null
exit 0

Or, if the exec-for-process-replacement is intentional (avoid zombie), use || true inside a subshell:

{ curl -sS ... --max-time 3 2>/dev/null || true; exit 0; } &
wait

The simplest is the exit 0 form.


🟡 P2 — expandTilde matches ~username incorrectly (config.ts)

function expandTilde(value: string): string {
  return value.startsWith("~") ? join(homedir(), value.slice(1)) : value;
}

value.startsWith("~") also matches tilde-expansion patterns like ~user/foo (NFS homes) or a config typo ~foo/bar, turning them into <homedir>user/foo silently. The spec only documents ~/-prefixed paths.

Fix:

function expandTilde(value: string): string {
  if (value === "~") return homedir();
  if (value.startsWith("~/")) return join(homedir(), value.slice(2));
  return value;
}

🟡 P2 — Deep path imports in CLI commands bypass package boundaries

Three CLI commands import directly from package internals:

// packages/cli/src/commands/dispatch.ts
import { dispatchEpic } from "`@middle/dispatcher/src/dispatch.ts`";

// packages/cli/src/commands/doctor.ts
import { getTmuxVersion, MIN_TMUX_VERSION, tmuxVersionAtLeast } from "`@middle/dispatcher/src/tmux.ts`";

// packages/cli/src/commands/status.ts
import { openDb } from "`@middle/dispatcher/src/db.ts`";

These bypass @middle/dispatcher's declared entrypoint ("main": "src/main.ts"), coupling the CLI to internal implementation files. This isn't a runtime error in Bun monorepos, but it means @middle/dispatcher can never reorganize its internals without updating the CLI.

The lower-risk fix is to re-export the surface area the CLI needs from @middle/dispatcher's src/main.ts (or a dedicated src/index.ts). The version utilities (getTmuxVersion etc.) could alternatively live in @middle/core since doctor.ts has no other reason to reach into the dispatcher.


🟡 P2 — #stashed map in HookServer has no eviction path

`#deliver`(key: string, payload: HookPayload): void {
  const waiter = this.#waiters.get(key);
  if (waiter) { ... } else if (!this.#stashed.has(key)) {
    this.#stashed.set(key, payload);  // never deleted unless consumed via `#await`
  }
}

If a hook arrives for a session that the workflow never awaits (wrong session name, early workflow death before registering an awaiter), the entry stays in #stashed for the lifetime of the HookServer. In dispatchEpic the server lives for one workflow invocation, so this doesn't accumulate across runs — but it could bite if a retry-scenario fires a hook for a now-dead session on the same long-running dispatcher instance in Phase 2+.

A stop() call does call #stashed.clear(), which handles the cleanup on shutdown. Low priority for Phase 1 given the single-dispatch-per-process lifecycle, but worth tracking as a Phase 2 item when the dispatcher becomes long-lived.


Test Coverage — Strong

The test suite is well-structured and the leak assertions are a standout:

  • All 5 classifyStop branches ✅
  • Happy path, failed classifyStop, launch throw → compensated
  • Zero-worktree-leak + zero-session-leak assertions in all three workflow paths ✅
  • HookServer: session isolation, stash-keeps-first, timeout, stop() rejects waiters ✅
  • pollPaneFor<T>: match / timeout / session-gone / tag-logs-stderr ✅
  • detectNeedsLogin + detectBypassPrompt unit tests ✅
  • CLI input validation (non-integer, < 1, non-git-repo) ✅
  • EADDRINUSE friendly-error path ✅
  • mm doctor happy-path smoke test ✅

One gap worth noting: enterAutoMode's bypass-prompt branch (the Down+Enter send path) is not exercised by unit tests — the test only covers the "missing session bails fast" path. This is expected (needs a live session showing the bypass prompt), and it's been exercised manually per the PR brief. Documenting this as the remaining live-verification item is appropriate.


Schema (001_initial.sql) — Matches Build Spec

Spot-checked against the Epic #6 spec's "SQLite schema" block: all tables (workflows, events, rate_limit_state, repo_config, waitfor_signals, schema_version), all CHECK constraints, all indices, and the INSERT INTO schema_version VALUES (1) are present and correct. The controlled_by default of 'middle' and the meta_json scratch column are both present.


Design Decisions — Sound

The four load-bearing decisions all check out:

  • Two-event hook receiversession.started + agent.stopped is the correct minimal set; a SessionStart-only receiver can't reach the acceptance gate.
  • Factory + structural deps — enables real worktree verification in tests while stubbing tmux; the zero-leak assertions are meaningful as a result.
  • compensated vs failed — correctly distinguishes "workflow machinery failed + rolled back" from "agent ran to completion but reported failure."
  • engine.close(false) before finally — the inline drain before teardown correctly handles bunqueue's exec.state='failed'-before-compensation ordering.

The parallel enterAutoMode / awaitSessionStart pattern is well-motivated and the chicken-and-egg problem is clearly documented in the decisions log.


Summary

The one actionable pre-merge fix is the exec curl … || exit 0 dead-code bug in hooks.ts — it's the only item that can cause silent real-Claude dispatch failures that would look like flakiness rather than a clear error. The expandTilde and deep-import items are quality improvements that can land in a follow-up. Everything else is either Phase 2+ scope or minor.

✅ Actions performed

Full review triggered.

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a85d2995-7460-43a1-b64d-3f66a24ffa64

📥 Commits

Reviewing files that changed from the base of the PR and between 417c195 and 9f84cd6.

📒 Files selected for processing (1)
  • packages/cli/test/dispatch.test.ts

📝 Walkthrough

<review_stack_artifact_start />
<stack_title>Phase 1 Minimal Dispatcher with Claude Adapter</stack_title>
<stack_summary>Implements a Phase‑1 dispatcher: core contracts, Claude adapter, tmux TUI, SQLite migrations, git worktree management, HookServer, bunqueue workflow, CLI, tests and docs.</stack_summary>

Comprehensive implementation of the dispatcher workflow engine, Claude adapter, and supporting infrastructure for executing and observing long-running AI agent tasks in isolated git worktrees. Public contracts, event types, TOML-backed config loader, and public re-exports forming the core API surface. range_546e568b2d25 range_bee2114bfa9c range_77118ea5637e range_53137103bbd5 range_dffe472c874d range_fa1f334a1dc4 range_3344654f067c range_265ddf2faf6c range_0ce2b75a2b9b range_2c45b9b82fdc range_47a9b77ecaf3 range_d2831e317a01 range_b98c48543303 range_368655898804 range_546e568b2d25 Composable tmux helpers: runTmux wrapper, capturePane, sendText/sendKeys, and pollPaneFor with diagnostics and tests. range_64b6e854450c range_eb3e3d1aa347 range_9561f1705dfe range_0c79b9a8c30d range_8ff1fd3091c2 range_e70ca79601a9 range_7b8ccbafe47c range_3a60928fea4a range_aeb76d89b163 range_f35323e202fe range_499d33f96179 range_64acf004cfc8 range_7cee1e4a0bca range_f99b197443c0 range_76419614e692 Claude adapter implementation: prompt builder, transcript parsing, classifyStop, installHooks, bypass/login detection, enterAutoMode, and adapter wiring with tests. range_09593db63ea1 range_31b100915c54 range_57687c77b226 range_406f9fbca551 range_359aaadf276e range_d2fff66facd4 range_8fc42898ffdf range_e4c80cdfc628 range_3115812e0a51 range_2fe62452f04b range_14fd7128165d range_694adc937f87 range_db435952a78f range_11382de9f62d range_e66b2badaad4 range_20bf5bb92bce range_ddeaea2ea19c range_8a92362853c8 range_2e555915b77a range_00890c99898f range_fcbc2f6b0e60 range_de126cecb027 Runnable CLI (mm) with start/stop/status/doctor/dispatch commands, path helpers, and command tests for validation and lifecycle handling. range_7356d5f430ff range_f43dce891c1b range_6e6f45eb96f6 range_bf5093b1163c range_f143248d6b92 range_9600d446f5e9 range_aff6681daaf2 range_6016c04c0309 range_dcface5771d3 range_ceadf83714b3 range_56e7f6c42ff3 range_2c0ef5ceca33 range_20e618ace925 range_d108df491e1f range_270e0c9b0a0a range_85f4347022db range_a3380b8329f5 range_03e0a6d9b239 range_736c7f55e27f range_d905fa289382 range_31cb04b42086 range_541113f48fae range_2f7e16a8626f range_ab89214563f9 DB open/migrate helpers, migration loader/runner, and initial schema SQL with tests for migrations and constraints. range_409b04c796ec range_41e19bae4ffa range_00c9e1382c9e range_84563c484e1c range_51b20b98c7fb range_ac9219f1b32f range_fb6f6c0ab4e4 range_1bf5390b2bce range_5d51ab7bbda8 range_16123e4cf184 range_664b046be0bf range_fe35b9aa0295 range_288f9c93d592 range_d32f27d8f166 Typed tmux wrapper (TmuxError, newSession, sendText/capturePane, version parsing) and git worktree management (create/destroy/list) with safety checks and tests. range_d5daa5753792 range_2cf4cb47c475 range_72625d09a98a range_bc8dc20a7e98 range_adde82f06326 range_ad3108836c1a range_d94ac11b6051 range_5d2c6e044672 range_7e1162b44ed7 range_12cb43acd1e6 range_691b8881c09c range_018260d363dd range_534c155272bb range_993592e3fcf2 range_7c6d46798096 HookServer bound to localhost with stash/wait delivery semantics and typed DB accessors for workflow records (create/update/get) with tests. range_a07965c5e66b range_8a6201c7b5b5 range_7da888c3b7b0 range_9439cf73c4b2 range_b567d20c1d60 range_78c1a013adae range_38499cd2987f range_18163c2f3d21 range_49a3c58a6dba range_968d6445143c range_0227abef6628 range_fbede278cffc range_422805351f31 range_8d6ab212c298 range_5a7eb9cb1c8c range_24a2c5f34db5 range_a6247f8b6132 range_c97cd447ee17 range_64acf004cfc8 range_7cee1e4a0bca range_f99b197443c0 range_76419614e692 Bunqueue workflow factory orchestrating prepare-worktree, launch-and-drive (tmux/hooks/adapter), and cleanup with compensation, state mapping, and integration tests. range_194707087ae0 range_281850c5d58b range_fddf4168b5ed range_e6bb698429cd range_80496b3845ad range_11d985ab3ec5 range_8720b8ba62ec range_3b484ccd5966 range_664b046be0bf range_fb6f6c0ab4e4 dispatchEpic composes DB/hook-server/engine, registers workflow, polls to settle, and performs ordered teardown; main() bootstraps dispatcher and handles graceful shutdown on signals. range_257b04c9d42e range_eed900124407 range_9d798827f206 range_c7df46b490c4 range_281850c5d58b range_2e97b0fe72da range_d89b038ae709 range_6abb012dbe99 range_c611aa27702a range_f4e7a07121f3 range_fb6884cfc42b range_281850c5d58b All test suites, planning docs, decisions log, and dev.sh used to run the dispatcher locally; included as reviewable support material. range_e3b8644083c1 range_e70ca79601a9 range_7b8ccbafe47c range_e3b8644083c1 range_4ff2ca7e96ca range_175a80465f13 range_726c2474be4e range_04f7e37e4361 range_6dcf43058498 range_04f7e37e4361 range_2f11b2260214 range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_2f11b2260214 range_c403b1d32c7d range_175a80465f13 range_726c2474be4e range_04f7e37e4361 range_6dcf43058498 range_04f7e37e4361 range_2f11b2260214 range_c403b1d32c7d range_4ff2ca7e96ca range_c403b1d32c7d range_726c2474be4e range_04f7e37e4361 range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_c403b1d32c7d range_5cb1d60b8f00 range_265ddf2faf6c range_0ce2b75a2b9b range_2c45b9b82fdc range_47a9b77ecaf3 range_d2831e317a01 range_b98c48543303 range_368655898804 range_64acf004cfc8 range_7cee1e4a0bca range_f99b197443c0 range_76419614e692 range_9bd81b720946 range_e66b2badaad4 range_20bf5bb92bce range_18a83b51a9d1 range_943f884ab8df range_061b00fce83b range_d4c8de93a89c range_3627faa452cc range_824dd7e043f7 range_a7caa9c43824 range_221912e87413 range_0f5ca7917828 range_aa7a7c071760 range_a66c41591527 range_e41bdd4dff5e range_536aa0fa2412 range_050d4ccb03e3 range_888c06746ae1 range_484ef81e1335 range_d86d8046fdad range_78d8bf694a12 range_f1ca584eca19 range_84929a48605b range_746232f43f25 range_20b74a97b148 range_b68d8e059148 range_085f517c307d range_d5a2cd2b3f77

…r, localhost bind, branch-delete check, repo-scoped session names
@thejustinwalsh

Copy link
Copy Markdown
Owner Author

Second Greptile review round — all 6 findings fixed in 5b23056. 123 pass, tsc clean.

File Fix
adapters/claude/src/hooks.ts Dropped exec so the hook's || exit 0 failure-is-a-no-op contract actually holds; absolute hook command path so Stop delivery survives the agent cd-ing into a subdir
cli/src/commands/start.ts Write the pid file before proc.unref() — no orphaned detached dispatcher on a write failure
dispatcher/src/hook-server.ts Bind 127.0.0.1 (was 0.0.0.0) — the no-HMAC Phase 1 receiver is no longer network-reachable
dispatcher/src/worktree.ts git branch -D failure now throws WorktreeError instead of silently lingering the branch
dispatcher/src/workflows/implementation.ts Session names are middle-<repoSlug>-<epic> — no cross-repo collision on the same issue number

Per-thread replies have the specifics. Full reasoning in planning/issues/6/decisions.md ("Second Greptile review round").

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (7)
packages/dispatcher/src/db.ts (1)

37-44: ⚡ Quick win

Sort migrations by parsed version, not filename text.

Current ordering relies on lexicographic filename sort. A non-zero-padded file (for example 2_... and 10_...) will execute out of order even though the numeric prefix is parsed.

Proposed fix
 export function loadMigrations(dir: string = MIGRATIONS_DIR): Migration[] {
-  return readdirSync(dir)
+  const migrations = readdirSync(dir)
     .filter((f) => f.endsWith(".sql"))
-    .sort()
     .map((name) => {
       const match = /^(\d+)_/.exec(name);
       if (!match) throw new Error(`migration filename missing numeric prefix: ${name}`);
       return { version: Number(match[1]), name, sql: readFileSync(join(dir, name), "utf8") };
     });
+  migrations.sort((a, b) => a.version - b.version || a.name.localeCompare(b.name));
+  return migrations;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dispatcher/src/db.ts` around lines 37 - 44, The migration list is
currently sorted lexicographically by filename which breaks numeric ordering
(e.g., "10_" after "2_"); change the pipeline to first map filenames to objects
with parsed numeric version (using the existing regex/match logic that produces
version) and then sort the resulting array by the numeric version property
before returning so executions run in numeric order; update the chain around
readdirSync/filter/map/sort (or replace the current sort() call) so sorting uses
the parsed Number(match[1]) rather than the raw filename string.
packages/dispatcher/test/workflow-record.test.ts (1)

81-91: ⚡ Quick win

Assert the no-op patch contract for updatedAt as well.

Line 81’s test name says “no-op patch,” but it only checks state. Since updateWorkflow guarantees updated_at always changes, this path should assert the timestamp bump to prevent regressions.

Suggested test addition
   test("a no-op patch leaves the row intact", () => {
     createWorkflowRecord(db, {
       id: "exec-1",
       kind: "implementation",
       repo: "o/r",
       epicNumber: 6,
       adapter: "claude",
     });
+    const before = getWorkflow(db, "exec-1")!.updatedAt;
     updateWorkflow(db, "exec-1", {});
-    expect(getWorkflow(db, "exec-1")!.state).toBe("pending");
+    const after = getWorkflow(db, "exec-1")!;
+    expect(after.state).toBe("pending");
+    expect(after.updatedAt).toBeGreaterThanOrEqual(before);
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dispatcher/test/workflow-record.test.ts` around lines 81 - 91, The
test currently only asserts state after calling updateWorkflow with an empty
patch; also capture the initial timestamp from getWorkflow (e.g., const before =
getWorkflow(db, "exec-1")!.updated_at or updatedAt), call updateWorkflow(db,
"exec-1", {}), then read the record again and assert the new updated_at is
greater than the previous value to enforce the "updatedAt always changes"
contract while keeping the existing state assertion; use the same helpers
(createWorkflowRecord, updateWorkflow, getWorkflow) and preserve the test name.
packages/cli/src/commands/dispatch.ts (1)

6-6: ⚡ Quick win

Use dispatcher public exports instead of /src/* deep import.

Importing dispatchEpic from an internal file tightly couples CLI to dispatcher internals. Prefer a public barrel export from @middle/dispatcher to keep package boundaries stable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/dispatch.ts` at line 6, The import in dispatch.ts
uses a deep path into `@middle/dispatcher` internals; replace it with the
package's public export and update the dispatcher package if needed. Export
dispatchEpic from the public barrel (e.g., add/export dispatchEpic in
`@middle/dispatcher` index barrel) and change the import in
packages/cli/src/commands/dispatch.ts to import { dispatchEpic } from
"`@middle/dispatcher`"; ensure the exported symbol name matches dispatchEpic in
the dispatcher package.
packages/cli/src/commands/doctor.ts (1)

1-5: ⚡ Quick win

Avoid deep import from dispatcher internals in doctor command.

@middle/dispatcher/src/tmux.ts is an internal path; re-export these helpers from the dispatcher package entrypoint (or move shared capability to @middle/core) and import from that stable surface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/doctor.ts` around lines 1 - 5, The doctor command
currently deep-imports dispatcher internals; update imports of getTmuxVersion,
MIN_TMUX_VERSION, and tmuxVersionAtLeast to come from a stable package
entrypoint (or from `@middle/core` if you move the helpers there). Modify the
dispatcher package to re-export these symbols from its public index (or relocate
the tmux helpers to `@middle/core`) and then change the import in doctor.ts to
import { getTmuxVersion, MIN_TMUX_VERSION, tmuxVersionAtLeast } from the
dispatcher/core package entrypoint instead of "`@middle/dispatcher/src/tmux.ts`".
packages/cli/src/commands/status.ts (1)

3-3: ⚡ Quick win

Avoid deep-importing dispatcher internals from the CLI.

Importing @middle/dispatcher/src/db.ts bypasses the package boundary and can break on internal refactors. Re-export openDb from the dispatcher public entrypoint and consume that instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/status.ts` at line 3, The CLI is deep-importing
openDb from dispatcher internals; instead re-export openDb from the dispatcher
package public entrypoint and import that public symbol from the package. In the
dispatcher package add a public export (e.g. export { openDb } from './src/db')
to its entrypoint module, then change the CLI import in status.ts to import {
openDb } from '`@middle/dispatcher`' so the CLI consumes the public API rather
than the internal path.
packages/cli/test/start-stop.test.ts (1)

64-70: ⚡ Quick win

Replace fixed sleeps with bounded polling to reduce flakiness.

Line [64], Line [69], and Line [80] rely on timing assumptions that can fail on slower CI runners. A small waitUntil(() => isAlive(pid), timeout) / inverse check will make lifecycle assertions deterministic.

Also applies to: 80-80

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/test/start-stop.test.ts` around lines 64 - 70, Replace the fixed
Bun.sleep waits around the lifecycle assertions with bounded polling so tests
don't flake on slow CI: instead of awaiting Bun.sleep(150) then asserting
isAlive(pid) or !isAlive(pid), call a waitUntil-style helper that repeatedly
checks isAlive(pid) (or its inverse) until a timeout and then assert the
expected boolean; update the assertions around runStop({ pidFile }),
existsSync(pidFile) and the before/after liveness checks to use this polling
helper (referencing isAlive, runStop, existsSync, and pidFile) so the test waits
deterministically for the process state change.
packages/cli/test/doctor.test.ts (1)

4-8: ⚡ Quick win

Make this test hermetic instead of host-toolchain dependent.

Line [4]–Line [7] explicitly tie success to locally installed binaries, which makes this unit test fragile across contributors/CI images. Prefer stubbing the command-exec/check layer and asserting formatting + exit-code logic here; keep real-binary verification as an opt-in smoke test.

Also applies to: 10-27

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/test/doctor.test.ts` around lines 4 - 8, The test in
doctor.test.ts currently shells out to real binaries via runDoctor and thus
depends on the host toolchain; change it to be hermetic by stubbing the
command-exec/check layer (the function(s) that run commands or check versions
used by runDoctor) rather than invoking real binaries. Locate the command
wrapper used by runDoctor (e.g., the exec/which/spawn helper or a wrapper like
runCommand/checkBinary used in the doctor code), replace calls in the test with
a mock (jest.spyOn or DI-injected stub) that returns controlled success/failure
outputs, then assert the formatting of output and exit-code behavior from
runDoctor; keep any real-binary verification as a separate opt-in smoke test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/adapters/claude/src/hooks.ts`:
- Around line 53-55: The generated hook command strings are interpolating the
unquoted scriptPath into the hooks in hooks.ts (the objects with keys Stop and
the earlier hook array), which breaks when opts.worktree contains spaces or
shell metacharacters; change the command generation to wrap scriptPath in quotes
(e.g., produce "scriptPath session.started" and "scriptPath agent.stopped") so
the entire path is treated as one token, and update the adapter tests in
packages/adapters/claude/test/adapter.test.ts to expect the quoted substrings
(including the surrounding quotes) instead of the current unquoted ones.

In `@packages/cli/src/commands/start.ts`:
- Around line 36-38: The PID read/used needs to be validated as a positive
integer before invoking process.kill; update the checks so the value parsed into
existing (in start flow) is tested with Number.isInteger(existing) && existing >
0 before calling isAlive(existing) (or adjust isAlive to early-return false for
non-positive pids), and in the stop flow ensure the parsed pid is > 0 before
calling process.kill(pid, "SIGTERM"); this prevents pid === 0 or negative values
from triggering process group signals.

In `@packages/cli/src/commands/status.ts`:
- Around line 44-47: The catch block that currently logs “database has no
workflows table yet…” must only handle the specific "no such table" error for
the workflows table; update the catch to examine the caught error (e) and if
e.message (or e.code) indicates "no such table" for "workflows" then keep the
original console.log and return 0, otherwise write the full error to
console.error (or process.stderr) and return 1 so real SQLite failures
(corruption, permission, lock, etc.) surface; locate and modify the catch in
packages/cli/src/commands/status.ts (the catch handling the workflows query) to
implement this conditional error handling.

In `@packages/cli/src/commands/stop.ts`:
- Around line 25-31: The PID validation currently only checks
Number.isInteger(pid) and may allow 0 or negative PIDs; update the check in the
stop command to require pid to be a positive integer (e.g., if
(!Number.isInteger(pid) || pid <= 0) { console.error(...); return 1; }) before
calling process.kill(pid, "SIGTERM") so you never call process.kill with 0 or
negative values; update the error message if desired and keep the same
early-return behavior.
- Around line 30-35: The current runStop flow clears the pid file early and
treats any process.kill error as “not running”; instead, in runStop ensure you
only treat error.code === "ESRCH" as the process-not-running case (clear the pid
file and return 0), but for EPERM/other errors do NOT clear the pid file and
return a non-zero exit (or rethrow) so callers know signaling failed; adjust the
try/catch around process.kill(pid, "SIGTERM") to catch the error object, inspect
error.code, and only remove or log-and-return-success on ESRCH, otherwise
propagate or return failure, and only remove the pid file after a confirmed
successful kill or ESRCH.

In `@packages/core/src/config.ts`:
- Around line 141-143: The expandTilde function currently expands any string
starting with "~", incorrectly mapping "~username/..." to the current user's
home; change the condition to only handle the bare "~" and the "~/..." form
(i.e. value === "~" or value.startsWith("~/")). When value === "~" return
homedir(), when it startsWith("~/") return join(homedir(), value.slice(2)),
otherwise return the original value; keep using the existing expandTilde
function name and the homedir()/join utilities.

In `@packages/dispatcher/src/db.ts`:
- Around line 66-69: openAndMigrate currently opens the Database via openDb and
then calls runMigrations but does not close the Database if runMigrations
throws, leaving the sqlite handle open; update openAndMigrate to wrap
runMigrations(db, dir) in a try/catch, call db.close() (or the appropriate close
method on the Database returned by openDb) inside the catch before rethrowing
the error, ensuring the Database handle is always closed on migration failure
(and consider a finally block to guarantee closure only on error-free or error
paths as appropriate).

In `@packages/dispatcher/src/dispatch.ts`:
- Around line 118-124: dispatchEpic can throw after constructing const engine =
new Engine({ embedded: true }) leaving the Engine running because
engine.close(...) is only invoked in the finally via runCleanups; ensure engine
is always closed on error by adding a try/catch/finally around the
post-construction work in dispatchEpic so that engine.close(...) is called in
the finally (or register engine.close as an early cleanup before actions like
engine.register/engine.start/waitForSettle), referencing the Engine instance and
its close method (engine.close) and the existing runCleanups/cleanup mechanism
so Engine shutdown occurs even if engine.register, engine.start, or
waitForSettle throws.

In `@packages/dispatcher/src/hook-server.ts`:
- Around line 64-80: The handler currently accepts any event name and allows
empty session identities; update the HTTP boundary to validate the parsed event
and session before delivering: after extracting event from match (the variable
event) and computing sessionName (the variable sessionName from req.headers or
payload.sessionName), check that event is one of the supported lifecycle events
(use a whitelist/enum of allowed event names) and that sessionName is a
non-empty string; if either check fails, log the rejection and return an
appropriate 4xx Response (do not call this.#deliver or stash payloads),
otherwise proceed to call this.#deliver(`${event}:${sessionName}`, payload) as
before. Ensure the validation is applied prior to any side effects and uses the
same HookPayload parsing logic currently in place.

In `@packages/dispatcher/src/main.ts`:
- Around line 32-40: The shutdown function (shutdown) should guard teardown
calls so failures don't prevent process.exit; wrap hookServer.stop(), await
engine.close(true) and db.close() in a try/catch (or two: one for each resource
group) and log any thrown errors (use console.error or the module logger) and
then call process.exit(1) on failure; on success call process.exit(0). Ensure
shuttingDown is still set at the top of shutdown and that any thrown/rejected
promises from engine.close or db.close are awaited inside the try block so they
cannot become unhandled.

In `@packages/dispatcher/src/worktree.ts`:
- Around line 114-120: The code builds the worktree path using unvalidated
opts.repo causing possible path traversal; in createWorktree (and the
corresponding cleanup/remove function that deletes the worktree)
validate/sanitize opts.repo before using it: ensure it is a single safe segment
(e.g., reject if contains ../ or path.sep, or use path.basename) or normalize
and then resolve the candidate path and assert the resolved path startsWith the
resolved worktreeRoot; if validation fails, throw an error. Update references
where path = join(root, opts.repo, unit) and where the deletion uses that path
to rely only on a validated/sanitized repo value.

In `@packages/dispatcher/test/main.test.ts`:
- Around line 42-57: The readiness loop can block on reader.read() so the
Date.now() check doesn't enforce a timeout and the spawned proc may leak on
assertion failures; change the loop to race each await reader.read() against a
timeout Promise (e.g., Promise.race([reader.read(), timeoutPromise])) so the
loop actually stops when the deadline passes and mark/throw on timeout, and wrap
the entire wait + assertion in try/finally that always calls
proc.kill("SIGTERM") and awaits proc.exited and calls reader.releaseLock();
update references to reader.read(), deadline, proc, reader.releaseLock(), and
the expect(...) assertion accordingly.

In `@packages/dispatcher/test/worktree.test.ts`:
- Around line 29-31: The tests call git(repoPath, ["commit", "--allow-empty",
"-m", "init"]) relying on global git config; make the test setup deterministic
by configuring a local git identity before that commit: call git(repoPath,
["config", "user.name", "Test User"]) and git(repoPath, ["config", "user.email",
"test@example.com"]) (or similar) immediately after initializing the repo and
before the git(...) commit call (in the same test setup function used in
worktree.test.ts and the other mentioned tests) so commits never fail due to
missing user config.

---

Nitpick comments:
In `@packages/cli/src/commands/dispatch.ts`:
- Line 6: The import in dispatch.ts uses a deep path into `@middle/dispatcher`
internals; replace it with the package's public export and update the dispatcher
package if needed. Export dispatchEpic from the public barrel (e.g., add/export
dispatchEpic in `@middle/dispatcher` index barrel) and change the import in
packages/cli/src/commands/dispatch.ts to import { dispatchEpic } from
"`@middle/dispatcher`"; ensure the exported symbol name matches dispatchEpic in
the dispatcher package.

In `@packages/cli/src/commands/doctor.ts`:
- Around line 1-5: The doctor command currently deep-imports dispatcher
internals; update imports of getTmuxVersion, MIN_TMUX_VERSION, and
tmuxVersionAtLeast to come from a stable package entrypoint (or from
`@middle/core` if you move the helpers there). Modify the dispatcher package to
re-export these symbols from its public index (or relocate the tmux helpers to
`@middle/core`) and then change the import in doctor.ts to import {
getTmuxVersion, MIN_TMUX_VERSION, tmuxVersionAtLeast } from the dispatcher/core
package entrypoint instead of "`@middle/dispatcher/src/tmux.ts`".

In `@packages/cli/src/commands/status.ts`:
- Line 3: The CLI is deep-importing openDb from dispatcher internals; instead
re-export openDb from the dispatcher package public entrypoint and import that
public symbol from the package. In the dispatcher package add a public export
(e.g. export { openDb } from './src/db') to its entrypoint module, then change
the CLI import in status.ts to import { openDb } from '`@middle/dispatcher`' so
the CLI consumes the public API rather than the internal path.

In `@packages/cli/test/doctor.test.ts`:
- Around line 4-8: The test in doctor.test.ts currently shells out to real
binaries via runDoctor and thus depends on the host toolchain; change it to be
hermetic by stubbing the command-exec/check layer (the function(s) that run
commands or check versions used by runDoctor) rather than invoking real
binaries. Locate the command wrapper used by runDoctor (e.g., the
exec/which/spawn helper or a wrapper like runCommand/checkBinary used in the
doctor code), replace calls in the test with a mock (jest.spyOn or DI-injected
stub) that returns controlled success/failure outputs, then assert the
formatting of output and exit-code behavior from runDoctor; keep any real-binary
verification as a separate opt-in smoke test.

In `@packages/cli/test/start-stop.test.ts`:
- Around line 64-70: Replace the fixed Bun.sleep waits around the lifecycle
assertions with bounded polling so tests don't flake on slow CI: instead of
awaiting Bun.sleep(150) then asserting isAlive(pid) or !isAlive(pid), call a
waitUntil-style helper that repeatedly checks isAlive(pid) (or its inverse)
until a timeout and then assert the expected boolean; update the assertions
around runStop({ pidFile }), existsSync(pidFile) and the before/after liveness
checks to use this polling helper (referencing isAlive, runStop, existsSync, and
pidFile) so the test waits deterministically for the process state change.

In `@packages/dispatcher/src/db.ts`:
- Around line 37-44: The migration list is currently sorted lexicographically by
filename which breaks numeric ordering (e.g., "10_" after "2_"); change the
pipeline to first map filenames to objects with parsed numeric version (using
the existing regex/match logic that produces version) and then sort the
resulting array by the numeric version property before returning so executions
run in numeric order; update the chain around readdirSync/filter/map/sort (or
replace the current sort() call) so sorting uses the parsed Number(match[1])
rather than the raw filename string.

In `@packages/dispatcher/test/workflow-record.test.ts`:
- Around line 81-91: The test currently only asserts state after calling
updateWorkflow with an empty patch; also capture the initial timestamp from
getWorkflow (e.g., const before = getWorkflow(db, "exec-1")!.updated_at or
updatedAt), call updateWorkflow(db, "exec-1", {}), then read the record again
and assert the new updated_at is greater than the previous value to enforce the
"updatedAt always changes" contract while keeping the existing state assertion;
use the same helpers (createWorkflowRecord, updateWorkflow, getWorkflow) and
preserve the test name.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5bdc8695-5084-42ee-ad8c-e629a9ddb3d6

📥 Commits

Reviewing files that changed from the base of the PR and between a2f41e9 and 5b23056.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (44)
  • packages/adapters/claude/src/classify.ts
  • packages/adapters/claude/src/hooks.ts
  • packages/adapters/claude/src/index.ts
  • packages/adapters/claude/src/prompt.ts
  • packages/adapters/claude/src/transcript.ts
  • packages/adapters/claude/test/adapter.test.ts
  • packages/cli/package.json
  • packages/cli/src/commands/dispatch.ts
  • packages/cli/src/commands/doctor.ts
  • packages/cli/src/commands/start.ts
  • packages/cli/src/commands/status.ts
  • packages/cli/src/commands/stop.ts
  • packages/cli/src/index.ts
  • packages/cli/src/paths.ts
  • packages/cli/test/dispatch.test.ts
  • packages/cli/test/doctor.test.ts
  • packages/cli/test/start-stop.test.ts
  • packages/cli/test/status.test.ts
  • packages/core/src/adapter.ts
  • packages/core/src/config.ts
  • packages/core/src/events.ts
  • packages/core/src/index.ts
  • packages/core/src/tmux-tui.ts
  • packages/core/test/config.test.ts
  • packages/core/test/tmux-tui.test.ts
  • packages/dispatcher/src/db.ts
  • packages/dispatcher/src/db/migrations/001_initial.sql
  • packages/dispatcher/src/dispatch.ts
  • packages/dispatcher/src/hook-server.ts
  • packages/dispatcher/src/main.ts
  • packages/dispatcher/src/tmux.ts
  • packages/dispatcher/src/workflow-record.ts
  • packages/dispatcher/src/workflows/implementation.ts
  • packages/dispatcher/src/worktree.ts
  • packages/dispatcher/test/db.test.ts
  • packages/dispatcher/test/hook-server.test.ts
  • packages/dispatcher/test/implementation-workflow.test.ts
  • packages/dispatcher/test/main.test.ts
  • packages/dispatcher/test/tmux.test.ts
  • packages/dispatcher/test/workflow-record.test.ts
  • packages/dispatcher/test/worktree.test.ts
  • planning/issues/6/decisions.md
  • planning/issues/6/plan.md
  • scripts/dev.sh

Comment thread packages/adapters/claude/src/hooks.ts Outdated
Comment thread packages/cli/src/commands/start.ts
Comment thread packages/cli/src/commands/status.ts Outdated
Comment thread packages/cli/src/commands/stop.ts Outdated
Comment thread packages/cli/src/commands/stop.ts
Comment thread packages/dispatcher/src/hook-server.ts
Comment thread packages/dispatcher/src/main.ts
Comment thread packages/dispatcher/src/worktree.ts
Comment thread packages/dispatcher/test/main.test.ts Outdated
Comment thread packages/dispatcher/test/worktree.test.ts
…h traversal, hook quoting, shutdown safety, test hermeticity
@thejustinwalsh

Copy link
Copy Markdown
Owner Author

CodeRabbit review — all 13 inline findings fixed in 417c195. 123 pass, tsc clean.

Severity File Fix
🔴 cli/src/commands/start.ts, stop.ts reject pid <= 0 before process.kill (0/negative target process groups)
🟠 dispatcher/src/dispatch.ts regression I introduced — engine drain moved back onto the cleanups stack so it runs on the throw path too, still before hookServer/db teardown
🟠 cli/src/commands/stop.ts distinguish ESRCH (gone → clear pid, exit 0) from EPERM/other (alive → keep pid, exit 1)
🟠 cli/src/commands/status.ts only swallow no such table; real errors print + exit 1
🟠 dispatcher/src/db.ts close db handle before rethrowing on migration failure
🟠 dispatcher/src/hook-server.ts reject empty session identity (400) instead of stashing an unreachable key
🟠 dispatcher/src/main.ts try/catch each shutdown teardown so process.exit(0) always runs
🟠 dispatcher/src/worktree.ts reject a repo that resolves outside the worktree root (path traversal)
🟠 adapters/claude/src/hooks.ts double-quote the absolute hook path (home dir with spaces)
🟠 dispatcher/test/main.test.ts wall-clock-capped readiness read + finally reap of the spawned dispatcher
🟠 3 test files scratch-repo git identity via env (not -c) — deterministic on clean hosts
🟡 core/src/config.ts expandTilde only ~ and ~/…; leave ~user/… alone

Per-thread replies have the specifics; full reasoning in planning/issues/6/decisions.md ("CodeRabbit review round").

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/adapters/claude/src/hooks.ts (1)

50-57: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Escape embedded quotes in Claude hook command generation

Claude runs type: "command" in shell form (no args), so wrapping scriptPath with just " fails if scriptPath contains embedded " and the hook command won’t run correctly.

Suggested patch
 export async function installHooks(opts: InstallHookOpts): Promise<void> {
   const scriptPath = join(opts.worktree, opts.hookScriptPath);
+  const quotedScriptPath = shellQuote(scriptPath);
@@
   const settings = {
     hooks: {
       SessionStart: [
-        { hooks: [{ type: "command", command: `"${scriptPath}" session.started` }] },
+        { hooks: [{ type: "command", command: `${quotedScriptPath} session.started` }] },
       ],
-      Stop: [{ hooks: [{ type: "command", command: `"${scriptPath}" agent.stopped` }] }],
+      Stop: [{ hooks: [{ type: "command", command: `${quotedScriptPath} agent.stopped` }] }],
     },
   };
@@
 }
+
+function shellQuote(value: string): string {
+  return `'${value.replace(/'/g, `'\\''`)}'`;
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/adapters/claude/src/hooks.ts` around lines 50 - 57, The generated
hook command wraps scriptPath in double-quotes but doesn’t escape any embedded
quotes, so commands like `"${scriptPath}" session.started` will break if
scriptPath contains " characters; update the hook generation in hooks.ts to
escape embedded double-quotes in scriptPath (e.g., compute an escapedScriptPath
= scriptPath.replace(/"/g, '\\"') or equivalent) and use that escaped value when
constructing the command for the SessionStart and Stop entries so the command
string becomes `"<escapedScriptPath>" session.started` / `"<escapedScriptPath>"
agent.stopped`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/test/dispatch.test.ts`:
- Around line 71-77: The test currently ignores the exit codes of the git setup
commands (init and commit), so assert they succeeded before proceeding: after
awaiting init.exited check init.exitCode (or the spawn result status) and throw
or fail the test if it is non-zero, and do the same for the commit spawn that
uses gitEnv; this ensures the repository setup succeeded and prevents setup
failures from being mistaken for the EADDRINUSE failure-path under test.

---

Duplicate comments:
In `@packages/adapters/claude/src/hooks.ts`:
- Around line 50-57: The generated hook command wraps scriptPath in
double-quotes but doesn’t escape any embedded quotes, so commands like
`"${scriptPath}" session.started` will break if scriptPath contains "
characters; update the hook generation in hooks.ts to escape embedded
double-quotes in scriptPath (e.g., compute an escapedScriptPath =
scriptPath.replace(/"/g, '\\"') or equivalent) and use that escaped value when
constructing the command for the SessionStart and Stop entries so the command
string becomes `"<escapedScriptPath>" session.started` / `"<escapedScriptPath>"
agent.stopped`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 434d154a-bb12-4e9d-beb9-34cdb2e46b68

📥 Commits

Reviewing files that changed from the base of the PR and between 5b23056 and 417c195.

📒 Files selected for processing (16)
  • packages/adapters/claude/src/hooks.ts
  • packages/adapters/claude/test/adapter.test.ts
  • packages/cli/src/commands/start.ts
  • packages/cli/src/commands/status.ts
  • packages/cli/src/commands/stop.ts
  • packages/cli/test/dispatch.test.ts
  • packages/core/src/config.ts
  • packages/dispatcher/src/db.ts
  • packages/dispatcher/src/dispatch.ts
  • packages/dispatcher/src/hook-server.ts
  • packages/dispatcher/src/main.ts
  • packages/dispatcher/src/worktree.ts
  • packages/dispatcher/test/implementation-workflow.test.ts
  • packages/dispatcher/test/main.test.ts
  • packages/dispatcher/test/worktree.test.ts
  • planning/issues/6/decisions.md
✅ Files skipped from review due to trivial changes (1)
  • planning/issues/6/decisions.md

Comment thread packages/cli/test/dispatch.test.ts Outdated
@thejustinwalsh thejustinwalsh merged commit a9bfc05 into main May 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Minimal dispatcher (worktree, spawn, cleanup)

1 participant