From 89bc1c5d06bc51da27a284c0ecf328136cb54378 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Wed, 3 Jun 2026 10:46:50 -0400 Subject: [PATCH 1/7] docs(issue-200): implementation plan for file-mode completeness --- planning/issues/200/plan.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 planning/issues/200/plan.md diff --git a/planning/issues/200/plan.md b/planning/issues/200/plan.md new file mode 100644 index 00000000..395290ab --- /dev/null +++ b/planning/issues/200/plan.md @@ -0,0 +1,34 @@ +# Issue #200: file-mode completeness beyond Phase 1/2 (review-resume, recommender, browse cache) + +**Link:** https://github.com/thejustinwalsh/middle/issues/200 +**Branch:** middle-issue-200 + +## Goal +Close the three documented Phase-1/2 file-mode gaps from Epic #190 so a file-backed Epic reaches full parity with a GitHub-backed one: PR-review resume, recommender/auto-dispatch, and browse-cache/dashboard visibility. + +## Approach +- The #190 foundation is already in `main` (`packages/dispatcher/src/epic-store/`). Each gap is an independent surface that routes an already-mode-aware seam through to a path that's still GitHub-hardcoded. +- Reuse the existing routing pattern (`makeRoutingEpicGateway`/`makeRoutingPollGateway` in `epic-store/index.ts`, `readEpicStoreConfig` for mode) rather than inventing new selectors. +- PRs/reviews are GitHub-native in **both** modes. File mode just can't resolve the PR via `Closes #` — so resolve it via the Epic file's durable `meta.pr` stamp instead. +- Each phase ships with a unit test for the seam **and** an integration test that drives the real path (poller resume / auto-dispatch readState / cache refresh+read), per the integration-verified definition of done. + +## Phases +1. **File-mode PR-review resume** — `file-poll-gateway.findPrForEpic`/`findEpicPrLifecycle` resolve a slug's PR via `meta.pr` → `gh.getPullRequest`, so `review-changes`/`CHANGES_REQUESTED`/merged-reconcile resume works in file mode. +2. **File-mode recommender + auto-dispatch** — route auto-dispatch `readState` + recommender state I/O through a routing state gateway (file ⇄ github); bump `state-issue` `InFlightItem.issue` to carry a string ref (slug); source in-flight rows from `epicRef`. +3. **File-mode Epic browse cache + dashboard** — make `epics-cache` ref-keyed (nullable `number`), route `refreshEpics` per mode, add `ref` to the dashboard `EpicCard` wire type, and route dashboard `listEpics` lookups by ref so file Epics surface in `mm status` / the dashboard. + +## Files likely to change +- `packages/dispatcher/src/epic-store/file-poll-gateway.ts` — resolve PR by `meta.pr` for slugs (P1) +- `packages/dispatcher/test/epic-store/file-poll-gateway.test.ts` — flip the "returns null for slug" cases (P1) +- `packages/state-issue/src/schema.v1.ts`, `parser.ts`, `validate.ts` + `schemas/state-issue.v1.md` — `InFlightItem.issue` string ref (P2) +- `packages/dispatcher/src/epic-store/index.ts` — add `makeRoutingStateGateway` (P2) +- `packages/dispatcher/src/main.ts` — route auto-dispatch/recommender state gateway + `refreshEpics` per mode (P2, P3) +- `packages/dispatcher/src/workflows/recommender.ts` — source in-flight from `epicRef` (P2) +- `packages/dispatcher/src/epics-cache.ts` + new migration `010_*` — ref-keyed cache (P3) +- `packages/dashboard/src/wire.ts`, `db-deps.ts`, `app/components/Epics.tsx` — `ref` on the card, ref-based lookup (P3) + +## Out of scope (already shipped in #190, per issue body) +- File-mode dispatch, question-park, file-watcher question-resume, the parity test, the CLI surface, the skill abstraction — all on merged PR #198. + +## Open questions +- None blocking. The `InFlightItem` change is additive (numeric refs are a subset of string refs; render format `**#**` is unchanged), so it stays schema v1 rather than forcing a v1→v2 bump. Documented in decisions.md. From 4131281c6eb37fd0177e92ff62748e4ca457c8d4 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Wed, 3 Jun 2026 10:54:36 -0400 Subject: [PATCH 2/7] feat(epic-store): file-mode PR-review resume via meta.pr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve a file-mode Epic's PR from the Epic file's durable meta.pr stamp so review-changes/CHANGES_REQUESTED (and the merged/closed reconcile) resume work in file mode — previously the file poll gateway returned null for any slug. - Widen PollGateway with by-PR-number prSnapshot/prLifecycle; ghPollGateway's Closes-#N finder now reuses the extracted fetchPrSnapshot builder. - file-poll-gateway resolves a slug via meta.pr then fetches by number; numeric refs still delegate to gh's finders. A stale/absent meta.pr degrades to null. - Route the two methods through makeRoutingPollGateway. - Integration test drives the real runPoller path end to end for a file Epic. --- .../src/epic-store/file-poll-gateway.ts | 39 ++-- packages/dispatcher/src/epic-store/index.ts | 2 + packages/dispatcher/src/poller-gateway.ts | 130 +++++++----- packages/dispatcher/src/poller.ts | 16 ++ .../test/epic-store/file-poll-gateway.test.ts | 74 ++++++- .../file-review-resume-integration.test.ts | 187 ++++++++++++++++++ .../file-watcher-integration.test.ts | 6 + .../test/epic-store/selector.test.ts | 7 + packages/dispatcher/test/poller.test.ts | 13 ++ planning/issues/200/decisions.md | 24 +++ 10 files changed, 433 insertions(+), 65 deletions(-) create mode 100644 packages/dispatcher/test/epic-store/file-review-resume-integration.test.ts create mode 100644 planning/issues/200/decisions.md diff --git a/packages/dispatcher/src/epic-store/file-poll-gateway.ts b/packages/dispatcher/src/epic-store/file-poll-gateway.ts index d7aa2a95..2cd01c27 100644 --- a/packages/dispatcher/src/epic-store/file-poll-gateway.ts +++ b/packages/dispatcher/src/epic-store/file-poll-gateway.ts @@ -6,11 +6,14 @@ * author-login heuristic — that's what closes #178's class for file mode (the * poller's `classifyNewHumanReply` keys resume off `!authorIsBot`). * - * The PR-poll methods are GitHub-native: `getRateLimit` delegates straight to gh. - * `findPrForEpic`/`findEpicPrLifecycle` delegate for a numeric ref but return - * `null` for a file-mode slug — GitHub's PR-finders resolve by `Closes #`, - * which a file Epic (slug, no GitHub issue) can't carry. File-mode review-resume - * rides Phase 2's watcher work (see `planning/issues/190/decisions.md`). + * The PR-poll methods are GitHub-native: `getRateLimit`, `prSnapshot`, and + * `prLifecycle` delegate straight to gh (the PR exists on GitHub in both modes). + * For a **numeric** ref `findPrForEpic`/`findEpicPrLifecycle` delegate to gh's + * `Closes #` finders; for a **file-mode slug** (no `Closes #` linkage) + * they resolve the Epic's PR from the Epic file's durable `meta.pr` stamp and + * fetch by number — that's what makes `review-changes`/`CHANGES_REQUESTED` (and + * the merged/closed reconcile) resume work in file mode, alongside the + * file-watcher's question-resume (#200; see `planning/issues/200/decisions.md`). */ import type { EpicPrLifecycle, IssueComment, PollGateway, PrSnapshot } from "../poller.ts"; @@ -80,12 +83,19 @@ function conversationToPollComments(conversation: ConversationEntry[]): IssueCom * Build the file-backed `PollGateway` (plus the Phase-2 `pollFileSignals`) for one * repo's Epic directory. `listIssueComments` reads the Epic file's conversation when * a file exists for the ref (deriving `authorIsBot` structurally from marker kind), - * else delegates to the injected `gh` backend. The PR-poll methods (`findPrForEpic`, - * `findEpicPrLifecycle`) delegate only for a numeric ref and return `null` for a - * file-mode slug (no `Closes #N` linkage); `getRateLimit` always delegates to `gh`. + * else delegates to the injected `gh` backend. The PR finders (`findPrForEpic`, + * `findEpicPrLifecycle`) delegate to gh for a numeric ref; for a file-mode slug they + * resolve the PR from the Epic file's `meta.pr` and fetch by number via gh's + * `prSnapshot`/`prLifecycle` (no stamped PR yet → `null`). `prSnapshot`/`prLifecycle`/ + * `getRateLimit` always delegate to `gh` (PRs are GitHub-native in both modes). */ export function makeFilePollGateway(deps: FilePollGatewayDeps): FilePollGateway { const { epicsDir, gh } = deps; + /** The PR number stamped on the Epic file's `meta.pr`, or null (no file / no stamp). */ + function stampedPr(epicRef: string): number | null { + const epic = readEpicFile(epicsDir, epicRef); + return epic?.meta.pr ?? null; + } return { pollFileSignals: (sinceMs) => pollFileSignals(epicsDir, sinceMs), async listIssueComments(repo, ref): Promise { @@ -96,14 +106,21 @@ export function makeFilePollGateway(deps: FilePollGatewayDeps): FilePollGateway }, async findPrForEpic(repo, epicRef): Promise { - // A file-mode slug has no `Closes #` linkage gh can search. - return isNumericRef(epicRef) ? gh.findPrForEpic(repo, epicRef) : null; + // Numeric ref → gh's `Closes #` finder. File-mode slug → resolve the PR + // from the Epic file's `meta.pr` stamp and fetch it by number. + if (isNumericRef(epicRef)) return gh.findPrForEpic(repo, epicRef); + const prNumber = stampedPr(epicRef); + return prNumber === null ? null : gh.prSnapshot(repo, prNumber); }, async findEpicPrLifecycle(repo, epicRef): Promise { - return isNumericRef(epicRef) ? gh.findEpicPrLifecycle(repo, epicRef) : null; + if (isNumericRef(epicRef)) return gh.findEpicPrLifecycle(repo, epicRef); + const prNumber = stampedPr(epicRef); + return prNumber === null ? null : gh.prLifecycle(repo, prNumber); }, + prSnapshot: (repo, prNumber) => gh.prSnapshot(repo, prNumber), + prLifecycle: (repo, prNumber) => gh.prLifecycle(repo, prNumber), getRateLimit: () => gh.getRateLimit(), }; } diff --git a/packages/dispatcher/src/epic-store/index.ts b/packages/dispatcher/src/epic-store/index.ts index 601fb728..0177b07e 100644 --- a/packages/dispatcher/src/epic-store/index.ts +++ b/packages/dispatcher/src/epic-store/index.ts @@ -170,6 +170,8 @@ export function makeRoutingPollGateway(deps: { listIssueComments: (repo, ref) => pollFor(repo).listIssueComments(repo, ref), findPrForEpic: (repo, epicRef) => pollFor(repo).findPrForEpic(repo, epicRef), findEpicPrLifecycle: (repo, epicRef) => pollFor(repo).findEpicPrLifecycle(repo, epicRef), + prSnapshot: (repo, prNumber) => pollFor(repo).prSnapshot(repo, prNumber), + prLifecycle: (repo, prNumber) => pollFor(repo).prLifecycle(repo, prNumber), getRateLimit: () => ghPoll.getRateLimit(), }; } diff --git a/packages/dispatcher/src/poller-gateway.ts b/packages/dispatcher/src/poller-gateway.ts index b096301b..782ee2ff 100644 --- a/packages/dispatcher/src/poller-gateway.ts +++ b/packages/dispatcher/src/poller-gateway.ts @@ -76,6 +76,83 @@ function isBotLogin(login: string, type: string | undefined): boolean { return type === "Bot" || login.endsWith("[bot]"); } +/** + * Build a {@link PrSnapshot} for a known PR number (review decision, individual + * reviews, labels, CI). Shared by the Epic finder (which resolves the number via + * `Closes #`) and the by-number gateway method (file mode, which resolves it + * from `meta.pr`). Returns `null` if the PR can't be viewed (e.g. it doesn't + * exist), so a stale `meta.pr` degrades to "no PR" rather than throwing the pass. + */ +async function fetchPrSnapshot(repo: string, prNumber: number): Promise { + let viewOut: string; + try { + viewOut = await gh([ + "pr", + "view", + String(prNumber), + "--repo", + repo, + "--json", + "reviewDecision,labels,statusCheckRollup", + ]); + } catch { + return null; + } + const view = JSON.parse(viewOut) as { + reviewDecision: string | null; + labels: Array<{ name: string }>; + statusCheckRollup: CheckRollupEntry[] | null; + }; + + const reviewsOut = await gh([ + "api", + "--paginate", + "--slurp", + `repos/${repo}/pulls/${prNumber}/reviews`, + ]); + const reviewRows = ( + JSON.parse(reviewsOut) as Array< + Array<{ + id: number; + state: string; + body: string; + submitted_at: string | null; + user: { login: string } | null; + }> + > + ).flat(); + const reviews: PrReview[] = reviewRows.map((r) => ({ + id: r.id, + state: r.state, + body: r.body ?? "", + submittedAt: r.submitted_at ? Date.parse(r.submitted_at) : 0, + authorLogin: r.user?.login ?? "", + })); + + return { + number: prNumber, + reviewDecision: view.reviewDecision ?? null, + reviews, + labels: view.labels.map((l) => l.name), + ci: deriveCiStatus(view.statusCheckRollup), + }; +} + +/** Lifecycle (state) for a known PR number. Returns `null` if the PR can't be + * viewed (stale `meta.pr` → "no PR" rather than a thrown pass). */ +async function fetchPrLifecycle(repo: string, prNumber: number): Promise { + let out: string; + try { + out = await gh(["pr", "view", String(prNumber), "--repo", repo, "--json", "state"]); + } catch { + return null; + } + const { state } = JSON.parse(out) as { state: string }; + const norm: "OPEN" | "MERGED" | "CLOSED" = + state === "OPEN" ? "OPEN" : state === "MERGED" ? "MERGED" : "CLOSED"; + return { number: prNumber, state: norm }; +} + export const ghPollGateway: PollGateway = { async listIssueComments(repo: string, ref: string): Promise { const issueNumber = refToIssueNumber(ref); @@ -128,54 +205,15 @@ export const ghPollGateway: PollGateway = { const prs = JSON.parse(listOut) as Array<{ number: number; body: string | null }>; const prNumber = prs.find((pr) => closesRe.test(pr.body ?? ""))?.number; if (prNumber === undefined) return null; + return fetchPrSnapshot(repo, prNumber); + }, - const viewOut = await gh([ - "pr", - "view", - String(prNumber), - "--repo", - repo, - "--json", - "reviewDecision,labels,statusCheckRollup", - ]); - const view = JSON.parse(viewOut) as { - reviewDecision: string | null; - labels: Array<{ name: string }>; - statusCheckRollup: CheckRollupEntry[] | null; - }; - - const reviewsOut = await gh([ - "api", - "--paginate", - "--slurp", - `repos/${repo}/pulls/${prNumber}/reviews`, - ]); - const reviewRows = ( - JSON.parse(reviewsOut) as Array< - Array<{ - id: number; - state: string; - body: string; - submitted_at: string | null; - user: { login: string } | null; - }> - > - ).flat(); - const reviews: PrReview[] = reviewRows.map((r) => ({ - id: r.id, - state: r.state, - body: r.body ?? "", - submittedAt: r.submitted_at ? Date.parse(r.submitted_at) : 0, - authorLogin: r.user?.login ?? "", - })); + prSnapshot(repo: string, prNumber: number): Promise { + return fetchPrSnapshot(repo, prNumber); + }, - return { - number: prNumber, - reviewDecision: view.reviewDecision ?? null, - reviews, - labels: view.labels.map((l) => l.name), - ci: deriveCiStatus(view.statusCheckRollup), - }; + prLifecycle(repo: string, prNumber: number): Promise { + return fetchPrLifecycle(repo, prNumber); }, async findEpicPrLifecycle(repo: string, epicRef: string): Promise { diff --git a/packages/dispatcher/src/poller.ts b/packages/dispatcher/src/poller.ts index f977be2e..b983bf3e 100644 --- a/packages/dispatcher/src/poller.ts +++ b/packages/dispatcher/src/poller.ts @@ -91,6 +91,22 @@ export type PollGateway = { * sees a merged/closed PR so a parked workflow can be reconciled to terminal. */ findEpicPrLifecycle(repo: string, epicRef: string): Promise; + /** + * Review snapshot for a **known** PR number — the resolve-then-fetch half of + * {@link findPrForEpic} factored out. The github finder resolves the Epic's PR + * by its `Closes #` linkage and then calls this; a file-mode Epic (a slug + * with no `Closes #` linkage) instead resolves its PR from the Epic file's + * durable `meta.pr` stamp and calls this directly. `null` when no such open PR + * exists. The PR itself is GitHub-native in both Epic-store modes. + */ + prSnapshot(repo: string, prNumber: number): Promise; + /** + * Lifecycle (open/merged/closed) for a **known** PR number — the file-mode + * counterpart to {@link findEpicPrLifecycle}, which resolves a single PR from + * the Epic file's `meta.pr` rather than searching `Closes #` across history. + * `null` when the PR number doesn't resolve. + */ + prLifecycle(repo: string, prNumber: number): Promise; /** * Current REST budget. Read from `gh api rate_limit`, whose own request does * not consume quota — so the poller can consult it every pass for free. diff --git a/packages/dispatcher/test/epic-store/file-poll-gateway.test.ts b/packages/dispatcher/test/epic-store/file-poll-gateway.test.ts index e05fee63..33cce279 100644 --- a/packages/dispatcher/test/epic-store/file-poll-gateway.test.ts +++ b/packages/dispatcher/test/epic-store/file-poll-gateway.test.ts @@ -34,11 +34,19 @@ function baseEpic(conversation: EpicFile["conversation"]): EpicFile { /** A poll gh backend stub recording delegated calls. */ function ghStub(): { gh: PollGateway; - calls: { findPrForEpic: string[]; findEpicPrLifecycle: string[]; rateLimit: number }; + calls: { + findPrForEpic: string[]; + findEpicPrLifecycle: string[]; + prSnapshot: number[]; + prLifecycle: number[]; + rateLimit: number; + }; } { const calls = { findPrForEpic: [] as string[], findEpicPrLifecycle: [] as string[], + prSnapshot: [] as number[], + prLifecycle: [] as number[], rateLimit: 0, }; const gh: PollGateway = { @@ -53,6 +61,14 @@ function ghStub(): { calls.findEpicPrLifecycle.push(epicRef); return { number: 5, state: "OPEN" }; }, + async prSnapshot(_repo, prNumber): Promise { + calls.prSnapshot.push(prNumber); + return { number: prNumber, reviewDecision: "CHANGES_REQUESTED", reviews: [], labels: [] }; + }, + async prLifecycle(_repo, prNumber): Promise { + calls.prLifecycle.push(prNumber); + return { number: prNumber, state: "MERGED" }; + }, async getRateLimit(): Promise { calls.rateLimit += 1; return { remaining: 4999, resetAt: 0 }; @@ -98,23 +114,65 @@ describe("filePollGateway", () => { expect(comments[0]!.body).toBe("gh"); }); - test("findPrForEpic delegates a numeric ref but returns null for a file-mode slug", async () => { + test("findPrForEpic resolves a slug via meta.pr; delegates a numeric ref to gh's finder", async () => { const { gh, calls } = ghStub(); - const gw = makeFilePollGateway({ epicsDir: tmpEpicsDir(), gh }); - expect(await gw.findPrForEpic("o/r", "rollout-epic-store")).toBeNull(); - expect(calls.findPrForEpic).toEqual([]); // slug never reaches gh's `Closes #N` search + const dir = tmpEpicsDir(); + seedEpic(dir, { ...baseEpic([]), meta: { slug: "rollout-epic-store", pr: 77 } }); + const gw = makeFilePollGateway({ epicsDir: dir, gh }); + // Slug → resolve `meta.pr` (77) → gh.prSnapshot by number (never the `Closes #N` finder). + expect(await gw.findPrForEpic("o/r", "rollout-epic-store")).toMatchObject({ + number: 77, + reviewDecision: "CHANGES_REQUESTED", + }); + expect(calls.prSnapshot).toEqual([77]); + expect(calls.findPrForEpic).toEqual([]); + // Numeric ref → gh's `Closes #N` finder. expect(await gw.findPrForEpic("o/r", "42")).toMatchObject({ number: 5 }); expect(calls.findPrForEpic).toEqual(["42"]); }); - test("findEpicPrLifecycle delegates a numeric ref but returns null for a slug", async () => { + test("findPrForEpic returns null for a slug whose Epic file has no stamped meta.pr", async () => { const { gh, calls } = ghStub(); - const gw = makeFilePollGateway({ epicsDir: tmpEpicsDir(), gh }); - expect(await gw.findEpicPrLifecycle("o/r", "rollout-epic-store")).toBeNull(); + const dir = tmpEpicsDir(); + seedEpic(dir, baseEpic([])); // no meta.pr + const gw = makeFilePollGateway({ epicsDir: dir, gh }); + expect(await gw.findPrForEpic("o/r", "rollout-epic-store")).toBeNull(); + expect(calls.prSnapshot).toEqual([]); // no PR to fetch + expect(calls.findPrForEpic).toEqual([]); // and never the slug-rejecting finder + }); + + test("findEpicPrLifecycle resolves a slug via meta.pr; delegates a numeric ref to gh", async () => { + const { gh, calls } = ghStub(); + const dir = tmpEpicsDir(); + seedEpic(dir, { ...baseEpic([]), meta: { slug: "rollout-epic-store", pr: 77 } }); + const gw = makeFilePollGateway({ epicsDir: dir, gh }); + expect(await gw.findEpicPrLifecycle("o/r", "rollout-epic-store")).toMatchObject({ + number: 77, + state: "MERGED", + }); + expect(calls.prLifecycle).toEqual([77]); expect(await gw.findEpicPrLifecycle("o/r", "42")).toMatchObject({ number: 5, state: "OPEN" }); expect(calls.findEpicPrLifecycle).toEqual(["42"]); }); + test("findEpicPrLifecycle returns null for a slug with no stamped meta.pr", async () => { + const { gh, calls } = ghStub(); + const dir = tmpEpicsDir(); + seedEpic(dir, baseEpic([])); + const gw = makeFilePollGateway({ epicsDir: dir, gh }); + expect(await gw.findEpicPrLifecycle("o/r", "rollout-epic-store")).toBeNull(); + expect(calls.prLifecycle).toEqual([]); + }); + + test("prSnapshot / prLifecycle delegate straight to gh by PR number", async () => { + const { gh, calls } = ghStub(); + const gw = makeFilePollGateway({ epicsDir: tmpEpicsDir(), gh }); + expect(await gw.prSnapshot("o/r", 9)).toMatchObject({ number: 9 }); + expect(await gw.prLifecycle("o/r", 9)).toMatchObject({ number: 9, state: "MERGED" }); + expect(calls.prSnapshot).toEqual([9]); + expect(calls.prLifecycle).toEqual([9]); + }); + test("getRateLimit delegates straight to gh", async () => { const { gh, calls } = ghStub(); const budget = await makeFilePollGateway({ epicsDir: tmpEpicsDir(), gh }).getRateLimit(); diff --git a/packages/dispatcher/test/epic-store/file-review-resume-integration.test.ts b/packages/dispatcher/test/epic-store/file-review-resume-integration.test.ts new file mode 100644 index 00000000..68817a74 --- /dev/null +++ b/packages/dispatcher/test/epic-store/file-review-resume-integration.test.ts @@ -0,0 +1,187 @@ +/** + * Integration: a file-mode Epic parked on `review-changes` resumes when its PR + * gets a CHANGES_REQUESTED review — the real poller path (#200 gap 1). + * + * Drives the genuine wiring end to end: `runPoller` → the routing poll gateway + * (file mode for this repo) → `makeFilePollGateway.findPrForEpic` resolving the + * slug's PR from the on-disk Epic file's `meta.pr` stamp → `gh.prSnapshot` by + * number → `classifyReviewOutcome` → `fireSignal`. Before this gap closed, the + * file gateway returned `null` for a slug and the parked workflow never resumed. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openAndMigrate } from "../../src/db.ts"; +import { makeRoutingPollGateway } from "../../src/epic-store/index.ts"; +import { renderEpicFile } from "../../src/epic-store/epic-file/renderer.ts"; +import type { EpicFile } from "../../src/epic-store/epic-file/types.ts"; +import { + runPoller, + type EpicPrLifecycle, + type PollGateway, + type PrSnapshot, + type RateLimitStatus, + type ResumeSignalPayload, +} from "../../src/poller.ts"; +import { + armWaitForSignal, + createWorkflowRecord, + updateWorkflow, +} from "../../src/workflow-record.ts"; +import { signalNameFor } from "../../src/workflows/implementation.ts"; +import { setEpicStoreConfig } from "../../src/repo-config.ts"; + +const REPO = "o/file-repo"; +const SLUG = "rollout-epic-store"; +const PR = 77; +const ARMED_AT = 1_000_000; + +let scratch: string; +let db: Database; +let repoRoot: string; +let epicsDir: string; + +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), "middle-review-resume-")); + db = openAndMigrate(join(scratch, "db.sqlite3")); + repoRoot = join(scratch, "repo"); + epicsDir = join(repoRoot, "planning/epics"); + mkdirSync(epicsDir, { recursive: true }); + setEpicStoreConfig(db, REPO, { + mode: "file", + epicsDir: "planning/epics", + stateFile: ".middle/state.md", + }); +}); + +afterEach(() => { + db.close(); + rmSync(scratch, { recursive: true, force: true }); +}); + +/** Write a real Epic file under `epicsDir`, optionally stamping `meta.pr`. */ +function seedEpic(pr?: number): void { + const epic: EpicFile = { + title: "feat: rollout the epic store", + meta: pr === undefined ? { slug: SLUG } : { slug: SLUG, pr }, + context: "ctx", + acceptanceCriteria: [], + subIssues: [], + conversation: [], + }; + writeFileSync(join(epicsDir, `${SLUG}.md`), renderEpicFile(epic)); +} + +/** Park an `implementation` workflow on the slug with an armed review-changes wait. */ +function seedParkedOnReview(): string { + const id = crypto.randomUUID(); + createWorkflowRecord(db, { + id, + kind: "implementation", + repo: REPO, + epicRef: SLUG, + adapter: "claude", + }); + updateWorkflow(db, id, { state: "waiting-human" }); + armWaitForSignal( + db, + signalNameFor(SLUG, "review-changes"), + id, + JSON.stringify({ reason: "review-changes" }), + ); + db.run("UPDATE waitfor_signals SET created_at = ? WHERE workflow_id = ?", [ARMED_AT, id]); + return id; +} + +/** A gh poll backend that returns a fresh CHANGES_REQUESTED snapshot for `PR`. */ +function ghBackend(): { gh: PollGateway; prSnapshotCalls: number[] } { + const prSnapshotCalls: number[] = []; + const gh: PollGateway = { + async listIssueComments(): Promise { + return []; + }, + async findPrForEpic(): Promise { + return null; // a slug never reaches this — meta.pr → prSnapshot is the path + }, + async findEpicPrLifecycle(): Promise { + return null; + }, + async prSnapshot(_repo, prNumber): Promise { + prSnapshotCalls.push(prNumber); + return { + number: prNumber, + reviewDecision: "CHANGES_REQUESTED", + reviews: [ + { + id: 7, + state: "CHANGES_REQUESTED", + authorLogin: "coderabbitai[bot]", + submittedAt: ARMED_AT + 10, // fresh: posted after the wait armed + body: "Actionable comments posted: 3", + }, + ], + labels: [], + }; + }, + async prLifecycle(_repo, prNumber): Promise { + return { number: prNumber, state: "OPEN" }; + }, + async getRateLimit(): Promise { + return { remaining: 5000, resetAt: 0 }; + }, + }; + return { gh, prSnapshotCalls }; +} + +function captureFires(): { + fired: Array<{ workflowId: string; payload: ResumeSignalPayload }>; + fireSignal: (id: string, p: ResumeSignalPayload) => Promise; +} { + const fired: Array<{ workflowId: string; payload: ResumeSignalPayload }> = []; + return { + fired, + fireSignal: async (workflowId, payload) => void fired.push({ workflowId, payload }), + }; +} + +describe("file-mode PR-review resume (real poller path)", () => { + test("a CHANGES_REQUESTED review on the stamped PR resumes the parked file-mode Epic", async () => { + seedEpic(PR); + const id = seedParkedOnReview(); + const { gh, prSnapshotCalls } = ghBackend(); + const github = makeRoutingPollGateway({ db, resolveRepoPath: () => repoRoot, ghPoll: gh }); + const { fired, fireSignal } = captureFires(); + + const n = await runPoller({ db, github, fireSignal, now: () => ARMED_AT + 1000 }); + + expect(n).toBe(1); + expect(prSnapshotCalls).toEqual([PR]); // resolved via meta.pr, fetched by number + expect(fired).toEqual([ + { + workflowId: id, + payload: { + reason: "review-changes", + outcome: "changes-requested", + reviewId: 7, + decision: "CHANGES_REQUESTED", + }, + }, + ]); + }); + + test("no resume while the Epic file has no stamped meta.pr (PR not opened yet)", async () => { + seedEpic(); // no meta.pr + seedParkedOnReview(); + const { gh, prSnapshotCalls } = ghBackend(); + const github = makeRoutingPollGateway({ db, resolveRepoPath: () => repoRoot, ghPoll: gh }); + const { fired, fireSignal } = captureFires(); + + const n = await runPoller({ db, github, fireSignal, now: () => ARMED_AT + 1000 }); + + expect(n).toBe(0); + expect(prSnapshotCalls).toEqual([]); // nothing to fetch + expect(fired).toEqual([]); + }); +}); diff --git a/packages/dispatcher/test/epic-store/file-watcher-integration.test.ts b/packages/dispatcher/test/epic-store/file-watcher-integration.test.ts index 0ebc08bf..ead5f171 100644 --- a/packages/dispatcher/test/epic-store/file-watcher-integration.test.ts +++ b/packages/dispatcher/test/epic-store/file-watcher-integration.test.ts @@ -147,6 +147,12 @@ const stubGithubPoll: PollGateway = { async findEpicPrLifecycle(): Promise { return null; }, + async prSnapshot(): Promise { + return null; + }, + async prLifecycle(): Promise { + return null; + }, async getRateLimit(): Promise { return { remaining: 5000, resetAt: 0 }; }, diff --git a/packages/dispatcher/test/epic-store/selector.test.ts b/packages/dispatcher/test/epic-store/selector.test.ts index 9998fa18..b74acb12 100644 --- a/packages/dispatcher/test/epic-store/selector.test.ts +++ b/packages/dispatcher/test/epic-store/selector.test.ts @@ -136,6 +136,13 @@ describe("makeRoutingPollGateway", () => { async findEpicPrLifecycle(): Promise { return { number: 5, state: "OPEN" }; }, + async prSnapshot(_repo, prNumber): Promise { + ghCalls.push(`prSnapshot:${prNumber}`); + return { number: prNumber, reviewDecision: null, reviews: [], labels: [] }; + }, + async prLifecycle(_repo, prNumber): Promise { + return { number: prNumber, state: "OPEN" }; + }, async getRateLimit(): Promise { ghCalls.push("rate"); return { remaining: 4999, resetAt: 0 }; diff --git a/packages/dispatcher/test/poller.test.ts b/packages/dispatcher/test/poller.test.ts index 8ba1b40c..f5fcee34 100644 --- a/packages/dispatcher/test/poller.test.ts +++ b/packages/dispatcher/test/poller.test.ts @@ -96,6 +96,13 @@ function makeGateway(opts: { async findEpicPrLifecycle() { return null; // these tests exercise the resume poller, not reconciliation }, + async prSnapshot() { + g.prCalls++; + return opts.pr ?? null; + }, + async prLifecycle() { + return null; + }, async getRateLimit() { g.rateLimitCalls++; return opts.rateLimit ?? { remaining: 5000, resetAt: 0 }; @@ -475,6 +482,12 @@ describe("runPoller — resilience", () => { async findEpicPrLifecycle() { return null; }, + async prSnapshot() { + return null; + }, + async prLifecycle() { + return null; + }, async getRateLimit() { return { remaining: 5000, resetAt: 0 }; }, diff --git a/planning/issues/200/decisions.md b/planning/issues/200/decisions.md new file mode 100644 index 00000000..bdefa657 --- /dev/null +++ b/planning/issues/200/decisions.md @@ -0,0 +1,24 @@ +# Issue #200 — decisions log + +## Resolve a file-mode Epic's PR via `meta.pr`, not a PR-body marker +**File(s):** `packages/dispatcher/src/epic-store/file-poll-gateway.ts`, `packages/dispatcher/src/poller-gateway.ts:79` +**Date:** 2026-06-03 + +**Decision:** For a file-mode slug, `findPrForEpic`/`findEpicPrLifecycle` resolve the Epic's PR number from the Epic file's durable `meta.pr` stamp, then fetch the snapshot/lifecycle by number via two new by-PR-number gateway methods (`prSnapshot`/`prLifecycle`). +**Why:** The issue offered "resolve by marker / `meta.pr`". The `` PR-body marker is **not actually written** anywhere in the current codebase (grep finds no writer), whereas `meta.pr` is already the established resolution key — `file-epic-gateway.findEpicPr` resolves the PR object the exact same way (`epic.meta.pr` → `gh.getPullRequest`). Mirroring that keeps one resolution mechanism for file-mode PRs instead of introducing a second (a body marker) that nothing maintains. `meta.pr` is stamped when the PR opens, so it's authoritative. +**Evidence:** `file-epic-gateway.ts` `findEpicPr` already does `epic.meta.pr === undefined → null; else gh.getPullRequest(repo, epic.meta.pr)`. The renderer/parser already round-trip `pr:` in the meta block. + +## Add by-PR-number `prSnapshot`/`prLifecycle` to the shared `PollGateway` interface +**File(s):** `packages/dispatcher/src/poller.ts:84`, `packages/dispatcher/src/poller-gateway.ts` +**Date:** 2026-06-03 + +**Decision:** Widen `PollGateway` with `prSnapshot(repo, prNumber)` and `prLifecycle(repo, prNumber)` rather than structurally widening only the file gateway's `gh` backend dep. +**Why:** The github finder is genuinely "resolve the Epic's PR number, then fetch by number"; exposing the fetch half as its own method is the honest decomposition and lets `ghPollGateway.findPrForEpic` reuse it (DRY — one snapshot builder). The alternative (a richer `gh` backend type on just `FilePollGatewayDeps`) loses the extra methods to structural erasure at the `PollGateway` boundary in the routing chain (`buildFileGateways`/`trioForRepo` thread `ghPoll: PollGateway`), forcing fragile type-plumbing across the public routing API for file mode only. The interface widening costs two extra methods on ~4 test stubs and the routing delegate — mechanical and type-checked. +**Evidence:** `fetchPrSnapshot` is now the single snapshot builder shared by `findPrForEpic` and `prSnapshot`. `fetchPrSnapshot`/`fetchPrLifecycle` swallow a `gh pr view` failure → `null`, so a stale `meta.pr` degrades to "no PR" instead of throwing the whole poll pass. + +## Bonus: the merged/closed reconcile path works in file mode for free +**File(s):** `packages/dispatcher/src/poller.ts` `reconcileMergedParks` +**Date:** 2026-06-03 + +**Decision:** No separate work for reconcile — it already calls `findEpicPrLifecycle`, which now resolves a slug via `meta.pr`. +**Why:** Same gateway method, same blast radius. A file-mode Epic whose PR is merged/closed now reconciles to `completed`/`cancelled` rather than stalling in `waiting-human`, identical to github mode. From c9757bd13a2687b79ea0ffc938efe9d0a78c6d42 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Wed, 3 Jun 2026 11:19:20 -0400 Subject: [PATCH 3/7] feat(epic-store): file-mode recommender + auto-dispatch Route the auto-dispatch readState and the recommender state I/O through a new makeRoutingStateGateway (file <-> github per repo), so a file-mode repo's ranked plan in its state_file drives dispatch and the recommender reads/writes it. - state-issue: InFlightItem.issue widened number -> string (numeric Epic number or file-mode slug); parser reads #([\w-]+); stays schema v1 (additive, round -trip holds; fuzz + samples seeded with slugs). - auto-dispatch: enqueue by epicRef (string); parseEpicRef extracts #; a file Epic dispatches by slug. main.ts runs auto-dispatch for file-mode repos (sentinel state issue 0; the file gateway ignores it). - recommender: in-flight rows source epicRef (added to ActiveImplementationWorkflow via the migration-009 epic_ref column); state I/O routed; resolveRecommenderOptions accepts file mode; the prompt reframes for the file store. surface() skips the gh comment for sentinel 0. Live ranking quality is operator-smoke (per #190). - core: parse [epic_store] into MiddleConfig.epicStore. - Integration test drives the real file-mode auto-dispatch readState path. --- packages/core/src/config.ts | 32 ++++ packages/core/src/index.ts | 1 + packages/dispatcher/src/auto-dispatch.ts | 30 ++-- packages/dispatcher/src/epic-store/index.ts | 33 ++++ packages/dispatcher/src/main.ts | 47 ++++-- packages/dispatcher/src/recommender-run.ts | 22 ++- packages/dispatcher/src/workflow-record.ts | 10 +- .../dispatcher/src/workflows/recommender.ts | 58 +++++-- .../dispatcher/test/auto-dispatch.test.ts | 47 ++++-- .../file-auto-dispatch-integration.test.ts | 144 ++++++++++++++++++ .../dispatcher/test/recommender-run.test.ts | 42 +++++ .../test/recommender-workflow.test.ts | 51 +++++-- packages/dispatcher/test/state-issue.test.ts | 2 +- packages/state-issue/src/parser.ts | 8 +- packages/state-issue/src/schema.v1.ts | 8 +- packages/state-issue/test/fuzz.test.ts | 4 +- packages/state-issue/test/sample-states.ts | 2 +- packages/state-issue/test/validate.test.ts | 2 +- planning/issues/200/decisions.md | 21 +++ schemas/state-issue.v1.md | 8 +- 20 files changed, 496 insertions(+), 76 deletions(-) create mode 100644 packages/dispatcher/test/epic-store/file-auto-dispatch-integration.test.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 58ac18f6..8c72f5eb 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -99,6 +99,23 @@ export type BootstrapSettings = { installedAt: string; }; +/** + * The `[epic_store]` section — selects where a repo's Epics + dispatch state live. + * Absent (or `mode = "github"`) means the default GitHub-backed store (Epics are + * issues, state is a state issue). `mode = "file"` is the file-backed store (#190): + * Epics are Markdown files under `epicsDir` and the ranked dispatch state is + * `stateFile`. Mirrors the DB `repo_config` columns (migration 008) — `mm init` + * writes both; the config-toml copy is what config-only callers (the recommender + * run resolution) read to learn a repo is file-mode without a DB handle (#200). + */ +export type EpicStoreSettings = { + mode: "github" | "file"; + /** Repo-relative Epic directory (file mode). */ + epicsDir?: string; + /** Repo-relative ranked-state file (file mode). */ + stateFile?: string; +}; + /** * The `[staleness]` section — per-repo overrides for the anti-staleness drift * check. `spec_path` is the repo-relative build-spec path the check reads; omit @@ -125,6 +142,7 @@ export type MiddleConfig = { limits?: LimitsSettings; recommender?: RecommenderSettings; stateIssue?: StateIssueSettings; + epicStore?: EpicStoreSettings; bootstrap?: BootstrapSettings; docs?: DocsSettings; staleness?: StalenessSettings; @@ -281,6 +299,19 @@ function mapBootstrap(raw: RawTable): BootstrapSettings | undefined { return { version: b.version as number, installedAt: b.installed_at as string }; } +function mapEpicStore(raw: RawTable): EpicStoreSettings | undefined { + if (!isPlainObject(raw.epic_store)) return undefined; + const e = raw.epic_store; + // Anything other than the explicit "file" string is the github default — a + // typo'd mode must never silently route a repo to the file store. + const mode = e.mode === "file" ? "file" : "github"; + return { + mode, + epicsDir: e.epics_dir as string | undefined, + stateFile: e.state_file as string | undefined, + }; +} + /** * Map the `[docs]` section. Unlike the strict per-repo mappers, the bot fields * default rather than trust presence — a tool/path-only override block (the @@ -338,6 +369,7 @@ export function loadConfig(opts: LoadConfigOptions): MiddleConfig { limits: mapLimits(merged), recommender: mapRecommender(merged), stateIssue: mapStateIssue(merged), + epicStore: mapEpicStore(merged), bootstrap: mapBootstrap(merged), docs: mapDocs(merged), staleness: mapStaleness(merged), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index faf97bc3..478dbd8f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,6 +39,7 @@ export type { LimitsSettings, RecommenderSettings, StateIssueSettings, + EpicStoreSettings, BootstrapSettings, DocsSettings, StalenessSettings, diff --git a/packages/dispatcher/src/auto-dispatch.ts b/packages/dispatcher/src/auto-dispatch.ts index ad5bce50..d75e9d2f 100644 --- a/packages/dispatcher/src/auto-dispatch.ts +++ b/packages/dispatcher/src/auto-dispatch.ts @@ -35,15 +35,16 @@ export type AutoDispatchDeps = { /** * Enqueue one implementation workflow. Returns the workflow id, or `null` if * the enqueue was refused (e.g. the Epic already has an active workflow — the - * collision guard). A refused enqueue must NOT consume a local slot. + * collision guard). A refused enqueue must NOT consume a local slot. `epicRef` + * is the dispatch unit: a numeric Epic number (github mode) or a file-mode slug. */ - enqueue: (input: { repo: string; epicNumber: number; adapter: string }) => Promise; + enqueue: (input: { repo: string; epicRef: string; adapter: string }) => Promise; }; /** What an auto-dispatch pass enqueued, and why it stopped. */ export type AutoDispatchResult = { - /** The Epics enqueued this pass, in dispatch order. */ - enqueued: { epicNumber: number; adapter: string }[]; + /** The Epics enqueued this pass, in dispatch order (`epicRef`: number or slug). */ + enqueued: { epicRef: string; adapter: string }[]; /** * - `disabled` — the repo's auto-dispatch is off (or paused); nothing was read. * - `slots-exhausted` — the loop stopped because the repo or global total filled. @@ -65,10 +66,15 @@ export function didReadState(result: AutoDispatchResult): boolean { return result.reason !== "disabled"; } -/** Extract the leading `#` Epic number from a Ready row's `epic` cell, or null. */ -function parseEpicNumber(epic: string): number | null { - const match = /^#(\d+)\b/.exec(epic.trim()); - return match ? Number(match[1]) : null; +/** + * Extract the leading `#` Epic reference from a Ready row's `epic` cell, or + * null. `` is `[\w-]+`: a numeric Epic number in github mode (`#42`) or a + * file-mode Epic slug (`#rollout-epic-store`). The dispatch path is ref-agnostic + * — `startDispatchImpl` already takes an `epicRef` string (#200). + */ +function parseEpicRef(epic: string): string | null { + const match = /^#([\w-]+)\b/.exec(epic.trim()); + return match ? match[1]! : null; } /** @@ -127,8 +133,8 @@ export async function autoDispatch(deps: AutoDispatchDeps): Promise string; + ghState?: StateGateway; + ghEpic?: EpicGateway; + ghPoll?: PollGateway; +}): StateGateway { + const ghEpic = deps.ghEpic ?? ghGitHub; + const ghPoll = deps.ghPoll ?? ghPollGateway; + // trioForRepo wires github's state gateway from `buildGitHubGateways`'s default; + // override it so callers can inject a stub gh state backend in tests. + const ghState = deps.ghState ?? ghStateIssueGateway; + const stateFor = (repo: string): StateGateway => { + const cfg = readEpicStoreConfig(deps.db, repo); + if (cfg.mode !== "file") return ghState; + return trioForRepo(deps.db, repo, deps.resolveRepoPath, { epic: ghEpic, poll: ghPoll }) + .stateGateway; + }; + return { + readBody: (repo, issueNumber) => stateFor(repo).readBody(repo, issueNumber), + writeBody: (repo, issueNumber, body) => stateFor(repo).writeBody(repo, issueNumber, body), + }; +} + /** * Append a `` block to an Epic file's conversation — the * file-mode `postQuestion` endpoint (the agent-side of #178's class, structurally diff --git a/packages/dispatcher/src/main.ts b/packages/dispatcher/src/main.ts index f12d7f4a..d3a9bfa5 100644 --- a/packages/dispatcher/src/main.ts +++ b/packages/dispatcher/src/main.ts @@ -41,7 +41,11 @@ import { readEpicStoreConfig, registerManagedRepo, } from "./repo-config.ts"; -import { makeRoutingEpicGateway, makeRoutingPollGateway } from "./epic-store/index.ts"; +import { + makeRoutingEpicGateway, + makeRoutingPollGateway, + makeRoutingStateGateway, +} from "./epic-store/index.ts"; import { runFileWatcherTick } from "./epic-store/watcher.ts"; import { getSlotState, hasFreeSlot } from "./slots.ts"; import { ghStateIssueGateway, readState, type StateGateway } from "./state-issue.ts"; @@ -251,6 +255,10 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { ghEpic: ghGitHub, ghPoll: ghPollGateway, }); + // The state gateway is routed too: a file-mode repo's ranked plan lives in its + // `state_file`, not a GitHub state issue — so auto-dispatch's `readState` and the + // recommender's read/write must hit the file backend for those repos (#200). + const routingStateGateway = makeRoutingStateGateway({ db, resolveRepoPath, ghEpic: ghGitHub }); // ── Auto-dispatch (build spec → "Auto-dispatch loop") ────────────────────── // The collision-guarded enqueue: the single source of truth for the 409 guard @@ -349,8 +357,13 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { if (repoPath === undefined) return; // unknown checkout — can't locate the repo const repoConfig = loadRepoConfig(repo); if (!repoConfig) return; - const stateIssueNumber = repoConfig.stateIssue?.number; - if (stateIssueNumber === undefined || stateIssueNumber === 0) return; + // github mode dispatches from a state **issue** (a configured number is + // required); file mode reads the repo's `state_file` via the routing state + // gateway, which ignores the issue number — so a file-mode repo runs without + // a `stateIssue.number` (#200). The sentinel `0` is the file-mode "no issue". + const epicStore = readEpicStoreConfig(db, repo); + const stateIssueNumber = repoConfig.stateIssue?.number ?? 0; + if (epicStore.mode !== "file" && stateIssueNumber === 0) return; const limits = resolveSlotLimits(repoConfig); const adapters = Object.keys(repoConfig.adapters); let result; @@ -360,21 +373,24 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { // Enabled = the per-repo toggle is on AND the repo isn't paused (#51). isAutoDispatchEnabled: () => (repoConfig.recommender?.autoDispatch ?? false) && !isPaused(db, repo), - readState: () => readState(ghStateIssueGateway, repo, stateIssueNumber), + readState: () => readState(routingStateGateway, repo, stateIssueNumber), rateLimitedAdapters: () => rateLimitedAdapters(adapters), getSlotState: () => getSlotState(db, repo, limits), - enqueue: ({ repo: r, epicNumber, adapter }) => - startDispatchImpl({ repo: r, repoPath, epicRef: String(epicNumber), adapter }, "auto"), + enqueue: ({ repo: r, epicRef, adapter }) => + startDispatchImpl({ repo: r, repoPath, epicRef, adapter }, "auto"), }); } catch (error) { // Announce a parse failure on the state issue (deduped); other errors fall // through to the scheduler's stderr log. Best-effort: a failed comment must - // not mask the original error. - await parseFailureSurfacer - .surface(repo, stateIssueNumber, error as Error) - .catch((e: unknown) => - console.error(`[auto-dispatch] ${repo} surfaceProblem failed: ${(e as Error).message}`), - ); + // not mask the original error. File mode has no state issue to comment on + // (sentinel 0), so the surfacer is github-only — it falls through to stderr. + if (stateIssueNumber > 0) { + await parseFailureSurfacer + .surface(repo, stateIssueNumber, error as Error) + .catch((e: unknown) => + console.error(`[auto-dispatch] ${repo} surfaceProblem failed: ${(e as Error).message}`), + ); + } throw error; } // Re-arm surfacing only after a pass that actually read the state — a @@ -382,7 +398,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { // unfixed parse failure without an intervening healthy read (#180). if (didReadState(result)) parseFailureSurfacer.reset(repo); if (result.enqueued.length > 0) { - const list = result.enqueued.map((e) => `#${e.epicNumber}(${e.adapter})`).join(", "); + const list = result.enqueued.map((e) => `#${e.epicRef}(${e.adapter})`).join(", "); console.log(`[auto-dispatch] ${repo}: enqueued ${list} — ${result.reason}`); } } @@ -510,6 +526,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { repo: resolved.options.repoSlug, stateIssue: resolved.options.stateIssue, adapter: resolved.options.adapterName, + epicStore: resolved.options.epicStore, }); } catch (error) { return { status: 500, body: `recommender enqueue failed: ${(error as Error).message}` }; @@ -647,7 +664,9 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise { }, worktreeRoot: config.global.worktreeRoot, dispatcherUrl: deps.dispatcherUrl, - stateIssue: ghStateIssueGateway, + // Routed: a file-mode repo's recommender reads/writes its `state_file`, a + // github repo its state issue — keyed per repo on the call's `repo` arg (#200). + stateIssue: routingStateGateway, surfaceProblem: ghSurfaceProblem, triggerAutoDispatch: async ({ repo }) => scheduleAutoDispatch(repo), gatherContext: (repo) => { diff --git a/packages/dispatcher/src/recommender-run.ts b/packages/dispatcher/src/recommender-run.ts index 0158b7bf..72c36e45 100644 --- a/packages/dispatcher/src/recommender-run.ts +++ b/packages/dispatcher/src/recommender-run.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync } from "node:fs"; import { basename, dirname } from "node:path"; -import type { AgentAdapter, MiddleConfig } from "@middle/core"; +import type { AgentAdapter, EpicStoreSettings, MiddleConfig } from "@middle/core"; import { STATE_ISSUE_SCHEMA_PATH } from "@middle/state-issue"; import { Engine } from "bunqueue/workflow"; import { installBunqueueRaceSwallower } from "./bunqueue-race.ts"; @@ -45,8 +45,19 @@ export type DispatchRecommenderOptions = { repoPath: string; /** `owner/name` — recorded on the workflow row and used by the gateways. */ repoSlug: string; - /** The state issue number to rewrite. */ + /** + * The state issue number to rewrite, or `0` in file mode (the ranked state + * lives in `epicStore.stateFile`, not a GitHub issue — the routed state gateway + * ignores this number for a file-mode repo). See {@link epicStore}. + */ stateIssue: number; + /** + * The repo's Epic-store mode, so the run + prompt can frame a file-mode repo + * correctly (rank Epic files under `epicsDir`, rewrite `stateFile`) instead of + * pointing the agent at a `#` state issue that doesn't exist (#200). Absent + * → github mode. + */ + epicStore?: EpicStoreSettings; /** Adapter to run the recommender with. */ adapterName: string; getAdapter: (name: string) => AgentAdapter; @@ -109,7 +120,11 @@ export async function resolveRecommenderOptions( config: MiddleConfig, getAdapter: (name: string) => AgentAdapter, ): Promise { - const stateIssue = config.stateIssue?.number; + // File mode has no state issue — the ranked plan lives in `state_file`; the + // routed state gateway reads/writes it and ignores the sentinel `0`. Github mode + // still requires a configured issue number. + const fileMode = config.epicStore?.mode === "file"; + const stateIssue = fileMode ? 0 : config.stateIssue?.number; if (stateIssue === undefined) { return { ok: false, error: `no state issue configured for this repo (run \`mm init\` first)` }; } @@ -142,6 +157,7 @@ export async function resolveRecommenderOptions( repoPath, repoSlug, stateIssue, + epicStore: config.epicStore, adapterName, getAdapter, dbPath: config.global.dbPath, diff --git a/packages/dispatcher/src/workflow-record.ts b/packages/dispatcher/src/workflow-record.ts index 05e9f689..fc4be2cf 100644 --- a/packages/dispatcher/src/workflow-record.ts +++ b/packages/dispatcher/src/workflow-record.ts @@ -615,6 +615,12 @@ export function countActiveImplementationSlots(db: Database, repo?: string): Slo /** A live implementation workflow, as the recommender's `in_flight` reports it. */ export type ActiveImplementationWorkflow = { epicNumber: number | null; + /** + * The canonical Epic reference (migration 009): `String(epicNumber)` in github + * mode, or a file-mode Epic slug. Sourced for the recommender's in-flight ref so + * a file-mode row carries its slug (#200); null only for a pre-009 / non-issue row. + */ + epicRef: string | null; adapter: string; sessionName: string | null; state: WorkflowState; @@ -641,12 +647,13 @@ export function listActiveImplementationWorkflows( const params = repo === undefined ? TERMINAL_STATES : [...TERMINAL_STATES, repo]; const rows = db .query( - `SELECT epic_number, adapter, session_name, state, last_heartbeat FROM workflows + `SELECT epic_number, epic_ref, adapter, session_name, state, last_heartbeat FROM workflows WHERE kind = 'implementation' AND state NOT IN (${placeholders})${repoClause} ORDER BY created_at ASC, rowid ASC`, ) .all(...params) as { epic_number: number | null; + epic_ref: string | null; adapter: string; session_name: string | null; state: string; @@ -654,6 +661,7 @@ export function listActiveImplementationWorkflows( }[]; return rows.map((r) => ({ epicNumber: r.epic_number, + epicRef: r.epic_ref, adapter: r.adapter, sessionName: r.session_name, state: r.state as WorkflowState, diff --git a/packages/dispatcher/src/workflows/recommender.ts b/packages/dispatcher/src/workflows/recommender.ts index 98bd7fee..8db51c5f 100644 --- a/packages/dispatcher/src/workflows/recommender.ts +++ b/packages/dispatcher/src/workflows/recommender.ts @@ -1,7 +1,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { Database } from "bun:sqlite"; -import type { AgentAdapter, RepoConfig } from "@middle/core"; +import type { AgentAdapter, EpicStoreSettings, RepoConfig } from "@middle/core"; import { isParseError, parseStateIssue, renderStateIssue, validate } from "@middle/state-issue"; import type { InFlightItem, RateLimits, SlotUsage } from "@middle/state-issue"; import { Workflow } from "bunqueue/workflow"; @@ -24,10 +24,17 @@ import type { TmuxOps, WorktreeOps } from "./implementation.ts"; export type RecommenderInput = { /** `owner/name` — the repo whose state issue is rewritten. */ repo: string; - /** The state issue number to rewrite. */ + /** The state issue number to rewrite, or `0` in file mode (state lives in a file). */ stateIssue: number; /** Adapter to run the recommender agent with. */ adapter: string; + /** + * The repo's Epic-store mode. When `mode === "file"` the prompt frames the run + * for the file-backed store (rank Epic files under `epicsDir`, rewrite + * `stateFile`) instead of pointing the agent at the `#` state issue (#200). + * Absent → github mode. + */ + epicStore?: EpicStoreSettings; }; /** The `config` block injected into the recommender prompt (skill "Phase 1"). */ @@ -40,8 +47,12 @@ export type RecommenderRunConfig = { /** One currently-running agent, as the recommender's `in_flight` array reports it. */ export type InFlightSummary = { - /** The Epic/issue number, or null for a non-issue workflow. */ - issue: number | null; + /** + * The Epic reference — a numeric Epic/issue number (github mode) or a file-mode + * Epic slug — or null for a non-issue workflow. Sourced from the workflow's + * canonical `epicRef` so a file-mode in-flight row carries its slug (#200). + */ + issue: string | null; adapter: string; /** "sub-issue m/n" or "running". */ progress: string; @@ -179,9 +190,24 @@ export function assembleRecommenderPrompt(parts: { priorBody: string; context: RecommenderContext; config: RecommenderRunConfig; + /** File mode reframes the run for the file-backed store (#200); absent → github. */ + epicStore?: EpicStoreSettings; }): string { - const { repo, stateIssue, schemaPath, priorBody, context, config } = parts; + const { repo, stateIssue, schemaPath, priorBody, context, config, epicStore } = parts; const json = (value: unknown): string => JSON.stringify(value, null, 2); + const fileMode = epicStore?.mode === "file"; + // In file mode the ranked state lives in `state_file`, not a `#` issue, and + // the dispatch units are Epic files under `epics_dir` — frame the run that way + // so the agent follows the skill's file-mode commands, not a phantom #0 issue. + const targetLine = fileMode + ? `- \`epic_store\`: file (epics_dir: \`${epicStore?.epicsDir ?? "planning/epics"}\`, state_file: \`${epicStore?.stateFile ?? ".middle/state.md"}\`)` + : `- \`state_issue\`: ${stateIssue}`; + const priorBodySource = fileMode + ? `The current contents of the \`state_file\` (\`${epicStore?.stateFile ?? ".middle/state.md"}\`), between the markers below.` + : `The current contents of state issue #${stateIssue}, between the markers below.`; + const storeNote = fileMode + ? "\nThis repo uses the **file-backed** Epic store. Follow the skill's file-mode\ncommands: rank the Epic files under `epics_dir` (not GitHub issues) and rewrite\nthe `state_file` (not a state issue). Epic references in the body are file\nslugs (e.g. `#rollout-epic-store`), not `#`.\n" + : ""; // Render slots in the skill's documented "Phase 1" shape: per-adapter entries // at the top level keyed by adapter, `total` a sibling with snake_case globals. const slotsForPrompt = { @@ -195,12 +221,12 @@ export function assembleRecommenderPrompt(parts: { }; return `# Recommender run — dispatcher context -You are the dispatch recommender. Rewrite the state issue body following the +You are the dispatch recommender. Rewrite the state body following the \`recommending-github-issues\` skill. The dispatcher provides everything below; read all of it before any \`gh\` calls. - +${storeNote} - \`repo\`: ${repo} -- \`state_issue\`: ${stateIssue} +${targetLine} - \`schema_path\`: ${schemaPath} ## config @@ -228,7 +254,7 @@ ${json(slotsForPrompt)} \`\`\` ## prior_body -The current contents of state issue #${stateIssue}, between the markers below. +${priorBodySource} The In-flight, Rate limits, and Slot usage sections are DISPATCHER-OWNED — the dispatcher overwrites all three with authoritative values (heartbeats included) @@ -298,7 +324,9 @@ export function buildRecommenderContext(opts: { github: opts.githubStatus ?? "UNKNOWN", }, inFlight: listActiveImplementationWorkflows(opts.db, opts.repo).map((w) => ({ - issue: w.epicNumber, + // The canonical Epic ref (string) — numeric in github mode, a slug in file + // mode — so a file-mode in-flight row renders its slug, not a dropped null. + issue: w.epicRef, adapter: w.adapter, progress: w.state === "running" ? "running" : w.state, session: w.sessionName, @@ -334,8 +362,8 @@ export function heartbeatRel(ts: number | null, now: number): string { * dispatcher-owned sections after the agent runs (#180). The dispatcher, not the * recommender agent, is the single source of truth for these: * - In-flight: each summary becomes a 5-field {@link InFlightItem}, with the - * heartbeat the agent never had. Entries with no issue number are dropped — the - * section's `#` shape can't represent a non-issue workflow. + * heartbeat the agent never had. Entries with no Epic ref are dropped — the + * section's `#` shape can't represent a non-issue workflow. * - Rate limits: passed through (already the dispatcher's shape). * - Slot usage: the per-adapter/total/global view flattened to {@link SlotUsage}. */ @@ -344,7 +372,7 @@ export function dispatcherSectionsFromContext( now: number, ): Required { const inFlight: InFlightItem[] = ctx.inFlight - .filter((s): s is InFlightSummary & { issue: number } => s.issue !== null) + .filter((s): s is InFlightSummary & { issue: string } => s.issue !== null) .map((s) => ({ issue: s.issue, adapter: s.adapter, @@ -475,6 +503,7 @@ export function createRecommenderWorkflow(deps: RecommenderDeps): Workflow, problem: string): Promise { console.error(`[recommender] ${problem}`); if (!deps.surfaceProblem) return; + // File mode (sentinel stateIssue 0) has no GitHub issue to comment on — the + // problem is already on stderr; a `gh` comment on #0 would only error (#200). + if (ctx.input.stateIssue === 0) return; try { await deps.surfaceProblem({ repo: ctx.input.repo, diff --git a/packages/dispatcher/test/auto-dispatch.test.ts b/packages/dispatcher/test/auto-dispatch.test.ts index 70891f22..6fefd147 100644 --- a/packages/dispatcher/test/auto-dispatch.test.ts +++ b/packages/dispatcher/test/auto-dispatch.test.ts @@ -14,10 +14,10 @@ import type { SlotState } from "../src/slots.ts"; // decrement a local slot view as it enqueues. Disabled repos do nothing. The // deps are injected so the loop is exercised without the engine or `gh`. -function readyRow(rank: number, epicNumber: number, adapter: string): ReadyRow { +function readyRow(rank: number, epicRef: number | string, adapter: string): ReadyRow { return { rank, - epic: `#${epicNumber} some title`, + epic: `#${epicRef} some title`, adapter, subIssues: 2, reason: "criteria clear", @@ -63,7 +63,7 @@ function slots(opts: { return { byAdapter, repo: dim(opts.repo), global: dim(opts.global) }; } -type EnqueueCall = { repo: string; epicNumber: number; adapter: string }; +type EnqueueCall = { repo: string; epicRef: string; adapter: string }; function makeDeps(overrides: Partial & { _enqueued?: EnqueueCall[] } = {}): { deps: AutoDispatchDeps; @@ -84,7 +84,7 @@ function makeDeps(overrides: Partial & { _enqueued?: EnqueueCa }), enqueue: async (input) => { enqueued.push(input); - return `wf-${input.epicNumber}`; + return `wf-${input.epicRef}`; }, ...overrides, }; @@ -96,12 +96,12 @@ describe("autoDispatch", () => { const { deps, enqueued } = makeDeps(); const result = await autoDispatch(deps); expect(enqueued).toEqual([ - { repo: "o/r", epicNumber: 101, adapter: "claude" }, - { repo: "o/r", epicNumber: 102, adapter: "codex" }, + { repo: "o/r", epicRef: "101", adapter: "claude" }, + { repo: "o/r", epicRef: "102", adapter: "codex" }, ]); expect(result.enqueued).toEqual([ - { epicNumber: 101, adapter: "claude" }, - { epicNumber: 102, adapter: "codex" }, + { epicRef: "101", adapter: "claude" }, + { epicRef: "102", adapter: "codex" }, ]); expect(result.reason).toBe("drained"); }); @@ -119,7 +119,7 @@ describe("autoDispatch", () => { }); const result = await autoDispatch(deps); // #101 (claude) skipped; #102 (codex) still dispatched. - expect(enqueued).toEqual([{ repo: "o/r", epicNumber: 102, adapter: "codex" }]); + expect(enqueued).toEqual([{ repo: "o/r", epicRef: "102", adapter: "codex" }]); expect(result.reason).toBe("drained"); }); @@ -134,7 +134,7 @@ describe("autoDispatch", () => { }), }); const result = await autoDispatch(deps); - expect(enqueued).toEqual([{ repo: "o/r", epicNumber: 102, adapter: "codex" }]); + expect(enqueued).toEqual([{ repo: "o/r", epicRef: "102", adapter: "codex" }]); expect(result.reason).toBe("drained"); }); @@ -181,7 +181,7 @@ describe("autoDispatch", () => { }), }); const result = await autoDispatch(deps); - expect(enqueued).toEqual([{ repo: "o/r", epicNumber: 201, adapter: "claude" }]); + expect(enqueued).toEqual([{ repo: "o/r", epicRef: "201", adapter: "claude" }]); expect(result.reason).toBe("slots-exhausted"); }); @@ -203,16 +203,35 @@ describe("autoDispatch", () => { return null; // collision } enqueued.push(input); - return `wf-${input.epicNumber}`; + return `wf-${input.epicRef}`; }, }); const result = await autoDispatch(deps); - expect(enqueued).toEqual([{ repo: "o/r", epicNumber: 302, adapter: "claude" }]); + expect(enqueued).toEqual([{ repo: "o/r", epicRef: "302", adapter: "claude" }]); // Both rows were walked (the collision was a no-op, the second dispatched), // so the loop drained rather than breaking on exhaustion. expect(result.reason).toBe("drained"); }); + test("dispatches a file-mode Epic by its slug ref (#200)", async () => { + // A file-mode Ready cell is `# ` — the ref is a kebab slug, not + // a number. The loop must extract it and enqueue it verbatim (the dispatch + // path is ref-agnostic), never drop it as a "malformed" numeric cell. + const { deps, enqueued } = makeDeps({ + readState: async () => + stateWith([ + readyRow(1, "rollout-epic-store", "claude"), + readyRow(2, 102, "codex"), // a numeric row still dispatches alongside + ]), + }); + const result = await autoDispatch(deps); + expect(enqueued).toEqual([ + { repo: "o/r", epicRef: "rollout-epic-store", adapter: "claude" }, + { repo: "o/r", epicRef: "102", adapter: "codex" }, + ]); + expect(result.reason).toBe("drained"); + }); + test("ignores the empty-state (no ready rows) without enqueuing", async () => { const { deps, enqueued } = makeDeps({ readState: async () => stateWith([]) }); const result = await autoDispatch(deps); @@ -233,7 +252,7 @@ describe("autoDispatch", () => { }; const { deps, enqueued } = makeDeps({ readState: async () => stateWith([big]) }); const result = await autoDispatch(deps); - expect(enqueued).toEqual([{ repo: "o/r", epicNumber: 401, adapter: "claude" }]); + expect(enqueued).toEqual([{ repo: "o/r", epicRef: "401", adapter: "claude" }]); expect(result.reason).toBe("drained"); }); }); diff --git a/packages/dispatcher/test/epic-store/file-auto-dispatch-integration.test.ts b/packages/dispatcher/test/epic-store/file-auto-dispatch-integration.test.ts new file mode 100644 index 00000000..1acc87a6 --- /dev/null +++ b/packages/dispatcher/test/epic-store/file-auto-dispatch-integration.test.ts @@ -0,0 +1,144 @@ +/** + * Integration: file-mode auto-dispatch reads the repo's `state_file` (not a + * GitHub state issue) and dispatches its ranked Epics **by slug** (#200 gap 2). + * + * Drives the genuine wiring: a ranked `state_file` on disk → `readState` through + * `makeRoutingStateGateway` (file mode for this repo, issue number ignored) → + * `parseStateIssue` → `autoDispatch` walking the Ready table → `enqueue` with the + * file Epic's **slug** ref. Before this gap closed, auto-dispatch always read the + * GitHub state issue and `parseEpicNumber` dropped any non-numeric (slug) cell. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { renderStateIssue, type ParsedState, type ReadyRow } from "@middle/state-issue"; +import { openAndMigrate } from "../../src/db.ts"; +import { makeRoutingStateGateway } from "../../src/epic-store/index.ts"; +import { autoDispatch } from "../../src/auto-dispatch.ts"; +import { readState } from "../../src/state-issue.ts"; +import { setEpicStoreConfig } from "../../src/repo-config.ts"; +import type { SlotState } from "../../src/slots.ts"; + +const REPO = "o/file-repo"; +const STATE_FILE_REL = ".middle/state.md"; + +let scratch: string; +let db: Database; +let repoRoot: string; + +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), "middle-file-autodispatch-")); + db = openAndMigrate(join(scratch, "db.sqlite3")); + repoRoot = join(scratch, "repo"); + setEpicStoreConfig(db, REPO, { + mode: "file", + epicsDir: "planning/epics", + stateFile: STATE_FILE_REL, + }); +}); + +afterEach(() => { + db.close(); + rmSync(scratch, { recursive: true, force: true }); +}); + +/** Render a full state body with the given Ready rows and write it to the state_file. */ +function writeStateFile(ready: ReadyRow[]): void { + const state: ParsedState = { + version: 1, + generated: "2026-06-03T00:00:00.000Z", + runId: "abcd1234", + intervalMinutes: 15, + readyToDispatch: ready, + needsHumanInput: [], + blocked: [], + inFlight: [], + excluded: [], + rateLimits: { claude: "AVAILABLE", codex: "AVAILABLE", github: "UNKNOWN" }, + slotUsage: { + adapters: [{ adapter: "claude", used: 0, max: 2 }], + total: { used: 0, max: 3 }, + global: { used: 0, max: 4 }, + }, + }; + const path = join(repoRoot, STATE_FILE_REL); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, renderStateIssue(state)); +} + +function openSlots(): SlotState { + const dim = (used: number, max: number) => ({ used, max, available: Math.max(0, max - used) }); + return { byAdapter: { claude: dim(0, 2) }, repo: dim(0, 3), global: dim(0, 4) }; +} + +describe("file-mode auto-dispatch (real readState path)", () => { + test("reads the state_file and enqueues a file Epic by its slug ref", async () => { + writeStateFile([ + { + rank: 1, + epic: "#rollout-epic-store Roll out the store", + adapter: "claude", + subIssues: 3, + reason: "ready", + }, + ]); + const stateGateway = makeRoutingStateGateway({ db, resolveRepoPath: () => repoRoot }); + const enqueued: Array<{ repo: string; epicRef: string; adapter: string }> = []; + + const result = await autoDispatch({ + repo: REPO, + isAutoDispatchEnabled: () => true, + // The sentinel issue number (0) is ignored by the file state gateway — it + // reads the configured state_file. This is exactly main.ts's file-mode call. + readState: () => readState(stateGateway, REPO, 0), + rateLimitedAdapters: () => new Set<string>(), + getSlotState: openSlots, + enqueue: async (input) => { + enqueued.push(input); + return `wf-${input.epicRef}`; + }, + }); + + expect(enqueued).toEqual([{ repo: REPO, epicRef: "rollout-epic-store", adapter: "claude" }]); + expect(result.reason).toBe("drained"); + }); + + test("a github-mode repo still routes readState to the gh state issue gateway", async () => { + // No file config for this repo → the router falls through to the injected gh + // state backend, proving the router doesn't hijack github repos. + const ghRepo = "o/gh-repo"; + const readArgs: Array<{ repo: string; issue: number }> = []; + const stateGateway = makeRoutingStateGateway({ + db, + resolveRepoPath: () => repoRoot, + ghState: { + async readBody(repo, issueNumber) { + readArgs.push({ repo, issue: issueNumber }); + return renderStateIssue({ + version: 1, + generated: "2026-06-03T00:00:00.000Z", + runId: "abcd1234", + intervalMinutes: 15, + readyToDispatch: [], + needsHumanInput: [], + blocked: [], + inFlight: [], + excluded: [], + rateLimits: { claude: "AVAILABLE", codex: "AVAILABLE", github: "UNKNOWN" }, + slotUsage: { + adapters: [{ adapter: "claude", used: 0, max: 2 }], + total: { used: 0, max: 3 }, + global: { used: 0, max: 4 }, + }, + }); + }, + async writeBody() {}, + }, + }); + + await readState(stateGateway, ghRepo, 42); + expect(readArgs).toEqual([{ repo: ghRepo, issue: 42 }]); + }); +}); diff --git a/packages/dispatcher/test/recommender-run.test.ts b/packages/dispatcher/test/recommender-run.test.ts index a1f2c225..53f82fe0 100644 --- a/packages/dispatcher/test/recommender-run.test.ts +++ b/packages/dispatcher/test/recommender-run.test.ts @@ -248,6 +248,48 @@ describe("resolveRecommenderOptions — adapter enabled-gate", () => { expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain("adapter codex is disabled in config"); }); + + test("file mode resolves without a state issue — sentinel 0 + epicStore carried (#200)", async () => { + // A file-mode repo has no `[state_issue] number`; the ranked plan lives in + // `state_file`. resolveRecommenderOptions must NOT reject it (github mode does + // require a number), and must carry the epicStore through so the prompt frames + // the run for the file store. + const config = configWithAdapters(); + delete config.stateIssue; // file mode: no state issue configured + config.epicStore = { mode: "file", epicsDir: "planning/epics", stateFile: ".middle/state.md" }; + config.recommender = { + enabled: true, + adapter: "claude", + intervalMinutes: 15, + autoDispatch: false, + }; + + const result = await resolveRecommenderOptions(repoPath, config, () => stubAdapter()); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.options.stateIssue).toBe(0); + expect(result.options.epicStore).toEqual({ + mode: "file", + epicsDir: "planning/epics", + stateFile: ".middle/state.md", + }); + } + }); + + test("github mode still requires a configured state issue number", async () => { + const config = configWithAdapters(); + delete config.stateIssue; // no number, and NOT file mode + config.recommender = { + enabled: true, + adapter: "claude", + intervalMinutes: 15, + autoDispatch: false, + }; + + const result = await resolveRecommenderOptions(repoPath, config, () => stubAdapter()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("no state issue configured"); + }); }); describe("resolveRecommenderOptions — schema resolution (issue #107)", () => { diff --git a/packages/dispatcher/test/recommender-workflow.test.ts b/packages/dispatcher/test/recommender-workflow.test.ts index 598aa8e1..a39d8a3f 100644 --- a/packages/dispatcher/test/recommender-workflow.test.ts +++ b/packages/dispatcher/test/recommender-workflow.test.ts @@ -96,7 +96,7 @@ const SAMPLE_CONTEXT: RecommenderContext = { rateLimits: { claude: "AVAILABLE", codex: "RATE_LIMITED until 16:32Z", github: "4180/5000" }, inFlight: [ { - issue: 6, + issue: "6", adapter: "claude", progress: "sub-issue 2/5", session: "middle-x-6", @@ -406,6 +406,30 @@ describe("recommender workflow — #44 build-prompt: every required input, verba expect(prompt).toContain("decision INPUT"); }); + test("file mode reframes the prompt for the file-backed store (#200)", () => { + const prompt = assembleRecommenderPrompt({ + repo: REPO, + stateIssue: 0, // sentinel — no state issue in file mode + schemaPath: "/abs/schemas/state-issue.v1.md", + priorBody: PRIOR, + context: SAMPLE_CONTEXT, + config: { defaultAdapter: "claude", autoDispatch: false, prMode: "worktree" }, + epicStore: { mode: "file", epicsDir: "planning/epics", stateFile: ".middle/state.md" }, + }); + + // Points at the file store, not a phantom `#0` state issue. + expect(prompt).toContain("file-backed"); + expect(prompt).toContain("epics_dir: `planning/epics`"); + expect(prompt).toContain("state_file: `.middle/state.md`"); + expect(prompt).toContain("`#rollout-epic-store`"); + expect(prompt).not.toContain("state_issue`: 0"); + expect(prompt).not.toContain("state issue #0"); + // The state_file (not a state issue) is what the prior_body section describes. + expect(prompt).toContain("`state_file` (`.middle/state.md`)"); + // Dispatcher-owned framing still applies in file mode. + expect(prompt).toContain("DISPATCHER-OWNED"); + }); + test("writes the assembled prompt to .middle/prompt.md and launches it via the @-reference", async () => { const h = makeHarness({ bodies: [PRIOR, validBody()] }); h.deps.schemaPath = "/somewhere/state-issue.v1.md"; @@ -537,16 +561,23 @@ describe("recommender workflow — #180 dispatcher is the sole In-flight writer" rateLimits: { claude: "AVAILABLE", codex: "UNKNOWN", github: "4180/5000" }, inFlight: [ { - issue: 6, + issue: "6", adapter: "claude", progress: "sub-issue 2/5", session: "middle-x-6", lastHeartbeat: now - 30_000, }, - // A non-issue workflow — can't be rendered as #<n>, so it's dropped. + // A non-issue workflow — can't be rendered as #<ref>, so it's dropped. { issue: null, adapter: "claude", progress: "running", session: "x", lastHeartbeat: now }, - // Not yet launched (null session) and no heartbeat → "pending"/"unknown". - { issue: 8, adapter: "codex", progress: "running", session: null, lastHeartbeat: null }, + // A file-mode Epic in flight: the ref is a slug, not a number — it must + // render as #<slug>, proving file-mode in-flight rows survive (#200). + { + issue: "rollout-epic-store", + adapter: "codex", + progress: "running", + session: null, + lastHeartbeat: null, + }, ], slots: { perAdapter: { claude: { used: 1, max: 2 }, codex: { used: 0, max: 1 } }, @@ -556,14 +587,14 @@ describe("recommender workflow — #180 dispatcher is the sole In-flight writer" const sections = dispatcherSectionsFromContext(ctx, now); expect(sections.inFlight).toEqual([ { - issue: 6, + issue: "6", adapter: "claude", progress: "sub-issue 2/5", lastHeartbeat: "30s ago", tmuxSession: "middle-x-6", }, { - issue: 8, + issue: "rollout-epic-store", adapter: "codex", progress: "running", lastHeartbeat: "unknown", @@ -714,14 +745,14 @@ describe("recommender workflow — #44 buildRecommenderContext: from dispatcher expect(ctx.rateLimits.github).toBe("4180/5000"); expect(ctx.inFlight).toEqual([ { - issue: 6, + issue: "6", adapter: "claude", progress: "running", session: "middle-x-6", lastHeartbeat: 1_700_000_000_000, }, { - issue: 7, + issue: "7", adapter: "claude", progress: "running", session: "middle-x-7", @@ -778,7 +809,7 @@ describe("recommender workflow — #44 buildRecommenderContext: from dispatcher // Per-repo: only REPO's one agent counts toward used / in_flight. expect(ctx.slots.perAdapter.claude).toEqual({ used: 1, max: 2 }); expect(ctx.slots.total.used).toBe(1); - expect(ctx.inFlight.map((w) => w.issue)).toEqual([6]); + expect(ctx.inFlight.map((w) => w.issue)).toEqual(["6"]); // Global: both repos' agents count toward global_used (shared db). expect(ctx.slots.total.globalUsed).toBe(2); expect(ctx.slots.total.globalMax).toBe(4); diff --git a/packages/dispatcher/test/state-issue.test.ts b/packages/dispatcher/test/state-issue.test.ts index 1e2d7997..325d3e7e 100644 --- a/packages/dispatcher/test/state-issue.test.ts +++ b/packages/dispatcher/test/state-issue.test.ts @@ -40,7 +40,7 @@ const original: ParsedState = { blocked: [{ issue: 48, blocker: "#42", context: "needs the recommender first" }], inFlight: [ { - issue: 64, + issue: "64", adapter: "claude", progress: "sub-issue 2/5", lastHeartbeat: "42s ago", diff --git a/packages/state-issue/src/parser.ts b/packages/state-issue/src/parser.ts index e9a6ca3d..cbb6c7a7 100644 --- a/packages/state-issue/src/parser.ts +++ b/packages/state-issue/src/parser.ts @@ -189,7 +189,11 @@ function parseBlocked(content: string[]): BlockedItem[] { }); } -const IN_FLIGHT_RE = /^- \*\*#(\d+)\*\* · (.+?) · (.+?) · last heartbeat (.+?) · \[tmux: (.+?)\]$/; +// The `#` ref is `[\w-]+` (not `\d+`): a numeric Epic number in github mode OR a +// file-mode Epic slug (kebab-case file stem). Captured as a string and kept raw +// so the dispatcher-owned section round-trips a slug unchanged (#200). +const IN_FLIGHT_RE = + /^- \*\*#([\w-]+)\*\* · (.+?) · (.+?) · last heartbeat (.+?) · \[tmux: (.+?)\]$/; function parseInFlight(content: string[]): InFlightItem[] { // Accepts the canonical "- _no agents in flight_", a generic placeholder, or an @@ -199,7 +203,7 @@ function parseInFlight(content: string[]): InFlightItem[] { const m = IN_FLIGHT_RE.exec(line); if (!m) fail(`malformed "In-flight" item: "${line}"`); return { - issue: Number(m[1]), + issue: m[1]!, adapter: m[2]!, progress: m[3]!, lastHeartbeat: m[4]!, diff --git a/packages/state-issue/src/schema.v1.ts b/packages/state-issue/src/schema.v1.ts index 1e369f8e..6e98d0b1 100644 --- a/packages/state-issue/src/schema.v1.ts +++ b/packages/state-issue/src/schema.v1.ts @@ -36,7 +36,13 @@ export type BlockedItem = { /** An item under "In-flight" (dispatcher-owned section). */ export type InFlightItem = { - issue: number; + /** + * The Epic reference rendered after `#` — a numeric Epic/issue number in + * github mode (`"200"`), or a file-mode Epic slug (`"rollout-epic-store"`). + * Kept as a string so a file-mode in-flight row, whose Epic has no GitHub + * issue number, round-trips through the dispatcher-owned section unchanged. + */ + issue: string; adapter: string; /** "sub-issue <m>/<n>" or "running". */ progress: string; diff --git a/packages/state-issue/test/fuzz.test.ts b/packages/state-issue/test/fuzz.test.ts index 1eda908f..36f70e2c 100644 --- a/packages/state-issue/test/fuzz.test.ts +++ b/packages/state-issue/test/fuzz.test.ts @@ -107,7 +107,9 @@ function genBlocked(rng: Rng): BlockedItem { function genInFlight(rng: Rng): InFlightItem { return { - issue: rng.int(1, 9999), + // A numeric ref (github mode) or a kebab file-mode slug — both round-trip + // through the `#<ref>` shape (#200). + issue: rng.bool() ? String(rng.int(1, 9999)) : `epic-${rng.int(1, 9999)}-rollout`, adapter: rng.pick(ADAPTERS), progress: rng.bool() ? "running" : `sub-issue ${rng.int(1, 9)}/${rng.int(1, 12)}`, lastHeartbeat: `${rng.int(1, 59)}${rng.pick(["s", "m", "h"])} ago`, diff --git a/packages/state-issue/test/sample-states.ts b/packages/state-issue/test/sample-states.ts index 3a463f6c..561eb4e1 100644 --- a/packages/state-issue/test/sample-states.ts +++ b/packages/state-issue/test/sample-states.ts @@ -59,7 +59,7 @@ export const fullState: ParsedState = { ], inFlight: [ { - issue: 54, + issue: "54", adapter: "claude", progress: "sub-issue 2/5", lastHeartbeat: "30s ago", diff --git a/packages/state-issue/test/validate.test.ts b/packages/state-issue/test/validate.test.ts index 8e8ad928..153a75df 100644 --- a/packages/state-issue/test/validate.test.ts +++ b/packages/state-issue/test/validate.test.ts @@ -26,7 +26,7 @@ describe("validate", () => { ...fullState, inFlight: [ { - issue: 1, + issue: "1", adapter: "gemini", progress: "running", lastHeartbeat: "1m ago", diff --git a/planning/issues/200/decisions.md b/planning/issues/200/decisions.md index bdefa657..52c268ab 100644 --- a/planning/issues/200/decisions.md +++ b/planning/issues/200/decisions.md @@ -22,3 +22,24 @@ **Decision:** No separate work for reconcile — it already calls `findEpicPrLifecycle`, which now resolves a slug via `meta.pr`. **Why:** Same gateway method, same blast radius. A file-mode Epic whose PR is merged/closed now reconciles to `completed`/`cancelled` rather than stalling in `waiting-human`, identical to github mode. + +## InFlightItem.issue: number → string (additive, stays schema v1) +**File(s):** `packages/state-issue/src/schema.v1.ts:38`, `parser.ts:195`, `schemas/state-issue.v1.md:46` +**Date:** 2026-06-03 + +**Decision:** Widen `InFlightItem.issue` from `number` to `string` (a numeric Epic number OR a file-mode slug), parse `#([\w-]+)` instead of `#(\d+)`, keep schema **v1** rather than bumping to v2. +**Why:** A file-mode Epic has no GitHub issue number; its in-flight row must carry the slug. The change is additive — numeric refs are a subset of string refs and render identically (`**#200**`), so byte-identical round-trip holds (verified by the fuzz + sample-state round-trip tests, now seeded with slug refs). A v1→v2 bump would force a parallel parser/renderer/validate/schema-doc fork for a backward-compatible widening — cost with no safety gain. Scoped to `InFlightItem` (the gap's explicit ask); the Ready table's `epic` cell is already a raw string so it accommodates slugs without a type change, and the auto-dispatch ref-extractor (`parseEpicRef`) reads `#([\w-]+)` from it. needs-human/blocked/excluded stay numeric (out of the gap's scope). + +## Routing StateGateway + sentinel-0 for file mode +**File(s):** `packages/dispatcher/src/epic-store/index.ts` (`makeRoutingStateGateway`), `main.ts` (auto-dispatch + recommender wiring) +**Date:** 2026-06-03 + +**Decision:** Add `makeRoutingStateGateway` (mirrors the epic/poll routers) and wire both auto-dispatch's `readState` and the recommender's `stateIssue` through it. File-mode repos use a **sentinel issue number `0`** that the file state gateway ignores (it reads `state_file`); github repos pass the real number. +**Why:** The `StateGateway` interface is `(repo, issueNumber)`-keyed; rather than thread a `number | fileTarget` union through the recommender's ~8 call sites, the sentinel keeps the numeric signature and the router resolves file vs gh per repo. Auto-dispatch enqueues by `epicRef` (string) so a slug dispatches; the recommender's in-flight rows source `epicRef` (added to `ActiveImplementationWorkflow`, selecting the migration-009 `epic_ref` column). + +## Recommender file-mode run-enablement (live ranking = operator smoke) +**File(s):** `packages/core/src/config.ts` (`[epic_store]` parse), `recommender-run.ts` (file-mode gate), `workflows/recommender.ts` (prompt framing) +**Date:** 2026-06-03 + +**Decision:** Parse `[epic_store]` into `MiddleConfig.epicStore`; `resolveRecommenderOptions` no longer rejects a file-mode repo (uses sentinel `0`); the recommender prompt reframes for the file store (rank Epic files under `epics_dir`, rewrite `state_file`, refs are slugs) instead of pointing at a phantom `#0` issue; `surface` skips the gh comment for sentinel 0. +**Why:** Routing the recommender's state I/O is moot if the run can't even start for a file repo (the pre-existing `config.stateIssue?.number` gate blocked it — a constraint the gap didn't name). The wiring is unit/integration-tested (resolution returns ok + sentinel; prompt framing asserted). The recommender agent's *live ranking quality* over file Epics is verified by operator smoke, matching #190's "operator-only live smoke" precedent for file-mode dispatch — it can't be gated in CI (a live agent run). diff --git a/schemas/state-issue.v1.md b/schemas/state-issue.v1.md index ad511a7f..86570573 100644 --- a/schemas/state-issue.v1.md +++ b/schemas/state-issue.v1.md @@ -43,8 +43,12 @@ Bulleted list. `- **#<n>** waiting on #<blocker> · <context>` ### 4. ## In-flight [DISPATCHER-OWNED] -`- **#<n>** · <adapter> · <progress> · last heartbeat <rel> · [tmux: <session>]` -Progress: `sub-issue <m>/<n>` (which phase of the Epic the agent is on) or `running` +`- **#<ref>** · <adapter> · <progress> · last heartbeat <rel> · [tmux: <session>]` +`<ref>` is the dispatched Epic: a numeric Epic/issue number in github mode, or a +file-mode Epic **slug** (kebab-case, e.g. `rollout-epic-store`) for a repo whose +Epic store is file-backed — a file Epic has no GitHub issue number, so its +in-flight row carries the slug. Progress: `sub-issue <m>/<n>` (which phase of the +Epic the agent is on) or `running` Empty: `- _no agents in flight_` ### 5. ## Excluded From 9323fe9b0be295c3367f64e47ee8e97ec03dc38d Mon Sep 17 00:00:00 2001 From: Justin Walsh <contact.me@thejustinwalsh.com> Date: Wed, 3 Jun 2026 11:32:41 -0400 Subject: [PATCH 4/7] feat(epic-store): file-aware Epic browse cache + dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-key the epics browse cache from (repo, number) to (repo, ref) [migration 010] so a file-mode Epic — a slug with no GitHub number — is cached and surfaces in the dashboard. refreshEpics routes through the routing Epic gateway (per-mode listing); readEpics orders github Epics newest-first, file Epics after. - epics-cache: ref-keyed upsert/close, EpicRow gains ref + nullable number, the file-Epic skip is removed. - dashboard: EpicCard carries ref + nullable number; the card renders via the existing <EpicRef> (#N label or file:// slug link); workflowForEpic keys on epic_ref (both modes); the ready-row join matches by ref. In-dashboard force -dispatch is disabled for file Epics (numeric route only) with a title pointing at 'mm dispatch <slug>' — browsable, not falsely dispatchable. - Tests: file-mode cache (cache/close/order), dashboard listEpics by ref, the Epics component file:// render. Schema-version assertions bumped 9 -> 10. --- packages/cli/test/db-scripts.test.ts | 2 +- .../dashboard/src/app/components/Epics.tsx | 27 ++++-- packages/dashboard/src/db-deps.ts | 28 ++++-- packages/dashboard/src/wire.ts | 8 +- packages/dashboard/test/app.test.tsx | 4 +- packages/dashboard/test/epics-deps.test.ts | 46 +++++++++- packages/dashboard/test/epics.test.tsx | 17 ++++ .../src/db/migrations/010_epics_ref_key.sql | 33 +++++++ packages/dispatcher/src/epics-cache.ts | 76 ++++++++++------ packages/dispatcher/src/main.ts | 6 +- packages/dispatcher/test/db.test.ts | 12 +-- packages/dispatcher/test/epics-cache.test.ts | 90 +++++++++++++++++++ planning/issues/200/decisions.md | 14 +++ 13 files changed, 306 insertions(+), 57 deletions(-) create mode 100644 packages/dispatcher/src/db/migrations/010_epics_ref_key.sql diff --git a/packages/cli/test/db-scripts.test.ts b/packages/cli/test/db-scripts.test.ts index f8081e71..58e9b860 100644 --- a/packages/cli/test/db-scripts.test.ts +++ b/packages/cli/test/db-scripts.test.ts @@ -72,7 +72,7 @@ describe("backup.sh + reset-db.sh round-trip", () => { // The restored db is intact: schema migrated, and the seeded row survived. const db = openAndMigrate(join(home, "db.sqlite3")); - expect(currentSchemaVersion(db)).toBe(9); + expect(currentSchemaVersion(db)).toBe(10); const row = db.query("SELECT id FROM workflows WHERE id = 'wf-keep'").get(); expect(row).toEqual({ id: "wf-keep" }); db.close(); diff --git a/packages/dashboard/src/app/components/Epics.tsx b/packages/dashboard/src/app/components/Epics.tsx index 7470c151..f31e861b 100644 --- a/packages/dashboard/src/app/components/Epics.tsx +++ b/packages/dashboard/src/app/components/Epics.tsx @@ -7,6 +7,7 @@ */ import { useState } from "react"; import type { EpicCard } from "../../wire.ts"; +import { EpicRef } from "./EpicRef.tsx"; function ProgressBar({ closed, total }: { closed: number; total: number }) { const pct = total > 0 ? Math.round((closed / total) * 100) : 0; @@ -36,7 +37,11 @@ function DispatchControl({ // (it isn't a configured/dispatchable adapter, so the server would reject it anyway). const slot = card.dispatch.freeSlots.find((s) => s.adapter === adapter); const noSlot = slot ? !slot.available : true; - const disabled = card.dispatch.inFlight || noSlot; + // A file-mode Epic (null number) has no numeric handle for the dashboard's + // numeric dispatch route; force-dispatch it from the CLI (`mm dispatch <slug>`). + // It's still browsable here — only the in-dashboard dispatch button is gated. + const isFileEpic = card.number === null; + const disabled = card.dispatch.inFlight || noSlot || isFileEpic; return ( <div className="epic-dispatch"> <select @@ -54,10 +59,20 @@ function DispatchControl({ </select> <button type="button" - aria-label={`Dispatch Epic #${card.number}`} + aria-label={`Dispatch Epic ${card.ref}`} disabled={disabled} - title={card.dispatch.inFlight ? "already in flight" : noSlot ? "no free slot" : ""} - onClick={() => onDispatch(card.repo, card.number, adapter)} + title={ + isFileEpic + ? "file-mode Epic — dispatch from the CLI: mm dispatch " + card.ref + : card.dispatch.inFlight + ? "already in flight" + : noSlot + ? "no free slot" + : "" + } + onClick={() => { + if (card.number !== null) onDispatch(card.repo, card.number, adapter); + }} > dispatch </button> @@ -84,10 +99,10 @@ export function Epics({ ) : ( <ul> {epics.map((card) => ( - <li key={`${card.repo}#${card.number}`} className="epic-card" data-epic={card.number}> + <li key={`${card.repo}#${card.ref}`} className="epic-card" data-epic={card.ref}> <div className="epic-head"> <span className="epic-title"> - #{card.number} {card.title} + <EpicRef epicNumber={card.number} epicRef={card.ref} /> {card.title} </span> {card.runner ? ( <button diff --git a/packages/dashboard/src/db-deps.ts b/packages/dashboard/src/db-deps.ts index e6ee390a..9f7bea1e 100644 --- a/packages/dashboard/src/db-deps.ts +++ b/packages/dashboard/src/db-deps.ts @@ -254,16 +254,18 @@ export function createDbDeps(opts: DbDepsOptions): DashboardDeps { .get(session, session) as WorkflowRow | null; } - /** The non-terminal implementation workflow owning an Epic, if any. */ - function workflowForEpic(repo: string, epicNumber: number): WorkflowRow | null { + /** The non-terminal implementation workflow owning an Epic, if any. Keyed on the + * canonical `epic_ref` (migration 009) so it resolves both github numbers and + * file-mode slugs. */ + function workflowForEpic(repo: string, epicRef: string): WorkflowRow | null { const placeholders = TERMINAL_STATES.map(() => "?").join(", "); return db .query( `SELECT ${WORKFLOW_COLUMNS} FROM workflows - WHERE repo = ? AND epic_number = ? AND kind = 'implementation' AND state NOT IN (${placeholders}) + WHERE repo = ? AND epic_ref = ? AND kind = 'implementation' AND state NOT IN (${placeholders}) ORDER BY created_at DESC LIMIT 1`, ) - .get(repo, epicNumber, ...TERMINAL_STATES) as WorkflowRow | null; + .get(repo, epicRef, ...TERMINAL_STATES) as WorkflowRow | null; } /** Slot limits for `hasFreeSlot`, from the merged config. */ @@ -327,11 +329,20 @@ export function createDbDeps(opts: DbDepsOptions): DashboardDeps { available: hasFreeSlot(state, adapter), })); return rows.map((row) => { - const wf = workflowForEpic(repo, row.number); - const need = parsed?.needsHumanInput.find((i) => i.issue === row.number) ?? null; - const blocked = parsed?.blocked.find((b) => b.issue === row.number) ?? null; + const wf = workflowForEpic(repo, row.ref); + // Decision/ready joins key on the GitHub number; a file-mode Epic (null + // number) carries its own decisions in the file state, not this state + // issue, so these naturally yield null for it (no match). + const need = + row.number === null + ? null + : (parsed?.needsHumanInput.find((i) => i.issue === row.number) ?? null); + const blocked = + row.number === null + ? null + : (parsed?.blocked.find((b) => b.issue === row.number) ?? null); const ready = parsed?.readyToDispatch.find( - (r) => Number(r.epic.replace(/^#/, "").split(/\s/)[0]) === row.number, + (r) => r.epic.replace(/^#/, "").split(/\s/)[0] === row.ref, ); let decision: EpicCard["decision"] = null; if (need) { @@ -349,6 +360,7 @@ export function createDbDeps(opts: DbDepsOptions): DashboardDeps { } return { repo, + ref: row.ref, number: row.number, title: row.title, progress: { closed: row.subClosed, total: row.subTotal }, diff --git a/packages/dashboard/src/wire.ts b/packages/dashboard/src/wire.ts index f5b2cb78..df8a64e2 100644 --- a/packages/dashboard/src/wire.ts +++ b/packages/dashboard/src/wire.ts @@ -158,7 +158,13 @@ export type AttachResult = { /** One Epic card in the Epic-centric browse view — cache + workflows + state-issue join. */ export type EpicCard = { repo: string; - number: number; + /** + * Canonical Epic reference: the numeric string in github mode, the slug in file + * mode. The SPA renders it via `<EpicRef>` (a `#N` label or a `file://` link). + */ + ref: string; + /** GitHub issue number, or null for a file-mode Epic (renders as a file:// slug link). */ + number: number | null; title: string; /** Sub-issue progress from the cache. */ progress: { closed: number; total: number }; diff --git a/packages/dashboard/test/app.test.tsx b/packages/dashboard/test/app.test.tsx index 55088ff7..3d3207fb 100644 --- a/packages/dashboard/test/app.test.tsx +++ b/packages/dashboard/test/app.test.tsx @@ -58,8 +58,8 @@ test("api.epics reads Epic cards from a live server", async () => { const { db, cleanup } = makeDb(); try { db.run( - `INSERT INTO epics (repo, number, title, state, labels_json, sub_total, sub_closed, last_refreshed) - VALUES ('o/r', 247, 'OAuth refresh', 'open', '[]', 4, 2, 0)`, + `INSERT INTO epics (repo, ref, number, title, state, labels_json, sub_total, sub_closed, last_refreshed) + VALUES ('o/r', '247', 247, 'OAuth refresh', 'open', '[]', 4, 2, 0)`, ); seedWorkflow(db, { id: "wf1", diff --git a/packages/dashboard/test/epics-deps.test.ts b/packages/dashboard/test/epics-deps.test.ts index 318e2f0e..7c37cae8 100644 --- a/packages/dashboard/test/epics-deps.test.ts +++ b/packages/dashboard/test/epics-deps.test.ts @@ -21,9 +21,25 @@ function seedEpic( labels: string[] = [], ): void { db.run( - `INSERT INTO epics (repo, number, title, state, labels_json, sub_total, sub_closed, last_refreshed) - VALUES (?, ?, ?, 'open', ?, ?, ?, 0)`, - [repo, number, title, JSON.stringify(labels), total, closed], + `INSERT INTO epics (repo, ref, number, title, state, labels_json, sub_total, sub_closed, last_refreshed) + VALUES (?, ?, ?, ?, 'open', ?, ?, ?, 0)`, + [repo, String(number), number, title, JSON.stringify(labels), total, closed], + ); +} + +/** Seed a file-mode Epic row (slug ref, null number) into the browse cache. */ +function seedFileEpic( + repo: string, + ref: string, + title: string, + total: number, + closed: number, + labels: string[] = [], +): void { + db.run( + `INSERT INTO epics (repo, ref, number, title, state, labels_json, sub_total, sub_closed, last_refreshed) + VALUES (?, ?, NULL, ?, 'open', ?, ?, ?, 0)`, + [repo, ref, title, JSON.stringify(labels), total, closed], ); } @@ -179,6 +195,30 @@ describe("createDbDeps.listEpics", () => { }); }); + test("surfaces a file-mode Epic (slug ref, null number) and resolves its runner by ref (#200)", async () => { + seedFileEpic("o/r", "rollout-epic-store", "Roll out the store", 5, 2, ["epic"]); + seedWorkflow(db, { + id: "wf-file", + repo: "o/r", + epicRef: "rollout-epic-store", // file-mode slug, no numeric epicNumber + adapter: "claude", + state: "running", + sessionName: "o-r-rollout", + currentSubIssue: 3, + }); + const deps = createDbDeps({ db, config: makeConfig() }); + const cards = await deps.listEpics("o/r"); + expect(cards).toHaveLength(1); + const c = cards[0]!; + // The card carries the slug ref and a null number (renders as a file:// link). + expect(c.ref).toBe("rollout-epic-store"); + expect(c.number).toBeNull(); + expect(c.progress).toEqual({ closed: 2, total: 5 }); + // The runner is resolved by epic_ref, not epic_number. + expect(c.runner).toMatchObject({ adapter: "claude", state: "running", currentSubIssue: 3 }); + expect(c.dispatch.inFlight).toBe(true); + }); + test("dispatchEpic + refreshEpics delegate to the injected callbacks", async () => { const deps = createDbDeps({ db, diff --git a/packages/dashboard/test/epics.test.tsx b/packages/dashboard/test/epics.test.tsx index ce31998e..f9f1074c 100644 --- a/packages/dashboard/test/epics.test.tsx +++ b/packages/dashboard/test/epics.test.tsx @@ -5,6 +5,7 @@ import type { EpicCard } from "../src/wire.ts"; const card = (over: Partial<EpicCard> = {}): EpicCard => ({ repo: "o/r", + ref: "247", number: 247, title: "OAuth refresh", progress: { closed: 2, total: 4 }, @@ -38,6 +39,22 @@ describe("Epics", () => { expect(out).toContain("No open Epics for this repo."); }); + test("a file-mode Epic renders a file:// slug link and disables in-dashboard dispatch (#200)", () => { + const out = html( + card({ number: null, ref: "rollout-epic-store", title: "Roll out the store" }), + ); + // Browsable: the slug renders as a file:// link to the on-disk Epic file. + expect(out).toContain('href="file://planning/epics/rollout-epic-store.md"'); + expect(out).toContain("rollout-epic-store"); + expect(out).toContain("Roll out the store"); + // No phantom `#null`. + expect(out).not.toContain("#null"); + // Force-dispatch is CLI-only for a file Epic — the button is disabled and + // points at the working path. + expect(out).toContain("disabled"); + expect(out).toContain("mm dispatch rollout-epic-store"); + }); + test("disables dispatch when in flight", () => { const out = html( card({ diff --git a/packages/dispatcher/src/db/migrations/010_epics_ref_key.sql b/packages/dispatcher/src/db/migrations/010_epics_ref_key.sql new file mode 100644 index 00000000..a690e4f9 --- /dev/null +++ b/packages/dispatcher/src/db/migrations/010_epics_ref_key.sql @@ -0,0 +1,33 @@ +-- 010_epics_ref_key.sql +-- Re-key the Epic browse cache from (repo, number) to (repo, ref) so a file-mode +-- Epic — a slug with no GitHub issue number — is representable in the cache and +-- surfaces in `mm status` / the dashboard (#200). Mirrors migration 009's +-- workflows.epic_ref: `ref` is the canonical identifier (the numeric string in +-- github mode, the slug in file mode); `number` becomes nullable (null for a +-- file Epic). SQLite can't change a PRIMARY KEY in place, so rebuild the table. +-- +-- Backfill: every existing (github-mode) row gets ref = CAST(number AS TEXT), +-- byte-identical to how github-mode workflows derive epic_ref. + +CREATE TABLE epics_new ( + repo TEXT NOT NULL, + ref TEXT NOT NULL, -- canonical: numeric string | slug + number INTEGER, -- null for a file-mode Epic + title TEXT NOT NULL, + state TEXT NOT NULL, -- 'open' | 'closed' + labels_json TEXT NOT NULL DEFAULT '[]', + sub_total INTEGER NOT NULL DEFAULT 0, + sub_closed INTEGER NOT NULL DEFAULT 0, + gh_updated_at TEXT, -- reserved (see migration 005) + last_refreshed INTEGER NOT NULL, + PRIMARY KEY (repo, ref) +); + +INSERT INTO epics_new + (repo, ref, number, title, state, labels_json, sub_total, sub_closed, gh_updated_at, last_refreshed) + SELECT repo, CAST(number AS TEXT), number, title, state, labels_json, + sub_total, sub_closed, gh_updated_at, last_refreshed + FROM epics; + +DROP TABLE epics; +ALTER TABLE epics_new RENAME TO epics; diff --git a/packages/dispatcher/src/epics-cache.ts b/packages/dispatcher/src/epics-cache.ts index e2e34ce2..2b8377ea 100644 --- a/packages/dispatcher/src/epics-cache.ts +++ b/packages/dispatcher/src/epics-cache.ts @@ -1,8 +1,12 @@ /** - * The Epic browse cache (table `epics`, migration 005). `refreshEpics` pulls a - * repo's open Epics from GitHub and upserts them; Epics no longer in the open set - * are marked `closed` (kept, not deleted, so a just-closed Epic doesn't flicker - * out mid-view). `readEpics` returns the open rows the dashboard browses. + * The Epic browse cache (table `epics`, migrations 005 + 010). `refreshEpics` + * pulls a repo's open Epics from its mode's gateway (github or file, via the + * routing gateway) and upserts them, keyed on the canonical `ref` (numeric string + * in github mode, slug in file mode); Epics no longer in the open set are marked + * `closed` (kept, not deleted, so a just-closed Epic doesn't flicker out + * mid-view). `readEpics` returns the open rows the dashboard browses. Ref-keying + * (010) is what lets a file-mode Epic — which has no GitHub number — be cached + * and surface in `mm status` / the dashboard (#200). */ import type { Database } from "bun:sqlite"; import type { EpicGateway } from "./github.ts"; @@ -10,7 +14,10 @@ import type { EpicGateway } from "./github.ts"; /** A cached Epic row, projected for the dashboard join. */ export type EpicRow = { repo: string; - number: number; + /** Canonical Epic reference: the numeric string (github) or the slug (file). */ + ref: string; + /** GitHub issue number, or null for a file-mode Epic (which has only a slug). */ + number: number | null; title: string; state: string; labels: string[]; @@ -19,37 +26,47 @@ export type EpicRow = { lastRefreshed: number; }; -/** Refresh a repo's Epic cache from GitHub. One paginated list call; repo-scoped. */ +/** + * Refresh a repo's Epic cache from its mode's Epic gateway. One paginated list + * call; repo-scoped. Pass the routing Epic gateway so file-mode repos list their + * Epic files and github-mode repos list issues — both yield the canonical + * `EpicListItem` (`ref` + nullable `number`) the cache keys on. + */ export async function refreshEpics(db: Database, repo: string, github: EpicGateway): Promise<void> { const epics = await github.listOpenEpics(repo); const now = Date.now(); const upsert = db.query( - `INSERT INTO epics (repo, number, title, state, labels_json, sub_total, sub_closed, last_refreshed) - VALUES (?, ?, ?, 'open', ?, ?, ?, ?) - ON CONFLICT(repo, number) DO UPDATE SET - title = excluded.title, state = 'open', labels_json = excluded.labels_json, - sub_total = excluded.sub_total, sub_closed = excluded.sub_closed, - last_refreshed = excluded.last_refreshed`, + `INSERT INTO epics (repo, ref, number, title, state, labels_json, sub_total, sub_closed, last_refreshed) + VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?) + ON CONFLICT(repo, ref) DO UPDATE SET + number = excluded.number, title = excluded.title, state = 'open', + labels_json = excluded.labels_json, sub_total = excluded.sub_total, + sub_closed = excluded.sub_closed, last_refreshed = excluded.last_refreshed`, ); const close = db.query( - `UPDATE epics SET state = 'closed', last_refreshed = ? WHERE repo = ? AND number = ?`, + `UPDATE epics SET state = 'closed', last_refreshed = ? WHERE repo = ? AND ref = ?`, ); - const open = new Set<number>(); + const open = new Set<string>(); const tx = db.transaction(() => { for (const e of epics) { - // The browse cache is numeric-keyed (`(repo, number)`); a file-mode Epic has - // only a slug (`number === null`) and isn't representable here yet, so skip it. - // (File-mode Epic browse is a later phase; github-mode rows are unaffected.) - if (e.number === null) continue; - upsert.run(repo, e.number, e.title, JSON.stringify(e.labels), e.subTotal, e.subClosed, now); - open.add(e.number); + upsert.run( + repo, + e.ref, + e.number, + e.title, + JSON.stringify(e.labels), + e.subTotal, + e.subClosed, + now, + ); + open.add(e.ref); } // Mark cached-but-no-longer-open Epics closed (kept for non-flicker). - const stale = db - .query(`SELECT number FROM epics WHERE repo = ? AND state = 'open'`) - .all(repo) as { number: number }[]; + const stale = db.query(`SELECT ref FROM epics WHERE repo = ? AND state = 'open'`).all(repo) as { + ref: string; + }[]; for (const row of stale) { - if (!open.has(row.number)) close.run(now, repo, row.number); + if (!open.has(row.ref)) close.run(now, repo, row.ref); } }); tx(); @@ -65,17 +82,22 @@ function safeLabels(json: string): string[] { } } -/** The repo's open Epics, newest (highest number) first. */ +/** + * The repo's open Epics. github-mode Epics come first, newest (highest number) + * first; file-mode Epics (null number) sort after — SQLite orders NULLs last + * under `DESC` — by ref so the ordering is stable. + */ export function readEpics(db: Database, repo: string): EpicRow[] { const rows = db .query( - `SELECT repo, number, title, state, labels_json AS labelsJson, + `SELECT repo, ref, number, title, state, labels_json AS labelsJson, sub_total AS subTotal, sub_closed AS subClosed, last_refreshed AS lastRefreshed - FROM epics WHERE repo = ? AND state = 'open' ORDER BY number DESC`, + FROM epics WHERE repo = ? AND state = 'open' ORDER BY number DESC, ref ASC`, ) .all(repo) as (Omit<EpicRow, "labels"> & { labelsJson: string })[]; return rows.map((r) => ({ repo: r.repo, + ref: r.ref, number: r.number, title: r.title, state: r.state, diff --git a/packages/dispatcher/src/main.ts b/packages/dispatcher/src/main.ts index d3a9bfa5..599b5f23 100644 --- a/packages/dispatcher/src/main.ts +++ b/packages/dispatcher/src/main.ts @@ -467,7 +467,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<void> { }; } scheduleAutoDispatch(normalizedRepo); - void refreshEpics(db, normalizedRepo, ghGitHub).catch((error: unknown) => { + void refreshEpics(db, normalizedRepo, routingEpicGateway).catch((error: unknown) => { console.error( `[epics] post-dispatch refresh ${normalizedRepo} failed: ${(error as Error).message}`, ); @@ -482,7 +482,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<void> { return { status: 404, body: JSON.stringify({ error: `unknown repo: ${normalizedRepo}` }) }; } try { - await refreshEpics(db, normalizedRepo, ghGitHub); + await refreshEpics(db, normalizedRepo, routingEpicGateway); return { status: 200, body: JSON.stringify({ ok: true }) }; } catch (error) { return { status: 502, body: JSON.stringify({ error: (error as Error).message }) }; @@ -953,7 +953,7 @@ export async function runDaemon(opts: RunDaemonOptions = {}): Promise<void> { // independently completes-or-logs without corrupting the cache. function refreshAllEpics(): void { for (const repo of repoPaths.keys()) { - void refreshEpics(db, repo, ghGitHub).catch((e: unknown) => + void refreshEpics(db, repo, routingEpicGateway).catch((e: unknown) => console.error(`[epics] refresh ${repo} failed: ${(e as Error).message}`), ); } diff --git a/packages/dispatcher/test/db.test.ts b/packages/dispatcher/test/db.test.ts index b17edf0a..654be25b 100644 --- a/packages/dispatcher/test/db.test.ts +++ b/packages/dispatcher/test/db.test.ts @@ -61,8 +61,8 @@ describe("runMigrations", () => { test("applies every migration and reports the latest version", () => { const db = openDb(dbPath); - expect(runMigrations(db)).toBe(9); - expect(currentSchemaVersion(db)).toBe(9); + expect(runMigrations(db)).toBe(10); + expect(currentSchemaVersion(db)).toBe(10); db.close(); }); @@ -85,8 +85,8 @@ describe("runMigrations", () => { test("is idempotent — running twice leaves version at the latest and does not throw", () => { const db = openDb(dbPath); runMigrations(db); - expect(runMigrations(db)).toBe(9); - expect(currentSchemaVersion(db)).toBe(9); + expect(runMigrations(db)).toBe(10); + expect(currentSchemaVersion(db)).toBe(10); db.close(); }); @@ -160,7 +160,7 @@ describe("runMigrations", () => { db.run(`INSERT INTO events (workflow_id, ts, type) VALUES ('w1', 2, 'session.started')`); // Now apply the remaining migrations (003 rebuild, then 004, 005, 006) over the seeded data. - expect(runMigrations(db, realDir)).toBe(9); + expect(runMigrations(db, realDir)).toBe(10); // The row survived the rebuild... expect( @@ -183,7 +183,7 @@ describe("runMigrations", () => { describe("openAndMigrate", () => { test("opens, migrates, and returns a ready database", () => { const db = openAndMigrate(dbPath); - expect(currentSchemaVersion(db)).toBe(9); + expect(currentSchemaVersion(db)).toBe(10); db.close(); }); }); diff --git a/packages/dispatcher/test/epics-cache.test.ts b/packages/dispatcher/test/epics-cache.test.ts index 685b98ca..f99e8c8e 100644 --- a/packages/dispatcher/test/epics-cache.test.ts +++ b/packages/dispatcher/test/epics-cache.test.ts @@ -98,6 +98,96 @@ describe("epics-cache", () => { expect(raw.state).toBe("open"); }); + test("caches a file-mode Epic (slug ref, null number) and surfaces it in readEpics (#200)", async () => { + await refreshEpics( + db, + "o/r", + fakeGitHub([ + { + ref: "rollout-epic-store", + number: null, + title: "Roll out the store", + state: "open", + labels: ["epic"], + subTotal: 5, + subClosed: 2, + }, + ]), + ); + const rows = readEpics(db, "o/r"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + ref: "rollout-epic-store", + number: null, + title: "Roll out the store", + subTotal: 5, + subClosed: 2, + labels: ["epic"], + }); + }); + + test("mixed github + file Epics: github (by number desc) first, file (null number) after", async () => { + await refreshEpics( + db, + "o/r", + fakeGitHub([ + { + ref: "10", + number: 10, + title: "ten", + state: "open", + labels: [], + subTotal: 1, + subClosed: 0, + }, + { + ref: "rollout-epic-store", + number: null, + title: "file epic", + state: "open", + labels: [], + subTotal: 0, + subClosed: 0, + }, + { + ref: "20", + number: 20, + title: "twenty", + state: "open", + labels: [], + subTotal: 1, + subClosed: 0, + }, + ]), + ); + // github Epics by number desc, then the file Epic (NULL number sorts last in DESC). + expect(readEpics(db, "o/r").map((r) => r.ref)).toEqual(["20", "10", "rollout-epic-store"]); + }); + + test("a file Epic that vanishes is marked closed by its slug ref", async () => { + await refreshEpics( + db, + "o/r", + fakeGitHub([ + { + ref: "rollout-epic-store", + number: null, + title: "f", + state: "open", + labels: [], + subTotal: 0, + subClosed: 0, + }, + ]), + ); + await refreshEpics(db, "o/r", fakeGitHub([])); // gone from the open set + expect(readEpics(db, "o/r")).toEqual([]); + const raw = db + .query("SELECT state FROM epics WHERE repo='o/r' AND ref='rollout-epic-store'") + .get() as { state: string }; + expect(raw.state).toBe("closed"); + }); + test("refresh is repo-scoped — another repo's rows are untouched", async () => { await refreshEpics( db, diff --git a/planning/issues/200/decisions.md b/planning/issues/200/decisions.md index 52c268ab..225dc1ff 100644 --- a/planning/issues/200/decisions.md +++ b/planning/issues/200/decisions.md @@ -43,3 +43,17 @@ **Decision:** Parse `[epic_store]` into `MiddleConfig.epicStore`; `resolveRecommenderOptions` no longer rejects a file-mode repo (uses sentinel `0`); the recommender prompt reframes for the file store (rank Epic files under `epics_dir`, rewrite `state_file`, refs are slugs) instead of pointing at a phantom `#0` issue; `surface` skips the gh comment for sentinel 0. **Why:** Routing the recommender's state I/O is moot if the run can't even start for a file repo (the pre-existing `config.stateIssue?.number` gate blocked it — a constraint the gap didn't name). The wiring is unit/integration-tested (resolution returns ok + sentinel; prompt framing asserted). The recommender agent's *live ranking quality* over file Epics is verified by operator smoke, matching #190's "operator-only live smoke" precedent for file-mode dispatch — it can't be gated in CI (a live agent run). + +## Re-key the Epic browse cache (repo, number) → (repo, ref) [migration 010] +**File(s):** `packages/dispatcher/src/db/migrations/010_epics_ref_key.sql`, `packages/dispatcher/src/epics-cache.ts` +**Date:** 2026-06-03 + +**Decision:** Rebuild the `epics` table keyed on `(repo, ref)` with `number` nullable + a new `ref` column; `refreshEpics` upserts by ref and routes through the routing Epic gateway; `readEpics` orders `number DESC, ref ASC` (github Epics newest-first, file Epics — null number — after). The old `if (e.number === null) continue;` skip is gone. +**Why:** A file-mode Epic has no GitHub number, so a numeric PK couldn't represent it — it was explicitly skipped, invisible in the dashboard. Ref-keying mirrors migration 009's `workflows.epic_ref` exactly (`ref = CAST(number AS TEXT)` backfill), so the two canonical-ref columns stay consistent. SQLite can't change a PK in place; the migration rebuilds the table (the runner disables FK enforcement around the loop for exactly this). `EpicListItem` was already ref-first (shipped in #190), so `refreshEpics` needed no gateway-shape change — just the routing gateway instead of hardcoded `ghGitHub`. + +## Dashboard: render file Epics, gate in-dashboard dispatch +**File(s):** `packages/dashboard/src/wire.ts` (`EpicCard.ref`), `db-deps.ts` (`workflowForEpic` by `epic_ref`), `app/components/Epics.tsx` +**Date:** 2026-06-03 + +**Decision:** `EpicCard` carries `ref` + nullable `number`; the card renders via the existing `<EpicRef>` (a `#N` label or a `file://planning/epics/<slug>.md` link, shipped in #190); the workflow lookup keys on `epic_ref` (resolves both modes); the force-dispatch **button is disabled for a file Epic** with a title pointing at `mm dispatch <slug>`. +**Why:** The browse-visibility deliverable is "file Epics appear and are inspectable" — `<EpicRef>` already does the file:// rendering, so the work was plumbing `ref` through the wire + join. In-dashboard force-dispatch goes through a numeric route (`onDispatch(repo, number, adapter)`); a file Epic has no number, and threading a slug through that route is a separate capability (manual `mm dispatch <slug>` already works, per #190). Disabling the button with an explicit pointer is honest — visible but not falsely dispatchable. The ready-row join also switched from number-match to `ref`-match, so a file Epic's recommended-adapter pill works too. From b67c9101b97b70a2fd579462fc1ec95eaef9d811 Mon Sep 17 00:00:00 2001 From: Justin Walsh <contact.me@thejustinwalsh.com> Date: Wed, 3 Jun 2026 11:47:32 -0400 Subject: [PATCH 5/7] fix(epic-store): harden ref-shape assumptions (self-review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial self-review pass before marking ready — resolve the ref-shape class: - recommender-run: forward opts.epicStore into RecommenderInput (was dropped on the standalone-helper path; the daemon path already forwarded it). Test captures the on-disk prompt of a file-mode run. - file-poll-gateway: discriminate file vs github by epicFileExists (the check listIssueComments already uses), not a ^\d+$ heuristic — a numeric-named file Epic (42.md) resolves via meta.pr, not github issue #42. - ref regexes: parseEpicRef #(\S+), IN_FLIGHT_RE #([^*\s]+) — match the real ref grammar (space / ** delimited), so a non-kebab file stem (v1.2-rollout) doesn't truncate and break the byte-identical round-trip invariant. Each fix carries a failing-first test. --- packages/dispatcher/src/auto-dispatch.ts | 10 +++--- .../src/epic-store/file-poll-gateway.ts | 18 +++++------ packages/dispatcher/src/recommender-run.ts | 4 +++ .../dispatcher/test/auto-dispatch.test.ts | 10 ++++++ .../test/epic-store/file-poll-gateway.test.ts | 13 ++++++++ .../dispatcher/test/recommender-run.test.ts | 31 ++++++++++++++++++- packages/state-issue/src/parser.ts | 11 ++++--- packages/state-issue/test/parser.test.ts | 25 +++++++++++++++ planning/issues/200/decisions.md | 10 ++++++ 9 files changed, 113 insertions(+), 19 deletions(-) diff --git a/packages/dispatcher/src/auto-dispatch.ts b/packages/dispatcher/src/auto-dispatch.ts index d75e9d2f..6023e8f5 100644 --- a/packages/dispatcher/src/auto-dispatch.ts +++ b/packages/dispatcher/src/auto-dispatch.ts @@ -68,12 +68,14 @@ export function didReadState(result: AutoDispatchResult): boolean { /** * Extract the leading `#<ref>` Epic reference from a Ready row's `epic` cell, or - * null. `<ref>` is `[\w-]+`: a numeric Epic number in github mode (`#42`) or a - * file-mode Epic slug (`#rollout-epic-store`). The dispatch path is ref-agnostic - * — `startDispatchImpl` already takes an `epicRef` string (#200). + * null. The cell is `#<ref> <title>`, so `<ref>` is everything up to the first + * whitespace: a numeric Epic number in github mode (`#42`) or a file-mode Epic + * slug (`#rollout-epic-store`, `#v1.2-rollout` — a file stem is not constrained + * to `[\w-]`). The dispatch path is ref-agnostic — `startDispatchImpl` already + * takes an `epicRef` string (#200). */ function parseEpicRef(epic: string): string | null { - const match = /^#([\w-]+)\b/.exec(epic.trim()); + const match = /^#(\S+)/.exec(epic.trim()); return match ? match[1]! : null; } diff --git a/packages/dispatcher/src/epic-store/file-poll-gateway.ts b/packages/dispatcher/src/epic-store/file-poll-gateway.ts index 2cd01c27..d8d65c01 100644 --- a/packages/dispatcher/src/epic-store/file-poll-gateway.ts +++ b/packages/dispatcher/src/epic-store/file-poll-gateway.ts @@ -35,12 +35,6 @@ export type FilePollGatewayDeps = { gh: PollGateway; }; -/** `true` when the ref is a numeric string — the only kind gh's `Closes #N` - * PR-finders can resolve (a file-mode slug is not). */ -function isNumericRef(ref: string): boolean { - return /^\d+$/.test(ref.trim()); -} - /** Map an Epic file's conversation into the poller's `IssueComment[]`, with * `authorIsBot` discriminated by marker kind. */ function conversationToPollComments(conversation: ConversationEntry[]): IssueComment[] { @@ -106,15 +100,19 @@ export function makeFilePollGateway(deps: FilePollGatewayDeps): FilePollGateway }, async findPrForEpic(repo, epicRef): Promise<PrSnapshot | null> { - // Numeric ref → gh's `Closes #<n>` finder. File-mode slug → resolve the PR - // from the Epic file's `meta.pr` stamp and fetch it by number. - if (isNumericRef(epicRef)) return gh.findPrForEpic(repo, epicRef); + // Authoritative discriminator (the same `epicFileExists` check + // `listIssueComments` uses): a file Epic is one with an Epic file on disk — + // resolve its PR from the file's `meta.pr` stamp. Otherwise the ref is a + // github number → gh's `Closes #<n>` finder. Keying on the file (not a + // `^\d+$` shape) means a numeric-named file Epic (`123.md`) still resolves + // via meta.pr rather than being mistaken for github issue #123. + if (!epicFileExists(epicsDir, epicRef)) return gh.findPrForEpic(repo, epicRef); const prNumber = stampedPr(epicRef); return prNumber === null ? null : gh.prSnapshot(repo, prNumber); }, async findEpicPrLifecycle(repo, epicRef): Promise<EpicPrLifecycle | null> { - if (isNumericRef(epicRef)) return gh.findEpicPrLifecycle(repo, epicRef); + if (!epicFileExists(epicsDir, epicRef)) return gh.findEpicPrLifecycle(repo, epicRef); const prNumber = stampedPr(epicRef); return prNumber === null ? null : gh.prLifecycle(repo, prNumber); }, diff --git a/packages/dispatcher/src/recommender-run.ts b/packages/dispatcher/src/recommender-run.ts index 72c36e45..6e644244 100644 --- a/packages/dispatcher/src/recommender-run.ts +++ b/packages/dispatcher/src/recommender-run.ts @@ -303,6 +303,10 @@ export async function dispatchRecommender( repo: opts.repoSlug, stateIssue: opts.stateIssue, adapter: opts.adapterName, + // Forward the Epic-store mode so the workflow's prompt frames a file-mode + // run for the file store (the daemon path forwards it too; this is the + // standalone-helper path) — without this it always took the github branch. + epicStore: opts.epicStore, }; const handle = await engine.start("recommender", input); console.error(`[recommender-run] workflow ${handle.id} enqueued`); diff --git a/packages/dispatcher/test/auto-dispatch.test.ts b/packages/dispatcher/test/auto-dispatch.test.ts index 6fefd147..b12002de 100644 --- a/packages/dispatcher/test/auto-dispatch.test.ts +++ b/packages/dispatcher/test/auto-dispatch.test.ts @@ -232,6 +232,16 @@ describe("autoDispatch", () => { expect(result.reason).toBe("drained"); }); + test("extracts a non-kebab slug ref up to the first space (#200)", async () => { + // A file stem isn't constrained to `[\w-]` — a dotted slug must be captured + // whole (up to the title's leading space), not truncated at the dot. + const { deps, enqueued } = makeDeps({ + readState: async () => stateWith([readyRow(1, "v1.2-rollout", "claude")]), + }); + await autoDispatch(deps); + expect(enqueued).toEqual([{ repo: "o/r", epicRef: "v1.2-rollout", adapter: "claude" }]); + }); + test("ignores the empty-state (no ready rows) without enqueuing", async () => { const { deps, enqueued } = makeDeps({ readState: async () => stateWith([]) }); const result = await autoDispatch(deps); diff --git a/packages/dispatcher/test/epic-store/file-poll-gateway.test.ts b/packages/dispatcher/test/epic-store/file-poll-gateway.test.ts index 33cce279..d30489c6 100644 --- a/packages/dispatcher/test/epic-store/file-poll-gateway.test.ts +++ b/packages/dispatcher/test/epic-store/file-poll-gateway.test.ts @@ -164,6 +164,19 @@ describe("filePollGateway", () => { expect(calls.prLifecycle).toEqual([]); }); + test("a numeric-named file Epic (e.g. 42.md) resolves via meta.pr, not gh's #42 finder (#200)", async () => { + // The discriminator is the Epic file on disk, not a `^\d+$` shape — so a file + // Epic whose slug happens to be numeric still resolves its PR from meta.pr + // instead of being mistaken for github issue #42. + const { gh, calls } = ghStub(); + const dir = tmpEpicsDir(); + seedEpic(dir, { ...baseEpic([]), meta: { slug: "42", pr: 88 } }); + const gw = makeFilePollGateway({ epicsDir: dir, gh }); + expect(await gw.findPrForEpic("o/r", "42")).toMatchObject({ number: 88 }); + expect(calls.prSnapshot).toEqual([88]); + expect(calls.findPrForEpic).toEqual([]); // never the github `Closes #42` finder + }); + test("prSnapshot / prLifecycle delegate straight to gh by PR number", async () => { const { gh, calls } = ghStub(); const gw = makeFilePollGateway({ epicsDir: tmpEpicsDir(), gh }); diff --git a/packages/dispatcher/test/recommender-run.test.ts b/packages/dispatcher/test/recommender-run.test.ts index 53f82fe0..b6f45841 100644 --- a/packages/dispatcher/test/recommender-run.test.ts +++ b/packages/dispatcher/test/recommender-run.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentAdapter, HookPayload, MiddleConfig } from "@middle/core"; import { renderStateIssue, STATE_ISSUE_SCHEMA_PATH } from "@middle/state-issue"; import { openAndMigrate } from "../src/db.ts"; +import { createWorktree, destroyWorktree } from "../src/worktree.ts"; import type { SessionGate } from "../src/hook-server.ts"; import { dispatchRecommender, @@ -193,6 +194,34 @@ describe("dispatchRecommender — enqueues a recommender workflow (read-only)", expect(calls).toEqual([{ repo: "thejustinwalsh/middle", stateIssue: 99 }]); }); + test("forwards epicStore so a file-mode run frames the prompt for the file store (#200)", async () => { + // Regression guard: dispatchRecommender must thread opts.epicStore into the + // RecommenderInput, or the workflow always takes the github prompt branch. + const dbPath = join(scratch, "db.sqlite3"); + let writtenPrompt = ""; + const opts = baseOptions( + dbPath, + makeOverrides({ + worktree: { + createWorktree, + destroyWorktree: async (handle) => { + const p = join(handle.path, ".middle", "prompt.md"); + if (existsSync(p)) writtenPrompt = readFileSync(p, "utf8"); + return destroyWorktree(handle); + }, + }, + }), + ); + opts.stateIssue = 0; // file mode: sentinel + opts.epicStore = { mode: "file", epicsDir: "planning/epics", stateFile: ".middle/state.md" }; + const result = await dispatchRecommender(opts); + expect(result.state).toBe("completed"); + // The file-mode framing reached the on-disk prompt (proves the forward). + expect(writtenPrompt).toContain("file-backed"); + expect(writtenPrompt).toContain("epics_dir: `planning/epics`"); + expect(writtenPrompt).not.toContain("state issue #0"); + }); + test("does not fire triggerAutoDispatch when auto_dispatch is off, even if wired", async () => { const dbPath = join(scratch, "db.sqlite3"); const calls: unknown[] = []; diff --git a/packages/state-issue/src/parser.ts b/packages/state-issue/src/parser.ts index cbb6c7a7..49840b66 100644 --- a/packages/state-issue/src/parser.ts +++ b/packages/state-issue/src/parser.ts @@ -189,11 +189,14 @@ function parseBlocked(content: string[]): BlockedItem[] { }); } -// The `#` ref is `[\w-]+` (not `\d+`): a numeric Epic number in github mode OR a -// file-mode Epic slug (kebab-case file stem). Captured as a string and kept raw -// so the dispatcher-owned section round-trips a slug unchanged (#200). +// The `#` ref is one or more non-space, non-`*` chars (not `\d+`): a numeric Epic +// number in github mode OR a file-mode Epic slug. A file stem isn't constrained +// to kebab (`v1.2-rollout` is valid), so match any char up to the closing `**` +// delimiter — that keeps the round-trip exact for whatever slug the file store +// produced. (Space/`*` can't appear in the ref: the line uses ` · ` separators +// and `**` bold delimiters.) Captured raw as a string (#200). const IN_FLIGHT_RE = - /^- \*\*#([\w-]+)\*\* · (.+?) · (.+?) · last heartbeat (.+?) · \[tmux: (.+?)\]$/; + /^- \*\*#([^*\s]+)\*\* · (.+?) · (.+?) · last heartbeat (.+?) · \[tmux: (.+?)\]$/; function parseInFlight(content: string[]): InFlightItem[] { // Accepts the canonical "- _no agents in flight_", a generic placeholder, or an diff --git a/packages/state-issue/test/parser.test.ts b/packages/state-issue/test/parser.test.ts index d01ef517..7f9beefd 100644 --- a/packages/state-issue/test/parser.test.ts +++ b/packages/state-issue/test/parser.test.ts @@ -75,6 +75,31 @@ describe("parseStateIssue", () => { expect(parsed).toEqual(fullState); }); + test("round-trips a file-mode in-flight ref, including a non-kebab slug (#200)", () => { + // A file Epic's slug is an unconstrained file stem — it can contain a dot + // (`v1.2-rollout`). The in-flight `#<ref>` must round-trip it byte-identically, + // not truncate at the first non-`[\w-]` char (which would break the invariant). + const state = { + ...emptyState, + inFlight: [ + { + issue: "v1.2-rollout", + adapter: "claude", + progress: "running", + lastHeartbeat: "5s ago", + tmuxSession: "middle-v1.2-rollout", + }, + ], + }; + const rendered = renderStateIssue(state); + expect(rendered).toContain("- **#v1.2-rollout** · claude · running"); + const parsed = parseStateIssue(rendered); + expect(isParseError(parsed)).toBe(false); + expect(parsed).toEqual(state); + // Byte-identical re-render (the hard invariant). + expect(renderStateIssue(parsed as typeof state)).toBe(rendered); + }); + test("returns ParseError when the open marker is missing", () => { const body = renderStateIssue(emptyState).replace("<!-- AGENT-QUEUE-STATE v1 -->\n", ""); expect(isParseError(parseStateIssue(body))).toBe(true); diff --git a/planning/issues/200/decisions.md b/planning/issues/200/decisions.md index 225dc1ff..8a799bdd 100644 --- a/planning/issues/200/decisions.md +++ b/planning/issues/200/decisions.md @@ -57,3 +57,13 @@ **Decision:** `EpicCard` carries `ref` + nullable `number`; the card renders via the existing `<EpicRef>` (a `#N` label or a `file://planning/epics/<slug>.md` link, shipped in #190); the workflow lookup keys on `epic_ref` (resolves both modes); the force-dispatch **button is disabled for a file Epic** with a title pointing at `mm dispatch <slug>`. **Why:** The browse-visibility deliverable is "file Epics appear and are inspectable" — `<EpicRef>` already does the file:// rendering, so the work was plumbing `ref` through the wire + join. In-dashboard force-dispatch goes through a numeric route (`onDispatch(repo, number, adapter)`); a file Epic has no number, and threading a slug through that route is a separate capability (manual `mm dispatch <slug>` already works, per #190). Disabling the button with an explicit pointer is honest — visible but not falsely dispatchable. The ready-row join also switched from number-match to `ref`-match, so a file Epic's recommended-adapter pill works too. + +## Self-review fixes (internal CodeRabbit pass) +**File(s):** `recommender-run.ts`, `epic-store/file-poll-gateway.ts`, `auto-dispatch.ts`, `state-issue/parser.ts` +**Date:** 2026-06-03 + +**Decision:** Three robustness fixes from an adversarial self-review before marking ready: +1. `dispatchRecommender` now forwards `opts.epicStore` into `RecommenderInput` — it was dropped, so the standalone-helper path always took the github prompt branch (the daemon path already forwarded it). Tested by capturing the on-disk prompt of a file-mode run. +2. `file-poll-gateway` discriminates file vs github by `epicFileExists` (the authoritative check `listIssueComments` already uses), not a `^\d+$` heuristic — so a numeric-named file Epic (`42.md`) resolves via `meta.pr` instead of being mistaken for github issue #42. +3. The ref regexes (`parseEpicRef` `#(\S+)`, `IN_FLIGHT_RE` `#([^*\s]+)`) match the actual ref grammar (delimited by space / `**`), not `[\w-]` — a file stem isn't constrained to kebab, and a dotted slug (`v1.2-rollout`) would otherwise truncate and break the round-trip invariant. +**Why:** Resolve-the-class within each finding's blast radius; each fix carries a failing-first test. The class was "ref-shape assumptions" — `[\w-]`/`^\d+$` heuristics for an unconstrained file-stem slug. From ca7663c032d109131e7bf0f36d8cf910c02efa1a Mon Sep 17 00:00:00 2001 From: Justin Walsh <contact.me@thejustinwalsh.com> Date: Wed, 3 Jun 2026 12:20:54 -0400 Subject: [PATCH 6/7] fix(poller-gateway): isolate the reviews fetch in fetchPrSnapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transient failure of the per-PR reviews API call (rate limit, network) threw out of fetchPrSnapshot, unlike the sibling `pr view` fetch which already degrades to null. Wrap it to match — a stale/unreachable PR snapshot degrades to "no PR" instead of propagating. Pass `env` to the `gh` helper so argv[0] resolves against the current PATH (behavior-identical in prod), which lets a fake gh on PATH drive both failure branches under test. --- packages/dispatcher/src/poller-gateway.ts | 31 +++++++-- .../dispatcher/test/poller-gateway.test.ts | 66 ++++++++++++++++++- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/packages/dispatcher/src/poller-gateway.ts b/packages/dispatcher/src/poller-gateway.ts index 782ee2ff..b6e55d88 100644 --- a/packages/dispatcher/src/poller-gateway.ts +++ b/packages/dispatcher/src/poller-gateway.ts @@ -61,7 +61,16 @@ export function deriveCiStatus(rollup: CheckRollupEntry[] | null | undefined): C */ async function gh(argv: string[]): Promise<string> { - const proc = Bun.spawn(["gh", ...argv], { stdout: "pipe", stderr: "pipe", stdin: "ignore" }); + // Pass `env` explicitly so argv[0] (`gh`) resolves against the *current* + // `process.env.PATH` rather than Bun's process-start PATH snapshot. Identical + // to the default inherited environment in production; it's what lets a test + // shim `gh` on PATH to exercise the failure-isolation branches below. + const proc = Bun.spawn(["gh", ...argv], { + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + env: process.env, + }); const [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), @@ -104,12 +113,20 @@ async function fetchPrSnapshot(repo: string, prNumber: number): Promise<PrSnapsh statusCheckRollup: CheckRollupEntry[] | null; }; - const reviewsOut = await gh([ - "api", - "--paginate", - "--slurp", - `repos/${repo}/pulls/${prNumber}/reviews`, - ]); + let reviewsOut: string; + try { + reviewsOut = await gh([ + "api", + "--paginate", + "--slurp", + `repos/${repo}/pulls/${prNumber}/reviews`, + ]); + } catch { + // Same isolation contract as the `pr view` fetch above: a transient reviews + // failure (rate limit, network) degrades to "no PR" rather than throwing and + // aborting the whole poll pass for every other parked workflow. + return null; + } const reviewRows = ( JSON.parse(reviewsOut) as Array< Array<{ diff --git a/packages/dispatcher/test/poller-gateway.test.ts b/packages/dispatcher/test/poller-gateway.test.ts index f0ed2f72..7d488033 100644 --- a/packages/dispatcher/test/poller-gateway.test.ts +++ b/packages/dispatcher/test/poller-gateway.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, test } from "bun:test"; -import { type CheckRollupEntry, deriveCiStatus } from "../src/poller-gateway.ts"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type CheckRollupEntry, deriveCiStatus, ghPollGateway } from "../src/poller-gateway.ts"; /** A finished GitHub Actions check run. */ function run(conclusion: string): CheckRollupEntry { @@ -45,3 +48,62 @@ describe("deriveCiStatus", () => { expect(deriveCiStatus([{ state: "SUCCESS" }, { state: "EXPECTED" }])).toBe("pending"); }); }); + +// A fake `gh` on PATH lets us exercise the per-fetch failure-isolation contract +// without a live GitHub. The script's `reviews` branch fails when +// FAKE_GH_REVIEWS_FAIL is set, so we can drive "pr view succeeds, reviews fetch +// throws" — the exact split the snapshot fetcher must isolate. +describe("ghPollGateway.prSnapshot failure isolation", () => { + let binDir: string; + let origPath: string | undefined; + + beforeEach(() => { + binDir = mkdtempSync(join(tmpdir(), "middle-fakegh-")); + const script = [ + "#!/bin/sh", + 'case "$*" in', + " *reviews*)", + ' if [ -n "$FAKE_GH_REVIEWS_FAIL" ]; then echo "simulated reviews failure" >&2; exit 1; fi', + " echo '[[]]'", + " ;;", + " *reviewDecision*)", + ' if [ -n "$FAKE_GH_VIEW_FAIL" ]; then echo "simulated pr-view failure" >&2; exit 1; fi', + ` echo '{"reviewDecision":"APPROVED","labels":[{"name":"ready-for-review"}],"statusCheckRollup":[]}'`, + " ;;", + " *) exit 0 ;;", + "esac", + "", + ].join("\n"); + writeFileSync(join(binDir, "gh"), script); + chmodSync(join(binDir, "gh"), 0o755); + origPath = process.env.PATH; + process.env.PATH = `${binDir}:${origPath ?? ""}`; + }); + + afterEach(() => { + if (origPath === undefined) delete process.env.PATH; + else process.env.PATH = origPath; + delete process.env.FAKE_GH_REVIEWS_FAIL; + delete process.env.FAKE_GH_VIEW_FAIL; + rmSync(binDir, { recursive: true, force: true }); + }); + + test("a transient reviews-fetch failure degrades to null, not a thrown pass", async () => { + process.env.FAKE_GH_REVIEWS_FAIL = "1"; + // `pr view` succeeds but the reviews API call throws — must isolate to null, + // mirroring the `pr view` failure path, so one workflow's transient error + // never aborts the whole poll pass. + expect(await ghPollGateway.prSnapshot("o/r", 42)).toBeNull(); + }); + + test("a `pr view` failure also degrades to null (the symmetric branch)", async () => { + process.env.FAKE_GH_VIEW_FAIL = "1"; + expect(await ghPollGateway.prSnapshot("o/r", 42)).toBeNull(); + }); + + test("both fetches succeed → a populated snapshot", async () => { + const snap = await ghPollGateway.prSnapshot("o/r", 42); + expect(snap).toMatchObject({ number: 42, reviewDecision: "APPROVED", reviews: [] }); + expect(snap?.labels).toEqual(["ready-for-review"]); + }); +}); From 2701b5153540e9944b4a6cc5a02a841eafcc45aa Mon Sep 17 00:00:00 2001 From: Justin Walsh <contact.me@thejustinwalsh.com> Date: Wed, 3 Jun 2026 12:20:54 -0400 Subject: [PATCH 7/7] docs(state-issue): scope rule 4 to numeric sections; widen in-flight slug wording The schema doc claimed in-flight `<ref>` is kebab-case and rule 4 numeric-checks all #N refs, but validate.ts only constrains Ready epics and Blocked blockers and parser.ts captures any non-space/non-\* stem for in-flight refs (file-mode Epic slugs, incl. dotted `v1.2-rollout`). Correct the schema doc and the authoritative build spec to match. Pin the behavior: a validate test for a slug in-flight ref, and a dotted-slug arm in the round-trip fuzz generator. --- packages/state-issue/test/fuzz.test.ts | 12 +++++++++--- packages/state-issue/test/validate.test.ts | 16 ++++++++++++++++ planning/middle-management-build-spec.md | 4 +++- schemas/state-issue.v1.md | 15 ++++++++++----- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/state-issue/test/fuzz.test.ts b/packages/state-issue/test/fuzz.test.ts index 36f70e2c..03885fb2 100644 --- a/packages/state-issue/test/fuzz.test.ts +++ b/packages/state-issue/test/fuzz.test.ts @@ -107,9 +107,15 @@ function genBlocked(rng: Rng): BlockedItem { function genInFlight(rng: Rng): InFlightItem { return { - // A numeric ref (github mode) or a kebab file-mode slug — both round-trip - // through the `#<ref>` shape (#200). - issue: rng.bool() ? String(rng.int(1, 9999)) : `epic-${rng.int(1, 9999)}-rollout`, + // A numeric ref (github mode) or a file-mode slug — both round-trip through + // the `#<ref>` shape (#200). The slug isn't constrained to kebab: the parser + // captures any non-space/non-`*` stem, so we fuzz a dotted variant too + // (`v1.2-rollout`-style) to pin that broadened capture against truncation. + issue: rng.bool() + ? String(rng.int(1, 9999)) + : rng.bool() + ? `epic-${rng.int(1, 9999)}-rollout` + : `v${rng.int(1, 9)}.${rng.int(0, 9)}-rollout-${rng.int(1, 9999)}`, adapter: rng.pick(ADAPTERS), progress: rng.bool() ? "running" : `sub-issue ${rng.int(1, 9)}/${rng.int(1, 12)}`, lastHeartbeat: `${rng.int(1, 59)}${rng.pick(["s", "m", "h"])} ago`, diff --git a/packages/state-issue/test/validate.test.ts b/packages/state-issue/test/validate.test.ts index 153a75df..77b86652 100644 --- a/packages/state-issue/test/validate.test.ts +++ b/packages/state-issue/test/validate.test.ts @@ -37,6 +37,22 @@ describe("validate", () => { expect(validate(bad, config).ok).toBe(false); }); + test("accepts a non-numeric file-mode Epic slug as an In-flight ref (rule 4 scopes the numeric check to Ready epics and blocked blockers, not In-flight)", () => { + const ok = { + ...fullState, + inFlight: [ + { + issue: "v1.2-rollout", // dotted file-stem slug — not /#\d+/, intentionally allowed + adapter: "claude", + progress: "running", + lastHeartbeat: "1m ago", + tmuxSession: "middle-v1.2-rollout", + }, + ], + }; + expect(validate(ok, config)).toEqual({ ok: true }); + }); + test("fails when generated is not ISO 8601", () => { expect(validate({ ...fullState, generated: "not-a-date" }, config).ok).toBe(false); }); diff --git a/planning/middle-management-build-spec.md b/planning/middle-management-build-spec.md index ad369473..a9769642 100644 --- a/planning/middle-management-build-spec.md +++ b/planning/middle-management-build-spec.md @@ -439,7 +439,9 @@ Body PASSES iff: 1. Both markers present 2. All 7 sections in order 3. Ready table has exact column header -4. All #N references match /#\d+/ +4. Numeric `#N` references match /#\d+/ — scoped to Ready row epics and Blocked + issue blockers; In-flight `<ref>` is exempt (may be a file-mode Epic slug). See + `schemas/state-issue.v1.md` rule 4 for the authoritative wording. 5. Adapter names are configured 6. Empty sections use documented empty state 7. Metadata `generated` parses as ISO 8601 diff --git a/schemas/state-issue.v1.md b/schemas/state-issue.v1.md index 86570573..8c3fc986 100644 --- a/schemas/state-issue.v1.md +++ b/schemas/state-issue.v1.md @@ -45,10 +45,13 @@ Bulleted list. `- **#<n>** waiting on #<blocker> · <context>` `- **#<ref>** · <adapter> · <progress> · last heartbeat <rel> · [tmux: <session>]` `<ref>` is the dispatched Epic: a numeric Epic/issue number in github mode, or a -file-mode Epic **slug** (kebab-case, e.g. `rollout-epic-store`) for a repo whose -Epic store is file-backed — a file Epic has no GitHub issue number, so its -in-flight row carries the slug. Progress: `sub-issue <m>/<n>` (which phase of the -Epic the agent is on) or `running` +file-mode Epic **slug** for a repo whose Epic store is file-backed — a file Epic +has no GitHub issue number, so its in-flight row carries the slug. The slug is a +file-stem token, not strictly kebab-case: the parser captures any run of +non-space, non-`*` characters (so dots and mixed tokens are valid, e.g. +`rollout-epic-store` or `v1.2-rollout`) to keep the round-trip byte-exact for +whatever stem the file store produced. Progress: `sub-issue <m>/<n>` (which phase +of the Epic the agent is on) or `running` Empty: `- _no agents in flight_` ### 5. ## Excluded @@ -76,7 +79,9 @@ Body PASSES iff: 1. Both markers present 2. All 7 sections in order 3. Ready table has exact column header -4. All #N references match /#\d+/ +4. Numeric `#N` references match /#\d+/ — scoped to **Ready** row epics and + **Blocked** issue blockers. In-flight `<ref>` is exempt: it may be a file-mode + Epic slug (see In-flight above), so it is not constrained to /#\d+/. 5. Adapter names are configured 6. Empty sections use documented empty state 7. Metadata `generated` parses as ISO 8601