diff --git a/bun.lock b/bun.lock index 63aee37a..19373155 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "mm": "src/index.ts", }, "dependencies": { + "@middle/adapter-claude": "workspace:*", "@middle/core": "workspace:*", "@middle/dispatcher": "workspace:*", "commander": "^14.0.3", diff --git a/packages/adapters/claude/src/classify.ts b/packages/adapters/claude/src/classify.ts new file mode 100644 index 00000000..01c46ee5 --- /dev/null +++ b/packages/adapters/claude/src/classify.ts @@ -0,0 +1,62 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { HookPayload, StopClassification } from "@middle/core"; + +const USAGE_LIMIT_RE = /You've hit your usage limit\. Resets at (.+?)\./; + +/** + * Classify the agent's state at a `Stop` hook. The interactive process does not + * exit between turns, so this — not an exit code — is the signal the workflow + * reacts to. Order matters: an open question outranks everything else. + * + * All three sentinel paths are anchored at `/.middle/`, not at + * `payload.cwd`. The Claude session's `cwd` at Stop time may be a subdirectory + * the agent has `cd`'d into (e.g. `worktree/src/`); only the worktree root is + * the stable home of the workstream's sentinel files. + * + * Phase 1 detects `done`/`failed` via `.middle/done.json` / `.middle/failed.json` + * sentinels, parallel to the `.middle/blocked.json` question sentinel. Phase 4 + * replaces the `done` path with the mechanically-enforced PR-ready hook gate. + */ +export function classifyStop(opts: { + payload: HookPayload; + transcriptPath: string; + sentinelPresent: boolean; + worktree: string; +}): StopClassification { + const middleDir = join(opts.worktree, ".middle"); + + if (opts.sentinelPresent) { + return { kind: "asked-question", sentinelPath: join(middleDir, "blocked.json") }; + } + + const match = USAGE_LIMIT_RE.exec(readTail(opts.transcriptPath)); + if (match) return { kind: "rate-limited", resetAt: match[1]! }; + + if (existsSync(join(middleDir, "done.json"))) return { kind: "done" }; + + const failedPath = join(middleDir, "failed.json"); + if (existsSync(failedPath)) { + return { kind: "failed", reason: readFailedReason(failedPath) }; + } + + return { kind: "bare-stop" }; +} + +function readTail(path: string): string { + try { + const raw = readFileSync(path, "utf8"); + return raw.length > 8192 ? raw.slice(-8192) : raw; + } catch { + return ""; + } +} + +function readFailedReason(path: string): string { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as { reason?: unknown }; + return typeof parsed.reason === "string" ? parsed.reason : "agent reported failure"; + } catch { + return "agent reported failure"; + } +} diff --git a/packages/adapters/claude/src/hooks.ts b/packages/adapters/claude/src/hooks.ts new file mode 100644 index 00000000..9e0d1f0a --- /dev/null +++ b/packages/adapters/claude/src/hooks.ts @@ -0,0 +1,62 @@ +import { chmod, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { InstallHookOpts } from "@middle/core"; + +/** + * The universal hook script — POSTs the hook payload to the dispatcher. Args: + * `$1` is the normalized event name. Never blocks the agent (3s timeout, + * failure → exit 0). Source of truth: build spec → "Normalized event taxonomy". + */ +// curl runs as a child (not `exec`) so the trailing `|| exit 0` actually fires: +// with `exec`, the shell is replaced by curl and a non-zero curl exit (refused +// connection, 3s timeout, DNS) would propagate as a failed hook. As a child, +// any curl failure is swallowed and the hook exits 0 — "failure is a no-op". +const HOOK_SCRIPT = `#!/bin/sh +# .middle/hooks/hook.sh — POSTs hook payloads to the middle dispatcher. +# Args: $1 = normalized event name. Never blocks the agent; failure is a no-op. +EVENT="$1" +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 || true +exit 0 +`; + +/** + * Phase 1 install: write the universal hook script into the worktree and a + * minimal `.claude/settings.json` registering the two load-bearing events the + * `implementation` workflow depends on: + * + * - `SessionStart` → `session.started` — discovers `session_id` + `transcript_path` + * - `Stop` → `agent.stopped` — the turn boundary `classifyStop` reacts to + * + * Phase 2 expands to the full event taxonomy, HMAC auth, and merging into any + * pre-existing settings file. + */ +export async function installHooks(opts: InstallHookOpts): Promise { + const scriptPath = join(opts.worktree, opts.hookScriptPath); + await mkdir(dirname(scriptPath), { recursive: true }); + await Bun.write(scriptPath, HOOK_SCRIPT); + await chmod(scriptPath, 0o755); + + const claudeDir = join(opts.worktree, ".claude"); + await mkdir(claudeDir, { recursive: true }); + + // Absolute path: Claude fires hooks from whatever directory the agent has + // `cd`'d into, so a relative `.middle/hooks/hook.sh` would fail to resolve + // from a subdirectory and silently skip the POST (→ awaitStop times out). + // Double-quote the path so a worktree under a home dir with spaces + // (e.g. /Users/Jane Doe/...) doesn't mis-parse the hook command. + const settings = { + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: `"${scriptPath}" session.started` }] }, + ], + Stop: [{ hooks: [{ type: "command", command: `"${scriptPath}" agent.stopped` }] }], + }, + }; + + await Bun.write(join(claudeDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`); +} diff --git a/packages/adapters/claude/src/index.ts b/packages/adapters/claude/src/index.ts index c6aa4ce1..e7641fc0 100644 --- a/packages/adapters/claude/src/index.ts +++ b/packages/adapters/claude/src/index.ts @@ -1,3 +1,101 @@ // @middle/adapter-claude — implements AgentAdapter for the Claude CLI. -// Source lands in build-spec Phase 1 (spawn + classify) and Phase 2 (hooks). -export {}; +import type { AgentAdapter } from "@middle/core"; +import { capturePane, pollPaneFor, sendKeys } from "@middle/core"; +import { classifyStop } from "./classify.ts"; +import { installHooks } from "./hooks.ts"; +import { buildPromptText } from "./prompt.ts"; +import { readTranscriptState, resolveTranscriptPath } from "./transcript.ts"; + +/** + * `--dangerously-skip-permissions` is the auto-mode flag — runtime-equivalent + * to bypassPermissions, but still pops a one-time "are you sure?" warning at + * boot. `enterAutoMode` dismisses it. + */ +const AUTO_MODE_FLAG = "--dangerously-skip-permissions"; + +const BYPASS_PROMPT_RE = /bypass\s+permissions?|skip\s+permissions?|dangerously/i; +const NEEDS_LOGIN_RE = + /please\s+(?:run\s+|use\s+)?(?:claude\s+)?\/?(?:login|sign[ -]?in)|not\s+(?:logged\s+in|authenticated|signed\s+in)|welcome\s+to\s+claude\s+code.*sign|invalid\s+(?:api\s+key|credentials)/i; + +/** Whether a captured pane shows Claude's bypass-mode confirmation prompt. */ +export function detectBypassPrompt(paneContent: string): boolean { + return BYPASS_PROMPT_RE.test(paneContent); +} + +/** Whether a captured pane shows a "you need to log in" message. */ +export function detectNeedsLogin(paneContent: string): boolean { + return NEEDS_LOGIN_RE.test(paneContent); +} + +/** Long polling window — covers Claude's slowest boot up to launchTimeout. */ +const BOOT_DETECT_TIMEOUT_MS = 90_000; + +type BootOutcome = "bypass-prompt" | "needs-login"; + +/** + * Pre-SessionStart boot polling. Runs in parallel with `awaitSessionStart` + * because Claude does not fire SessionStart until past the bypass-mode + * warning. Two outcomes drive action: + * + * - `bypass-prompt`: send Down + Enter (split with a 100ms delay so the menu + * has time to advance selection between keys) to select "Yes, I accept". + * Claude proceeds and fires SessionStart shortly after. + * - `needs-login`: throw a clean error so `mm dispatch` exits with a useful + * "claude is not authenticated" message instead of hanging on a 90s + * SessionStart timeout. + */ +async function enterAutoMode(opts: { sessionName: string }): Promise { + const tag = `claude:${opts.sessionName}`; + const outcome = await pollPaneFor( + opts.sessionName, + (pane) => { + if (detectNeedsLogin(pane)) return "needs-login"; + if (detectBypassPrompt(pane)) return "bypass-prompt"; + return null; + }, + { timeoutMs: BOOT_DETECT_TIMEOUT_MS, pollIntervalMs: 200, tag }, + ); + + if (outcome === "needs-login") { + throw new Error( + "claude is not authenticated — run `claude` interactively in a normal terminal to sign in, then retry the dispatch", + ); + } + if (outcome === "bypass-prompt") { + console.error(`[${tag}] bypass prompt detected — settling then Down then Enter`); + await Bun.sleep(200); + await sendKeys(opts.sessionName, ["Down", "Enter"], { delayBetweenMs: 100 }); + // Post-keystroke capture confirms whether the menu actually advanced. + await Bun.sleep(300); + const after = await capturePane(opts.sessionName); + const afterTail = (after ?? "").replace(/\s+/g, " ").trim().slice(-300); + console.error(`[${tag}] post-keystroke pane tail: "${afterTail}"`); + } + // outcome null: neither prompt nor login screen appeared. SessionStart should + // already have fired (or be about to). enterAutoMode has nothing else to do. +} + +export const claudeAdapter: AgentAdapter = { + name: "claude", + readyEvent: "session.started", + installHooks, + buildLaunchCommand(opts) { + // Interactive — no `-p`, no prompt. `--dangerously-skip-permissions` + // engages auto mode AND suppresses the API-permission gate (the bypass + // confirmation TUI is separate, dismissed via enterAutoMode). Env is + // injected by tmux at spawn time. + return { + argv: ["claude", AUTO_MODE_FLAG], + env: { + MIDDLE_SESSION: opts.sessionName, + MIDDLE_SESSION_TOKEN: opts.sessionToken, + ...opts.envOverrides, + }, + }; + }, + buildPromptText, + enterAutoMode, + resolveTranscriptPath, + readTranscriptState, + classifyStop, +}; diff --git a/packages/adapters/claude/src/prompt.ts b/packages/adapters/claude/src/prompt.ts new file mode 100644 index 00000000..78873a61 --- /dev/null +++ b/packages/adapters/claude/src/prompt.ts @@ -0,0 +1,21 @@ +/** + * The literal text `send-keys` carries into a tmux session to start or continue + * the agent. `send-keys` cannot cleanly carry a multi-line prompt — embedded + * newlines submit early — so the full prompt lives on disk and this returns a + * one-line `@`-reference that force-includes it. A single `@` prefixes the + * whole relative path. + */ +export function buildPromptText(opts: { + promptFile: string; + kind: "initial" | "resume" | "answer"; +}): string { + const ref = `@${opts.promptFile}`; + switch (opts.kind) { + case "initial": + return ref; + case "resume": + return `Resuming this workstream — re-read the linked context and continue. ${ref}`; + case "answer": + return `A human answered your open question — read the answer and continue. ${ref}`; + } +} diff --git a/packages/adapters/claude/src/transcript.ts b/packages/adapters/claude/src/transcript.ts new file mode 100644 index 00000000..b314bca0 --- /dev/null +++ b/packages/adapters/claude/src/transcript.ts @@ -0,0 +1,76 @@ +import { readFileSync } from "node:fs"; +import type { HookPayload, TranscriptState } from "@middle/core"; + +/** Claude delivers `transcript_path` directly in the SessionStart hook payload. */ +export function resolveTranscriptPath(payload: HookPayload): string { + const path = payload.transcript_path; + if (typeof path !== "string" || path.length === 0) { + throw new Error("SessionStart payload has no transcript_path"); + } + return path; +} + +type TranscriptLine = { + type?: string; + timestamp?: string; + message?: { + role?: string; + content?: unknown; + usage?: Record; + }; +}; + +function isToolUseBlock(block: unknown): block is { type: "tool_use"; name?: string } { + return ( + typeof block === "object" && + block !== null && + (block as { type?: string }).type === "tool_use" + ); +} + +/** + * Parse the JSONL transcript for activity, turn count, last tool use, and + * context-token usage. Corrupt lines are skipped rather than thrown on — the + * transcript reconciler cron (Phase 2) is the authoritative reader; this is the + * fast-path estimate. `contextTokens` is the input side of the last assistant + * turn (prompt + cache), i.e. how full the context window is. + */ +export function readTranscriptState(transcriptPath: string): TranscriptState { + const raw = readFileSync(transcriptPath, "utf8"); + let lastActivity = ""; + let turnCount = 0; + let lastToolUse: string | null = null; + let contextTokens = 0; + + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "") continue; + let entry: TranscriptLine; + try { + entry = JSON.parse(trimmed) as TranscriptLine; + } catch { + continue; + } + if (typeof entry.timestamp === "string") lastActivity = entry.timestamp; + if (entry.type !== "assistant") continue; + + turnCount++; + const content = entry.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (isToolUseBlock(block) && typeof block.name === "string") { + lastToolUse = block.name; + } + } + } + const usage = entry.message?.usage; + if (usage) { + contextTokens = + (usage.input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0); + } + } + + return { lastActivity, contextTokens, turnCount, lastToolUse }; +} diff --git a/packages/adapters/claude/test/adapter.test.ts b/packages/adapters/claude/test/adapter.test.ts new file mode 100644 index 00000000..b4bbfd9d --- /dev/null +++ b/packages/adapters/claude/test/adapter.test.ts @@ -0,0 +1,348 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { HookPayload } from "@middle/core"; +import { claudeAdapter, detectBypassPrompt, detectNeedsLogin } from "../src/index.ts"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "middle-claude-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("claudeAdapter identity", () => { + test("name is 'claude' and readyEvent is session.started", () => { + expect(claudeAdapter.name).toBe("claude"); + expect(claudeAdapter.readyEvent).toBe("session.started"); + }); +}); + +describe("buildLaunchCommand", () => { + test("argv launches interactive claude in auto mode via --dangerously-skip-permissions", () => { + const { argv } = claudeAdapter.buildLaunchCommand({ + worktree: dir, + sessionName: "middle-6", + sessionToken: "tok", + }); + expect(argv).toEqual(["claude", "--dangerously-skip-permissions"]); + expect(argv).not.toContain("-p"); // never headless + // bypassPermissions via --permission-mode would pop a one-time confirmation + // prompt the dispatcher cannot answer — never use that variant. + expect(argv).not.toContain("--permission-mode"); + }); + + test("env carries the session vars and merges envOverrides", () => { + const { env } = claudeAdapter.buildLaunchCommand({ + worktree: dir, + sessionName: "middle-6", + sessionToken: "secret-token", + envOverrides: { MIDDLE_DISPATCHER_URL: "http://127.0.0.1:8822", MIDDLE_EPIC: "6" }, + }); + expect(env.MIDDLE_SESSION).toBe("middle-6"); + expect(env.MIDDLE_SESSION_TOKEN).toBe("secret-token"); + expect(env.MIDDLE_DISPATCHER_URL).toBe("http://127.0.0.1:8822"); + expect(env.MIDDLE_EPIC).toBe("6"); + }); +}); + +describe("buildPromptText", () => { + test("initial returns the bare @-reference one-liner", () => { + expect( + claudeAdapter.buildPromptText({ promptFile: ".middle/prompt.md", kind: "initial" }), + ).toBe("@.middle/prompt.md"); + }); + + test("resume frames the @-reference as a continuation", () => { + const text = claudeAdapter.buildPromptText({ + promptFile: ".middle/resume.md", + kind: "resume", + }); + expect(text).toContain("@.middle/resume.md"); + expect(text.toLowerCase()).toContain("resum"); + }); + + test("answer frames the @-reference as a human reply", () => { + const text = claudeAdapter.buildPromptText({ + promptFile: ".middle/answer.md", + kind: "answer", + }); + expect(text).toContain("@.middle/answer.md"); + expect(text.toLowerCase()).toContain("answer"); + }); +}); + +describe("resolveTranscriptPath", () => { + test("returns transcript_path from the SessionStart payload", () => { + const payload: HookPayload = { + session_id: "abc", + transcript_path: "/home/u/.claude/projects/x/abc.jsonl", + }; + expect(claudeAdapter.resolveTranscriptPath(payload)).toBe( + "/home/u/.claude/projects/x/abc.jsonl", + ); + }); + + test("throws when the payload has no transcript_path", () => { + expect(() => claudeAdapter.resolveTranscriptPath({ session_id: "abc" })).toThrow(); + }); +}); + +describe("readTranscriptState", () => { + test("parses activity, turn count, last tool use, and context tokens", () => { + const transcript = join(dir, "t.jsonl"); + writeFileSync( + transcript, + [ + JSON.stringify({ + type: "user", + message: { role: "user", content: "go" }, + timestamp: "2026-05-14T12:00:00.000Z", + }), + JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "ok" }], + usage: { input_tokens: 100, cache_read_input_tokens: 900, output_tokens: 50 }, + }, + timestamp: "2026-05-14T12:00:05.000Z", + }), + JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }], + usage: { input_tokens: 200, cache_read_input_tokens: 1800, output_tokens: 30 }, + }, + timestamp: "2026-05-14T12:00:10.000Z", + }), + "", // trailing blank line — must be tolerated + ].join("\n"), + ); + const state = claudeAdapter.readTranscriptState(transcript); + expect(state.lastActivity).toBe("2026-05-14T12:00:10.000Z"); + expect(state.turnCount).toBe(2); + expect(state.lastToolUse).toBe("Bash"); + expect(state.contextTokens).toBe(2000); // 200 + 1800 from the last assistant turn + }); + + test("tolerates a corrupt line without throwing", () => { + const transcript = join(dir, "corrupt.jsonl"); + writeFileSync( + transcript, + [ + "{ not json", + JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, + timestamp: "2026-05-14T12:00:01.000Z", + }), + ].join("\n"), + ); + const state = claudeAdapter.readTranscriptState(transcript); + expect(state.turnCount).toBe(1); + expect(state.lastActivity).toBe("2026-05-14T12:00:01.000Z"); + }); +}); + +function writeMiddleDir(): { cwd: string; middle: string; transcript: string } { + const cwd = join(dir, "worktree"); + const middle = join(cwd, ".middle"); + mkdirSync(middle, { recursive: true }); + const transcript = join(dir, "stop.jsonl"); + writeFileSync(transcript, ""); + return { cwd, middle, transcript }; +} + +describe("classifyStop", () => { + test("sentinelPresent → asked-question, with the worktree-anchored blocked.json path", () => { + const { cwd, transcript } = writeMiddleDir(); + const result = claudeAdapter.classifyStop({ + payload: { cwd }, + transcriptPath: transcript, + sentinelPresent: true, + worktree: cwd, + }); + expect(result.kind).toBe("asked-question"); + if (result.kind === "asked-question") { + expect(result.sentinelPath).toBe(join(cwd, ".middle", "blocked.json")); + } + }); + + test("usage-limit message in the transcript tail → rate-limited", () => { + const { cwd, transcript } = writeMiddleDir(); + writeFileSync( + transcript, + JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "text", text: "You've hit your usage limit. Resets at 2026-05-14T18:00:00Z." }, + ], + }, + timestamp: "2026-05-14T12:30:00.000Z", + }), + ); + const result = claudeAdapter.classifyStop({ + payload: { cwd }, + transcriptPath: transcript, + sentinelPresent: false, + worktree: cwd, + }); + expect(result.kind).toBe("rate-limited"); + if (result.kind === "rate-limited") { + expect(result.resetAt).toBe("2026-05-14T18:00:00Z"); + } + }); + + test("done.json sentinel → done", () => { + const { cwd, middle, transcript } = writeMiddleDir(); + writeFileSync(join(middle, "done.json"), JSON.stringify({ pr: 73 })); + const result = claudeAdapter.classifyStop({ + payload: { cwd }, + transcriptPath: transcript, + sentinelPresent: false, + worktree: cwd, + }); + expect(result.kind).toBe("done"); + }); + + test("failed.json sentinel → failed, carrying its reason", () => { + const { cwd, middle, transcript } = writeMiddleDir(); + writeFileSync(join(middle, "failed.json"), JSON.stringify({ reason: "3 consecutive denials" })); + const result = claudeAdapter.classifyStop({ + payload: { cwd }, + transcriptPath: transcript, + sentinelPresent: false, + worktree: cwd, + }); + expect(result.kind).toBe("failed"); + if (result.kind === "failed") { + expect(result.reason).toBe("3 consecutive denials"); + } + }); + + test("sentinels are found even when payload.cwd is a worktree subdirectory", () => { + // Regression: agent did `cd src/` before stopping. `done.json` lives at the + // worktree root and must still resolve `done`, not `bare-stop`. + const { cwd: worktree, middle, transcript } = writeMiddleDir(); + writeFileSync(join(middle, "done.json"), JSON.stringify({ pr: 73 })); + const subdir = join(worktree, "src"); + mkdirSync(subdir); + const result = claudeAdapter.classifyStop({ + payload: { cwd: subdir }, + transcriptPath: transcript, + sentinelPresent: false, + worktree, + }); + expect(result.kind).toBe("done"); + }); + + test("nothing notable → bare-stop", () => { + const { cwd, transcript } = writeMiddleDir(); + const result = claudeAdapter.classifyStop({ + payload: { cwd }, + transcriptPath: transcript, + sentinelPresent: false, + worktree: cwd, + }); + expect(result.kind).toBe("bare-stop"); + }); +}); + +describe("installHooks", () => { + async function installInto(worktree: string): Promise { + await claudeAdapter.installHooks({ + worktree, + hookScriptPath: ".middle/hooks/hook.sh", + dispatcherUrl: "http://127.0.0.1:8822", + sessionName: "middle-6", + sessionToken: "tok", + epicNumber: 6, + }); + } + + test("registers both SessionStart and Stop hooks in .claude/settings.json", async () => { + const worktree = join(dir, "wt-events"); + mkdirSync(worktree, { recursive: true }); + await installInto(worktree); + const settings = JSON.parse( + await Bun.file(join(worktree, ".claude", "settings.json")).text(), + ) as { hooks: Record }; + expect(Object.keys(settings.hooks).sort()).toEqual(["SessionStart", "Stop"]); + // absolute, quoted path: "/.middle/hooks/hook.sh" + expect(JSON.stringify(settings.hooks.SessionStart)).toContain( + `${join(worktree, ".middle/hooks/hook.sh")}\\" session.started`, + ); + expect(JSON.stringify(settings.hooks.Stop)).toContain( + `${join(worktree, ".middle/hooks/hook.sh")}\\" agent.stopped`, + ); + }); + + test("writes an executable hook.sh into the worktree at the configured path", async () => { + const worktree = join(dir, "wt-script"); + mkdirSync(worktree, { recursive: true }); + await installInto(worktree); + const scriptPath = join(worktree, ".middle/hooks/hook.sh"); + const contents = await Bun.file(scriptPath).text(); + expect(contents).toStartWith("#!/bin/sh"); + expect(contents).toContain("curl"); + expect(contents).toContain("${MIDDLE_DISPATCHER_URL}"); + const mode = (await import("node:fs/promises")).stat(scriptPath); + expect(((await mode).mode & 0o111) !== 0).toBe(true); // some exec bit set + }); +}); + +describe("detectBypassPrompt", () => { + test("matches representative bypass-mode confirmation strings", () => { + expect(detectBypassPrompt("You are entering Bypass Permissions mode")).toBe(true); + expect(detectBypassPrompt("skip permissions checks?")).toBe(true); + expect(detectBypassPrompt("Running --dangerously-skip-permissions")).toBe(true); + }); + + test("does not match normal Claude pane content", () => { + expect(detectBypassPrompt("> ")).toBe(false); + expect(detectBypassPrompt("Welcome to Claude Code 2.1.142")).toBe(false); + expect(detectBypassPrompt("")).toBe(false); + }); +}); + +describe("detectNeedsLogin", () => { + test("matches representative not-authenticated messages", () => { + expect(detectNeedsLogin("Please run claude login to authenticate")).toBe(true); + expect(detectNeedsLogin("You are not logged in")).toBe(true); + expect(detectNeedsLogin("Not authenticated — please sign in")).toBe(true); + expect(detectNeedsLogin("Welcome to Claude Code — please sign in to continue")).toBe(true); + expect(detectNeedsLogin("Invalid API key")).toBe(true); + }); + + test("does not match the bypass prompt or normal pane content", () => { + expect(detectNeedsLogin("Bypass Permissions mode")).toBe(false); + expect(detectNeedsLogin("> ")).toBe(false); + expect(detectNeedsLogin("Loaded skill: implementing-github-issues")).toBe(false); + expect(detectNeedsLogin("")).toBe(false); + }); +}); + +describe("enterAutoMode", () => { + test("returns immediately when the target session does not exist", async () => { + // capture-pane against a missing session fails → enterAutoMode bails fast, + // never blocking the workflow when tmux state is unexpectedly gone + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const start = Date.now(); + try { + await expect( + claudeAdapter.enterAutoMode({ sessionName: "middle-does-not-exist" }), + ).resolves.toBeUndefined(); + } finally { + errSpy.mockRestore(); + } + expect(Date.now() - start).toBeLessThan(2000); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 927c8953..d6d74367 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,6 +8,7 @@ "mm": "src/index.ts" }, "dependencies": { + "@middle/adapter-claude": "workspace:*", "@middle/core": "workspace:*", "@middle/dispatcher": "workspace:*", "commander": "^14.0.3" diff --git a/packages/cli/src/commands/dispatch.ts b/packages/cli/src/commands/dispatch.ts new file mode 100644 index 00000000..92981b93 --- /dev/null +++ b/packages/cli/src/commands/dispatch.ts @@ -0,0 +1,96 @@ +import { existsSync } from "node:fs"; +import { basename, join } from "node:path"; +import { claudeAdapter } from "@middle/adapter-claude"; +import type { AgentAdapter } from "@middle/core"; +import { loadConfig } from "@middle/core"; +import { dispatchEpic } from "@middle/dispatcher/src/dispatch.ts"; + +export type DispatchOptions = { + /** Override the global config path (defaults to `~/.middle/config.toml`). */ + configPath?: string; +}; + +/** Derive an `owner/name` slug from the repo's `origin` remote, falling back to its directory name. */ +async function deriveRepoSlug(repoPath: string): Promise { + const proc = Bun.spawn(["git", "-C", repoPath, "remote", "get-url", "origin"], { + stdout: "pipe", + stderr: "ignore", + }); + const url = (await new Response(proc.stdout).text()).trim(); + if ((await proc.exited) === 0 && url) { + const match = /[:/]([^/]+\/[^/]+?)(?:\.git)?$/.exec(url); + if (match) return match[1]!; + } + return basename(repoPath); +} + +/** Phase 1 adapter registry — only `claude` is implemented. */ +function getAdapter(name: string): AgentAdapter { + if (name !== "claude") throw new Error(`unknown adapter: ${name}`); + return claudeAdapter; +} + +/** + * `mm dispatch ` — force-dispatch an Epic (or standalone issue) + * through the Phase 1 `implementation` workflow: spawn the agent in tmux, drive + * it, observe the `Stop`, finalize, and clean up the worktree. Returns a process + * exit code: 0 when the workflow completes, 1 otherwise. + */ +export async function runDispatch( + repoPath: string, + epicArg: string, + opts: DispatchOptions = {}, +): Promise { + const epicNumber = Number(epicArg); + if (!Number.isInteger(epicNumber) || epicNumber < 1) { + console.error(`mm dispatch: invalid epic number "${epicArg}"`); + return 1; + } + if (!existsSync(join(repoPath, ".git"))) { + console.error(`mm dispatch: "${repoPath}" is not a git repository`); + return 1; + } + + let config: ReturnType; + try { + config = loadConfig({ globalPath: opts.configPath }); + } catch (error) { + console.error(`mm dispatch: failed to load config — ${(error as Error).message}`); + return 1; + } + + const adapterName = config.global.defaultAdapter; + if (adapterName !== "claude") { + console.error( + `mm dispatch: only the 'claude' adapter is available in Phase 1 (config asks for "${adapterName}")`, + ); + return 1; + } + + const repoSlug = await deriveRepoSlug(repoPath); + let result: Awaited>; + try { + result = await dispatchEpic({ + repoPath, + repoSlug, + epicNumber, + adapterName, + getAdapter, + dbPath: config.global.dbPath, + worktreeRoot: config.global.worktreeRoot, + dispatcherPort: config.global.dispatcherPort, + }); + } catch (error) { + // Most likely: EADDRINUSE when `mm start` is already holding the dispatcher + // port, or a SQLite open/migration failure. Surface a friendly message in + // the same `mm dispatch: …` style as the other failure paths rather than + // letting commander dump the raw JS error. + console.error(`mm dispatch: failed — ${(error as Error).message}`); + return 1; + } + + console.log( + `mm dispatch: ${repoSlug} epic #${epicNumber} → workflow ${result.workflowId} settled — ${result.state}`, + ); + return result.state === "completed" ? 0 : 1; +} diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 00000000..c42022a8 --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,112 @@ +import { + getTmuxVersion, + MIN_TMUX_VERSION, + tmuxVersionAtLeast, +} from "@middle/dispatcher/src/tmux.ts"; + +type CheckStatus = "pass" | "warn" | "fail"; +type Check = { name: string; status: CheckStatus; detail: string }; + +const STATUS_ICON: Record = { pass: "✓", warn: "!", fail: "✗" }; + +async function runCommand( + argv: string[], +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { stdout, stderr, exitCode: await proc.exited }; +} + +async function checkTmux(): Promise { + const version = await getTmuxVersion(); + if (!version) { + return { name: "tmux", status: "fail", detail: "not installed (not on PATH)" }; + } + if (!tmuxVersionAtLeast(version, MIN_TMUX_VERSION)) { + return { + name: "tmux", + status: "warn", + detail: `${version.raw} — extended-keys-format needs ≥ ${MIN_TMUX_VERSION.raw}; agent interactivity degraded`, + }; + } + return { name: "tmux", status: "pass", detail: version.raw }; +} + +async function checkBinary( + name: string, + argv: string[], + parseDetail: (stdout: string) => string = (out) => out.split("\n")[0]!.trim(), +): Promise { + if (!Bun.which(argv[0]!)) { + return { name, status: "fail", detail: `${argv[0]} not installed (not on PATH)` }; + } + const result = await runCommand(argv); + if (result.exitCode !== 0) { + return { + name, + status: "fail", + detail: `\`${argv.join(" ")}\` exited ${result.exitCode}: ${result.stderr.trim()}`, + }; + } + return { name, status: "pass", detail: parseDetail(result.stdout) }; +} + +async function checkGhAuth(): Promise { + if (!Bun.which("gh")) { + return { name: "gh auth", status: "fail", detail: "gh not installed" }; + } + const result = await runCommand(["gh", "auth", "status"]); + if (result.exitCode !== 0) { + return { + name: "gh auth", + status: "fail", + detail: "not authenticated — run `gh auth login`", + }; + } + // gh auth status writes its summary to stderr + const summary = (result.stderr.trim() || result.stdout.trim()) + .split("\n") + .map((line) => line.trim()) + .find((line) => line.startsWith("✓") || line.includes("Logged in")) ?? "authenticated"; + return { name: "gh auth", status: "pass", detail: summary }; +} + +/** + * `mm doctor` — run a system check for every external tool the dispatcher + * shells out to: `bun`, `tmux` (≥ 3.5), `claude`, `git`, `gh`, and `gh` auth. + * Exits 0 when no check fails; 1 if anything is missing or broken. Warnings + * (degraded but functional) do not fail the run. + */ +export async function runDoctor(): Promise { + const checks: Check[] = [ + await checkBinary("bun", ["bun", "--version"]), + await checkTmux(), + await checkBinary("claude", ["claude", "--version"]), + await checkBinary("git", ["git", "--version"]), + await checkBinary("gh", ["gh", "--version"]), + await checkGhAuth(), + ]; + + console.log("middle — system check\n"); + for (const c of checks) { + console.log(` ${STATUS_ICON[c.status]} ${c.name.padEnd(9)} ${c.detail}`); + } + console.log(""); + + const fails = checks.filter((c) => c.status === "fail"); + const warns = checks.filter((c) => c.status === "warn"); + + if (fails.length > 0) { + console.log(`${fails.length} blocking issue(s) — fix before running \`mm dispatch\`.`); + return 1; + } + if (warns.length > 0) { + console.log(`${warns.length} warning(s) — mm will run, but interactive UX is degraded.`); + return 0; + } + console.log("all checks pass."); + return 0; +} diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts new file mode 100644 index 00000000..782cd878 --- /dev/null +++ b/packages/cli/src/commands/start.ts @@ -0,0 +1,64 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { defaultPidFile } from "../paths.ts"; + +export type StartOptions = { + /** Override the pid-file path (defaults to `~/.middle/dispatcher.pid`). */ + pidFile?: string; + /** Override the dispatcher entrypoint (defaults to `@middle/dispatcher`'s main). */ + entrypoint?: string; +}; + +/** Whether a process with this pid is currently alive. */ +function isAlive(pid: number): boolean { + // Reject pid <= 0: process.kill(0, …) signals the caller's whole process + // group and negative pids target other groups — never what we mean here. + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function resolveDispatcherEntrypoint(): string { + return Bun.resolveSync("@middle/dispatcher", import.meta.dir); +} + +/** + * `mm start` — spawn the long-running dispatcher process (hook server + bunqueue + * engine), detached, and record its pid for `mm stop`. A stale pid file (the + * recorded process is gone) is cleared and a fresh dispatcher is started. + * Returns a process exit code. + */ +export function runStart(opts: StartOptions = {}): number { + const pidFile = opts.pidFile ?? defaultPidFile(); + + if (existsSync(pidFile)) { + const existing = Number(readFileSync(pidFile, "utf8").trim()); + if (Number.isInteger(existing) && isAlive(existing)) { + console.error(`mm start: dispatcher already running (pid ${existing})`); + return 1; + } + rmSync(pidFile, { force: true }); // stale — the recorded process is gone + } + + const entrypoint = opts.entrypoint ?? resolveDispatcherEntrypoint(); + const proc = Bun.spawn(["bun", entrypoint], { + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }); + + // Write the pid file BEFORE unref-ing. If the write throws (disk full, + // permissions), the exception propagates while Bun still tracks the child — + // we never end up with a detached, orphaned dispatcher that `mm stop` can't + // find and that a second `mm start` would duplicate. + mkdirSync(dirname(pidFile), { recursive: true }); + writeFileSync(pidFile, String(proc.pid)); + proc.unref(); + + console.log(`mm start: dispatcher started (pid ${proc.pid})`); + return 0; +} diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts new file mode 100644 index 00000000..3e9d3127 --- /dev/null +++ b/packages/cli/src/commands/status.ts @@ -0,0 +1,73 @@ +import { existsSync } from "node:fs"; +import { loadConfig } from "@middle/core"; +import { openDb } from "@middle/dispatcher/src/db.ts"; + +export type StatusOptions = { + /** Override the global config path (defaults to `~/.middle/config.toml`). */ + configPath?: string; + /** Override the database path (defaults to the config's `db_path`). */ + dbPath?: string; +}; + +type StateCount = { repo: string; state: string; n: number }; + +/** + * `mm status` — a one-screen summary of every repo's workflow states, read + * straight from SQLite. Returns a process exit code: 0 on success, 1 on error. + */ +export function runStatus(opts: StatusOptions = {}): number { + let dbPath: string; + try { + dbPath = opts.dbPath ?? loadConfig({ globalPath: opts.configPath }).global.dbPath; + } catch (error) { + console.error(`mm status: failed to load config — ${(error as Error).message}`); + return 1; + } + + if (!existsSync(dbPath)) { + console.log("middle: no dispatcher database yet — nothing in flight."); + return 0; + } + + const db = openDb(dbPath); + try { + let rows: StateCount[]; + try { + rows = db + .query( + `SELECT repo, state, count(*) AS n + FROM workflows + GROUP BY repo, state + ORDER BY repo, state`, + ) + .all() as StateCount[]; + } catch (error) { + const message = (error as Error).message ?? ""; + if (/no such table/i.test(message)) { + console.log("middle: database has no workflows table yet — nothing in flight."); + return 0; + } + // Corruption / lock / permission errors are real — surface them. + console.error(`mm status: failed to read workflows — ${message}`); + return 1; + } + + if (rows.length === 0) { + console.log("middle: no workflows recorded."); + return 0; + } + + console.log("middle — workflow status"); + let currentRepo = ""; + for (const row of rows) { + if (row.repo !== currentRepo) { + console.log(`\n ${row.repo}`); + currentRepo = row.repo; + } + console.log(` ${row.state.padEnd(14)} ${row.n}`); + } + return 0; + } finally { + db.close(); + } +} diff --git a/packages/cli/src/commands/stop.ts b/packages/cli/src/commands/stop.ts new file mode 100644 index 00000000..8269e4f1 --- /dev/null +++ b/packages/cli/src/commands/stop.ts @@ -0,0 +1,50 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { defaultPidFile } from "../paths.ts"; + +export type StopOptions = { + /** Override the pid-file path (defaults to `~/.middle/dispatcher.pid`). */ + pidFile?: string; +}; + +/** + * `mm stop` — read the recorded dispatcher pid, SIGTERM it, and clear the pid + * file. A missing pid file means nothing is running (exit 1); a pid that is + * already gone is treated as a clean stop. Returns a process exit code. + */ +export function runStop(opts: StopOptions = {}): number { + const pidFile = opts.pidFile ?? defaultPidFile(); + + if (!existsSync(pidFile)) { + console.error("mm stop: dispatcher not running (no pid file)"); + return 1; + } + + const pid = Number(readFileSync(pidFile, "utf8").trim()); + + // Reject pid <= 0: process.kill(0, …) / negative pids target process groups, + // not the single dispatcher. A malformed pid file is cleared as junk. + if (!Number.isInteger(pid) || pid <= 0) { + rmSync(pidFile, { force: true }); + console.error("mm stop: pid file was malformed — cleared it"); + return 1; + } + + try { + process.kill(pid, "SIGTERM"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") { + // No such process — already gone. Safe to clear the stale pid file. + rmSync(pidFile, { force: true }); + console.log(`mm stop: dispatcher (pid ${pid}) was not running — cleared pid file`); + return 0; + } + // EPERM or anything else: the process may still be alive. Do NOT clear the + // pid file (that would orphan a live dispatcher from `mm stop`'s view). + console.error(`mm stop: failed to signal pid ${pid} — ${(error as Error).message}`); + return 1; + } + + rmSync(pidFile, { force: true }); + console.log(`mm stop: dispatcher stopped (pid ${pid})`); + return 0; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e3b41f3b..849a4aeb 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,3 +1,53 @@ -// @middle/cli — the `mm` binary, commander wiring + bootstrap-assets. -// Source lands in build-spec Phase 1+. -export {}; +#!/usr/bin/env bun +// @middle/cli — the `mm` binary. commander wiring over the command functions. +import { Command } from "commander"; +import { runDispatch } from "./commands/dispatch.ts"; +import { runDoctor } from "./commands/doctor.ts"; +import { runStart } from "./commands/start.ts"; +import { runStatus } from "./commands/status.ts"; +import { runStop } from "./commands/stop.ts"; + +const VERSION = "0.0.0"; + +const program = new Command(); +program + .name("mm") + .description("middle-management — autonomous GitHub issue dispatch") + .version(VERSION); + +program + .command("start") + .description("Start the dispatcher process (hook server + workflow engine)") + .action(() => process.exit(runStart())); + +program + .command("stop") + .description("Stop the dispatcher process") + .action(() => process.exit(runStop())); + +program + .command("status") + .description("One-screen summary of repos and workflow states") + .action(() => process.exit(runStatus())); + +program + .command("doctor") + .description("Check tmux/claude/git/gh preconditions for `mm dispatch`") + .action(async () => process.exit(await runDoctor())); + +program + .command("dispatch") + .description("Force-dispatch an Epic (or standalone issue) through the implementation workflow") + .argument("", "path to the local repo checkout") + .argument("", "Epic or standalone issue number") + .action(async (repo: string, epic: string) => process.exit(await runDispatch(repo, epic))); + +program + .command("version") + .description("Print the mm version") + .action(() => { + console.log(VERSION); + process.exit(0); + }); + +program.parseAsync(process.argv); diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts new file mode 100644 index 00000000..80c3e4f1 --- /dev/null +++ b/packages/cli/src/paths.ts @@ -0,0 +1,12 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** middle's per-user home — `~/.middle`. */ +export function middleHome(): string { + return join(homedir(), ".middle"); +} + +/** Where `mm start` records the dispatcher process id for `mm stop` to find. */ +export function defaultPidFile(): string { + return join(middleHome(), "dispatcher.pid"); +} diff --git a/packages/cli/test/dispatch.test.ts b/packages/cli/test/dispatch.test.ts new file mode 100644 index 00000000..1c48aa34 --- /dev/null +++ b/packages/cli/test/dispatch.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runDispatch } from "../src/commands/dispatch.ts"; + +type BunServer = ReturnType; + +// The full `mm dispatch` happy path spawns a real Claude session in tmux and is +// verified manually (see the reviewer's brief). These tests cover the input +// validation that fails fast, before any process is spawned. + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "middle-cli-dispatch-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function silenceError(): () => void { + const err = spyOn(console, "error").mockImplementation(() => {}); + return () => err.mockRestore(); +} + +describe("runDispatch — input validation", () => { + test("rejects a non-integer epic number", async () => { + const restore = silenceError(); + try { + expect(await runDispatch(dir, "not-a-number")).toBe(1); + } finally { + restore(); + } + }); + + test("rejects an epic number below 1", async () => { + const restore = silenceError(); + try { + expect(await runDispatch(dir, "0")).toBe(1); + } finally { + restore(); + } + }); + + test("rejects a path that is not a git repository", async () => { + const restore = silenceError(); + try { + expect(await runDispatch(dir, "6")).toBe(1); + } finally { + restore(); + } + }); +}); + +describe("runDispatch — dispatchEpic failure path", () => { + test("surfaces a friendly 'mm dispatch: failed —' message and returns 1 on EADDRINUSE", async () => { + // make `repoPath` a real git repo so input validation passes + const repoPath = join(realpathSync(dir), "repo"); + { + // Deterministic identity via env (not `-c`) so the fixture commit doesn't + // depend on host-level git config. + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "middle-test", + GIT_AUTHOR_EMAIL: "middle-test@example.invalid", + GIT_COMMITTER_NAME: "middle-test", + GIT_COMMITTER_EMAIL: "middle-test@example.invalid", + }; + const init = Bun.spawn(["git", "init", repoPath], { stdout: "ignore", stderr: "ignore" }); + expect(await init.exited).toBe(0); + const commit = Bun.spawn( + ["git", "-C", repoPath, "commit", "--allow-empty", "-m", "init"], + { stdout: "ignore", stderr: "ignore", env: gitEnv }, + ); + expect(await commit.exited).toBe(0); + } + + // bind the port (on the same 127.0.0.1 interface HookServer uses) so + // dispatchEpic's hookServer.start() reliably throws EADDRINUSE + const blocker: BunServer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => new Response("ok"), + }); + const configPath = join(dir, "config.toml"); + writeFileSync( + configPath, + [ + "[global]", + `dispatcher_port = ${blocker.port}`, + `db_path = "${join(dir, "db.sqlite3")}"`, + `worktree_root = "${join(dir, "worktrees")}"`, + `log_dir = "${join(dir, "logs")}"`, + "", + ].join("\n"), + ); + + const errLines: string[] = []; + const errSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errLines.push(args.join(" ")); + }); + try { + const code = await runDispatch(repoPath, "6", { configPath }); + expect(code).toBe(1); + expect(errLines.join("\n")).toContain("mm dispatch: failed"); + } finally { + errSpy.mockRestore(); + blocker.stop(true); + } + }); +}); diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts new file mode 100644 index 00000000..d9b02179 --- /dev/null +++ b/packages/cli/test/doctor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { runDoctor } from "../src/commands/doctor.ts"; + +// runDoctor shells out to bun/tmux/claude/git/gh — these all exist on the +// machine middle is built for, so the happy path is verifiable. We don't fake +// out missing binaries here (that's interactive operator territory); the unit +// behavior of the version checks is covered by the tmux helpers' unit tests. + +describe("runDoctor — happy path", () => { + test("returns 0 and prints a check per tool when the toolchain is healthy", async () => { + const lines: string[] = []; + const spy = spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(args.join(" ")); + }); + let code: number; + try { + code = await runDoctor(); + } finally { + spy.mockRestore(); + } + expect(code).toBe(0); + + const output = lines.join("\n"); + expect(output).toContain("middle — system check"); + for (const name of ["bun", "tmux", "claude", "git", "gh", "gh auth"]) { + expect(output).toContain(name); + } + }); +}); diff --git a/packages/cli/test/start-stop.test.ts b/packages/cli/test/start-stop.test.ts new file mode 100644 index 00000000..60a19702 --- /dev/null +++ b/packages/cli/test/start-stop.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runStart } from "../src/commands/start.ts"; +import { runStop } from "../src/commands/stop.ts"; + +let dir: string; +let pidFile: string; +let entrypoint: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "middle-cli-startstop-")); + pidFile = join(dir, "dispatcher.pid"); + // a stand-in dispatcher: an idle process that simply stays alive + entrypoint = join(dir, "fake-dispatcher.ts"); + writeFileSync(entrypoint, "await new Promise(() => {});\n"); +}); + +afterEach(() => { + if (existsSync(pidFile)) { + const pid = Number(readFileSync(pidFile, "utf8").trim()); + if (Number.isInteger(pid)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone + } + } + } + rmSync(dir, { recursive: true, force: true }); +}); + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function readPid(): number { + return Number(readFileSync(pidFile, "utf8").trim()); +} + +function silence(): () => void { + const log = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + return () => { + log.mockRestore(); + err.mockRestore(); + }; +} + +describe("runStart / runStop lifecycle", () => { + test("start spawns a detached process and records its pid; stop kills it", async () => { + const restore = silence(); + try { + expect(runStart({ pidFile, entrypoint })).toBe(0); + expect(existsSync(pidFile)).toBe(true); + const pid = readPid(); + expect(Number.isInteger(pid)).toBe(true); + await Bun.sleep(150); + expect(isAlive(pid)).toBe(true); + + expect(runStop({ pidFile })).toBe(0); + expect(existsSync(pidFile)).toBe(false); + await Bun.sleep(150); + expect(isAlive(pid)).toBe(false); + } finally { + restore(); + } + }); + + test("start refuses when a live dispatcher is already recorded", async () => { + const restore = silence(); + try { + expect(runStart({ pidFile, entrypoint })).toBe(0); + await Bun.sleep(100); + expect(runStart({ pidFile, entrypoint })).toBe(1); // already running + expect(runStop({ pidFile })).toBe(0); + } finally { + restore(); + } + }); + + test("start clears a stale pid file and launches fresh", async () => { + writeFileSync(pidFile, "999999999"); // a pid that is not alive + const restore = silence(); + try { + expect(runStart({ pidFile, entrypoint })).toBe(0); + expect(existsSync(pidFile)).toBe(true); + expect(readPid()).not.toBe(999999999); + expect(runStop({ pidFile })).toBe(0); + } finally { + restore(); + } + }); + + test("stop exits non-zero when no dispatcher is running", () => { + const restore = silence(); + try { + expect(runStop({ pidFile })).toBe(1); + } finally { + restore(); + } + }); +}); diff --git a/packages/cli/test/status.test.ts b/packages/cli/test/status.test.ts new file mode 100644 index 00000000..7dc7276b --- /dev/null +++ b/packages/cli/test/status.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openAndMigrate } from "@middle/dispatcher/src/db.ts"; +import { createWorkflowRecord, updateWorkflow } from "@middle/dispatcher/src/workflow-record.ts"; +import { runStatus } from "../src/commands/status.ts"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "middle-cli-status-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +/** Capture everything written to console.log while running `fn`. */ +function captureLog(fn: () => number): { code: number; lines: string[] } { + const lines: string[] = []; + const spy = spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(args.join(" ")); + }); + try { + return { code: fn(), lines }; + } finally { + spy.mockRestore(); + } +} + +function writeConfig(dbPath: string): string { + const path = join(dir, "config.toml"); + writeFileSync(path, `[global]\ndb_path = "${dbPath}"\n`); + return path; +} + +describe("runStatus", () => { + test("prints a per-repo, per-state summary of recorded workflows", () => { + const dbPath = join(dir, "db.sqlite3"); + const db = openAndMigrate(dbPath); + createWorkflowRecord(db, { + id: "w1", + kind: "implementation", + repo: "thejustinwalsh/middle", + epicNumber: 6, + adapter: "claude", + }); + createWorkflowRecord(db, { + id: "w2", + kind: "implementation", + repo: "thejustinwalsh/middle", + epicNumber: 7, + adapter: "claude", + }); + updateWorkflow(db, "w2", { state: "completed" }); + db.close(); + + const { code, lines } = captureLog(() => runStatus({ configPath: writeConfig(dbPath) })); + expect(code).toBe(0); + const output = lines.join("\n"); + expect(output).toContain("thejustinwalsh/middle"); + expect(output).toContain("pending"); + expect(output).toContain("completed"); + }); + + test("reports cleanly when the database does not exist yet", () => { + const { code, lines } = captureLog(() => + runStatus({ configPath: writeConfig(join(dir, "absent.sqlite3")) }), + ); + expect(code).toBe(0); + expect(lines.join("\n")).toContain("no dispatcher database"); + }); + + test("reports cleanly when the database has no workflows", () => { + const dbPath = join(dir, "empty.sqlite3"); + openAndMigrate(dbPath).close(); + const { code, lines } = captureLog(() => runStatus({ configPath: writeConfig(dbPath) })); + expect(code).toBe(0); + expect(lines.join("\n")).toContain("no workflows recorded"); + }); + + test("exits non-zero when the config file is malformed", () => { + const badConfig = join(dir, "bad.toml"); + writeFileSync(badConfig, "this is = = not valid toml ]["); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(runStatus({ configPath: badConfig })).toBe(1); + } finally { + errSpy.mockRestore(); + } + }); +}); diff --git a/packages/core/src/adapter.ts b/packages/core/src/adapter.ts new file mode 100644 index 00000000..ef591398 --- /dev/null +++ b/packages/core/src/adapter.ts @@ -0,0 +1,96 @@ +import type { HookPayload, NormalizedEvent } from "./events.ts"; + +/** + * The single interface every CLI agent sits behind. middle dispatches every + * agent as an interactive CLI session inside tmux — there is no headless mode. + * The adapter abstracts the per-CLI launch command, prompt-delivery text, how + * to enter auto mode, how to locate and read the on-disk transcript, and how to + * classify a turn boundary. Source of truth: build spec → "Adapter interface". + */ +export interface AgentAdapter { + readonly name: string; // 'claude' | 'codex' | ... + + /** Write hook config + any per-CLI setup into the worktree. */ + installHooks(opts: InstallHookOpts): Promise; + + /** Build the INTERACTIVE launch command. tmux runs this; it takes no prompt. */ + buildLaunchCommand(opts: LaunchOpts): { + argv: string[]; + env: Record; + }; + + /** + * The literal text to send-keys into the session to start or continue the + * agent — includes the `@`-reference to the on-disk prompt file. + */ + buildPromptText(opts: { + promptFile: string; // path, relative to the worktree + kind: "initial" | "resume" | "answer"; + }): string; + + /** Put the ready session into auto mode — a launch flag or post-ready keystrokes. */ + enterAutoMode(opts: { sessionName: string }): Promise; + + /** The normalized event that signals the CLI is ready for input. */ + readonly readyEvent: NormalizedEvent; + + /** Locate the on-disk session transcript from the ready/session hook payload. */ + resolveTranscriptPath(payload: HookPayload): string; + + /** Read activity, state, and context/token usage from the transcript. */ + readTranscriptState(transcriptPath: string): TranscriptState; + + /** + * Classify the agent's state at a Stop hook. `worktree` is the workstream's + * root (where `.middle/` lives) — sentinel files are resolved from here, + * never from `payload.cwd`, which may be a subdirectory the agent has + * `cd`'d into. + */ + classifyStop(opts: { + payload: HookPayload; + transcriptPath: string; + sentinelPresent: boolean; + worktree: string; + }): StopClassification; + + /** Optional: detect a rate-limit message in a Stop-hook payload or transcript. */ + detectRateLimit?(opts: { + payload: HookPayload; + transcriptPath: string; + }): RateLimitDetection | null; +} + +export type InstallHookOpts = { + worktree: string; + hookScriptPath: string; // .middle/hooks/hook.sh in the worktree + dispatcherUrl: string; // http://127.0.0.1:8822 + sessionName: string; + sessionToken: string; // HMAC token for hook auth + epicNumber: number; // the Epic (or standalone issue) being dispatched +}; + +export type LaunchOpts = { + worktree: string; + sessionName: string; + sessionToken: string; + envOverrides?: Record; +}; + +export type TranscriptState = { + lastActivity: string; // ISO + contextTokens: number; // for the context-overflow monitor + turnCount: number; + lastToolUse: string | null; +}; + +export type StopClassification = + | { kind: "done" } // agent marked the PR ready + | { kind: "asked-question"; sentinelPath: string } + | { kind: "rate-limited"; resetAt: string /* ISO */ } + | { kind: "bare-stop" } // stopped, no sentinel, not done + | { kind: "failed"; reason: string }; + +export type RateLimitDetection = { + resetAt: string; + source: "stop-hook" | "transcript"; +}; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index dbdf2142..e01ca86c 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,7 +1,252 @@ -// Minimal RepoConfig — only the fields Phase 0's state-issue validate() needs. -// The full config.toml shape + loader (global + per-repo TOML merge) lands in -// build-spec Phase 1. +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { parse as parseToml } from "smol-toml"; + +/** + * Minimal config consumed by `@middle/state-issue`'s `validate()` — just the + * configured adapter names. Kept as its own narrow type so the state-issue + * package does not depend on the full `MiddleConfig` shape. + */ export type RepoConfig = { /** Configured adapter names, e.g. ["claude", "codex"]. */ adapters: string[]; }; + +export type AdapterConfig = { + enabled: boolean; + binary: string; + /** Claude only. */ + permissionMode?: string; + /** Codex only. */ + sandbox?: string; + /** Codex only. */ + approvalPolicy?: string; + extraArgs: string[]; +}; + +export type GlobalSettings = { + dispatcherPort: number; + maxConcurrent: number; + defaultAdapter: string; + logDir: string; + worktreeRoot: string; + dbPath: string; +}; + +export type DashboardSettings = { + windowed: boolean; + theme: string; +}; + +export type RepoSettings = { + owner: string; + name: string; + defaultBranch: string; + prMode: string; +}; + +export type LimitsSettings = { + maxConcurrent: number; + maxConcurrentPerAdapter: Record; + complexityCeiling: number; +}; + +export type RecommenderSettings = { + enabled: boolean; + intervalMinutes: number; + adapter: string; + autoDispatch: boolean; +}; + +export type StateIssueSettings = { + number: number; + label: string; +}; + +export type BootstrapSettings = { + version: number; + installedAt: string; +}; + +/** + * The merged result of the global and per-repo config files. The global-derived + * sections are always present (documented defaults fill any gap); the per-repo + * sections are present only when a per-repo config file was loaded. + */ +export type MiddleConfig = { + global: GlobalSettings; + adapters: Record; + dashboard: DashboardSettings; + repo?: RepoSettings; + limits?: LimitsSettings; + recommender?: RecommenderSettings; + stateIssue?: StateIssueSettings; + bootstrap?: BootstrapSettings; +}; + +export type LoadConfigOptions = { + /** Path to the global config; defaults to `~/.middle/config.toml`. */ + globalPath?: string; + /** Path to the per-repo config (`/.middle/config.toml`); optional. */ + repoPath?: string; +}; + +type RawTable = Record; + +/** Documented defaults from the build spec's "Global config" block. */ +const GLOBAL_DEFAULTS: RawTable = { + global: { + dispatcher_port: 8822, + max_concurrent: 4, + default_adapter: "claude", + log_dir: "~/.middle/logs", + worktree_root: "~/.middle/worktrees", + db_path: "~/.middle/db.sqlite3", + }, + adapters: { + claude: { enabled: true, binary: "claude", permission_mode: "auto", extra_args: [] }, + codex: { + enabled: true, + binary: "codex", + sandbox: "workspace-write", + approval_policy: "never", + extra_args: [], + }, + }, + dashboard: { windowed: false, theme: "auto" }, +}; + +function isPlainObject(value: unknown): value is RawTable { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Recursively merge `override` onto `base`; arrays and scalars are replaced wholesale. */ +function deepMerge(base: RawTable, override: RawTable): RawTable { + const out: RawTable = { ...base }; + for (const [key, value] of Object.entries(override)) { + const existing = out[key]; + out[key] = + isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value; + } + return out; +} + +function readToml(path: string | undefined): RawTable { + if (!path || !existsSync(path)) return {}; + const parsed = parseToml(readFileSync(path, "utf8")); + return isPlainObject(parsed) ? parsed : {}; +} + +function expandTilde(value: string): string { + // Only bare `~` and `~/...` expand to the current home. Leave `~user/...` + // (another user's home) untouched rather than wrongly rewriting it. + if (value === "~") return homedir(); + if (value.startsWith("~/")) return join(homedir(), value.slice(2)); + return value; +} + +function asTable(value: unknown): RawTable { + return isPlainObject(value) ? value : {}; +} + +function mapGlobal(raw: RawTable): GlobalSettings { + const g = asTable(raw.global); + return { + dispatcherPort: g.dispatcher_port as number, + maxConcurrent: g.max_concurrent as number, + defaultAdapter: g.default_adapter as string, + logDir: expandTilde(g.log_dir as string), + worktreeRoot: expandTilde(g.worktree_root as string), + dbPath: expandTilde(g.db_path as string), + }; +} + +function mapAdapters(raw: RawTable): Record { + const adapters = asTable(raw.adapters); + const out: Record = {}; + for (const [name, value] of Object.entries(adapters)) { + const a = asTable(value); + out[name] = { + enabled: a.enabled as boolean, + binary: a.binary as string, + permissionMode: a.permission_mode as string | undefined, + sandbox: a.sandbox as string | undefined, + approvalPolicy: a.approval_policy as string | undefined, + extraArgs: (a.extra_args as string[] | undefined) ?? [], + }; + } + return out; +} + +function mapDashboard(raw: RawTable): DashboardSettings { + const d = asTable(raw.dashboard); + return { windowed: d.windowed as boolean, theme: d.theme as string }; +} + +function mapRepo(raw: RawTable): RepoSettings | undefined { + if (!isPlainObject(raw.repo)) return undefined; + const r = raw.repo; + return { + owner: r.owner as string, + name: r.name as string, + defaultBranch: r.default_branch as string, + prMode: r.pr_mode as string, + }; +} + +function mapLimits(raw: RawTable): LimitsSettings | undefined { + if (!isPlainObject(raw.limits)) return undefined; + const l = raw.limits; + return { + maxConcurrent: l.max_concurrent as number, + maxConcurrentPerAdapter: asTable(l.max_concurrent_per_adapter) as Record, + complexityCeiling: l.complexity_ceiling as number, + }; +} + +function mapRecommender(raw: RawTable): RecommenderSettings | undefined { + if (!isPlainObject(raw.recommender)) return undefined; + const r = raw.recommender; + return { + enabled: r.enabled as boolean, + intervalMinutes: r.interval_minutes as number, + adapter: r.adapter as string, + autoDispatch: r.auto_dispatch as boolean, + }; +} + +function mapStateIssue(raw: RawTable): StateIssueSettings | undefined { + if (!isPlainObject(raw.state_issue)) return undefined; + const s = raw.state_issue; + return { number: s.number as number, label: s.label as string }; +} + +function mapBootstrap(raw: RawTable): BootstrapSettings | undefined { + if (!isPlainObject(raw.bootstrap)) return undefined; + const b = raw.bootstrap; + return { version: b.version as number, installedAt: b.installed_at as string }; +} + +/** + * Load and merge the global and per-repo config files into one typed object. + * Per-repo values override global on any colliding key (deep merge). Missing + * 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 { + const globalPath = opts.globalPath ?? join(homedir(), ".middle", "config.toml"); + const globalRaw = deepMerge(GLOBAL_DEFAULTS, readToml(globalPath)); + const merged = deepMerge(globalRaw, readToml(opts.repoPath)); + + return { + global: mapGlobal(merged), + adapters: mapAdapters(merged), + dashboard: mapDashboard(merged), + repo: mapRepo(merged), + limits: mapLimits(merged), + recommender: mapRecommender(merged), + stateIssue: mapStateIssue(merged), + bootstrap: mapBootstrap(merged), + }; +} diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts new file mode 100644 index 00000000..bc12aab6 --- /dev/null +++ b/packages/core/src/events.ts @@ -0,0 +1,35 @@ +/** + * The normalized event vocabulary every adapter emits. The per-CLI hook script + * maps its native hook names onto these; the dispatcher only ever sees these. + * Source of truth: build spec → "Normalized event taxonomy". + */ +export type NormalizedEvent = + | "session.started" + | "turn.started" + | "tool.pre" + | "tool.post" + | "tool.failed" + | "agent.notification" + | "agent.stopped" + | "session.ended" + | "rate-limit.detected"; + +/** + * The JSON body a hook delivers. Shape is per-CLI, so this is an open record; + * the fields below are the ones middle relies on across adapters. The + * `SessionStart` payload is load-bearing — it carries `session_id` and + * `transcript_path`, which is how the dispatcher discovers the transcript. + */ +export type HookPayload = Record & { + session_id?: string; + transcript_path?: string; + cwd?: string; + hook_event_name?: string; +}; + +/** What the universal `hook.sh` POSTs to the dispatcher per fired hook. */ +export type HookEnvelope = { + type: NormalizedEvent; + sessionName: string; + payload: HookPayload; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 147be9ec..28854380 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,29 @@ // @middle/core — shared types, schemas, adapter interface, config loader. -// Most source lands in build-spec Phase 1+. The RepoConfig type needed by -// @middle/state-issue's validate() lives in ./config.ts. -export type { RepoConfig } from "./config.ts"; +export type { + RepoConfig, + AdapterConfig, + GlobalSettings, + DashboardSettings, + RepoSettings, + LimitsSettings, + RecommenderSettings, + StateIssueSettings, + BootstrapSettings, + MiddleConfig, + LoadConfigOptions, +} from "./config.ts"; +export { loadConfig } from "./config.ts"; + +export type { NormalizedEvent, HookPayload, HookEnvelope } from "./events.ts"; + +export type { + AgentAdapter, + InstallHookOpts, + LaunchOpts, + TranscriptState, + StopClassification, + RateLimitDetection, +} from "./adapter.ts"; + +export { capturePane, sendText, sendKeys, pollPaneFor } from "./tmux-tui.ts"; +export type { SendKeysOpts, PollPaneOpts } from "./tmux-tui.ts"; diff --git a/packages/core/src/tmux-tui.ts b/packages/core/src/tmux-tui.ts new file mode 100644 index 00000000..933bbb69 --- /dev/null +++ b/packages/core/src/tmux-tui.ts @@ -0,0 +1,113 @@ +/** + * Composable tmux TUI driving — `capturePane`, `sendText`, `sendKeys`, and the + * load-bearing primitive `pollPaneFor`. Adapters use these to dismiss boot + * prompts, detect login-required screens, wait for ready states, and any other + * "watch the pane, react to it" flow. The dispatcher's `tmux.ts` keeps the + * session-lifecycle ops (new/has/kill/status) on top of these. + */ + +type TmuxResult = { stdout: string; stderr: string; exitCode: number }; + +async function runTmux(args: string[]): Promise { + const proc = Bun.spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { stdout, stderr, exitCode: await proc.exited }; +} + +/** + * Capture the visible contents of a tmux pane. Returns null if the session is + * gone or tmux isn't available — callers treat null as "give up", not "throw". + */ +export async function capturePane(sessionName: string): Promise { + try { + const result = await runTmux(["capture-pane", "-p", "-t", sessionName]); + return result.exitCode === 0 ? result.stdout : null; + } catch { + return null; + } +} + +/** Send literal text (`send-keys -l`) so the content is never interpreted as key names. */ +export async function sendText(sessionName: string, text: string): Promise { + await runTmux(["send-keys", "-t", sessionName, "-l", text]); +} + +export type SendKeysOpts = { + /** + * Delay between successive keys, in ms. Default 0 — all keys in one + * `send-keys` call. Use a non-zero value (50-200ms) when the target TUI + * needs time to update its menu/selection between keys. + */ + delayBetweenMs?: number; +}; + +/** + * Send a sequence of tmux key names (e.g. ["Down", "Enter"], ["S-Tab"]). + * `delayBetweenMs` separates the keys into individual `send-keys` calls with + * a sleep between them — necessary when a single combined call races the + * receiving TUI's input handler. + */ +export async function sendKeys( + sessionName: string, + keys: string[], + opts: SendKeysOpts = {}, +): Promise { + if (keys.length === 0) return; + const delay = opts.delayBetweenMs ?? 0; + if (delay <= 0) { + await runTmux(["send-keys", "-t", sessionName, ...keys]); + return; + } + for (let i = 0; i < keys.length; i++) { + await runTmux(["send-keys", "-t", sessionName, keys[i]!]); + if (i < keys.length - 1) await Bun.sleep(delay); + } +} + +export type PollPaneOpts = { + /** Hard cap on the polling window, in ms. */ + timeoutMs: number; + /** Interval between successive captures, ms. Default 200. */ + pollIntervalMs?: number; + /** When set, writes one `[]` stderr line per iteration for diagnostics. */ + tag?: string; +}; + +/** + * Poll `tmux capture-pane` until `predicate` returns a non-null value, or + * timeout. Returns the predicate's value on match, null on timeout or session + * loss. Optional `tag` enables per-iteration diagnostic logging to stderr + * (paneLen, match boolean, tail preview). + */ +export async function pollPaneFor( + sessionName: string, + predicate: (paneContent: string) => T | null, + opts: PollPaneOpts, +): Promise { + const interval = opts.pollIntervalMs ?? 200; + const deadline = Date.now() + opts.timeoutMs; + const tag = opts.tag; + let iter = 0; + while (Date.now() < deadline) { + iter++; + const pane = await capturePane(sessionName); + if (pane === null) { + if (tag) console.error(`[${tag}] pollPaneFor iter ${iter}: capture-pane failed`); + return null; + } + const result = predicate(pane); + if (tag) { + const preview = pane.replace(/\s+/g, " ").trim().slice(-200); + console.error( + `[${tag}] pollPaneFor iter ${iter}: paneLen=${pane.length} match=${result !== null} tail="${preview}"`, + ); + } + if (result !== null) return result; + await Bun.sleep(interval); + } + if (tag) console.error(`[${tag}] pollPaneFor: timed out after ${opts.timeoutMs}ms`); + return null; +} diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts new file mode 100644 index 00000000..ba9ad35f --- /dev/null +++ b/packages/core/test/config.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "../src/config.ts"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "middle-config-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function write(name: string, contents: string): string { + const path = join(dir, name); + writeFileSync(path, contents); + return path; +} + +const GLOBAL_TOML = ` +[global] +dispatcher_port = 8822 +max_concurrent = 4 +default_adapter = "claude" +log_dir = "~/.middle/logs" +worktree_root = "~/.middle/worktrees" +db_path = "~/.middle/db.sqlite3" + +[adapters.claude] +enabled = true +binary = "claude" +permission_mode = "auto" +extra_args = [] + +[adapters.codex] +enabled = true +binary = "codex" +sandbox = "workspace-write" +approval_policy = "never" +extra_args = [] + +[dashboard] +windowed = false +theme = "auto" +`; + +const REPO_TOML = ` +[repo] +owner = "thejustinwalsh" +name = "middle" +default_branch = "main" +pr_mode = "single" + +[limits] +max_concurrent = 3 +max_concurrent_per_adapter = { claude = 2, codex = 1 } +complexity_ceiling = 3 + +[recommender] +enabled = true +interval_minutes = 15 +adapter = "claude" +auto_dispatch = false + +[state_issue] +number = 142 +label = "agent-queue:state" + +[bootstrap] +version = 1 +installed_at = "2026-05-13T15:00:00Z" +`; + +describe("loadConfig — global only", () => { + test("parses the global sections and leaves per-repo sections undefined", () => { + const config = loadConfig({ globalPath: write("global.toml", GLOBAL_TOML) }); + expect(config.global.dispatcherPort).toBe(8822); + expect(config.global.maxConcurrent).toBe(4); + expect(config.global.defaultAdapter).toBe("claude"); + expect(config.adapters.claude!.binary).toBe("claude"); + expect(config.adapters.claude!.permissionMode).toBe("auto"); + expect(config.adapters.codex!.sandbox).toBe("workspace-write"); + expect(config.dashboard.windowed).toBe(false); + expect(config.repo).toBeUndefined(); + expect(config.limits).toBeUndefined(); + }); + + test("expands ~ in path values", () => { + const config = loadConfig({ globalPath: write("global.toml", GLOBAL_TOML) }); + expect(config.global.dbPath).toBe(join(homedir(), ".middle/db.sqlite3")); + expect(config.global.logDir).toBe(join(homedir(), ".middle/logs")); + expect(config.global.worktreeRoot).toBe(join(homedir(), ".middle/worktrees")); + }); +}); + +describe("loadConfig — per-repo merge", () => { + test("populates per-repo sections alongside global", () => { + const config = loadConfig({ + globalPath: write("global.toml", GLOBAL_TOML), + repoPath: write("repo.toml", REPO_TOML), + }); + expect(config.repo!.owner).toBe("thejustinwalsh"); + expect(config.repo!.prMode).toBe("single"); + expect(config.limits!.maxConcurrent).toBe(3); + expect(config.limits!.maxConcurrentPerAdapter).toEqual({ claude: 2, codex: 1 }); + expect(config.limits!.complexityCeiling).toBe(3); + expect(config.recommender!.intervalMinutes).toBe(15); + expect(config.recommender!.autoDispatch).toBe(false); + expect(config.stateIssue!.number).toBe(142); + expect(config.bootstrap!.version).toBe(1); + }); + + test("per-repo values override global on a colliding key", () => { + const repoOverride = `${REPO_TOML}\n[global]\nmax_concurrent = 2\ndefault_adapter = "codex"\n`; + const config = loadConfig({ + globalPath: write("global.toml", GLOBAL_TOML), + repoPath: write("repo.toml", repoOverride), + }); + expect(config.global.maxConcurrent).toBe(2); + expect(config.global.defaultAdapter).toBe("codex"); + // untouched global keys survive the merge + expect(config.global.dispatcherPort).toBe(8822); + }); +}); + +describe("loadConfig — missing files", () => { + test("missing global file falls back to documented defaults without throwing", () => { + const config = loadConfig({ globalPath: join(dir, "does-not-exist.toml") }); + expect(config.global.dispatcherPort).toBe(8822); + expect(config.global.maxConcurrent).toBe(4); + expect(config.adapters.claude!.enabled).toBe(true); + expect(config.repo).toBeUndefined(); + }); + + test("missing per-repo file leaves per-repo sections undefined", () => { + const config = loadConfig({ + globalPath: write("global.toml", GLOBAL_TOML), + repoPath: join(dir, "no-repo.toml"), + }); + expect(config.global.dispatcherPort).toBe(8822); + expect(config.repo).toBeUndefined(); + expect(config.recommender).toBeUndefined(); + }); + + test("no paths at all yields an all-defaults config", () => { + const config = loadConfig({}); + expect(config.global.maxConcurrent).toBe(4); + expect(config.dashboard.theme).toBe("auto"); + expect(config.repo).toBeUndefined(); + }); +}); diff --git a/packages/core/test/tmux-tui.test.ts b/packages/core/test/tmux-tui.test.ts new file mode 100644 index 00000000..e9ccdf8d --- /dev/null +++ b/packages/core/test/tmux-tui.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { capturePane, pollPaneFor, sendKeys, sendText } from "../src/tmux-tui.ts"; + +const TMUX = Bun.which("tmux"); +const d = describe.skipIf(!TMUX); + +const created: string[] = []; + +function uniqueName(): string { + const name = `middle-tui-${crypto.randomUUID().slice(0, 8)}`; + created.push(name); + return name; +} + +async function killAll(): Promise { + while (created.length > 0) { + const name = created.pop()!; + const proc = Bun.spawn(["tmux", "kill-session", "-t", name], { + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; + } +} + +afterEach(async () => { + await killAll(); +}); + +async function newSession(name: string, cmd: string[]): Promise { + const proc = Bun.spawn(["tmux", "new-session", "-d", "-s", name, "-x", "80", "-y", "24", ...cmd], { + stdout: "ignore", + stderr: "pipe", + }); + if ((await proc.exited) !== 0) { + throw new Error(`tmux new-session failed: ${await new Response(proc.stderr).text()}`); + } +} + +d("capturePane", () => { + test("returns the visible pane contents of a live session", async () => { + const name = uniqueName(); + await newSession(name, ["sh", "-c", "echo BEACON-12345; sleep 5"]); + await Bun.sleep(150); + const pane = await capturePane(name); + expect(pane).not.toBeNull(); + expect(pane!).toContain("BEACON-12345"); + }); + + test("returns null for an unknown session", async () => { + const result = await capturePane("middle-tui-does-not-exist-xyz"); + expect(result).toBeNull(); + }); +}); + +d("sendText and sendKeys", () => { + test("sendText writes literal text into the pane", async () => { + const name = uniqueName(); + await newSession(name, ["cat"]); + await sendText(name, "literal-payload-789"); + await sendKeys(name, ["Enter"]); + await Bun.sleep(150); + const pane = await capturePane(name); + expect(pane!).toContain("literal-payload-789"); + }); + + test("sendKeys with delayBetweenMs sends each key in its own call", async () => { + const name = uniqueName(); + await newSession(name, ["cat"]); + await sendKeys(name, ["a", "b", "c"], { delayBetweenMs: 30 }); + await sendKeys(name, ["Enter"]); + await Bun.sleep(150); + const pane = await capturePane(name); + expect(pane!).toContain("abc"); + }); +}); + +d("pollPaneFor", () => { + test("resolves with the predicate's value when the pane matches", async () => { + const name = uniqueName(); + // session prints the marker after a small delay so polling has to actually iterate + await newSession(name, ["sh", "-c", "sleep 0.3; echo READY-MARKER-42; sleep 5"]); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await pollPaneFor( + name, + (pane) => (pane.includes("READY-MARKER-42") ? "matched" : null), + { timeoutMs: 2000, pollIntervalMs: 100 }, + ); + expect(result).toBe("matched"); + } finally { + errSpy.mockRestore(); + } + }); + + test("returns null on timeout when the pane never matches", async () => { + const name = uniqueName(); + await newSession(name, ["sh", "-c", "echo BORING; sleep 5"]); + const result = await pollPaneFor( + name, + () => null, + { timeoutMs: 400, pollIntervalMs: 100 }, + ); + expect(result).toBeNull(); + }); + + test("returns null and bails when the session disappears", async () => { + const result = await pollPaneFor( + "middle-tui-vanished-xyz", + () => "match", + { timeoutMs: 2000, pollIntervalMs: 100 }, + ); + expect(result).toBeNull(); + }); + + test("when `tag` is set, writes one stderr line per iteration", async () => { + const name = uniqueName(); + await newSession(name, ["sh", "-c", "echo HI; sleep 5"]); + const lines: string[] = []; + const errSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => { + lines.push(args.join(" ")); + }); + try { + await pollPaneFor(name, () => "stop", { + timeoutMs: 500, + pollIntervalMs: 100, + tag: "test-tag", + }); + } finally { + errSpy.mockRestore(); + } + expect(lines.some((line) => line.includes("[test-tag] pollPaneFor"))).toBe(true); + }); +}); diff --git a/packages/dispatcher/src/db.ts b/packages/dispatcher/src/db.ts new file mode 100644 index 00000000..884f4d29 --- /dev/null +++ b/packages/dispatcher/src/db.ts @@ -0,0 +1,77 @@ +import { Database } from "bun:sqlite"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** Default location of the numbered `.sql` migration files. */ +export const MIGRATIONS_DIR = join(import.meta.dir, "db", "migrations"); + +/** + * Open the SQLite database in WAL mode. Creates the file if absent. + * WAL is the documented mode for `~/.middle/db.sqlite3`; it lets the dispatcher + * read while crons and workers write. `:memory:` databases silently stay in + * "memory" journal mode — tests that assert WAL must use a file path. + */ +export function openDb(path: string): Database { + const db = new Database(path, { create: true }); + db.exec("PRAGMA journal_mode = WAL;"); + db.exec("PRAGMA foreign_keys = ON;"); + return db; +} + +/** The highest applied migration version, or 0 if the db has never been migrated. */ +export function currentSchemaVersion(db: Database): number { + const hasTable = db + .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'") + .get(); + if (!hasTable) return 0; + const row = db.query("SELECT max(version) AS v FROM schema_version").get() as { + v: number | null; + }; + return row?.v ?? 0; +} + +type Migration = { version: number; name: string; sql: string }; + +/** Load and order the migration files. Each filename must start with `NNN_`. */ +export function loadMigrations(dir: string = MIGRATIONS_DIR): Migration[] { + return 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") }; + }); +} + +/** + * Apply every migration newer than the recorded `schema_version`, each in its + * own transaction. A migration's SQL may record its own version row (001 does); + * the `INSERT OR IGNORE` here is the backstop so a migration that omits it is + * still tracked. Returns the resulting schema version. + */ +export function runMigrations(db: Database, dir: string = MIGRATIONS_DIR): number { + const applied = currentSchemaVersion(db); + const pending = loadMigrations(dir).filter((m) => m.version > applied); + for (const migration of pending) { + db.transaction(() => { + db.exec(migration.sql); + db.run("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [migration.version]); + })(); + } + return currentSchemaVersion(db); +} + +/** Open the database and bring it to the latest schema version in one call. */ +export function openAndMigrate(path: string, dir: string = MIGRATIONS_DIR): Database { + const db = openDb(path); + try { + runMigrations(db, dir); + } catch (error) { + // Don't leak the handle — an open db keeps the sqlite file locked, which + // would block retries after a migration failure. + db.close(); + throw error; + } + return db; +} diff --git a/packages/dispatcher/src/db/migrations/001_initial.sql b/packages/dispatcher/src/db/migrations/001_initial.sql new file mode 100644 index 00000000..206ccef9 --- /dev/null +++ b/packages/dispatcher/src/db/migrations/001_initial.sql @@ -0,0 +1,75 @@ +-- 001_initial.sql +-- middle's operational state. SQLite holds operational state only; GitHub is +-- the system of record. Schema source of truth: build spec → "SQLite schema". + +CREATE TABLE workflows ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('implementation', 'recommender')), + repo TEXT NOT NULL, -- 'owner/name' + epic_number INTEGER, -- the dispatched Epic or standalone issue; null for recommender + adapter TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ( + 'pending', 'launching', 'running', 'waiting-human', 'rate-limited', + 'completed', 'compensated', 'failed', 'cancelled' + )), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + bunqueue_execution_id TEXT, -- foreign reference into bunqueue's tables + worktree_path TEXT, + session_name TEXT, + session_token TEXT, + session_id TEXT, -- the CLI's own session id, from the SessionStart hook + transcript_path TEXT, -- on-disk JSONL transcript; retained after the tmux session ends so --resume stays available + controlled_by TEXT NOT NULL DEFAULT 'middle' CHECK (controlled_by IN ('middle', 'human')), + current_sub_issue INTEGER, -- which sub-issue/phase the agent is on; null for standalone + pr_number INTEGER, -- the one PR for this Epic + pr_branch TEXT, + last_heartbeat INTEGER, + meta_json TEXT -- adapter-specific scratch +); + +CREATE INDEX idx_workflows_state ON workflows(state); +CREATE INDEX idx_workflows_repo ON workflows(repo); +CREATE INDEX idx_workflows_heartbeat ON workflows(last_heartbeat); + +CREATE TABLE events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id TEXT NOT NULL REFERENCES workflows(id), + ts INTEGER NOT NULL, + type TEXT NOT NULL, -- normalized event name + payload_json TEXT, -- truncated to 16KB + FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE +); + +CREATE INDEX idx_events_workflow_ts ON events(workflow_id, ts); +CREATE INDEX idx_events_ts ON events(ts); -- for retention scans + +CREATE TABLE rate_limit_state ( + adapter TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK (status IN ('AVAILABLE', 'RATE_LIMITED', 'UNKNOWN')), + reset_at INTEGER, -- unix ms, null when AVAILABLE/UNKNOWN + observed_at INTEGER NOT NULL, + source TEXT, -- 'exit', 'stop-hook', 'manual' + detail TEXT +); + +CREATE TABLE repo_config ( + repo TEXT PRIMARY KEY, + config_json TEXT NOT NULL, -- snapshot of .middle/config.toml at last sync + state_issue_number INTEGER, + last_recommender_run INTEGER, + paused_until INTEGER, -- if non-null, no auto-dispatch + last_synced_at INTEGER NOT NULL +); + +CREATE TABLE waitfor_signals ( + signal_name TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflows(id), + created_at INTEGER NOT NULL, + payload_json TEXT +); + +CREATE TABLE schema_version ( + version INTEGER PRIMARY KEY +); +INSERT INTO schema_version VALUES (1); diff --git a/packages/dispatcher/src/dispatch.ts b/packages/dispatcher/src/dispatch.ts new file mode 100644 index 00000000..7f4012ef --- /dev/null +++ b/packages/dispatcher/src/dispatch.ts @@ -0,0 +1,165 @@ +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import type { AgentAdapter } from "@middle/core"; +import { Engine } from "bunqueue/workflow"; +import type { Execution } from "bunqueue/workflow"; +import { openAndMigrate } from "./db.ts"; +import { HookServer } from "./hook-server.ts"; +import { killSession, newSession, sendEnter, sendText } from "./tmux.ts"; +import { createImplementationWorkflow } from "./workflows/implementation.ts"; +import { createWorktree, destroyWorktree } from "./worktree.ts"; + +export type DispatchEpicOptions = { + /** Local checkout path of the repo to dispatch. */ + repoPath: string; + /** `owner/name` — recorded on the workflow row. */ + repoSlug: string; + /** The Epic (or standalone issue) number. */ + epicNumber: number; + /** Configured adapter name to dispatch with. */ + adapterName: string; + /** Adapter registry — keeps the dispatcher free of any concrete-adapter dependency. */ + getAdapter: (name: string) => AgentAdapter; + dbPath: string; + worktreeRoot: string; + /** Port for the hook receiver; 0 picks an ephemeral port. */ + dispatcherPort: number; +}; + +export type DispatchEpicResult = { + workflowId: string; + /** Terminal bunqueue execution state — `completed` on success. */ + state: string; +}; + +/** A generous outer guard so the loop cannot spin forever if bunqueue ever + * reports `null` for the execution (engine-state corruption). The workflow's + * own `stopTimeoutMs` (4h default) is the intended backstop in normal flow; + * this is the recoverable failsafe beyond it. */ +const SETTLE_DEADLINE_MS = 5 * 60 * 60 * 1000; + +async function waitForSettle( + engine: Engine, + executionId: string, + deadlineAt: number = Date.now() + SETTLE_DEADLINE_MS, +): Promise { + for (;;) { + const execution = engine.getExecution(executionId); + if (execution && execution.state !== "running" && execution.state !== "compensating") { + return execution; + } + if (Date.now() >= deadlineAt) return execution ?? null; + await Bun.sleep(200); + } +} + +/** + * bunqueue's worker can throw `Invalid or expired lock token …` from inside + * `handleJobFailure` when the engine is shutting down concurrently with a + * failing job — surfaces as a runtime-killing unhandledRejection. Swallow only + * that specific message during a dispatch, and remove the listener again on + * exit. Anything else falls through to the runtime's normal crash semantics. + */ +const BUNQUEUE_LOCK_TOKEN_RE = /Invalid or expired lock token for job/; + +function installBunqueueRaceSwallower(): () => void { + const listener = (reason: unknown): void => { + const message = reason instanceof Error ? reason.message : String(reason); + if (BUNQUEUE_LOCK_TOKEN_RE.test(message)) { + console.error(`[dispatch] suppressed benign bunqueue lifecycle race: ${message}`); + return; + } + // not ours — re-raise so Bun crashes the way it would have without us + queueMicrotask(() => { + throw reason; + }); + }; + process.on("unhandledRejection", listener); + return () => { + process.off("unhandledRejection", listener); + }; +} + +/** + * Run one Epic through the Phase 1 `implementation` workflow end to end: + * stand up a hook receiver and engine, dispatch the agent, wait for the + * workflow to settle, then tear everything down. Self-contained — the caller + * (`mm dispatch`) just supplies validated inputs and an adapter registry. + * + * Cleanup is stack-based: every acquired resource pushes its teardown onto + * `cleanups` as soon as it is acquired. A throw anywhere — including from + * `hookServer.start()` if the port is already bound — still runs every cleanup + * pushed before the throw, so the db never leaks. + */ +export async function dispatchEpic(opts: DispatchEpicOptions): Promise { + mkdirSync(dirname(opts.dbPath), { recursive: true }); + + const cleanups: Array<() => Promise | void> = []; + cleanups.push(installBunqueueRaceSwallower()); + + const runCleanups = async (): Promise => { + while (cleanups.length > 0) { + try { + await cleanups.pop()!(); + } catch { + // best-effort: one failing teardown must not block the rest + } + } + }; + + try { + const db = openAndMigrate(opts.dbPath); + cleanups.push(() => db.close()); + + const hookServer = new HookServer(); + hookServer.start(opts.dispatcherPort); + cleanups.push(() => hookServer.stop()); + + const engine = new Engine({ embedded: true }); + // Push the engine drain onto the cleanups stack LAST, so it pops FIRST — + // ahead of hookServer.stop / db.close. That ordering matters two ways: + // - on the failure path (engine.register/start/waitForSettle throws), the + // engine is still drained instead of leaking live bunqueue workers; + // - bunqueue's executor sets exec.state='failed' BEFORE awaiting + // compensation, so the drain must finish (compensation included) while + // hookServer/db are still alive — which they are, since they pop after. + // Capped at 10s so a hung bunqueue internal can't block the dispatch. + cleanups.push(async () => { + await Promise.race([ + engine.close(false).catch((err: unknown) => { + console.error(`[dispatch] engine.close errored: ${(err as Error).message}`); + }), + Bun.sleep(10_000).then(() => { + console.error(`[dispatch] engine.close drain timed out after 10s — proceeding`); + }), + ]); + }); + + engine.register( + createImplementationWorkflow({ + db, + getAdapter: opts.getAdapter, + sessionGate: hookServer, + tmux: { newSession, sendText, sendEnter, killSession }, + worktree: { createWorktree, destroyWorktree }, + resolveRepoPath: () => opts.repoPath, + worktreeRoot: opts.worktreeRoot, + dispatcherUrl: `http://127.0.0.1:${hookServer.port}`, + }), + ); + + const handle = await engine.start("implementation", { + repo: opts.repoSlug, + epicNumber: opts.epicNumber, + adapter: opts.adapterName, + }); + console.error(`[dispatch] workflow ${handle.id} enqueued`); + const execution = await waitForSettle(engine, handle.id); + console.error(`[dispatch] waitForSettle returned — state=${execution?.state ?? ""}`); + // The engine drain runs in `finally` via the cleanups stack (popped first, + // before hookServer/db), covering both success and failure paths. + return { workflowId: handle.id, state: execution?.state ?? "failed" }; + } finally { + await runCleanups(); + } +} diff --git a/packages/dispatcher/src/hook-server.ts b/packages/dispatcher/src/hook-server.ts new file mode 100644 index 00000000..0ef40945 --- /dev/null +++ b/packages/dispatcher/src/hook-server.ts @@ -0,0 +1,126 @@ +import type { HookPayload } from "@middle/core"; + +type BunServer = ReturnType; + +/** + * The readiness/turn-boundary channel the `implementation` workflow waits on. + * In production this is satisfied by the agent's hooks POSTing to `HookServer`; + * tests substitute a stub. + */ +export type SessionGate = { + awaitSessionStart(sessionName: string, timeoutMs: number): Promise; + awaitStop(sessionName: string, timeoutMs: number): Promise; +}; + +type Waiter = { + resolve: (payload: HookPayload) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +/** + * Phase 1 minimal hook receiver. It handles only the two load-bearing events — + * `session.started` (carries `session_id` + `transcript_path`, signals + * readiness) and `agent.stopped` (the turn boundary) — with no HMAC auth and no + * events-table persistence. Phase 2 expands this to the full taxonomy. + * + * Payloads that arrive before anyone is waiting are stashed and handed to the + * next awaiter, so a fast hook cannot race ahead of the workflow step. + */ +export class HookServer implements SessionGate { + #server: BunServer | undefined; + readonly #waiters = new Map(); + readonly #stashed = new Map(); + + start(port: number): void { + // Bind localhost only. The Phase 1 receiver has no HMAC auth and uses + // predictable session names, so a 0.0.0.0 bind (Bun's default) would let + // any host on the network POST a fake agent.stopped / session.started and + // hijack a running workflow. The dispatcherUrl is 127.0.0.1 everywhere. + this.#server = Bun.serve({ + hostname: "127.0.0.1", + port, + fetch: (req) => this.#handle(req), + }); + } + + stop(): void { + this.#server?.stop(true); + this.#server = undefined; + for (const waiter of this.#waiters.values()) { + clearTimeout(waiter.timer); + waiter.reject(new Error("hook server stopped")); + } + this.#waiters.clear(); + this.#stashed.clear(); + } + + /** The bound port — meaningful after `start`; resolves an ephemeral `start(0)`. */ + get port(): number { + return this.#server?.port ?? 0; + } + + async #handle(req: Request): Promise { + const match = /^\/hooks\/(.+)$/.exec(new URL(req.url).pathname); + if (!match || req.method !== "POST") { + return new Response("not found", { status: 404 }); + } + const event = match[1]!; + let payload: HookPayload = {}; + try { + payload = (await req.json()) as HookPayload; + } catch { + // tolerate an empty/garbled body — the hook still signals the event fired + } + const sessionName = + req.headers.get("X-Middle-Session") ?? + (typeof payload.sessionName === "string" ? payload.sessionName : ""); + if (sessionName === "") { + // No session identity → nothing can ever await this. Reject rather than + // stash an unreachable entry under an empty key. + console.error(`[hook-server] rejected ${event} with no session identity`); + return new Response("missing session", { status: 400 }); + } + console.error(`[hook-server] received ${event}:${sessionName}`); + this.#deliver(`${event}:${sessionName}`, payload); + return new Response("ok"); + } + + #deliver(key: string, payload: HookPayload): void { + const waiter = this.#waiters.get(key); + if (waiter) { + clearTimeout(waiter.timer); + this.#waiters.delete(key); + waiter.resolve(payload); + } else if (!this.#stashed.has(key)) { + // Keep the first arrival. A duplicate fires during a retry scenario and + // would otherwise silently overwrite the original — most acutely for + // `session.started`, where the payload carries `session_id` / + // `transcript_path` the workflow then commits to. + this.#stashed.set(key, payload); + } + } + + #await(key: string, timeoutMs: number): Promise { + const stashed = this.#stashed.get(key); + if (stashed) { + this.#stashed.delete(key); + return Promise.resolve(stashed); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#waiters.delete(key); + reject(new Error(`timed out waiting for ${key}`)); + }, timeoutMs); + this.#waiters.set(key, { resolve, reject, timer }); + }); + } + + awaitSessionStart(sessionName: string, timeoutMs: number): Promise { + return this.#await(`session.started:${sessionName}`, timeoutMs); + } + + awaitStop(sessionName: string, timeoutMs: number): Promise { + return this.#await(`agent.stopped:${sessionName}`, timeoutMs); + } +} diff --git a/packages/dispatcher/src/main.ts b/packages/dispatcher/src/main.ts index e0c459ad..9280a524 100644 --- a/packages/dispatcher/src/main.ts +++ b/packages/dispatcher/src/main.ts @@ -1,4 +1,65 @@ -// @middle/dispatcher — the long-running process: SQLite, bunqueue workflows, -// hook server, watchdog, rate limits, slots, auto-dispatch. -// Source lands in build-spec Phase 1+. -export {}; +// @middle/dispatcher — the long-running dispatcher process. +// +// Phase 1 scope: open the SQLite db (migrated), stand up the minimal hook +// receiver, create the bunqueue engine, and idle until signalled. The auto- +// dispatch loop, watchdog, and reconciler crons land in Phase 2+. `mm start` +// spawns this; `mm stop` sends it SIGTERM. +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { loadConfig } from "@middle/core"; +import { Engine } from "bunqueue/workflow"; +import { openAndMigrate } from "./db.ts"; +import { HookServer } from "./hook-server.ts"; + +async function main(): Promise { + const config = loadConfig({ globalPath: process.env.MIDDLE_CONFIG }); + + mkdirSync(dirname(config.global.dbPath), { recursive: true }); + const db = openAndMigrate(config.global.dbPath); + + const hookServer = new HookServer(); + hookServer.start(config.global.dispatcherPort); + + // In-memory engine for Phase 1 — durable queue persistence + crash recovery + // arrive with the watchdog/reconciler in Phase 2. + const engine = new Engine({ embedded: true }); + + console.log( + `middle dispatcher up — hooks on :${hookServer.port}, db ${config.global.dbPath}`, + ); + + let shuttingDown = false; + const shutdown = async (): Promise => { + if (shuttingDown) return; + shuttingDown = true; + // Guard each teardown so a throw/rejection can't skip process.exit and + // leak as an unhandledRejection (there's no swallower in this entrypoint). + try { + hookServer.stop(); + } catch (error) { + console.error(`shutdown: hookServer.stop failed — ${(error as Error).message}`); + } + try { + await engine.close(true); + } catch (error) { + console.error(`shutdown: engine.close failed — ${(error as Error).message}`); + } + try { + db.close(); + } catch (error) { + console.error(`shutdown: db.close failed — ${(error as Error).message}`); + } + console.log("middle dispatcher stopped"); + process.exit(0); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + + // idle — the hook server keeps the event loop alive + await new Promise(() => {}); +} + +main().catch((error: unknown) => { + console.error(`middle dispatcher failed: ${(error as Error).message}`); + process.exit(1); +}); diff --git a/packages/dispatcher/src/tmux.ts b/packages/dispatcher/src/tmux.ts new file mode 100644 index 00000000..4d9b5e68 --- /dev/null +++ b/packages/dispatcher/src/tmux.ts @@ -0,0 +1,158 @@ +/** + * tmux session helpers. tmux is middle's agent supervisor — agents run as + * interactive sessions inside tmux, driven by `send-keys`. These helpers shell + * out to the `tmux` binary and surface failures as typed `TmuxError`s rather + * than silent no-ops. Source of truth: build spec → "Top-level architecture". + */ + +export class TmuxError extends Error { + readonly args: string[]; + readonly exitCode: number; + readonly stderr: string; + + constructor(args: string[], exitCode: number, stderr: string) { + super(`tmux ${args.join(" ")} failed (exit ${exitCode}): ${stderr.trim()}`); + this.name = "TmuxError"; + this.args = args; + this.exitCode = exitCode; + this.stderr = stderr; + } +} + +type TmuxResult = { stdout: string; stderr: string; exitCode: number }; + +async function runTmux(args: string[]): Promise { + const proc = Bun.spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { stdout, stderr, exitCode }; +} + +/** Run a tmux command, throwing `TmuxError` on a non-zero exit. */ +async function tmux(args: string[]): Promise { + const result = await runTmux(args); + if (result.exitCode !== 0) { + throw new TmuxError(args, result.exitCode, result.stderr); + } + return result.stdout; +} + +export type NewSessionOpts = { + sessionName: string; + /** argv of the interactive command tmux runs inside the session. */ + command: string[]; + /** Working directory for the session. */ + cwd?: string; + /** Env vars injected at spawn time via `tmux new-session -e KEY=val`. */ + env?: Record; + /** Pane width; a generous fixed default keeps TUI output from wrapping. */ + width?: number; + /** Pane height. */ + height?: number; +}; + +const DEFAULT_WIDTH = 220; +const DEFAULT_HEIGHT = 50; + +/** + * Create a detached session running `command` at a generous fixed size. + * Throws `TmuxError` if the name is already taken. + */ +export async function newSession(opts: NewSessionOpts): Promise { + const args = [ + "new-session", + "-d", + "-s", + opts.sessionName, + "-x", + String(opts.width ?? DEFAULT_WIDTH), + "-y", + String(opts.height ?? DEFAULT_HEIGHT), + ]; + if (opts.cwd) args.push("-c", opts.cwd); + for (const [key, value] of Object.entries(opts.env ?? {})) { + args.push("-e", `${key}=${value}`); + } + args.push(...opts.command); + await tmux(args); +} + +/** + * Send literal text into a session. Uses `send-keys -l` so prompt content is + * sent verbatim and never interpreted as tmux key names. Does not press Enter. + */ +export async function sendText(sessionName: string, text: string): Promise { + await tmux(["send-keys", "-t", sessionName, "-l", text]); +} + +/** Press Enter in a session — the submit that follows `sendText`. */ +export async function sendEnter(sessionName: string): Promise { + await tmux(["send-keys", "-t", sessionName, "Enter"]); +} + +/** Return the visible pane contents — for readiness / echo confirmation. */ +export async function capturePane(sessionName: string): Promise { + return tmux(["capture-pane", "-t", sessionName, "-p"]); +} + +/** Whether a named session is currently alive. Never throws on "not found". */ +export async function hasSession(sessionName: string): Promise { + const result = await runTmux(["has-session", "-t", sessionName]); + return result.exitCode === 0; +} + +export type SessionStatus = { + alive: boolean; + paneCount: number; +}; + +/** Report liveness and pane count. Returns a not-alive status for an unknown session. */ +export async function status(sessionName: string): Promise { + const result = await runTmux(["list-panes", "-t", sessionName, "-F", "#{pane_id}"]); + if (result.exitCode !== 0) { + return { alive: false, paneCount: 0 }; + } + const paneCount = result.stdout.split("\n").filter((line) => line.trim() !== "").length; + return { alive: paneCount > 0, paneCount }; +} + +/** + * Terminate a named session. Idempotent: killing a session that is already gone + * is a no-op, not a throw. A real failure (a live session that refuses to die) + * still surfaces as a `TmuxError`. + */ +export async function killSession(sessionName: string): Promise { + if (!(await hasSession(sessionName))) return; + await tmux(["kill-session", "-t", sessionName]); +} + +export type TmuxVersion = { major: number; minor: number; raw: string }; + +/** Minimum tmux version we expect operators to run. */ +export const MIN_TMUX_VERSION: TmuxVersion = { major: 3, minor: 5, raw: "3.5" }; + +/** + * Parse a `tmux -V` line — `tmux 3.5a` / `tmux 3.4` / `tmux next-3.6` etc. — + * into a comparable `{major, minor}` pair. Pre-release `next-` builds are + * accepted at face value. Returns null when the version field is unrecognized. + */ +export function parseTmuxVersion(versionLine: string): TmuxVersion | null { + const match = /tmux\s+(?:next-)?(\d+)\.(\d+)/i.exec(versionLine); + if (!match) return null; + return { major: Number(match[1]), minor: Number(match[2]), raw: match[0]! }; +} + +/** Shell out to `tmux -V` and parse the result. Returns null if tmux is missing. */ +export async function getTmuxVersion(): Promise { + const result = await runTmux(["-V"]); + if (result.exitCode !== 0) return null; + return parseTmuxVersion(result.stdout.trim()); +} + +/** True when `v` is at least `min`. */ +export function tmuxVersionAtLeast(v: TmuxVersion, min: TmuxVersion): boolean { + return v.major > min.major || (v.major === min.major && v.minor >= min.minor); +} diff --git a/packages/dispatcher/src/workflow-record.ts b/packages/dispatcher/src/workflow-record.ts new file mode 100644 index 00000000..457eff41 --- /dev/null +++ b/packages/dispatcher/src/workflow-record.ts @@ -0,0 +1,124 @@ +import type { Database } from "bun:sqlite"; + +/** The lifecycle states a `workflows` row moves through (mirrors the schema CHECK). */ +export type WorkflowState = + | "pending" + | "launching" + | "running" + | "waiting-human" + | "rate-limited" + | "completed" + | "compensated" + | "failed" + | "cancelled"; + +export type WorkflowRecord = { + id: string; + kind: "implementation" | "recommender"; + repo: string; + epicNumber: number | null; + adapter: string; + state: WorkflowState; + createdAt: number; + updatedAt: number; + bunqueueExecutionId: string | null; + worktreePath: string | null; + sessionName: string | null; + sessionToken: string | null; + sessionId: string | null; + transcriptPath: string | null; + controlledBy: "middle" | "human"; +}; + +export type CreateWorkflowRecordInput = { + id: string; + kind: "implementation" | "recommender"; + repo: string; + epicNumber: number | null; + adapter: string; +}; + +/** Insert a fresh `pending` workflow row. `id` doubles as the bunqueue execution id. */ +export function createWorkflowRecord(db: Database, input: CreateWorkflowRecordInput): void { + const now = Date.now(); + db.run( + `INSERT INTO workflows + (id, kind, repo, epic_number, adapter, state, created_at, updated_at, bunqueue_execution_id) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)`, + [input.id, input.kind, input.repo, input.epicNumber, input.adapter, now, now, input.id], + ); +} + +export type WorkflowPatch = { + state?: WorkflowState; + worktreePath?: string; + sessionName?: string; + sessionToken?: string; + sessionId?: string; + transcriptPath?: string; +}; + +const PATCH_COLUMNS: Record = { + state: "state", + worktreePath: "worktree_path", + sessionName: "session_name", + sessionToken: "session_token", + sessionId: "session_id", + transcriptPath: "transcript_path", +}; + +/** Patch the given fields on a workflow row; always bumps `updated_at`. A no-op patch still touches `updated_at`. */ +export function updateWorkflow(db: Database, id: string, patch: WorkflowPatch): void { + const sets: string[] = ["updated_at = ?"]; + const values: (string | number)[] = [Date.now()]; + for (const [key, column] of Object.entries(PATCH_COLUMNS) as [keyof WorkflowPatch, string][]) { + const value = patch[key]; + if (value !== undefined) { + sets.push(`${column} = ?`); + values.push(value); + } + } + values.push(id); + db.run(`UPDATE workflows SET ${sets.join(", ")} WHERE id = ?`, values); +} + +type WorkflowRow = { + id: string; + kind: string; + repo: string; + epic_number: number | null; + adapter: string; + state: string; + created_at: number; + updated_at: number; + bunqueue_execution_id: string | null; + worktree_path: string | null; + session_name: string | null; + session_token: string | null; + session_id: string | null; + transcript_path: string | null; + controlled_by: string; +}; + +/** Fetch a workflow row by id, or null if it does not exist. */ +export function getWorkflow(db: Database, id: string): WorkflowRecord | null { + const row = db.query("SELECT * FROM workflows WHERE id = ?").get(id) as WorkflowRow | null; + if (!row) return null; + return { + id: row.id, + kind: row.kind as WorkflowRecord["kind"], + repo: row.repo, + epicNumber: row.epic_number, + adapter: row.adapter, + state: row.state as WorkflowState, + createdAt: row.created_at, + updatedAt: row.updated_at, + bunqueueExecutionId: row.bunqueue_execution_id, + worktreePath: row.worktree_path, + sessionName: row.session_name, + sessionToken: row.session_token, + sessionId: row.session_id, + transcriptPath: row.transcript_path, + controlledBy: row.controlled_by as WorkflowRecord["controlledBy"], + }; +} diff --git a/packages/dispatcher/src/workflows/implementation.ts b/packages/dispatcher/src/workflows/implementation.ts new file mode 100644 index 00000000..ac5b0b84 --- /dev/null +++ b/packages/dispatcher/src/workflows/implementation.ts @@ -0,0 +1,272 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Database } from "bun:sqlite"; +import type { AgentAdapter, StopClassification } from "@middle/core"; +import { Workflow } from "bunqueue/workflow"; +import type { StepContext } from "bunqueue/workflow"; +import type { SessionGate } from "../hook-server.ts"; +import type { CreateWorktreeOpts, WorktreeHandle } from "../worktree.ts"; +import { + createWorkflowRecord, + updateWorkflow, + type WorkflowState, +} from "../workflow-record.ts"; + +/** A dispatch unit: an Epic (or standalone issue) pointed at one adapter. */ +export type ImplementationInput = { + repo: string; + epicNumber: number; + adapter: string; +}; + +/** The tmux surface the workflow drives — structural so tests can stub it. */ +export type TmuxOps = { + newSession(opts: { + sessionName: string; + command: string[]; + cwd?: string; + env?: Record; + }): Promise; + sendText(sessionName: string, text: string): Promise; + sendEnter(sessionName: string): Promise; + killSession(sessionName: string): Promise; +}; + +/** The worktree surface the workflow drives — structural so tests can stub it. */ +export type WorktreeOps = { + createWorktree(opts: CreateWorktreeOpts): Promise; + destroyWorktree(handle: WorktreeHandle): Promise; +}; + +/** Everything the workflow needs that is not part of its per-run input. */ +export type ImplementationDeps = { + db: Database; + getAdapter: (name: string) => AgentAdapter; + sessionGate: SessionGate; + tmux: TmuxOps; + worktree: WorktreeOps; + resolveRepoPath: (repo: string) => string; + worktreeRoot: string; + dispatcherUrl: string; + launchTimeoutMs?: number; + stopTimeoutMs?: number; +}; + +const DEFAULT_LAUNCH_TIMEOUT_MS = 90_000; +const DEFAULT_STOP_TIMEOUT_MS = 4 * 60 * 60 * 1000; + +/** + * Session names are deterministic so compensations can recompute them, and + * namespaced by repo so concurrent dispatches of the same issue number across + * different repos don't collide on one tmux session (which would make the + * second dispatch's failure-path `killSession` tear down the first's live + * session). Matches the repo-namespaced worktree path layout. + */ +function sessionNameFor(input: ImplementationInput): string { + const repoSlug = input.repo.replace(/[^A-Za-z0-9_-]/g, "-"); + return `middle-${repoSlug}-${input.epicNumber}`; +} + +/** + * Write a plan-style placeholder `.middle/prompt.md` into the worktree if one + * is not already present. An operator (or Phase 3+ `mm init` / Phase 7's + * recommender) can override by committing a real prompt in the source repo — + * the worktree inherits it and this writer leaves it alone. + */ +function ensurePromptFile(worktreePath: string, epicNumber: number): void { + const middleDir = join(worktreePath, ".middle"); + const promptPath = join(middleDir, "prompt.md"); + if (existsSync(promptPath)) return; + mkdirSync(middleDir, { recursive: true }); + writeFileSync( + promptPath, + `# middle dispatch — Epic #${epicNumber} + +You are dispatched by middle (the autonomous GitHub-issue dispatcher) to work +on Epic #${epicNumber} in this repository. + +Use the \`implementing-github-issues\` skill — it is available in this worktree +at \`.claude/skills/implementing-github-issues/SKILL.md\`. Invoke it via the +Skill tool with name \`implementing-github-issues\` and input \`implement #${epicNumber}\`. + +If the skill is not present in this worktree (the target repo has not been +\`mm init\`'d and is not middle's own dogfood checkout), write a brief +explanation to \`.middle/failed.json\` as \`{ "reason": "" }\` and stop. +`, + ); +} + +function finalStateFor(classification: StopClassification): WorkflowState { + switch (classification.kind) { + case "done": + return "completed"; + case "failed": + return "failed"; + case "rate-limited": + return "rate-limited"; + case "asked-question": + return "waiting-human"; + case "bare-stop": + // the minimal 3-step workflow has no nudge loop — a clean stop is terminal here + return "completed"; + } +} + +type PrepareResult = { handle: WorktreeHandle }; +type DriveResult = { classification: StopClassification; sessionName: string }; + +/** + * The Phase 1 `implementation` workflow — deliberately just three steps: + * prepare-worktree → launch-and-drive → cleanup. No skill enforcement, no + * sub-issue plan resolution, no hook-driven heartbeats; those land in Phases + * 2 and 4. `launch-and-drive` runs the launch → drive → observe loop and reacts + * to the `Stop` boundary via the adapter's `classifyStop`. + * + * 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( + deps: ImplementationDeps, +): Workflow { + const launchTimeout = deps.launchTimeoutMs ?? DEFAULT_LAUNCH_TIMEOUT_MS; + const stopTimeout = deps.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS; + + async function prepareWorktree(ctx: StepContext): Promise { + createWorkflowRecord(deps.db, { + id: ctx.executionId, + kind: "implementation", + repo: ctx.input.repo, + epicNumber: ctx.input.epicNumber, + adapter: ctx.input.adapter, + }); + const handle = await deps.worktree.createWorktree({ + repoPath: deps.resolveRepoPath(ctx.input.repo), + repo: ctx.input.repo, + issueNumber: ctx.input.epicNumber, + worktreeRoot: deps.worktreeRoot, + }); + updateWorkflow(deps.db, ctx.executionId, { worktreePath: handle.path }); + return { handle }; + } + + /** Compensation for prepare-worktree: roll the worktree back, free the session. */ + async function cleanupWorktree(ctx: StepContext): Promise { + const prepared = ctx.steps["prepare-worktree"] as PrepareResult | undefined; + if (prepared?.handle) { + await deps.tmux.killSession(sessionNameFor(ctx.input)); + await deps.worktree.destroyWorktree(prepared.handle); + } + updateWorkflow(deps.db, ctx.executionId, { state: "compensated" }); + } + + async function launchAndDrive(ctx: StepContext): Promise { + const { handle } = ctx.steps["prepare-worktree"] as PrepareResult; + const adapter = deps.getAdapter(ctx.input.adapter); + const sessionName = sessionNameFor(ctx.input); + const sessionToken = crypto.randomUUID(); + const tag = `[workflow:${sessionName}]`; + + updateWorkflow(deps.db, ctx.executionId, { state: "launching", sessionName, sessionToken }); + + try { + console.error(`${tag} ensuring .middle/prompt.md exists in worktree`); + ensurePromptFile(handle.path, ctx.input.epicNumber); + + console.error(`${tag} installing hooks in ${handle.path}`); + await adapter.installHooks({ + worktree: handle.path, + hookScriptPath: ".middle/hooks/hook.sh", + dispatcherUrl: deps.dispatcherUrl, + sessionName, + sessionToken, + epicNumber: ctx.input.epicNumber, + }); + + const { argv, env } = adapter.buildLaunchCommand({ + worktree: handle.path, + sessionName, + sessionToken, + envOverrides: { + MIDDLE_DISPATCHER_URL: deps.dispatcherUrl, + MIDDLE_EPIC: String(ctx.input.epicNumber), + }, + }); + console.error(`${tag} launching tmux session: ${argv.join(" ")} (cwd=${handle.path})`); + await deps.tmux.newSession({ sessionName, command: argv, cwd: handle.path, env }); + + // Claude pops a bypass-mode warning at boot; SessionStart cannot fire + // until it is dismissed. Run the dismisser in *parallel* with the + // SessionStart wait — when it detects the prompt it sends Down+Enter and + // Claude proceeds past the warning. Fire-and-forget with .catch so a + // dismiss-side error never becomes an unhandled rejection. + console.error(`${tag} starting bypass-prompt dismisser (parallel to SessionStart wait)`); + const dismissPromise = adapter.enterAutoMode({ sessionName }).catch((err: unknown) => { + console.error(`${tag} enterAutoMode failed: ${(err as Error).message}`); + }); + + // drive: SessionStart yields session_id + transcript_path + console.error(`${tag} waiting for SessionStart hook (timeout ${launchTimeout}ms)`); + const startPayload = await deps.sessionGate.awaitSessionStart(sessionName, launchTimeout); + console.error( + `${tag} SessionStart received — session_id=${startPayload.session_id ?? ""}`, + ); + // dismissPromise will resolve on its own (answered the prompt, or never + // saw it within the polling window). No further enterAutoMode call. + void dismissPromise; + + const transcriptPath = adapter.resolveTranscriptPath(startPayload); + updateWorkflow(deps.db, ctx.executionId, { + state: "running", + sessionId: + typeof startPayload.session_id === "string" ? startPayload.session_id : undefined, + transcriptPath, + }); + + const promptText = adapter.buildPromptText({ + promptFile: ".middle/prompt.md", + kind: "initial", + }); + console.error(`${tag} sending prompt: "${promptText}"`); + await deps.tmux.sendText(sessionName, promptText); + await deps.tmux.sendEnter(sessionName); + + // observe: the Stop boundary is the signal — not a process exit + console.error(`${tag} waiting for Stop hook (timeout ${stopTimeout}ms)`); + const stopPayload = await deps.sessionGate.awaitStop(sessionName, stopTimeout); + const sentinelPresent = existsSync(join(handle.path, ".middle", "blocked.json")); + const classification = adapter.classifyStop({ + payload: stopPayload, + transcriptPath, + sentinelPresent, + worktree: handle.path, + }); + console.error(`${tag} Stop received — classification=${classification.kind}`); + return { classification, sessionName }; + } catch (error) { + // never leak a tmux session on the failure path; the compensation rolls + // back the worktree + console.error(`${tag} step failed: ${(error as Error).message}`); + await deps.tmux.killSession(sessionName); + throw error; + } + } + + async function cleanup(ctx: StepContext): Promise { + const { handle } = ctx.steps["prepare-worktree"] as PrepareResult; + const { classification, sessionName } = ctx.steps["launch-and-drive"] as DriveResult; + await deps.tmux.killSession(sessionName); + await deps.worktree.destroyWorktree(handle); + updateWorkflow(deps.db, ctx.executionId, { state: finalStateFor(classification) }); + } + + return new Workflow("implementation") + .step("prepare-worktree", prepareWorktree, { compensate: cleanupWorktree }) + // retry: 1 — bunqueue's `retry` is `maxAttempts` (loop runs `attempt = 1 + // … <= retry`), not "retries after the first attempt". `1` means exactly + // one attempt, no retries. Phase 1 fails fast and compensates: retrying a + // launch piles up tmux/branch state and aggravates bunqueue's + // job-lifecycle race on the failure path. The full workflow's retry + // budgets (spec) live on `plan` / `implement-loop`. + .step("launch-and-drive", launchAndDrive, { retry: 1 }) + .step("cleanup", cleanup); +} diff --git a/packages/dispatcher/src/worktree.ts b/packages/dispatcher/src/worktree.ts new file mode 100644 index 00000000..a94ce180 --- /dev/null +++ b/packages/dispatcher/src/worktree.ts @@ -0,0 +1,194 @@ +import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; + +/** + * git worktree helpers. Concurrent workflows are isolated by one git worktree + * each, under `~/.middle/worktrees//issue-/` (or `.../recommender/`). + * Helpers shell out to `git` and surface real failures as `WorktreeError`; the + * already-exists and already-removed cases are handled idempotently. + */ +export class WorktreeError extends Error { + constructor(message: string) { + super(message); + this.name = "WorktreeError"; + } +} + +export type WorktreeHandle = { + /** The main repo checkout this worktree belongs to. */ + repoPath: string; + /** Absolute path of the worktree directory. */ + path: string; + /** The fresh branch checked out in the worktree. */ + branch: string; + /** 'owner/name' — the dispatched repo. */ + repo: string; + /** 'issue-' for a dispatch unit, 'recommender' for the recommender. */ + unit: string; +}; + +export type CreateWorktreeOpts = { + /** Path to the main repo checkout. */ + repoPath: string; + /** 'owner/name' — drives the worktree directory layout. */ + repo: string; + /** Issue/Epic number; omit for the recommender. */ + issueNumber?: number; + /** Root for all worktrees; defaults to `~/.middle/worktrees`. */ + worktreeRoot?: string; + /** Branch name; defaults to `middle-`. */ + branch?: string; +}; + +function defaultRoot(): string { + return join(homedir(), ".middle", "worktrees"); +} + +function unitName(issueNumber?: number): string { + return issueNumber === undefined ? "recommender" : `issue-${issueNumber}`; +} + +/** Resolve the root to a real path, creating it if absent — keeps path comparisons honest. */ +function resolveRoot(worktreeRoot: string | undefined): string { + const root = worktreeRoot ?? defaultRoot(); + mkdirSync(root, { recursive: true }); + return realpathSync(root); +} + +type RawWorktree = { path: string; branch: string | null }; + +async function runGit( + cwd: string, + args: string[], +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { stdout, stderr, exitCode: await proc.exited }; +} + +/** Parse `git worktree list --porcelain` into path + branch records. */ +function parsePorcelain(stdout: string): RawWorktree[] { + const out: RawWorktree[] = []; + let current: RawWorktree | null = null; + for (const line of stdout.split("\n")) { + if (line.startsWith("worktree ")) { + current = { path: line.slice("worktree ".length), branch: null }; + out.push(current); + } else if (line.startsWith("branch ") && current) { + current.branch = line.slice("branch refs/heads/".length); + } + } + return out; +} + +async function rawList(repoPath: string): Promise { + const result = await runGit(repoPath, ["worktree", "list", "--porcelain"]); + if (result.exitCode !== 0) { + throw new WorktreeError(`git worktree list failed: ${result.stderr.trim()}`); + } + return parsePorcelain(result.stdout); +} + +function toHandle(repoPath: string, root: string, raw: RawWorktree): WorktreeHandle { + const rel = relative(root, raw.path); + const segments = rel.split(sep); + const unit = segments.pop() ?? rel; + return { + repoPath, + path: raw.path, + branch: raw.branch ?? "", + repo: segments.join("/"), + unit, + }; +} + +/** + * Create a worktree for a dispatch unit on a fresh branch. Idempotent: if the + * worktree is already registered, the existing handle is returned rather than + * re-running `git worktree add`. + */ +export async function createWorktree(opts: CreateWorktreeOpts): Promise { + const repoPath = realpathSync(opts.repoPath); + const root = resolveRoot(opts.worktreeRoot); + const unit = unitName(opts.issueNumber); + const path = join(root, opts.repo, unit); + // Guard against a malicious/garbled `repo` (e.g. "../../x" from a crafted + // git remote) escaping the worktree root — otherwise destroyWorktree's + // rmSync could later delete directories outside it. + const rel = relative(root, path); + if (rel.startsWith("..") || isAbsolute(rel)) { + throw new WorktreeError(`repo "${opts.repo}" resolves outside the worktree root`); + } + const branch = opts.branch ?? `middle-${unit}`; + const handle: WorktreeHandle = { repoPath, path, branch, repo: opts.repo, unit }; + + const existing = await rawList(repoPath); + if (existing.some((w) => w.path === path)) return handle; + + mkdirSync(dirname(path), { recursive: true }); + const result = await runGit(repoPath, ["worktree", "add", path, "-b", branch]); + if (result.exitCode !== 0) { + throw new WorktreeError(`git worktree add failed: ${result.stderr.trim()}`); + } + return handle; +} + +/** + * Remove a worktree and its branch, including the directory. Idempotent: an + * already-removed worktree and an already-deleted branch are both skipped + * silently; only an unexpected `git` failure surfaces as `WorktreeError`. + */ +export async function destroyWorktree(handle: WorktreeHandle): Promise { + const registered = await rawList(handle.repoPath); + if (registered.some((w) => w.path === handle.path)) { + const result = await runGit(handle.repoPath, [ + "worktree", + "remove", + "--force", + handle.path, + ]); + if (result.exitCode !== 0) { + throw new WorktreeError(`git worktree remove failed: ${result.stderr.trim()}`); + } + } + + if (handle.branch) { + const branchCheck = await runGit(handle.repoPath, [ + "rev-parse", + "--verify", + `refs/heads/${handle.branch}`, + ]); + if (branchCheck.exitCode === 0) { + const deleteResult = await runGit(handle.repoPath, ["branch", "-D", handle.branch]); + if (deleteResult.exitCode !== 0) { + // Surface the failure clearly — a silently-undeleted branch makes the + // next createWorktree's `git worktree add -b ` fail with a + // cryptic "branch already exists" on re-dispatch. + throw new WorktreeError( + `git branch -D ${handle.branch} failed: ${deleteResult.stderr.trim()}`, + ); + } + } + } + + if (existsSync(handle.path)) { + rmSync(handle.path, { recursive: true, force: true }); + } +} + +/** Enumerate the active worktrees registered to `repoPath` that live under the root. */ +export async function listWorktrees(opts: { + repoPath: string; + worktreeRoot?: string; +}): Promise { + const repoPath = realpathSync(opts.repoPath); + const root = resolveRoot(opts.worktreeRoot); + const raw = await rawList(repoPath); + return raw + .filter((w) => w.path.startsWith(root + sep)) + .map((w) => toHandle(repoPath, root, w)); +} diff --git a/packages/dispatcher/test/db.test.ts b/packages/dispatcher/test/db.test.ts new file mode 100644 index 00000000..bfe0a470 --- /dev/null +++ b/packages/dispatcher/test/db.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { currentSchemaVersion, openAndMigrate, openDb, runMigrations } from "../src/db.ts"; + +let dir: string; +let dbPath: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "middle-db-")); + dbPath = join(dir, "db.sqlite3"); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +const EXPECTED_TABLES = [ + "workflows", + "events", + "rate_limit_state", + "repo_config", + "waitfor_signals", + "schema_version", +]; + +const EXPECTED_INDEXES = [ + "idx_workflows_state", + "idx_workflows_repo", + "idx_workflows_heartbeat", + "idx_events_workflow_ts", + "idx_events_ts", +]; + +function names(db: Database, type: "table" | "index"): string[] { + return ( + db + .query(`SELECT name FROM sqlite_master WHERE type = ? ORDER BY name`) + .all(type) as { name: string }[] + ).map((r) => r.name); +} + +describe("openDb", () => { + test("opens a file database in WAL mode", () => { + const db = openDb(dbPath); + const mode = db.query("PRAGMA journal_mode").get() as { journal_mode: string }; + expect(mode.journal_mode).toBe("wal"); + db.close(); + }); +}); + +describe("runMigrations", () => { + test("a fresh db starts at schema version 0", () => { + const db = openDb(dbPath); + expect(currentSchemaVersion(db)).toBe(0); + db.close(); + }); + + test("applies 001_initial and reports version 1", () => { + const db = openDb(dbPath); + expect(runMigrations(db)).toBe(1); + expect(currentSchemaVersion(db)).toBe(1); + db.close(); + }); + + test("001_initial creates every documented table", () => { + const db = openDb(dbPath); + runMigrations(db); + const tables = names(db, "table"); + for (const t of EXPECTED_TABLES) expect(tables).toContain(t); + db.close(); + }); + + test("001_initial creates every documented index", () => { + const db = openDb(dbPath); + runMigrations(db); + const indexes = names(db, "index"); + for (const i of EXPECTED_INDEXES) expect(indexes).toContain(i); + db.close(); + }); + + test("is idempotent — running twice leaves version at 1 and does not throw", () => { + const db = openDb(dbPath); + runMigrations(db); + expect(runMigrations(db)).toBe(1); + expect(currentSchemaVersion(db)).toBe(1); + db.close(); + }); + + test("workflows.state CHECK rejects an unknown state", () => { + const db = openAndMigrate(dbPath); + const insert = () => + db.run( + `INSERT INTO workflows (id, kind, repo, adapter, state, created_at, updated_at) + VALUES ('w1', 'implementation', 'o/r', 'claude', 'bogus', 0, 0)`, + ); + expect(insert).toThrow(); + db.close(); + }); + + test("workflows.state CHECK accepts 'launching'", () => { + const db = openAndMigrate(dbPath); + db.run( + `INSERT INTO workflows (id, kind, repo, adapter, state, created_at, updated_at) + VALUES ('w1', 'implementation', 'o/r', 'claude', 'launching', 0, 0)`, + ); + const row = db.query("SELECT controlled_by FROM workflows WHERE id = 'w1'").get() as { + controlled_by: string; + }; + expect(row.controlled_by).toBe("middle"); + db.close(); + }); +}); + +describe("openAndMigrate", () => { + test("opens, migrates, and returns a ready database", () => { + const db = openAndMigrate(dbPath); + expect(currentSchemaVersion(db)).toBe(1); + db.close(); + }); +}); diff --git a/packages/dispatcher/test/hook-server.test.ts b/packages/dispatcher/test/hook-server.test.ts new file mode 100644 index 00000000..5dcb5c21 --- /dev/null +++ b/packages/dispatcher/test/hook-server.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { HookServer } from "../src/hook-server.ts"; + +let server: HookServer; + +beforeEach(() => { + server = new HookServer(); + server.start(0); // ephemeral port +}); + +afterEach(() => { + server.stop(); +}); + +async function postHook( + event: string, + sessionName: string, + payload: Record, +): Promise { + return fetch(`http://127.0.0.1:${server.port}/hooks/${event}`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Middle-Session": sessionName }, + body: JSON.stringify(payload), + }); +} + +describe("HookServer — SessionStart", () => { + test("awaitSessionStart resolves with the posted payload", async () => { + const pending = server.awaitSessionStart("middle-6", 1000); + const res = await postHook("session.started", "middle-6", { + session_id: "sess-abc", + transcript_path: "/home/u/.claude/projects/x/sess-abc.jsonl", + }); + expect(res.status).toBe(200); + const payload = await pending; + expect(payload.session_id).toBe("sess-abc"); + expect(payload.transcript_path).toBe("/home/u/.claude/projects/x/sess-abc.jsonl"); + }); + + test("a payload that arrives before anyone awaits is stashed and delivered", async () => { + await postHook("session.started", "middle-7", { session_id: "early" }); + const payload = await server.awaitSessionStart("middle-7", 1000); + expect(payload.session_id).toBe("early"); + }); + + test("duplicate pre-await arrivals keep the FIRST payload, not the last", async () => { + // a retry scenario could fire SessionStart twice with overlapping payloads; + // the second must not silently overwrite the first + await postHook("session.started", "middle-9", { session_id: "first" }); + await postHook("session.started", "middle-9", { session_id: "second" }); + const payload = await server.awaitSessionStart("middle-9", 1000); + expect(payload.session_id).toBe("first"); + }); + + test("waiters are keyed by session — one session's event does not satisfy another", async () => { + const pending = server.awaitSessionStart("middle-8", 300); + await postHook("session.started", "middle-DIFFERENT", { session_id: "x" }); + await expect(pending).rejects.toThrow(); + }); +}); + +describe("HookServer — Stop", () => { + test("awaitStop resolves on an agent.stopped POST", async () => { + const pending = server.awaitStop("middle-6", 1000); + await postHook("agent.stopped", "middle-6", { reason: "turn-end" }); + const payload = await pending; + expect(payload.reason).toBe("turn-end"); + }); +}); + +describe("HookServer — lifecycle", () => { + test("awaitSessionStart rejects on timeout", async () => { + await expect(server.awaitSessionStart("never", 50)).rejects.toThrow(); + }); + + test("non-POST and unknown paths return 404", async () => { + const res = await fetch(`http://127.0.0.1:${server.port}/nope`); + expect(res.status).toBe(404); + }); + + test("stop() rejects outstanding waiters", async () => { + const pending = server.awaitStop("middle-6", 5000); + server.stop(); + await expect(pending).rejects.toThrow(); + }); +}); diff --git a/packages/dispatcher/test/implementation-workflow.test.ts b/packages/dispatcher/test/implementation-workflow.test.ts new file mode 100644 index 00000000..c33c08cd --- /dev/null +++ b/packages/dispatcher/test/implementation-workflow.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { Database } from "bun:sqlite"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentAdapter, HookPayload, StopClassification } from "@middle/core"; +import { Engine } from "bunqueue/workflow"; +import { openAndMigrate } from "../src/db.ts"; +import type { SessionGate } from "../src/hook-server.ts"; +import { getWorkflow } from "../src/workflow-record.ts"; +import { + createImplementationWorkflow, + type ImplementationDeps, +} from "../src/workflows/implementation.ts"; +import { createWorktree, destroyWorktree, listWorktrees } from "../src/worktree.ts"; + +let scratch: string; +let repoPath: string; +let worktreeRoot: string; +let db: Database; +let engine: Engine; + +// Deterministic identity for the throwaway fixture repo via env (not `-c`), +// so `git commit` doesn't depend on host-level git config. +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: "middle-test", + GIT_AUTHOR_EMAIL: "middle-test@example.invalid", + GIT_COMMITTER_NAME: "middle-test", + GIT_COMMITTER_EMAIL: "middle-test@example.invalid", +}; + +async function git(cwd: string, args: string[]): Promise { + const proc = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "ignore", + stderr: "pipe", + env: GIT_ENV, + }); + if ((await proc.exited) !== 0) { + throw new Error(`git ${args.join(" ")}: ${await new Response(proc.stderr).text()}`); + } +} + +beforeEach(async () => { + scratch = realpathSync(mkdtempSync(join(tmpdir(), "middle-wf-"))); + repoPath = join(scratch, "repo"); + worktreeRoot = join(scratch, "worktrees"); + 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 + // engine, no filesystem vnode churn under the test's temp dir. + engine = new Engine({ embedded: true }); +}); + +afterEach(async () => { + await engine.close(true); + db.close(); + rmSync(scratch, { recursive: true, force: true }); +}); + +/** A tmux stub that records every session it is asked to create and kill. */ +function makeTmuxStub() { + const created: string[] = []; + const killed: string[] = []; + return { + created, + killed, + ops: { + async newSession(opts: { sessionName: string }) { + created.push(opts.sessionName); + }, + async sendText() {}, + async sendEnter() {}, + async killSession(sessionName: string) { + killed.push(sessionName); + }, + }, + }; +} + +/** A SessionGate stub that resolves both events immediately. */ +const readyGate: SessionGate = { + awaitSessionStart: async () => + ({ session_id: "stub-session", transcript_path: "/tmp/stub.jsonl" }) as HookPayload, + awaitStop: async () => ({ reason: "turn-end" }) as HookPayload, +}; + +/** A minimal AgentAdapter stub with a configurable classifyStop outcome. */ +function makeAdapterStub(classification: StopClassification): AgentAdapter { + return { + name: "stub", + readyEvent: "session.started", + async installHooks() {}, + buildLaunchCommand: () => ({ argv: ["true"], env: {} }), + buildPromptText: () => "@.middle/prompt.md", + async enterAutoMode() {}, + resolveTranscriptPath: (payload) => payload.transcript_path as string, + readTranscriptState: () => ({ + lastActivity: "", + contextTokens: 0, + turnCount: 0, + lastToolUse: null, + }), + classifyStop: () => classification, + }; +} + +function makeDeps(overrides: Partial): ImplementationDeps { + return { + db, + getAdapter: () => makeAdapterStub({ kind: "done" }), + sessionGate: readyGate, + tmux: makeTmuxStub().ops, + worktree: { createWorktree, destroyWorktree }, + resolveRepoPath: () => repoPath, + worktreeRoot, + dispatcherUrl: "http://127.0.0.1:8822", + launchTimeoutMs: 2000, + stopTimeoutMs: 2000, + ...overrides, + }; +} + +/** No session leak: every tmux session that was created was also killed. */ +function expectNoSessionLeak(tmux: { created: string[]; killed: string[] }): void { + expect(tmux.created.length).toBeGreaterThanOrEqual(1); + for (const session of new Set(tmux.created)) { + expect(tmux.killed).toContain(session); + } +} + +async function runToEnd(deps: ImplementationDeps): Promise { + engine.register(createImplementationWorkflow(deps)); + const handle = await engine.start("implementation", { + repo: "thejustinwalsh/middle", + epicNumber: 6, + adapter: "stub", + }); + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const execution = engine.getExecution(handle.id); + if (execution && execution.state !== "running" && execution.state !== "compensating") { + return handle.id; + } + await Bun.sleep(15); + } + throw new Error("workflow did not settle within 5s"); +} + +describe("implementation workflow — happy path", () => { + test("runs prepare → drive → cleanup, ends 'completed', leaks nothing", async () => { + const tmux = makeTmuxStub(); + const deps = makeDeps({ + tmux: tmux.ops, + getAdapter: () => makeAdapterStub({ kind: "done" }), + }); + const id = await runToEnd(deps); + + const record = getWorkflow(db, id)!; + expect(record.state).toBe("completed"); + expect(record.epicNumber).toBe(6); + expect(record.sessionName).toBe("middle-thejustinwalsh-middle-6"); + expect(record.sessionId).toBe("stub-session"); + expect(record.transcriptPath).toBe("/tmp/stub.jsonl"); + + // no worktree leak + expect(await listWorktrees({ repoPath, worktreeRoot })).toEqual([]); + // no session leak — every created session was killed + expectNoSessionLeak(tmux); + }); + + test("a 'failed' classifyStop ends the workflow 'failed' but still cleans up", async () => { + const tmux = makeTmuxStub(); + const deps = makeDeps({ + tmux: tmux.ops, + getAdapter: () => makeAdapterStub({ kind: "failed", reason: "stub failure" }), + }); + const id = await runToEnd(deps); + + expect(getWorkflow(db, id)!.state).toBe("failed"); + expect(await listWorktrees({ repoPath, worktreeRoot })).toEqual([]); + expectNoSessionLeak(tmux); + }); +}); + +describe("implementation workflow — compensation", () => { + test("a launch failure compensates: worktree rolled back, session freed, state 'compensated'", async () => { + const tmux = makeTmuxStub(); + const failingGate: SessionGate = { + awaitSessionStart: async () => { + throw new Error("launch timeout"); + }, + awaitStop: async () => ({}) as HookPayload, + }; + const deps = makeDeps({ tmux: tmux.ops, sessionGate: failingGate }); + + engine.register(createImplementationWorkflow(deps)); + const handle = await engine.start("implementation", { + repo: "thejustinwalsh/middle", + epicNumber: 6, + adapter: "stub", + }); + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const execution = engine.getExecution(handle.id); + if (execution && execution.state !== "running" && execution.state !== "compensating") break; + await Bun.sleep(15); + } + + expect(getWorkflow(db, handle.id)!.state).toBe("compensated"); + expect(await listWorktrees({ repoPath, worktreeRoot })).toEqual([]); + expectNoSessionLeak(tmux); + }); +}); diff --git a/packages/dispatcher/test/main.test.ts b/packages/dispatcher/test/main.test.ts new file mode 100644 index 00000000..2e9678c3 --- /dev/null +++ b/packages/dispatcher/test/main.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Verifies the dispatcher process entrypoint that `mm start` spawns and +// `mm stop` signals: it stands up the hook server, announces readiness, and +// shuts down cleanly on SIGTERM. + +let dir: string; +let configPath: string; +const mainEntrypoint = join(import.meta.dir, "..", "src", "main.ts"); + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "middle-main-")); + configPath = join(dir, "config.toml"); + writeFileSync( + configPath, + [ + "[global]", + "dispatcher_port = 0", // ephemeral — main.ts prints the resolved port + `db_path = "${join(dir, "db.sqlite3")}"`, + `worktree_root = "${join(dir, "worktrees")}"`, + `log_dir = "${join(dir, "logs")}"`, + "", + ].join("\n"), + ); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("dispatcher main", () => { + test("starts the hook server, announces readiness, and exits 0 on SIGTERM", async () => { + const proc = Bun.spawn(["bun", mainEntrypoint], { + env: { ...process.env, MIDDLE_CONFIG: configPath }, + stdout: "pipe", + stderr: "pipe", + }); + + try { + // Wait for the readiness line, with a real wall-clock cap: race each + // read against the remaining time so a blocking read can't outlast the + // deadline. + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + const deadline = Date.now() + 5000; + while (!output.includes("dispatcher up") && Date.now() < deadline) { + const result = await Promise.race([ + reader.read(), + Bun.sleep(deadline - Date.now()).then(() => "timed-out" as const), + ]); + if (typeof result === "string") break; // timed out + if (result.done) break; + output += decoder.decode(result.value); + } + reader.releaseLock(); + expect(output).toContain("middle dispatcher up"); + + proc.kill("SIGTERM"); + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + } finally { + // Always reap the spawned dispatcher, even if an assertion above threw. + proc.kill("SIGKILL"); + } + }); +}); diff --git a/packages/dispatcher/test/tmux.test.ts b/packages/dispatcher/test/tmux.test.ts new file mode 100644 index 00000000..12587f88 --- /dev/null +++ b/packages/dispatcher/test/tmux.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + capturePane, + getTmuxVersion, + hasSession, + killSession, + MIN_TMUX_VERSION, + newSession, + parseTmuxVersion, + sendEnter, + sendText, + status, + TmuxError, + tmuxVersionAtLeast, +} from "../src/tmux.ts"; + +const TMUX = Bun.which("tmux"); +const d = describe.skipIf(!TMUX); + +const created: string[] = []; + +function uniqueName(): string { + const name = `middle-test-${crypto.randomUUID().slice(0, 8)}`; + created.push(name); + return name; +} + +afterEach(async () => { + while (created.length > 0) { + const name = created.pop()!; + try { + await killSession(name); + } catch { + // best-effort cleanup + } + } +}); + +d("tmux session lifecycle", () => { + test("launch → has-session → send-text → capture-pane → kill", async () => { + const name = uniqueName(); + await newSession({ sessionName: name, command: ["cat"] }); + expect(await hasSession(name)).toBe(true); + + await sendText(name, "hello world"); + await sendEnter(name); + await Bun.sleep(250); + expect(await capturePane(name)).toContain("hello world"); + + const s = await status(name); + expect(s.alive).toBe(true); + expect(s.paneCount).toBeGreaterThanOrEqual(1); + + await killSession(name); + expect(await hasSession(name)).toBe(false); + }); + + test("newSession injects env via -e KEY=val", async () => { + const name = uniqueName(); + await newSession({ + sessionName: name, + command: ["sh", "-c", "echo VAR=$MIDDLE_TEST_VAR; sleep 5"], + env: { MIDDLE_TEST_VAR: "injected-value" }, + }); + await Bun.sleep(250); + expect(await capturePane(name)).toContain("VAR=injected-value"); + }); + + test("hasSession is false for an unknown session", async () => { + expect(await hasSession(`middle-test-nonexistent-${crypto.randomUUID().slice(0, 8)}`)).toBe( + false, + ); + }); + + test("status reports not-alive for an unknown session", async () => { + const s = await status(`middle-test-nonexistent-${crypto.randomUUID().slice(0, 8)}`); + expect(s.alive).toBe(false); + expect(s.paneCount).toBe(0); + }); + + test("killSession on an already-gone session is a no-op, not a throw", async () => { + const name = `middle-test-gone-${crypto.randomUUID().slice(0, 8)}`; + await killSession(name); // must not throw + expect(await hasSession(name)).toBe(false); + }); + + test("newSession rejects a duplicate session name with a TmuxError", async () => { + const name = uniqueName(); + await newSession({ sessionName: name, command: ["cat"] }); + await expect(newSession({ sessionName: name, command: ["cat"] })).rejects.toBeInstanceOf( + TmuxError, + ); + }); + + test("getTmuxVersion parses the installed tmux's version", async () => { + const v = await getTmuxVersion(); + expect(v).not.toBeNull(); + expect(v!.major).toBeGreaterThanOrEqual(2); + }); +}); + +describe("parseTmuxVersion", () => { + test("parses release versions", () => { + expect(parseTmuxVersion("tmux 3.5")).toEqual({ major: 3, minor: 5, raw: "tmux 3.5" }); + expect(parseTmuxVersion("tmux 3.4")).toEqual({ major: 3, minor: 4, raw: "tmux 3.4" }); + }); + + test("parses pre-release builds (next-X.Y, X.Ya)", () => { + const next = parseTmuxVersion("tmux next-3.6"); + expect(next?.major).toBe(3); + expect(next?.minor).toBe(6); + const patched = parseTmuxVersion("tmux 3.5a"); + expect(patched?.major).toBe(3); + expect(patched?.minor).toBe(5); + }); + + test("returns null on garbage input", () => { + expect(parseTmuxVersion("")).toBeNull(); + expect(parseTmuxVersion("not tmux at all")).toBeNull(); + }); +}); + +describe("tmuxVersionAtLeast", () => { + test("compares major then minor against the threshold", () => { + expect(tmuxVersionAtLeast({ major: 3, minor: 5, raw: "" }, MIN_TMUX_VERSION)).toBe(true); + expect(tmuxVersionAtLeast({ major: 3, minor: 6, raw: "" }, MIN_TMUX_VERSION)).toBe(true); + expect(tmuxVersionAtLeast({ major: 4, minor: 0, raw: "" }, MIN_TMUX_VERSION)).toBe(true); + expect(tmuxVersionAtLeast({ major: 3, minor: 4, raw: "" }, MIN_TMUX_VERSION)).toBe(false); + expect(tmuxVersionAtLeast({ major: 2, minor: 9, raw: "" }, MIN_TMUX_VERSION)).toBe(false); + }); +}); diff --git a/packages/dispatcher/test/workflow-record.test.ts b/packages/dispatcher/test/workflow-record.test.ts new file mode 100644 index 00000000..389cbae4 --- /dev/null +++ b/packages/dispatcher/test/workflow-record.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openAndMigrate } from "../src/db.ts"; +import { createWorkflowRecord, getWorkflow, updateWorkflow } from "../src/workflow-record.ts"; + +let dir: string; +let db: Database; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "middle-wfrec-")); + db = openAndMigrate(join(dir, "db.sqlite3")); +}); + +afterEach(() => { + db.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe("createWorkflowRecord", () => { + test("inserts a pending implementation row carrying epic_number", () => { + createWorkflowRecord(db, { + id: "exec-1", + kind: "implementation", + repo: "thejustinwalsh/middle", + epicNumber: 6, + adapter: "claude", + }); + const row = getWorkflow(db, "exec-1"); + expect(row).not.toBeNull(); + expect(row!.state).toBe("pending"); + expect(row!.epicNumber).toBe(6); + expect(row!.repo).toBe("thejustinwalsh/middle"); + expect(row!.bunqueueExecutionId).toBe("exec-1"); + expect(row!.controlledBy).toBe("middle"); + }); +}); + +describe("updateWorkflow", () => { + test("transitions state and bumps updated_at", async () => { + createWorkflowRecord(db, { + id: "exec-1", + kind: "implementation", + repo: "o/r", + epicNumber: 6, + adapter: "claude", + }); + const before = getWorkflow(db, "exec-1")!.updatedAt; + await Bun.sleep(2); + updateWorkflow(db, "exec-1", { state: "launching" }); + const after = getWorkflow(db, "exec-1")!; + expect(after.state).toBe("launching"); + expect(after.updatedAt).toBeGreaterThan(before); + }); + + test("patches session fields without disturbing others", () => { + createWorkflowRecord(db, { + id: "exec-1", + kind: "implementation", + repo: "o/r", + epicNumber: 6, + adapter: "claude", + }); + updateWorkflow(db, "exec-1", { worktreePath: "/wt/issue-6" }); + updateWorkflow(db, "exec-1", { + state: "running", + sessionName: "middle-6", + sessionId: "sess-abc", + transcriptPath: "/t/abc.jsonl", + }); + const row = getWorkflow(db, "exec-1")!; + expect(row.state).toBe("running"); + expect(row.worktreePath).toBe("/wt/issue-6"); + expect(row.sessionName).toBe("middle-6"); + expect(row.sessionId).toBe("sess-abc"); + expect(row.transcriptPath).toBe("/t/abc.jsonl"); + }); + + test("a no-op patch leaves the row intact", () => { + createWorkflowRecord(db, { + id: "exec-1", + kind: "implementation", + repo: "o/r", + epicNumber: 6, + adapter: "claude", + }); + updateWorkflow(db, "exec-1", {}); + expect(getWorkflow(db, "exec-1")!.state).toBe("pending"); + }); +}); + +describe("getWorkflow", () => { + test("returns null for an unknown id", () => { + expect(getWorkflow(db, "nope")).toBeNull(); + }); +}); diff --git a/packages/dispatcher/test/worktree.test.ts b/packages/dispatcher/test/worktree.test.ts new file mode 100644 index 00000000..65734c7e --- /dev/null +++ b/packages/dispatcher/test/worktree.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createWorktree, + destroyWorktree, + listWorktrees, + WorktreeError, +} from "../src/worktree.ts"; + +let scratch: string; +let repoPath: string; +let worktreeRoot: string; + +// Deterministic identity for the throwaway fixture repo via env (not `-c`), +// so `git commit` doesn't depend on host-level git config. +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: "middle-test", + GIT_AUTHOR_EMAIL: "middle-test@example.invalid", + GIT_COMMITTER_NAME: "middle-test", + GIT_COMMITTER_EMAIL: "middle-test@example.invalid", +}; + +async function git(cwd: string, args: string[]): Promise { + const proc = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "ignore", + stderr: "pipe", + env: GIT_ENV, + }); + const code = await proc.exited; + if (code !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${await new Response(proc.stderr).text()}`); + } +} + +beforeEach(async () => { + scratch = realpathSync(mkdtempSync(join(tmpdir(), "middle-wt-"))); + repoPath = join(scratch, "repo"); + worktreeRoot = join(scratch, "worktrees"); + await git(scratch, ["init", "repo"]); + // a worktree needs a HEAD to branch from; rely on the machine's git identity + await git(repoPath, ["commit", "--allow-empty", "-m", "init"]); +}); + +afterEach(() => { + rmSync(scratch, { recursive: true, force: true }); +}); + +describe("createWorktree → listWorktrees → destroyWorktree", () => { + test("create places the worktree under //issue- on a fresh branch", async () => { + const handle = await createWorktree({ + repoPath, + repo: "thejustinwalsh/middle", + issueNumber: 6, + worktreeRoot, + }); + expect(handle.path).toBe(join(worktreeRoot, "thejustinwalsh/middle", "issue-6")); + expect(handle.branch).toBe("middle-issue-6"); + expect(handle.unit).toBe("issue-6"); + expect(existsSync(handle.path)).toBe(true); + }); + + test("the recommender unit is named 'recommender'", async () => { + const handle = await createWorktree({ + repoPath, + repo: "thejustinwalsh/middle", + worktreeRoot, + }); + expect(handle.unit).toBe("recommender"); + expect(handle.path).toBe(join(worktreeRoot, "thejustinwalsh/middle", "recommender")); + }); + + test("list enumerates active worktrees under the root", async () => { + await createWorktree({ repoPath, repo: "o/r", issueNumber: 6, worktreeRoot }); + await createWorktree({ repoPath, repo: "o/r", issueNumber: 7, worktreeRoot }); + const listed = await listWorktrees({ repoPath, worktreeRoot }); + expect(listed.map((w) => w.unit).sort()).toEqual(["issue-6", "issue-7"]); + expect(listed.every((w) => w.repo === "o/r")).toBe(true); + }); + + test("destroy removes the worktree directory and its branch", async () => { + const handle = await createWorktree({ repoPath, repo: "o/r", issueNumber: 6, worktreeRoot }); + await destroyWorktree(handle); + expect(existsSync(handle.path)).toBe(false); + expect(await listWorktrees({ repoPath, worktreeRoot })).toEqual([]); + const branchCheck = Bun.spawn( + ["git", "-C", repoPath, "rev-parse", "--verify", `refs/heads/${handle.branch}`], + { stdout: "ignore", stderr: "ignore" }, + ); + expect(await branchCheck.exited).not.toBe(0); + }); +}); + +describe("idempotency", () => { + test("creating an already-existing worktree returns the handle without throwing", async () => { + const first = await createWorktree({ repoPath, repo: "o/r", issueNumber: 6, worktreeRoot }); + const second = await createWorktree({ repoPath, repo: "o/r", issueNumber: 6, worktreeRoot }); + expect(second).toEqual(first); + }); + + test("destroying an already-removed worktree is a no-op, not a throw", async () => { + const handle = await createWorktree({ repoPath, repo: "o/r", issueNumber: 6, worktreeRoot }); + await destroyWorktree(handle); + await destroyWorktree(handle); // must not throw + expect(existsSync(handle.path)).toBe(false); + }); +}); + +describe("failure surfacing", () => { + test("create against a non-git directory throws WorktreeError", async () => { + await expect( + createWorktree({ repoPath: scratch, repo: "o/r", issueNumber: 6, worktreeRoot }), + ).rejects.toBeInstanceOf(WorktreeError); + }); +}); diff --git a/planning/issues/6/decisions.md b/planning/issues/6/decisions.md new file mode 100644 index 00000000..38bff6d7 --- /dev/null +++ b/planning/issues/6/decisions.md @@ -0,0 +1,381 @@ +# Epic #6 — decisions log + +## Migration runner records the version, the migration SQL may too +**File(s):** `packages/dispatcher/src/db.ts:55` +**Date:** 2026-05-14 + +**Decision:** `runMigrations` applies each pending file in its own transaction, then +runs `INSERT OR IGNORE INTO schema_version (version) VALUES (?)`. `001_initial.sql` +also contains its own `INSERT INTO schema_version VALUES (1)` (verbatim from the spec). +**Why:** The spec's `001_initial.sql` self-inserts its version row, but relying on every +future migration author to remember that is fragile. The runner's `INSERT OR IGNORE` +makes version tracking a property of the runner, not a convention each `.sql` file must +honor — while staying a harmless no-op for 001, which already inserted the row. Keeps the +spec's SQL block byte-for-byte and still makes the runner self-sufficient. +**Evidence:** Idempotency test (`runMigrations` twice → version stays 1, no throw). + +## WAL assertions require a file-backed database +**File(s):** `packages/dispatcher/src/db.ts:16`, `packages/dispatcher/test/db.test.ts` +**Date:** 2026-05-14 + +**Decision:** `openDb` sets `PRAGMA journal_mode = WAL`; the test suite opens databases +under a `mkdtempSync` temp dir rather than `:memory:`. +**Why:** SQLite silently keeps `:memory:` databases in "memory" journal mode — a +`:memory:` test would assert `journal_mode = "memory"` and either fail or force a weaker +assertion. A real temp file is the only way to verify the production WAL path. +**Evidence:** `openDb opens a file database in WAL mode` test asserts `journal_mode = "wal"`. + +## Config merge is a generic deep merge; per-repo sections are optional on the type +**File(s):** `packages/core/src/config.ts:126` +**Date:** 2026-05-14 + +**Decision:** `loadConfig` deep-merges the raw parsed tables (per-repo over global, +arrays/scalars replaced wholesale) *before* mapping to the typed object. Global-derived +sections (`global`, `adapters`, `dashboard`) are always present — `GLOBAL_DEFAULTS` +fills any gap — while per-repo sections (`repo`, `limits`, `recommender`, `stateIssue`, +`bootstrap`) are typed `T | undefined` and populated only when the per-repo file exists. +**Why:** The spec says "per-repo overrides global" but the two files have almost +disjoint sections — a literal field-by-field override list would be brittle. A generic +deep merge means a per-repo file *can* override any global key (e.g. drop in its own +`[global]` block) for free, and the disjoint common case still works. Making per-repo +sections optional is honest: there is no sensible default for `repo.owner`, so a +global-only load leaves them `undefined` rather than inventing values. +**Evidence:** `per-repo values override global on a colliding key` test (repo file with +its own `[global]` block wins); `global only` and `missing files` tests. + +## classifyStop detects done/failed via sentinels, not PR state, in Phase 1 +**File(s):** `packages/adapters/claude/src/classify.ts:18` +**Date:** 2026-05-14 + +**Decision:** `classifyStop` returns `done` when `/.middle/done.json` exists and +`failed` when `/.middle/failed.json` exists — sentinel files parallel to the +`.middle/blocked.json` question sentinel. The interface signature +(`{ payload, transcriptPath, sentinelPresent }`) carries no PR handle. +**Why:** The spec describes `classifyStop` as "reads PR state for `done`", but the fixed +interface gives the adapter no PR number and no GitHub client — and Phase 1 explicitly +ships no skill enforcement or hook taxonomy. A sentinel keeps `done`/`failed` +deterministically classifiable (so every branch is unit-testable, per #9's acceptance) +without inventing dependencies. Phase 4's mechanically-enforced PR-ready hook gate +replaces the `done.json` path with the real "agent ran `gh pr ready`" signal. +**Evidence:** `classifyStop` tests cover all five branches against temp `.middle/` dirs. + +## enterAutoMode shells out to tmux directly; adapter does not depend on dispatcher +**File(s):** `packages/adapters/claude/src/index.ts:15` +**Date:** 2026-05-14 + +**Decision:** `enterAutoMode` runs `tmux send-keys -t S-Tab S-Tab` via +`Bun.spawn` inside the adapter package, rather than calling the dispatcher's `tmux.ts` +helper module. +**Why:** `@middle/adapter-claude` depends on `@middle/core` only; the tmux helpers live +in `@middle/dispatcher`, and an adapter → dispatcher dependency would invert the layering. +Entering auto mode is intrinsically a per-CLI keystroke concern the adapter owns, and the +keystroke call is two tokens of `tmux` — not worth a shared abstraction. Not unit-tested +in Phase 1 (needs a live tmux session); exercised by #12's workflow integration. +**Evidence:** dependency graph stays `adapters/* → core`; #9 acceptance does not require +an `enterAutoMode` unit test. + +## Minimal hook receiver handles two events, not just SessionStart +**File(s):** `packages/dispatcher/src/hook-server.ts:19` +**Date:** 2026-05-14 + +**Decision:** The Phase 1 `HookServer` receives both `session.started` (readiness + +`session_id`/`transcript_path` discovery) and `agent.stopped` (the turn boundary) — not +SessionStart alone. +**Why:** Build sequence item 10 names it the "minimal SessionStart hook receiver", but +#12's `launch-and-drive` must "react to the Stop boundary via classifyStop", 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 acceptance gate. "Minimal" still holds +relative to Phase 2: no HMAC auth, no events-table persistence, no full taxonomy — just +the two load-bearing events the launch→drive→observe loop cannot run without. +**Evidence:** `hook-server.test.ts` covers both event types; `implementation-workflow.test.ts` +drives the full loop through a stub `SessionGate`. + +## Workflow factory + structural deps; failure state is `compensated`, agent-failure is `failed` +**File(s):** `packages/dispatcher/src/workflows/implementation.ts:78` +**Date:** 2026-05-14 + +**Decision:** `createImplementationWorkflow(deps)` is a factory closing over a `deps` +bundle (db, adapter registry, `SessionGate`, structural `TmuxOps`/`WorktreeOps`, path +resolvers). `launch-and-drive` wraps its body in try/catch and kills the session on any +throw before rethrowing. Terminal DB states: a step that *throws* ends `compensated` (set +by the prepare-worktree compensation); a clean run whose `classifyStop` returns `failed` +ends `failed`. The bunqueue execution id doubles as `workflows.id`. +**Why:** bunqueue's `StepContext` carries only input/steps/signals — ambient collaborators +must come via closure, and 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. The catch-kill is needed because bunqueue runs compensation only for *completed* +steps — a step that fails mid-launch would otherwise leak its tmux session. Separating +`compensated` (workflow error, rolled back) from `failed` (agent reported failure, ran to +completion) keeps the terminal state honest about *what* failed. +**Evidence:** `implementation-workflow.test.ts` — happy path → `completed`, `failed` +classifyStop → `failed`, launch throw → `compensated`; all three assert zero worktree and +session leaks. bunqueue retries the failing step by default; the leak check tolerates that +by asserting every *distinct* created session was killed. + +## bunqueue runs in-memory in tests (no dataPath) +**File(s):** `packages/dispatcher/test/implementation-workflow.test.ts:38` +**Date:** 2026-05-14 + +**Decision:** The test `Engine` is constructed `{ embedded: true }` with no `dataPath`. +**Why:** With a `dataPath`, bunqueue's `SqliteStorage` opens a file-backed queue DB; under +a `mkdtemp` dir on macOS its write-buffer flushes during the retry path hit +`SQLITE_IOERR_VNODE` ("disk I/O error"). Omitting `dataPath` makes both the queue and the +workflow store in-memory — isolated per `Engine`, no vnode churn. The production dispatcher +(Phase 1 CLI / Phase 2) supplies a real `dataPath`; only the test runs in-memory. +**Evidence:** the I/O error reproduced reliably on the compensation (retry) test with a +temp-dir `dataPath` and vanished once `dataPath` was dropped. + +## `mm dispatch` is self-contained; `dispatchEpic` lives in the dispatcher with an injected adapter registry +**File(s):** `packages/dispatcher/src/dispatch.ts:46`, `packages/cli/src/commands/dispatch.ts:30` +**Date:** 2026-05-14 + +**Decision:** `mm dispatch` does not talk to the long-running `mm start` process — it +calls `dispatchEpic` (in `@middle/dispatcher`), which stands up its *own* hook server + +bunqueue engine for the run and tears them down when the workflow settles. `dispatchEpic` +takes a `getAdapter` registry function as a parameter; the CLI supplies +`(name) => claudeAdapter`. The CLI never imports `bunqueue` or the workflow internals — +only `dispatchEpic` and `openDb`. +**Why:** Routing a force-dispatch into the running dispatcher needs an IPC/HTTP trigger +endpoint, which is Phase 8 (auto-dispatch loop) territory — Phase 1's minimal hook server +is the only HTTP surface and it has no control plane. A self-contained `mm dispatch` meets +the Phase 1 acceptance gate ("spawns Claude in tmux … workflow finalizes … worktree +cleaned up") with no Phase 8 machinery. Putting `dispatchEpic` in the dispatcher package +(not the CLI) keeps `bunqueue`/tmux/worktree/workflow coupling contained; passing +`getAdapter` in keeps `@middle/dispatcher` free of any concrete-adapter dependency, so the +dependency graph stays `cli → {dispatcher, adapter-claude}` with no `dispatcher → adapter-*` +edge. Phase 8 will add the HTTP trigger that lets `mm dispatch` enqueue into the running +process instead. +**Evidence:** `bun run typecheck` clean with the layered imports; `cli/test/dispatch.test.ts` +covers the fail-fast validation; the real Claude end-to-end is a manual verification step +(see the reviewer's brief — it needs the `claude` binary, GitHub auth, and a real repo). + +## Review response (2026-05-15): six hot-path fixes +**File(s):** multiple +**Date:** 2026-05-15 + +**Decision:** Six fixes from Greptile review on PR #73: + +1. **`Stop` hook registration** (`packages/adapters/claude/src/hooks.ts`) — `installHooks` now registers both `SessionStart` → `session.started` *and* `Stop` → `agent.stopped`. It also writes an executable `.middle/hooks/hook.sh` (the universal `curl` POST script from the spec) into the worktree. Without these, a real Claude session would never POST `agent.stopped`, the workflow's `awaitStop` would time out after 4 hours, and every real dispatch would compensate instead of completing. +2. **Sentinel paths anchored to the worktree** (`packages/core/src/adapter.ts`, `packages/adapters/claude/src/classify.ts`, `packages/dispatcher/src/workflows/implementation.ts`) — `classifyStop`'s opts now take `worktree: string`; `.middle/{blocked,done,failed}.json` are resolved from there, not from `payload.cwd` (which may be a subdirectory the agent `cd`'d into, and was falling back to `""` when absent). The caller now passes `handle.path`, matching the anchor already used for `sentinelPresent`. +3. **Resource cleanup on early failure** (`packages/dispatcher/src/dispatch.ts`) — `dispatchEpic` uses a cleanups-stack pattern (`cleanups.push(...)` as each resource is acquired, popped in reverse in a single `finally`). A throw from `hookServer.start()` (e.g. port already bound by a running `mm start` dispatcher) now still closes the db. +4. **`enterAutoMode` throws on non-zero `tmux` exit** (`packages/adapters/claude/src/index.ts`) — the exit code is now checked and stderr captured; a missing session or missing `tmux` binary surfaces as an error so `launchAndDrive`'s catch kills the session and the workflow compensates, instead of silently proceeding to send the prompt into a session that never entered auto mode. +5. **`waitForSettle` outer deadline** (`packages/dispatcher/src/dispatch.ts`) — a 5-hour outer guard (workflow's 4h `stopTimeoutMs` + buffer) so a `null` execution from bunqueue cannot spin the loop forever. +6. **Hook-server stash keeps the first arrival** (`packages/dispatcher/src/hook-server.ts`) — duplicate pre-await hooks no longer overwrite earlier payloads. Matters most for `session.started`, whose payload commits `session_id`/`transcript_path` onto the workflow row. + +**Why:** Items 1 and 2 are correctness-blocking for the real-binary dispatch path the Phase 1 acceptance gate exercises. Items 3–6 are hardening on edge cases (port collision, missing tmux, engine-state corruption, retry-storm duplicate hooks) the real environment will inevitably exercise. +**How to apply:** Regression coverage added: subdir-cwd sentinel test, `enterAutoMode` rejects on missing tmux session, `installHooks` registers both events and writes an executable hook.sh, duplicate-pre-await stash keeps first. Full suite: 104 pass, `tsc` clean. + +## Auto mode via --permission-mode launch flag, not S-Tab keystrokes +**File(s):** `packages/adapters/claude/src/index.ts:14` +**Date:** 2026-05-15 + +**Decision:** `buildLaunchCommand` now produces `["claude", "--permission-mode", +"bypassPermissions"]`. `enterAutoMode` is a no-op — auto mode is engaged at process +launch, not after `SessionStart` via keystrokes. +**Why:** The keystroke path had three real fragilities that surfaced during the manual +end-to-end run: (1) Claude's current mode cycle is `default → acceptEdits → plan → +bypassPermissions`, so two Shift-Tabs lands on *plan mode*, not bypass — the wrong mode +for autonomous dispatch; (2) `SessionStart` fires when the session boots but Claude's +TUI may not be input-ready, and the keystrokes have no readiness gate; (3) two key +events in one `send-keys` call can be debounced/missed. The launch flag avoids all +three: the process starts in the right mode, the mode persists for the session, and +there is nothing to mis-time. The spec's "open empirical question" — flag vs. +keystrokes — resolves to "flag works in interactive mode". +**Evidence:** `buildLaunchCommand` test asserts the new argv; the previous +`enterAutoMode failure surfacing` test (which expected a throw on a missing session) is +replaced by a no-op assertion. The keystroke path remains a documented fallback in case +a future Claude build removes the flag from interactive mode — it's the path the spec +called out as the "guaranteed fallback" and the interface still has the hook. + +## Lifecycle hardening for repeated `mm dispatch` (2026-05-15) +**File(s):** `packages/dispatcher/src/dispatch.ts`, `packages/dispatcher/src/workflows/implementation.ts` +**Date:** 2026-05-15 + +**Decision:** Three changes to keep `mm dispatch` stable across repeated runs and failure paths: + +1. **`engine.close(false)`** in `dispatchEpic`'s cleanups — let the bunqueue worker finish any in-flight job-failure finalization before shutdown. `close(true)` was forcing a teardown while `handleJobFailure` was still inside `throwIfOwnershipConflict`, surfacing as an unhandled `Invalid or expired lock token for job …` and killing the process. +2. **`retry: 1` on `launch-and-drive`** — bunqueue's `retry` field is `maxAttempts` (the loop runs `attempt = 1 … <= retry`), so `1` is exactly one attempt with no retries. Phase 1's minimal workflow has no place for the retry to land — re-launches would pile up tmux session/branch state and aggravate the same lifecycle race. The full workflow's retry budgets (spec) belong on `plan` / `implement-loop`. Default was 3, which is why the operator saw `[3/3]` on a failing run. +3. **`unhandledRejection` swallower** scoped to `dispatchEpic`'s lifetime — matches only `/Invalid or expired lock token for job/` and logs a notice to stderr. Anything else is re-raised via `queueMicrotask` so the runtime crashes the way it would have without the listener. Belt to the suspenders of (1) — bunqueue's worker can still race in edge cases (concurrent shutdown signals, in-flight retries) and the swallower keeps a benign internal race from killing an otherwise-completed dispatch. + +**Why:** Encountered live during the manual end-to-end run on Epic #27. Run 1 completed (empty-prompt no-op turn), run 2 crashed mid-cleanup with the bunqueue lock-token throw before `dispatchEpic` could return. Without these, every dispatch whose `launch-and-drive` fails (which is most early-iteration runs) leaves the process in an inconsistent exit state. +**Evidence:** Full suite 105 pass; tests use the in-memory engine + stub adapter where the close-race doesn't manifest, but the same `dispatchEpic` path is exercised by `runDispatch` integration tests (including the EADDRINUSE failure path). + +## `mm doctor` — Phase-1 preflight for external tools +**File(s):** `packages/cli/src/commands/doctor.ts`, `packages/dispatcher/src/tmux.ts` +**Date:** 2026-05-15 + +**Decision:** Ship a small `mm doctor` subcommand even though the build spec parks it in +Phase 11. The command shells `bun --version`, `tmux -V`, `claude --version`, `git +--version`, `gh --version`, `gh auth status`, parses each, and prints a one-line +pass/warn/fail per tool. Fail is anything missing or broken; warn is "installed but below +the threshold middle expects" — currently tmux < 3.5 (the version that supports +`extended-keys-format = csi-u`, needed for clean Shift-Tab / extended-key passthrough to +Claude when an operator attaches). +**Why:** The Phase 1 manual end-to-end test surfaced two pure-environment puzzles +(`extended-keys-format` in a < 3.5 `.tmux.conf`, the consequence of running on tmux 3.4) +that took longer to diagnose than the dispatch path's own bugs. A doctor command turns +those into one obvious `mm doctor` output. The full `mm doctor` (build-spec Phase 11) adds +schema validation, db row counts, recent retention runs — those are unaffected by this +Phase 1 stub and can extend the same checks list. +**Evidence:** `bun packages/cli/src/index.ts doctor` returns 0 on a healthy machine, +listing one check per tool. `parseTmuxVersion` / `tmuxVersionAtLeast` are unit-tested +(release versions, `next-` pre-releases, `3.5a` patches, garbage rejection). The doctor +test runs the happy path on this machine where the full toolchain is present. + +## Bypass-mode prompt: detect + answer via Down+Enter +**File(s):** `packages/adapters/claude/src/index.ts:18` +**Date:** 2026-05-15 + +**Decision:** `enterAutoMode` polls `tmux capture-pane` against the live session every +~150ms (capped at 5s) for Claude's one-time bypass-mode confirmation, recognized via +`/bypass\s+permissions?|skip\s+permissions?|dangerously/i`. On match it sends +`tmux send-keys Down Enter` to select "Yes, I accept" and returns. If the prompt is +never seen within the window (or `capture-pane` fails — session gone, tmux missing) it +returns silently with no destructive keystrokes. +**Why:** Current Claude pops the bypass confirmation at boot even with +`--dangerously-skip-permissions` set, and autonomous dispatch has no human to answer it. +The flag still belongs on the launch argv (it makes the bypass mode the *intended* state), +but the prompt is a UX safety gate we must dismiss programmatically. Pure send-keys-on-a- +timer would be destructive if the prompt isn't actually showing (Enter on a ready prompt +input submits an empty turn); detection via capture-pane is the same gate the spec +describes ("capture-pane is a thin fallback if the transcript signal is ambiguous"), +narrowly scoped here to answering one well-known prompt. +**Evidence:** `detectBypassPrompt` unit tests on representative confirmation strings +(positive) and ordinary Claude pane content (negative); `enterAutoMode`'s +missing-session early-return verified to settle in under 2s. + +## enterAutoMode runs in PARALLEL with awaitSessionStart, not after +**File(s):** `packages/dispatcher/src/workflows/implementation.ts`, `packages/adapters/claude/src/index.ts` +**Date:** 2026-05-15 + +**Decision:** The bypass-prompt dismisser is kicked off *before* `awaitSessionStart`, +fire-and-forget with a `.catch` for error logging. The polling window is bumped to 90s +(matching `launchTimeout`). The post-readiness `enterAutoMode` call is removed. +**Why:** Claude's `SessionStart` hook does not fire until the agent is past the +bypass-mode warning screen — the warning gates the entire hook system. Calling +`enterAutoMode` *after* `awaitSessionStart` was a chicken-and-egg: the dismisser waited +for the very event the warning was blocking. With the dismisser running concurrently, +the polling capture-pane sees and answers the prompt while `awaitSessionStart` is still +waiting; Claude then proceeds past the warning and fires the hook. +**Evidence:** Empirically discovered during the manual end-to-end run on Epic #27 — +operator observed the warning sitting un-answered while the workflow logs showed +`waiting for SessionStart hook` indefinitely (then timed out). After the fix, the +expected order in stderr is: tmux launch → dismisser starts → SessionStart waits → +(prompt detected → Down+Enter sent) → SessionStart received. + +## Shared TUI primitives in @middle/core; login detection; plan-style prompt +**File(s):** `packages/core/src/tmux-tui.ts`, `packages/adapters/claude/src/index.ts`, `packages/dispatcher/src/workflows/implementation.ts` +**Date:** 2026-05-16 + +**Decision:** Three coordinated changes: + +1. **Shared TUI primitives in `@middle/core/src/tmux-tui.ts`** — `capturePane`, + `sendText`, `sendKeys` (with `delayBetweenMs`), and the load-bearing `pollPaneFor` + (predicate-based polling with optional `tag` for per-iteration stderr diagnostics). + Adapters import from `@middle/core` so they stay free of the dispatcher dep; the + dispatcher's `tmux.ts` keeps the session-lifecycle ops (`newSession`, `hasSession`, + `status`, `killSession`) which it owns alone. +2. **`detectNeedsLogin` + integration** — claude adapter exports both `detectBypassPrompt` + and `detectNeedsLogin`. `enterAutoMode` becomes a single `pollPaneFor` whose predicate + returns the discriminated `'bypass-prompt' | 'needs-login'` outcome. On `needs-login` + it throws a clean "claude is not authenticated — run claude interactively to sign in, + then retry the dispatch" so `mm dispatch` exits with a useful message instead of + hanging on the 90s SessionStart timeout. +3. **`ensurePromptFile` in `launchAndDrive`** — writes a plan-style placeholder + `/.middle/prompt.md` if missing, directing Claude to use the + `implementing-github-issues` skill on Epic #N. A committed `.middle/prompt.md` in the + source repo (operator override) is left alone. Works out-of-box for middle's own + dogfood checkout (`.claude/skills/implementing-github-issues/` is already committed). + Phase 3 `mm init` will install the skill in non-dogfood target repos. + +**Why:** TUI driving is "likely not a one-off" — login screens, ongoing prompts, and any +future "watch the pane, react to it" flow needs the same predicate-poll-then-send shape. +Centralizing once removes the ad-hoc duplication the bypass-prompt iterations accumulated. +The login state is essential: without it, a no-auth Claude hangs the dispatch on a 90s +timeout with a useless "step failed: timed out" message. The placeholder prompt closes the +"agent runs but does nothing" gap that surfaced during the first successful dispatch. + +**Evidence:** `bun test` 123 pass (10 new tests: `detectNeedsLogin` matching, the four +`pollPaneFor` paths — match / timeout / session-gone / tag-logs-stderr, capture/send +helpers against live tmux sessions). `tsc --noEmit` clean. `enterAutoMode` is now half its +previous line count, all the diagnostic logging is free via `pollPaneFor`'s `tag`. + +## Second Greptile review round — six hardening fixes (2026-05-22) +**File(s):** multiple +**Date:** 2026-05-22 + +Six new Greptile findings, all valid, all fixed: + +1. **`hook.sh` `|| exit 0` was dead code after `exec`** (`hooks.ts`) — `exec curl` replaces + the shell, so a non-zero curl exit (refused/timeout/DNS) propagated as a failed hook, + contradicting "failure is a no-op". Dropped `exec`; curl now runs as a child and the + script ends `|| true; exit 0`. +2. **Relative hook command broke Stop delivery from a subdirectory** (`hooks.ts`) — the + `.claude/settings.json` command was `.middle/hooks/hook.sh agent.stopped`, relative to + the agent's cwd at hook-fire time. Claude fires hooks from wherever the agent `cd`'d to, + so it could fail to resolve → no POST → `awaitStop` times out. Now an absolute path + (`join(worktree, hookScriptPath)`). +3. **`mm start` orphan window** (`start.ts`) — `proc.unref()` ran before the pid-file write; + a write failure left a detached dispatcher with no pid file (`mm stop` can't find it, a + second `mm start` duplicates it). Write the pid file first, then `unref()`. +4. **Hook server bound 0.0.0.0** (`hook-server.ts`) — no HMAC + predictable session names + meant any host on the network could POST a fake `agent.stopped`/`session.started` and + hijack a workflow. Now `hostname: "127.0.0.1"` (matches the hardcoded `dispatcherUrl`). +5. **`git branch -D` failure silently discarded** (`worktree.ts`) — a failed branch delete + left the branch on disk; the next `createWorktree`'s `-b` failed cryptically. Now throws + `WorktreeError` with the git stderr. +6. **Session-name collision across repos** (`implementation.ts`) — `middle-${epicNumber}` + had no repo component, so `dispatch /repo-a 7` and `dispatch /repo-b 7` both owned + `middle-7`; the second's failure-path `killSession` tore down the first's live session. + Now `middle-${repoSlug}-${epicNumber}`, matching the repo-namespaced worktree layout. + +**Why:** Findings 1, 2, and 4 are correctness/security-blocking for real dispatch (silent +hook failures, network-exposed control plane). 3, 5, 6 are robustness on edge cases the +real environment will hit (orphaned dispatchers, re-dispatch after a failed cleanup, +cross-repo concurrency). All cheap; none warranted pushback. +**Evidence:** `bun test` 123 pass, `tsc` clean. Session-name test updated to +`middle-thejustinwalsh-middle-6`; EADDRINUSE test's blocker now binds 127.0.0.1 to match +the hook server's interface so the port conflict is deterministic. + +## CodeRabbit review round — 13 fixes (2026-05-22) +**File(s):** multiple +**Date:** 2026-05-22 + +CodeRabbit (Greptile retired) flagged 13; all valid, all fixed. Notably it caught a +regression I introduced in the bunqueue-lifecycle work: + +1. **Engine leak on the failure path** (`dispatch.ts`) — when I pulled `engine.close` out + of the cleanups stack (to drain inline before teardown), I left the throw-path + uncovered: a throw in `engine.register`/`start`/`waitForSettle` skipped the drain. Fix: + push the drain onto the cleanups stack LAST (pops FIRST, before hookServer/db), capped + at 10s. Covers both paths and preserves the "compensation runs while deps alive" + ordering. Removed the now-redundant inline drain. +2. **pid <= 0 guards** (`start.ts`, `stop.ts`) — `process.kill(0|negative, …)` targets + process *groups*. Reject non-positive pids before any `process.kill`. +3. **ESRCH vs EPERM in `runStop`** — only treat ESRCH as "not running" (clear pid file, + exit 0); EPERM/other keeps the pid file and exits 1, so a still-alive dispatcher isn't + silently abandoned. +4. **`mm status` swallowed all query errors** — now only "no such table" → clean exit 0; + corruption/lock/permission errors print + exit 1. +5. **`expandTilde`** — only `~` and `~/...` expand; `~user/...` left untouched. +6. **`openAndMigrate` leaked the db handle on migration failure** — close before rethrow. +7. **Hook server accepted empty session identity** — reject with 400 instead of stashing + an unreachable entry. +8. **`main.ts` shutdown** — each teardown wrapped in try/catch so `process.exit(0)` always + runs (no swallower in that entrypoint). +9. **Worktree path traversal** — `createWorktree` rejects a `repo` that resolves outside + the worktree root (a crafted `../../x` remote slug could otherwise make + `destroyWorktree`'s rmSync delete unintended dirs). +10. **Quoted hook script path** (`hooks.ts`) — `"" ` so a home dir with + spaces doesn't mis-parse the command. +11. **`main.test.ts`** — readiness read now races a real wall-clock cap; spawned dispatcher + reaped in a `finally` even on assertion failure. +12. **Test git identity** — the three scratch-repo fixtures set `GIT_AUTHOR_*` / + `GIT_COMMITTER_*` via **env** (not `-c`, per the repo's git-identity rule) so commits + don't depend on host git config. + +**Why:** 1, 2, 3, 9 are correctness/safety on the dispatch hot path and cleanup; the rest +are robustness + test hermeticity. None warranted pushback. +**Evidence:** `bun test` 123 pass, `tsc` clean. Adapter hook-command test updated for the +quoted-path form; session/EADDRINUSE/expandTilde tests still green. diff --git a/planning/issues/6/plan.md b/planning/issues/6/plan.md new file mode 100644 index 00000000..58bf81ed --- /dev/null +++ b/planning/issues/6/plan.md @@ -0,0 +1,66 @@ +# Issue #6: Minimal dispatcher (worktree, spawn, cleanup) + +**Link:** https://github.com/thejustinwalsh/middle/issues/6 +**Branch:** worktree-6-minimal-dispatcher + +## Goal +Build build-spec Phase 1 — the minimal dispatcher: persistence, config, the adapter +interface with one concrete adapter, tmux/worktree helpers, a 3-step `implementation` +workflow, and the `mm start/stop/status` + `mm dispatch` CLI. No hooks taxonomy, no +skill enforcement (Phase 2/4). + +## Approach +- One Epic = one branch = one PR; the 7 sub-issues are the 7 phases, worked continuously. +- TDD throughout: every new module ships with `bun test` coverage in a sibling `test/`. +- Phase order respects the sub-issues' `Blocked by` graph: 7 → 8 → 9 → 10 → 11 → 12 → 13. +- The build spec (`planning/middle-management-build-spec.md`) is authoritative for the + SQLite schema, adapter interface, event taxonomy, config shape, and CLI surface. +- bunqueue ≥2.7.12 provides `Workflow` + `Engine` (`bunqueue/workflow`); the workflow is + a pure builder, the `Engine` runs it embedded with a `dataPath`. + +## Phases +1. **#7 — SQLite migrations + WAL db wrapper.** `packages/dispatcher/src/db.ts`, + numbered `.sql` migrations under `src/db/migrations/`, migration runner + + `schema_version`. `001_initial.sql` creates every table from the spec's "SQLite schema". +2. **#8 — TOML config loader.** `packages/core/src/config.ts` — parse + merge global + (`~/.middle/config.toml`) and per-repo (`/.middle/config.toml`) via `smol-toml`, + per-repo overrides global, `~` paths expanded. +3. **#9 — AgentAdapter interface + ClaudeAdapter.** `packages/core/src/adapter.ts` + + `events.ts`; `packages/adapters/claude/` implements `buildLaunchCommand`, + `buildPromptText`, `enterAutoMode`, `classifyStop`, `resolveTranscriptPath`, + `readTranscriptState`, minimal `SessionStart`-only `installHooks` stub. +4. **#10 — tmux session helpers.** `packages/dispatcher/src/tmux.ts` — `newSession`, + `sendText` (`-l`), `sendEnter`, `capturePane`, `hasSession`, `status`, `killSession`; + typed errors; lifecycle test skipped gracefully when `tmux` is absent. +5. **#11 — git worktree helpers.** `packages/dispatcher/src/worktree.ts` — + create/destroy/list under `~/.middle/worktrees//issue-/`; idempotent. +6. **#12 — 3-step implementation workflow.** `packages/dispatcher/src/workflows/ + implementation.ts` — bunqueue `Workflow` with prepare-worktree → launch-and-drive → + cleanup; minimal `SessionStart` receiver; `workflows` row transitions + pending → launching → running → completed; end-to-end test against a stub adapter. +7. **#13 — mm CLI.** `packages/cli/src/` — commander wiring, `mm start/stop/status`, + `mm dispatch`; `scripts/dev.sh`; config via the loader; non-zero exit on error. + +## Files likely to change +- `packages/dispatcher/src/db.ts`, `src/db/migrations/001_initial.sql` — new +- `packages/core/src/config.ts` — replace the Phase-0 minimal `RepoConfig` stub with the full loader +- `packages/core/src/adapter.ts`, `src/events.ts`, `src/index.ts` — new / updated exports +- `packages/adapters/claude/src/{index,prompt,classify,hooks}.ts` — new +- `packages/dispatcher/src/{tmux,worktree}.ts`, `src/workflows/implementation.ts`, + `src/hook-server.ts` (minimal), `src/main.ts` — new / updated +- `packages/cli/src/index.ts` + `src/commands/*` — new +- `scripts/dev.sh` — new +- sibling `test/` dirs in each package + +## Out of scope +- Full hook taxonomy, HMAC auth, events-table population, watchdog, reconciler cron (Phase 2) +- `installHooks` writing the whole `.claude/settings.json` event set (Phase 2) +- CodexAdapter (Phase 10); recommender workflow (Phase 7) +- Skill enforcement gates (Phase 4); `mm init`/`uninit`/`doctor`/dashboard (Phases 3/9/11) +- Retention crons for `events`/`workflows` (Phase 11) + +## Open questions +- `enterAutoMode` mechanism (launch flag vs. `S-Tab S-Tab`) is empirical; the keystroke + path is the guaranteed fallback and what this phase ships. Resolved at implementation. +- `RepoConfig` (used by `@middle/state-issue`'s `validate()`) currently only carries + `adapters: string[]`; the full config type must keep that field shape compatible. diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 00000000..9da7281d --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# scripts/dev.sh — start the middle dispatcher in dev mode (foreground, this +# repo's own checkout). Set MIDDLE_CONFIG to point at a non-default config. +set -e +cd "$(dirname "$0")/.." +exec bun run packages/dispatcher/src/main.ts "$@"