Skip to content
Merged
4 changes: 3 additions & 1 deletion packages/adapters/copilot/test/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,9 @@ describe("classifyStop", () => {
writeFileSync(join(middle, "done.json"), JSON.stringify({ pr: 207 }));
writeFileSync(
transcript,
ev("assistant.message", "2026-06-04T12:30:00.000Z", { content: "Error 429: Too Many Requests" }),
ev("assistant.message", "2026-06-04T12:30:00.000Z", {
content: "Error 429: Too Many Requests",
}),
);
const result = copilotAdapter.classifyStop({
payload: {},
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/bootstrap/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ export async function initRepo(
): Promise<InitResult> {
const epicStore: EpicStoreMode = opts.epicStore ?? "github";
const info = await validateTarget(repo, deps, epicStore);
// Shared-checkout collision guard (#226): once the repo is identified, reject a
// checkout already registered to a *different* slug BEFORE writing any files, so
// a rejected init leaves the target untouched (no half-scaffolded `.middle/`).
// Skipped under --dry-run (which writes nothing anyway).
if (!opts.dryRun) opts.checkCollision?.(`${info.owner}/${info.name}`);
const existing = readExistingConfig(repo);
const mode: InitResult["mode"] =
existing === null ? "fresh" : existing.version === BOOTSTRAP_VERSION ? "reinit" : "migrate";
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/bootstrap/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export type BootstrapOptions = {
* local Epic directory + state file and makes ZERO `gh`/GitHub calls.
*/
epicStore?: EpicStoreMode;
/**
* Shared-checkout collision guard (#226), run with the resolved `owner/name`
* slug *after* the repo is identified but *before* any files are written — so a
* rejected init scaffolds nothing. Throws to abort the init. Injected by the CLI
* (a daemon-db lookup); omitted in dry-run and in unit tests that don't assert it.
*/
checkCollision?: (slug: string) => void;
};

/** What `mm init` did (or, under `--dry-run`, would do). */
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ export type InitCliOptions = {
* `registerRepo`. Omitted → no write (e.g. unit tests that don't assert it).
*/
setEpicStore?: (repo: string, cfg: EpicStoreRegistration) => void;
/**
* Shared-checkout collision guard (#226) — wired by the CLI entry to a daemon-db
* lookup (`assertNoRepoPathCollision`). Run *before* any files are written (so a
* rejected init scaffolds nothing) with the resolved `owner/name` slug + the
* checkout path; a throw aborts the init with a non-zero exit. Unlike
* `registerRepo`/`setEpicStore`, this is NOT best-effort — a collision must fail
* the init. Omitted → no guard (unit tests that don't assert it).
*/
checkCollision?: (repo: string, repoPath: string) => void;
};

/**
Expand All @@ -49,6 +58,10 @@ export async function runInit(pathArg: string, opts: InitCliOptions = {}): Promi
const result = await initRepo(repo, deps, {
dryRun: opts.dryRun ?? false,
epicStore: opts.epicStore ?? "github",
// The guard runs inside initRepo (after the slug resolves, before any write)
// so a collision aborts the init before it scaffolds. The throw propagates to
// the catch below → `mm init: <message>` on stderr, non-zero exit (#226).
checkCollision: opts.checkCollision ? (slug) => opts.checkCollision!(slug, repo) : undefined,
});
const slug = `${result.info.owner}/${result.info.name}`;

Expand Down
23 changes: 22 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ import { runDocs } from "./commands/docs.ts";
import { runDoctor } from "./commands/doctor.ts";
import { loadConfig } from "@middle/core";
import { openAndMigrate } from "@middle/dispatcher/src/db.ts";
import { registerManagedRepo, setEpicStoreConfig } from "@middle/dispatcher/src/repo-config.ts";
import {
assertNoRepoPathCollision,
registerManagedRepo,
setEpicStoreConfig,
} from "@middle/dispatcher/src/repo-config.ts";
import { runInit, type EpicStoreRegistration } from "./commands/init.ts";

/**
Expand Down Expand Up @@ -64,6 +68,22 @@ function setEpicStoreInDaemonDb(repo: string, cfg: EpicStoreRegistration): void
db.close();
}
}

/**
* Shared-checkout collision guard (#226): reject `mm init` when this checkout path
* is already registered to a *different* repo slug — BEFORE any files are written.
* Throws {@link RepoPathCollisionError}; `runInit`'s catch turns it into a clear
* `mm init: …` message + non-zero exit. NOT best-effort (a collision must fail).
*/
function checkRepoCollisionInDaemonDb(repo: string, repoPath: string): void {
const config = loadConfig({ globalPath: process.env.MIDDLE_CONFIG });
const db = openAndMigrate(config.global.dbPath);
try {
assertNoRepoPathCollision(db, repo, repoPath);
} finally {
db.close();
}
}
import { runPause, runResume } from "./commands/pause.ts";
import { runResumeAnswer } from "./commands/resume-answer.ts";
import { runRecommender } from "./commands/run-recommender.ts";
Expand Down Expand Up @@ -102,6 +122,7 @@ program
epicStore: mode,
registerRepo: registerRepoInDaemonDb,
setEpicStore: setEpicStoreInDaemonDb,
checkCollision: checkRepoCollisionInDaemonDb,
}),
);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/db-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(10);
expect(currentSchemaVersion(db)).toBe(11);
const row = db.query("SELECT id FROM workflows WHERE id = 'wf-keep'").get();
expect(row).toEqual({ id: "wf-keep" });
db.close();
Expand Down
128 changes: 128 additions & 0 deletions packages/cli/test/init-collision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Database } from "bun:sqlite";
import { openAndMigrate } from "@middle/dispatcher/src/db.ts";
import {
assertNoRepoPathCollision,
getManagedRepoPath,
registerManagedRepo,
} from "@middle/dispatcher/src/repo-config.ts";
import type { BootstrapDeps } from "../src/bootstrap/types.ts";
import { runInit } from "../src/commands/init.ts";

// #226 — `mm init` must reject a checkout path already registered to a DIFFERENT
// repo slug, before it scaffolds anything. End-to-end: init repo `acme/a` (file
// mode) in a directory, then a second init that resolves to `acme/b` at the SAME
// directory must exit non-zero with a message naming both repos + the path, and
// the second repo's `.middle/<slug>.toml` must NOT be written.

/** File-mode deps (zero gh calls) resolving a controllable `owner/name` slug. */
function fileDeps(owner: string, name: string): BootstrapDeps {
const die = (m: string) => () => {
throw new Error(`gh call '${m}' must not happen in file mode`);
};
return {
isCleanWorktree: async () => true,
getRemoteUrl: async () => `git@github.com:${owner}/${name}.git`,
isGhAuthenticated: die("isGhAuthenticated") as () => Promise<boolean>,
resolveRepoInfo: die("resolveRepoInfo") as () => Promise<never>,
resolveRepoInfoLocal: async () => ({ owner, name, defaultBranch: "main" }),
github: {
ensureStateLabel: die("ensureStateLabel") as () => Promise<void>,
createStateIssue: die("createStateIssue") as () => Promise<number>,
closeStateIssue: die("closeStateIssue") as () => Promise<void>,
findStateIssues: die("findStateIssues") as () => Promise<number[]>,
},
now: () => new Date("2026-06-04T12:00:00.000Z"),
};
}

let dir: string;
let db: Database;
let errors: string[];
let restore: () => void;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "mm-init-collide-"));
mkdirSync(join(dir, ".git"));
db = openAndMigrate(":memory:");
errors = [];
const log = spyOn(console, "log").mockImplementation(() => {});
const err = spyOn(console, "error").mockImplementation((...a: unknown[]) => {
errors.push(a.join(" "));
});
restore = () => {
log.mockRestore();
err.mockRestore();
};
});

afterEach(() => {
restore();
db.close();
rmSync(dir, { recursive: true, force: true });
});

describe("mm init — shared-checkout collision guard (#226)", () => {
test("a second init at the same path with a different slug exits non-zero and writes nothing", async () => {
// First init: acme/a at `dir` succeeds and registers the path.
const first = await runInit(dir, {
deps: fileDeps("acme", "a"),
epicStore: "file",
registerRepo: (repo, repoPath) => registerManagedRepo(db, repo, repoPath),
checkCollision: (repo, repoPath) => assertNoRepoPathCollision(db, repo, repoPath),
});
expect(first).toBe(0);
expect(getManagedRepoPath(db, "acme/a")).not.toBeNull();
expect(existsSync(join(dir, ".middle", "acme-a.toml"))).toBe(true);

errors = [];

// Second init: acme/b at the SAME `dir` must be rejected before scaffolding.
const second = await runInit(dir, {
deps: fileDeps("acme", "b"),
epicStore: "file",
registerRepo: (repo, repoPath) => registerManagedRepo(db, repo, repoPath),
checkCollision: (repo, repoPath) => assertNoRepoPathCollision(db, repo, repoPath),
});

// Non-zero exit, with an error naming BOTH repos + the shared path.
expect(second).toBe(1);
const message = errors.join("\n");
expect(message).toContain("acme/a");
expect(message).toContain("acme/b");
expect(message).toContain(dir);

// The second repo never got a row, and its `.middle/<slug>.toml` was NOT written.
expect(getManagedRepoPath(db, "acme/b")).toBeNull();
expect(existsSync(join(dir, ".middle", "acme-b.toml"))).toBe(false);
});

test("re-initializing the SAME slug at the same path is allowed (idempotent, no collision)", async () => {
const opts = {
deps: fileDeps("acme", "a"),
epicStore: "file" as const,
registerRepo: (repo: string, repoPath: string) => registerManagedRepo(db, repo, repoPath),
checkCollision: (repo: string, repoPath: string) =>
assertNoRepoPathCollision(db, repo, repoPath),
};
expect(await runInit(dir, opts)).toBe(0);
expect(await runInit(dir, opts)).toBe(0); // re-init, same slug → no collision
expect(getManagedRepoPath(db, "acme/a")).not.toBeNull();
});

test("--dry-run skips the collision guard (it writes nothing anyway)", async () => {
// Register acme/a at `dir`, then a dry-run of acme/b at the same path must NOT
// throw — the guard only fires on the real write path.
registerManagedRepo(db, "acme/a", dir);
const code = await runInit(dir, {
deps: fileDeps("acme", "b"),
epicStore: "file",
dryRun: true,
checkCollision: (repo, repoPath) => assertNoRepoPathCollision(db, repo, repoPath),
});
expect(code).toBe(0);
});
});
19 changes: 19 additions & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ export type RecommenderSettings = {
* rewriting the state issue doesn't finish inside the default window.
*/
agentTimeoutMs?: number;
/**
* Max number of managed repos whose recommender runs fire **concurrently** in
* one cron pass (#227, from `max_concurrent_repos`). The cron parallelizes
* per-repo runs so a hung repo can't block the others; this bounds the fan-out
* to protect rate limits + memory. Daemon-global (read from the global config).
* Undefined → the cron's default (4).
*/
maxConcurrentRepos?: number;
/**
* Hard timeout for a single repo's recommender run inside the cron pass, in
* milliseconds (#227, from `run_timeout_seconds`). A run exceeding this is
* abandoned and marked failed for that repo (stamp rolled back) without
* affecting the others. Undefined → the cron's default (60s).
*/
runTimeoutMs?: number;
};

export type StateIssueSettings = {
Expand Down Expand Up @@ -285,6 +300,10 @@ function mapRecommender(raw: RawTable): RecommenderSettings | undefined {
autoDispatch: r.auto_dispatch as boolean,
agentTimeoutMs:
typeof r.agent_timeout_minutes === "number" ? r.agent_timeout_minutes * 60_000 : undefined,
maxConcurrentRepos:
typeof r.max_concurrent_repos === "number" ? r.max_concurrent_repos : undefined,
runTimeoutMs:
typeof r.run_timeout_seconds === "number" ? r.run_timeout_seconds * 1000 : undefined,
};
}

Expand Down
8 changes: 8 additions & 0 deletions packages/core/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ interval_minutes = 15
adapter = "claude"
auto_dispatch = false
agent_timeout_minutes = 20
max_concurrent_repos = 6
run_timeout_seconds = 90

[state_issue]
number = 142
Expand Down Expand Up @@ -200,6 +202,9 @@ describe("loadConfig — per-repo merge", () => {
expect(config.recommender!.intervalMinutes).toBe(15);
expect(config.recommender!.autoDispatch).toBe(false);
expect(config.recommender!.agentTimeoutMs).toBe(20 * 60_000);
// #227 — per-repo cron parallelism knobs.
expect(config.recommender!.maxConcurrentRepos).toBe(6);
expect(config.recommender!.runTimeoutMs).toBe(90 * 1000);
expect(config.stateIssue!.number).toBe(142);
expect(config.bootstrap!.version).toBe(1);
});
Expand Down Expand Up @@ -286,6 +291,9 @@ describe("loadConfig — committed policy layer", () => {
expect(config.repo!.owner).toBe("thejustinwalsh");
expect(config.limits!.complexityCeiling).toBe(5);
expect(config.recommender!.autoDispatch).toBe(false);
// The #227 cron knobs are optional — absent → undefined (cron applies its defaults).
expect(config.recommender!.maxConcurrentRepos).toBeUndefined();
expect(config.recommender!.runTimeoutMs).toBeUndefined();
// … and the local cache supplies the volatile fields.
expect(config.stateIssue!.number).toBe(142);
expect(config.bootstrap!.version).toBe(1);
Expand Down
Loading