-
Notifications
You must be signed in to change notification settings - Fork 1
feat(dispatcher): auto-dispatch + limits (Phase 8) #132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
2033e96
docs(issue-48): plan for auto-dispatch + limits epic
thejustinwalsh 11f9c71
feat(dispatcher): slot tracking + enqueue guard (#49)
thejustinwalsh 743d1a1
feat(dispatcher): auto-dispatch loop + four triggers (#50)
thejustinwalsh 91c3fa5
feat(cli,dispatcher): per-repo auto_dispatch toggle + pause/resume (#51)
thejustinwalsh 70bdba8
feat(dispatcher): route complexity pauses to waiting-human (#52)
thejustinwalsh 0bf30dc
feat(dispatcher): approved-label handling + manual slot limit + sourc…
thejustinwalsh ad5ab28
fix(dispatcher): make dispatch-brief context resolution failure-safe
thejustinwalsh 95a06bf
fix(dispatcher): enforce manual slot cap on a cold repo; escape mm co…
thejustinwalsh cfcd7b5
fix(cli): match TOML headers with trailing comments and inner whitespace
thejustinwalsh 6c36031
fix(cli): guard pause/resume pre-try failures so they return exit 1
thejustinwalsh f5d50aa
fix(dispatcher): isolate best-effort afterDispatch from the dispatch …
thejustinwalsh 63c6bc8
fix(dispatcher): hoist shuttingDown to avoid a TDZ on early auto-disp…
thejustinwalsh a86646c
refactor(dispatcher): use APPROVED_LABEL in the complexity-pause text
thejustinwalsh 81d35b3
test(adapters): assert asked-question kind in the unknown-kind fallback
thejustinwalsh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| import { existsSync, readFileSync, writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
|
|
||
| /** Overrides for {@link runConfig} — lets a caller (or a test) point at a config file other than the default. */ | ||
| export type ConfigOptions = { | ||
| /** Override the per-repo config path (defaults to `<repoPath>/.middle/config.toml`). */ | ||
| configFile?: string; | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * The keys `mm config` can set, with where they live and how the value is | ||
| * validated/normalized. v1 ships only `auto_dispatch`; the table is the | ||
| * extension point for further keys. | ||
| */ | ||
| const SETTABLE: Record<string, { section: string; normalize: (raw: string) => string | null }> = { | ||
| auto_dispatch: { | ||
| section: "recommender", | ||
| normalize: (raw) => (raw === "true" || raw === "false" ? raw : null), | ||
| }, | ||
| }; | ||
|
|
||
| /** | ||
| * Set `key = value` within `[section]`, preserving the rest of the file | ||
| * byte-for-byte (comments, ordering, unrelated keys). Replaces the key in place | ||
| * if present in that section, inserts it just under the section header if the | ||
| * section exists, or appends a fresh section. The match is scoped to the target | ||
| * section so an identically-named key in another section is never touched. | ||
| */ | ||
| function setTomlKey(source: string, section: string, key: string, value: string): string { | ||
| const lines = source.split("\n"); | ||
| // A TOML table header: `[section]`, tolerating whitespace inside the brackets | ||
| // (`[ section ]`) and a trailing line comment (`[section] # note`) — both are | ||
| // valid TOML the bare-`[section]` form would miss, appending a duplicate | ||
| // table. Returns the trimmed section name, or null if the line isn't a header. | ||
| const headerRe = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/; | ||
| const headerName = (line: string): string | null => { | ||
| const m = headerRe.exec(line); | ||
| return m ? m[1]!.trim() : null; | ||
| }; | ||
| // Escape the key — `SETTABLE` is the extension point, and a future key with | ||
| // regex metacharacters must match literally, not as a pattern. | ||
| const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| const keyRe = new RegExp(`^(\\s*)${escapedKey}\\s*=`); | ||
| let sectionStart = -1; | ||
| for (let i = 0; i < lines.length; i += 1) { | ||
| if (headerName(lines[i]!) === section) { | ||
| sectionStart = i; | ||
| break; | ||
| } | ||
| } | ||
| const assignment = `${key} = ${value}`; | ||
| if (sectionStart === -1) { | ||
| // No such section — append it. Keep exactly one blank line of separation. | ||
| const trimmed = source.replace(/\n+$/, ""); | ||
| return `${trimmed}\n\n[${section}]\n${assignment}\n`; | ||
| } | ||
| // Scan the section body (until the next header or EOF) for the key. | ||
| for (let i = sectionStart + 1; i < lines.length; i += 1) { | ||
| if (headerName(lines[i]!) !== null) break; // next section — key absent in this one | ||
| if (keyRe.test(lines[i]!)) { | ||
| lines[i] = lines[i]!.replace(keyRe, `$1${key} =`).replace(/=.*/, `= ${value}`); | ||
| return lines.join("\n"); | ||
| } | ||
| } | ||
| // Section exists but lacks the key — insert right after the header. | ||
| lines.splice(sectionStart + 1, 0, assignment); | ||
| return lines.join("\n"); | ||
| } | ||
|
|
||
| /** | ||
| * `mm config <repo> <key> <value>` — set a per-repo config value in | ||
| * `<repo>/.middle/config.toml`, preserving the file's comments and layout. v1 | ||
| * supports `auto_dispatch <true|false>` (the `[recommender]` toggle the | ||
| * auto-dispatch loop reads). Returns a process exit code: 0 on success, 1 on error. | ||
| */ | ||
| export function runConfig( | ||
| repoPath: string, | ||
| key: string, | ||
| value: string, | ||
| opts: ConfigOptions = {}, | ||
| ): number { | ||
| const spec = SETTABLE[key]; | ||
| if (!spec) { | ||
| const known = Object.keys(SETTABLE).join(", "); | ||
| console.error(`mm config: unknown key "${key}" (settable keys: ${known})`); | ||
| return 1; | ||
| } | ||
| const normalized = spec.normalize(value); | ||
| if (normalized === null) { | ||
| console.error(`mm config: invalid value "${value}" for ${key}`); | ||
| return 1; | ||
| } | ||
| const configFile = opts.configFile ?? join(repoPath, ".middle", "config.toml"); | ||
| if (!existsSync(configFile)) { | ||
| console.error(`mm config: no config at ${configFile} (run \`mm init\` first)`); | ||
| return 1; | ||
| } | ||
| try { | ||
| const updated = setTomlKey(readFileSync(configFile, "utf8"), spec.section, key, normalized); | ||
| writeFileSync(configFile, updated); | ||
| console.log(`mm config: set ${spec.section}.${key} = ${normalized}`); | ||
| return 0; | ||
| } catch (error) { | ||
| console.error(`mm config: ${(error as Error).message}`); | ||
| return 1; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import type { Database } from "bun:sqlite"; | ||
| import { existsSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| import { loadConfig } from "@middle/core"; | ||
| import { openAndMigrate } from "@middle/dispatcher/src/db.ts"; | ||
| import { clearPaused, setPausedUntil } from "@middle/dispatcher/src/repo-config.ts"; | ||
| import { deriveRepoSlug } from "../paths.ts"; | ||
|
|
||
| /** | ||
| * Overrides for {@link runPause} / {@link runResume} — the config and db paths | ||
| * and the repo-slug derivation, so a caller (or a test) can redirect them away | ||
| * from the on-disk defaults and the live git remote. | ||
| */ | ||
| export type PauseResumeOptions = { | ||
| /** Override the global config path (defaults to `~/.middle/config.toml`). */ | ||
| configPath?: string; | ||
| /** Override the database path (defaults to the config's `db_path`). */ | ||
| dbPath?: string; | ||
| /** Resolve the repo's `owner/name` slug (defaults to the git-remote derivation). */ | ||
| resolveSlug?: (repoPath: string) => Promise<string>; | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** Resolve the db path + the repo slug shared by `mm pause` and `mm resume`. */ | ||
| async function resolve( | ||
| command: string, | ||
| repoPath: string, | ||
| opts: PauseResumeOptions, | ||
| ): Promise<{ dbPath: string; repo: string } | null> { | ||
| if (!existsSync(join(repoPath, ".git"))) { | ||
| console.error(`mm ${command}: "${repoPath}" is not a git repository`); | ||
| return null; | ||
| } | ||
| let dbPath: string; | ||
| try { | ||
| dbPath = opts.dbPath ?? loadConfig({ globalPath: opts.configPath }).global.dbPath; | ||
| } catch (error) { | ||
| console.error(`mm ${command}: failed to load config — ${(error as Error).message}`); | ||
| return null; | ||
| } | ||
| const repo = await (opts.resolveSlug ?? deriveRepoSlug)(repoPath); | ||
| return { dbPath, repo }; | ||
| } | ||
|
|
||
| /** | ||
| * `mm pause <repo>` — suspend auto-dispatch for a repo by setting its | ||
| * `repo_config.paused_until`. With no duration the pause is indefinite (cleared | ||
| * by `mm resume`). The auto-dispatch loop skips a paused repo. Returns a process | ||
| * exit code: 0 on success, 1 on error. | ||
| */ | ||
| export async function runPause(repoPath: string, opts: PauseResumeOptions = {}): Promise<number> { | ||
| let db: Database | null = null; | ||
| try { | ||
| const resolved = await resolve("pause", repoPath, opts); | ||
| if (!resolved) return 1; | ||
| db = openAndMigrate(resolved.dbPath); | ||
| setPausedUntil(db, resolved.repo); | ||
| console.log(`mm pause: ${resolved.repo} auto-dispatch paused (resume with \`mm resume\`)`); | ||
| return 0; | ||
| } catch (error) { | ||
| console.error(`mm pause: ${(error as Error).message}`); | ||
| return 1; | ||
| } finally { | ||
| db?.close(); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * `mm resume <repo>` — clear a repo's pause (`repo_config.paused_until`), so the | ||
| * auto-dispatch loop considers it again. A no-op if the repo was never paused. | ||
| * Returns a process exit code: 0 on success, 1 on error. | ||
| */ | ||
| export async function runResume(repoPath: string, opts: PauseResumeOptions = {}): Promise<number> { | ||
| let db: Database | null = null; | ||
| try { | ||
| const resolved = await resolve("resume", repoPath, opts); | ||
| if (!resolved) return 1; | ||
| db = openAndMigrate(resolved.dbPath); | ||
| clearPaused(db, resolved.repo); | ||
| console.log(`mm resume: ${resolved.repo} auto-dispatch resumed`); | ||
| return 0; | ||
| } catch (error) { | ||
| console.error(`mm resume: ${(error as Error).message}`); | ||
| return 1; | ||
| } finally { | ||
| db?.close(); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.