From d527bb28f03c649ce0379dd80959a51d51d58c24 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:18:42 -0700 Subject: [PATCH] feat(miner): add --dry-run to attempt and loop Both are the two REMOTE-mutating commands in the miner CLI (attempt opens a real git worktree, claims the issue, and can run a real coding-agent driver that pushes/opens a PR; loop wraps attempt plus its own portfolio-queue/ledger writes). --live's absence already skipped the coding-agent driver, but every worktree/claim/ledger write still happened regardless -- not the "zero actual writes, local or remote" #4847 asks for. --dry-run reports what a real invocation would target (repo, issue, resolved coding-agent mode, base branch) and returns immediately, BEFORE any store is opened -- a provable zero-write path, not just "opened but didn't write to." For loop specifically, this also means skipping discovery, since it enqueues newly-found candidates into the local portfolio queue even before any attempt happens. The MINER_CODING_AGENT_PAUSED check still runs before the dry-run short-circuit, so a dry run of a paused config honestly reports the refusal rather than fabricating a "would succeed." Extending --dry-run to the remaining local-mutating commands (claim, queue, state, governor, discover, orb export) is scoped as a follow-up -- those are simple single-DB-write commands with none of attempt/loop's semantic questions, and bundling them here would risk the review getting bogged down in this PR's harder design decision. Advances #4847 --- .../gittensory-miner/lib/attempt-cli.d.ts | 11 ++- packages/gittensory-miner/lib/attempt-cli.js | 38 ++++++++++- packages/gittensory-miner/lib/cli.js | 6 +- packages/gittensory-miner/lib/loop-cli.d.ts | 1 + packages/gittensory-miner/lib/loop-cli.js | 45 +++++++++++- test/unit/miner-attempt-cli.test.ts | 68 ++++++++++++++++++- test/unit/miner-loop-cli.test.ts | 62 +++++++++++++++++ 7 files changed, 221 insertions(+), 10 deletions(-) diff --git a/packages/gittensory-miner/lib/attempt-cli.d.ts b/packages/gittensory-miner/lib/attempt-cli.d.ts index 3242fd7c43..d717ba7bac 100644 --- a/packages/gittensory-miner/lib/attempt-cli.d.ts +++ b/packages/gittensory-miner/lib/attempt-cli.d.ts @@ -27,6 +27,7 @@ type CommonAttemptResultFields = { * the plain exit-code return runAttempt itself still returns, unchanged, so bin/gittensory-miner.js's own * `process.exit(exitCode)` usage never breaks) -- the loop orchestrator's real caller for this data. */ export type AttemptCliResult = + | (CommonAttemptResultFields & { outcome: "dry_run" }) | (CommonAttemptResultFields & { outcome: "blocked_rejection_signaled"; reason: string }) | (CommonAttemptResultFields & { outcome: "blocked_worktree_preparation_failed"; reason: string }) | (CommonAttemptResultFields & { @@ -51,7 +52,15 @@ export type AttemptCliResult = export type ParsedAttemptArgs = | { error: string } - | { repoFullName: string; issueNumber: number; minerLogin: string; base: string; live: boolean; json: boolean }; + | { + repoFullName: string; + issueNumber: number; + minerLogin: string; + base: string; + live: boolean; + dryRun: boolean; + json: boolean; + }; export function parseAttemptArgs(args: string[]): ParsedAttemptArgs; diff --git a/packages/gittensory-miner/lib/attempt-cli.js b/packages/gittensory-miner/lib/attempt-cli.js index 986705074e..3bed5ccbe1 100644 --- a/packages/gittensory-miner/lib/attempt-cli.js +++ b/packages/gittensory-miner/lib/attempt-cli.js @@ -34,7 +34,8 @@ import { checkMinerKillSwitch } from "./governor-kill-switch.js"; import { buildAttemptGovernorContext, buildAttemptLoopInput } from "./attempt-input-builder.js"; import { runMinerAttempt } from "./attempt-runner.js"; -const ATTEMPT_USAGE = "Usage: gittensory-miner attempt --miner-login [--base ] [--live] [--json]"; +const ATTEMPT_USAGE = + "Usage: gittensory-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]"; function parseRepoTarget(value) { const trimmed = typeof value === "string" ? value.trim() : ""; @@ -44,7 +45,7 @@ function parseRepoTarget(value) { } export function parseAttemptArgs(args) { - const options = { json: false, minerLogin: null, base: "main", live: false }; + const options = { json: false, minerLogin: null, base: "main", live: false, dryRun: false }; const positional = []; for (let index = 0; index < args.length; index += 1) { @@ -60,6 +61,14 @@ export function parseAttemptArgs(args) { options.live = true; continue; } + // #4847: distinct from --live's absence above -- --live only ever gated the coding-agent DRIVER's mode, + // but a non---live run still opened every store and made real worktree/claim/ledger writes. --dry-run + // short-circuits BEFORE any of that infrastructure is even opened, guaranteeing zero writes rather than + // merely skipping the driver. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } if (token === "--miner-login") { const value = args[index + 1]; if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE }; @@ -93,6 +102,7 @@ export function parseAttemptArgs(args) { minerLogin: options.minerLogin, base: options.base, live: options.live, + dryRun: options.dryRun, json: options.json, }; } @@ -157,6 +167,30 @@ export async function runAttempt(args, options = {}) { const attemptId = options.attemptId ?? `${parsed.repoFullName.replace("/", "_")}-${parsed.issueNumber}-${nowMs}`; + // #4847: reports what a real run would do and returns BEFORE any store (allocator/claim/event/attempt-log/ + // governor ledger) is even opened, so this is a provable zero-write path -- not just "opened but didn't + // write to" the local stores, and nowhere near the real worktree clone, claim, or coding-agent driver. + if (parsed.dryRun) { + const dryRunResult = { + outcome: "dry_run", + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log( + `DRY RUN: would attempt ${parsed.repoFullName}#${parsed.issueNumber} for ${parsed.minerLogin} (mode: ${mode}, base: ${parsed.base}). No worktree, claim, or ledger writes were made.`, + ); + } + options.onResult?.(dryRunResult); + return 0; + } + let allocator = null; let claimLedger = null; let eventLedger = null; diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 059ac86194..6fe0e7f241 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -22,9 +22,9 @@ export function printHelp(input) { " gittensory-miner manage poll [--branch ] [--json]", " gittensory-miner discover [...] [--json]", " gittensory-miner discover --search [--json] Fan out, rank, and enqueue candidates", - " gittensory-miner attempt --miner-login [--base ] [--live] [--json]", - " gittensory-miner loop [...] --miner-login [--base ] [--live]", - " gittensory-miner loop --search --miner-login [--max-cycles ] [--cycle-delay-ms ] [--json]", + " gittensory-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]", + " gittensory-miner loop [...] --miner-login [--base ] [--live] [--dry-run]", + " gittensory-miner loop --search --miner-login [--max-cycles ] [--cycle-delay-ms ] [--dry-run] [--json]", " Autonomous discover->claim->attempt->reenter loop", " gittensory-miner queue list [--repo ] [--json] List portfolio backlog rows", " gittensory-miner queue next [--json] Claim the highest-priority queued item", diff --git a/packages/gittensory-miner/lib/loop-cli.d.ts b/packages/gittensory-miner/lib/loop-cli.d.ts index d218756182..8ad9c178c8 100644 --- a/packages/gittensory-miner/lib/loop-cli.d.ts +++ b/packages/gittensory-miner/lib/loop-cli.d.ts @@ -15,6 +15,7 @@ export type ParsedLoopArgs = minerLogin: string; base: string; live: boolean; + dryRun: boolean; maxCycles: number | undefined; cycleDelayMs: number; json: boolean; diff --git a/packages/gittensory-miner/lib/loop-cli.js b/packages/gittensory-miner/lib/loop-cli.js index fc446c20c0..a07acb4c45 100644 --- a/packages/gittensory-miner/lib/loop-cli.js +++ b/packages/gittensory-miner/lib/loop-cli.js @@ -44,7 +44,7 @@ import { parsePrNumberFromExecResult } from "./pr-number-parse.js"; import { DEFAULT_AMS_POLICY_SPEC } from "@jsonbored/gittensory-engine"; const LOOP_USAGE = - "Usage: gittensory-miner loop [...] | --search --miner-login [--base ] [--live] [--max-cycles ] [--cycle-delay-ms ] [--json]"; + "Usage: gittensory-miner loop [...] | --search --miner-login [--base ] [--live] [--dry-run] [--max-cycles ] [--cycle-delay-ms ] [--json]"; const DEFAULT_CYCLE_DELAY_MS = 60_000; const ISSUE_IDENTIFIER_PATTERN = /^issue:(\d+)$/; @@ -64,7 +64,16 @@ function normalizeOptionalPositiveInt(value, label) { } export function parseLoopArgs(args) { - const options = { json: false, minerLogin: null, base: "main", live: false, search: null, maxCycles: undefined, cycleDelayMs: DEFAULT_CYCLE_DELAY_MS }; + const options = { + json: false, + minerLogin: null, + base: "main", + live: false, + dryRun: false, + search: null, + maxCycles: undefined, + cycleDelayMs: DEFAULT_CYCLE_DELAY_MS, + }; const targets = []; for (let index = 0; index < args.length; index += 1) { @@ -77,6 +86,12 @@ export function parseLoopArgs(args) { options.live = true; continue; } + // #4847: see attempt-cli.js's own --dry-run comment -- distinct from --live's absence, this short-circuits + // BEFORE governor state or any other store is opened, guaranteeing zero discovery/queue/ledger writes. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } if (token === "--search") { const value = args[index + 1]; if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; @@ -136,6 +151,7 @@ export function parseLoopArgs(args) { minerLogin: options.minerLogin, base: options.base, live: options.live, + dryRun: options.dryRun, maxCycles: options.maxCycles, cycleDelayMs: options.cycleDelayMs, json: options.json, @@ -204,6 +220,31 @@ export async function runLoop(args, options = {}) { const nowMsFn = () => options.nowMs ?? Date.now(); const sessionStartMs = nowMsFn(); + // #4847: reports what a real loop invocation would target and returns BEFORE governor state or any other + // store (event/governor ledger, portfolio queue, run state) is opened -- a provable zero-write path, not just + // "opened but didn't write." The loop's own discovery call enqueues newly-found candidates into the LOCAL + // portfolio queue even before any attempt happens, so a faithful dry run cannot call it either. + if (parsed.dryRun) { + const dryRunResult = { + outcome: "dry_run", + targets: parsed.targets, + search: parsed.search, + minerLogin: parsed.minerLogin, + base: parsed.base, + live: parsed.live, + maxCycles: parsed.maxCycles ?? null, + }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + const target = parsed.search !== null ? `--search ${parsed.search}` : parsed.targets.join(", "); + console.log( + `DRY RUN: would run an autonomous loop against ${target} for ${parsed.minerLogin} (base: ${parsed.base}, live: ${parsed.live}). No discovery, queue, or ledger writes were made.`, + ); + } + return 0; + } + let governorState; try { governorState = (options.openGovernorState ?? openGovernorState)(); diff --git a/test/unit/miner-attempt-cli.test.ts b/test/unit/miner-attempt-cli.test.ts index 08f74617d5..dbe4ef73bf 100644 --- a/test/unit/miner-attempt-cli.test.ts +++ b/test/unit/miner-attempt-cli.test.ts @@ -95,23 +95,27 @@ afterEach(() => { describe("parseAttemptArgs (#5132)", () => { it("parses a full, valid argv", () => { - expect(parseAttemptArgs(["acme/widgets", "7", "--miner-login", "alice", "--base", "develop", "--live", "--json"])).toEqual({ + expect( + parseAttemptArgs(["acme/widgets", "7", "--miner-login", "alice", "--base", "develop", "--live", "--dry-run", "--json"]), + ).toEqual({ repoFullName: "acme/widgets", issueNumber: 7, minerLogin: "alice", base: "develop", live: true, + dryRun: true, json: true, }); }); - it("defaults base to main, live to false, and json to false", () => { + it("defaults base to main, live to false, dryRun to false, and json to false", () => { expect(parseAttemptArgs(["acme/widgets", "7", "--miner-login", "alice"])).toEqual({ repoFullName: "acme/widgets", issueNumber: 7, minerLogin: "alice", base: "main", live: false, + dryRun: false, json: false, }); }); @@ -234,6 +238,66 @@ describe("runAttempt (#5132)", () => { expect(openWorktreeAllocatorSpy).not.toHaveBeenCalled(); }); + it("#4847: --dry-run reports what would happen and returns 0 without opening any store", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const openWorktreeAllocatorSpy = vi.fn(); + const openClaimLedgerSpy = vi.fn(); + const initEventLedgerSpy = vi.fn(); + const initAttemptLogSpy = vi.fn(); + const initGovernorLedgerSpy = vi.fn(); + const onResult = vi.fn(); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--dry-run", "--json"], { + openWorktreeAllocator: openWorktreeAllocatorSpy, + openClaimLedger: openClaimLedgerSpy, + initEventLedger: initEventLedgerSpy, + initAttemptLog: initAttemptLogSpy, + initGovernorLedger: initGovernorLedgerSpy, + onResult, + }); + + expect(exitCode).toBe(0); + expect(openWorktreeAllocatorSpy).not.toHaveBeenCalled(); + expect(openClaimLedgerSpy).not.toHaveBeenCalled(); + expect(initEventLedgerSpy).not.toHaveBeenCalled(); + expect(initAttemptLogSpy).not.toHaveBeenCalled(); + expect(initGovernorLedgerSpy).not.toHaveBeenCalled(); + + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed).toMatchObject({ + outcome: "dry_run", + repoFullName: "acme/widgets", + issueNumber: 7, + minerLogin: "alice", + base: "main", + mode: "dry_run", + }); + expect(onResult).toHaveBeenCalledWith(printed); + }); + + it("#4847: --dry-run --live reports the live mode it would have used, and prints a human-readable message by default", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--dry-run", "--live"], {}); + expect(exitCode).toBe(0); + const printed = String(log.mock.calls[0]?.[0]); + expect(printed).toContain("DRY RUN: would attempt acme/widgets#7 for alice"); + expect(printed).toContain("mode: live"); + expect(printed).toContain("No worktree, claim, or ledger writes were made."); + }); + + it("#4847: --dry-run still reports globally-paused mode, matching what a real (non-dry-run) run would refuse to do", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--dry-run"], { + env: { MINER_CODING_AGENT_PAUSED: "1" }, + }); + // The pause check runs BEFORE the dry-run short-circuit, so a dry run of a paused config still refuses -- + // an honest reflection of what a real run would do, not a fabricated "would succeed." + expect(exitCode).toBe(3); + expect(error).toHaveBeenCalledWith(expect.stringContaining("globally paused")); + expect(log).not.toHaveBeenCalled(); + }); + it("REGRESSION: runs the full real pipeline end to end and reports a real submitted outcome (exit 0)", async () => { const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/test/unit/miner-loop-cli.test.ts b/test/unit/miner-loop-cli.test.ts index 32208b0c3d..d82de5c80a 100644 --- a/test/unit/miner-loop-cli.test.ts +++ b/test/unit/miner-loop-cli.test.ts @@ -92,6 +92,7 @@ describe("parseLoopArgs (#5135)", () => { "--base", "develop", "--live", + "--dry-run", "--max-cycles", "5", "--cycle-delay-ms", @@ -104,6 +105,7 @@ describe("parseLoopArgs (#5135)", () => { minerLogin: "alice", base: "develop", live: true, + dryRun: true, maxCycles: 5, cycleDelayMs: 1000, json: true, @@ -117,6 +119,7 @@ describe("parseLoopArgs (#5135)", () => { minerLogin: "alice", base: "main", live: false, + dryRun: false, maxCycles: undefined, cycleDelayMs: 60_000, json: false, @@ -168,6 +171,65 @@ describe("runLoop (#5135)", () => { expect(error).toHaveBeenCalledWith(expect.stringContaining("governor state cannot be loaded")); }); + it("#4847: --dry-run reports what would happen and returns 0 without opening any store", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const openGovernorStateSpy = vi.fn(); + const initEventLedgerSpy = vi.fn(); + const initGovernorLedgerSpy = vi.fn(); + const initPortfolioQueueSpy = vi.fn(); + const initRunStateStoreSpy = vi.fn(); + const runDiscoverSpy = vi.fn(); + const runAttemptSpy = vi.fn(); + + const exitCode = await runLoop( + ["acme/widgets", "acme/other", "--miner-login", "alice", "--base", "develop", "--dry-run", "--json"], + { + openGovernorState: openGovernorStateSpy, + initEventLedger: initEventLedgerSpy, + initGovernorLedger: initGovernorLedgerSpy, + initPortfolioQueue: initPortfolioQueueSpy, + initRunStateStore: initRunStateStoreSpy, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + }, + ); + + expect(exitCode).toBe(0); + expect(openGovernorStateSpy).not.toHaveBeenCalled(); + expect(initEventLedgerSpy).not.toHaveBeenCalled(); + expect(initGovernorLedgerSpy).not.toHaveBeenCalled(); + expect(initPortfolioQueueSpy).not.toHaveBeenCalled(); + expect(initRunStateStoreSpy).not.toHaveBeenCalled(); + expect(runDiscoverSpy).not.toHaveBeenCalled(); + expect(runAttemptSpy).not.toHaveBeenCalled(); + + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed).toEqual({ + outcome: "dry_run", + targets: ["acme/widgets", "acme/other"], + search: null, + minerLogin: "alice", + base: "develop", + live: false, + maxCycles: null, + }); + }); + + it("#4847: --dry-run --search prints a human-readable message by default", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const openGovernorStateSpy = vi.fn(); + + const exitCode = await runLoop(["--search", "label:good-first-issue", "--miner-login", "alice", "--dry-run"], { + openGovernorState: openGovernorStateSpy, + }); + + expect(exitCode).toBe(0); + expect(openGovernorStateSpy).not.toHaveBeenCalled(); + const printed = String(log.mock.calls[0]?.[0]); + expect(printed).toContain("DRY RUN: would run an autonomous loop against --search label:good-first-issue for alice"); + expect(printed).toContain("No discovery, queue, or ledger writes were made."); + }); + it("halts immediately on an active kill switch, before running discovery or any attempt", async () => { const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); const log = vi.spyOn(console, "log").mockImplementation(() => undefined);