Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/gittensory-miner/lib/attempt-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand All @@ -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;

Expand Down
38 changes: 36 additions & 2 deletions packages/gittensory-miner/lib/attempt-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner/repo> <issue#> --miner-login <login> [--base <branch>] [--live] [--json]";
const ATTEMPT_USAGE =
"Usage: gittensory-miner attempt <owner/repo> <issue#> --miner-login <login> [--base <branch>] [--live] [--dry-run] [--json]";

function parseRepoTarget(value) {
const trimmed = typeof value === "string" ? value.trim() : "";
Expand All @@ -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) {
Expand All @@ -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 };
Expand Down Expand Up @@ -93,6 +102,7 @@ export function parseAttemptArgs(args) {
minerLogin: options.minerLogin,
base: options.base,
live: options.live,
dryRun: options.dryRun,
json: options.json,
};
}
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ export function printHelp(input) {
" gittensory-miner manage poll <owner/repo> <pr#> [--branch <name>] [--json]",
" gittensory-miner discover <owner/repo> [<owner/repo>...] [--json]",
" gittensory-miner discover --search <query> [--json] Fan out, rank, and enqueue candidates",
" gittensory-miner attempt <owner/repo> <issue#> --miner-login <login> [--base <branch>] [--live] [--json]",
" gittensory-miner loop <owner/repo> [<owner/repo>...] --miner-login <login> [--base <branch>] [--live]",
" gittensory-miner loop --search <query> --miner-login <login> [--max-cycles <n>] [--cycle-delay-ms <ms>] [--json]",
" gittensory-miner attempt <owner/repo> <issue#> --miner-login <login> [--base <branch>] [--live] [--dry-run] [--json]",
" gittensory-miner loop <owner/repo> [<owner/repo>...] --miner-login <login> [--base <branch>] [--live] [--dry-run]",
" gittensory-miner loop --search <query> --miner-login <login> [--max-cycles <n>] [--cycle-delay-ms <ms>] [--dry-run] [--json]",
" Autonomous discover->claim->attempt->reenter loop",
" gittensory-miner queue list [--repo <owner/repo>] [--json] List portfolio backlog rows",
" gittensory-miner queue next [--json] Claim the highest-priority queued item",
Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-miner/lib/loop-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type ParsedLoopArgs =
minerLogin: string;
base: string;
live: boolean;
dryRun: boolean;
maxCycles: number | undefined;
cycleDelayMs: number;
json: boolean;
Expand Down
45 changes: 43 additions & 2 deletions packages/gittensory-miner/lib/loop-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner/repo> [<owner/repo>...] | --search <query> --miner-login <login> [--base <branch>] [--live] [--max-cycles <n>] [--cycle-delay-ms <ms>] [--json]";
"Usage: gittensory-miner loop <owner/repo> [<owner/repo>...] | --search <query> --miner-login <login> [--base <branch>] [--live] [--dry-run] [--max-cycles <n>] [--cycle-delay-ms <ms>] [--json]";
const DEFAULT_CYCLE_DELAY_MS = 60_000;
const ISSUE_IDENTIFIER_PATTERN = /^issue:(\d+)$/;

Expand All @@ -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) {
Expand All @@ -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 };
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)();
Expand Down
68 changes: 66 additions & 2 deletions test/unit/miner-attempt-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
Expand Down Expand Up @@ -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);
Expand Down
62 changes: 62 additions & 0 deletions test/unit/miner-loop-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ describe("parseLoopArgs (#5135)", () => {
"--base",
"develop",
"--live",
"--dry-run",
"--max-cycles",
"5",
"--cycle-delay-ms",
Expand All @@ -104,6 +105,7 @@ describe("parseLoopArgs (#5135)", () => {
minerLogin: "alice",
base: "develop",
live: true,
dryRun: true,
maxCycles: 5,
cycleDelayMs: 1000,
json: true,
Expand All @@ -117,6 +119,7 @@ describe("parseLoopArgs (#5135)", () => {
minerLogin: "alice",
base: "main",
live: false,
dryRun: false,
maxCycles: undefined,
cycleDelayMs: 60_000,
json: false,
Expand Down Expand Up @@ -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);
Expand Down