From 21579ecd055abeec3aa9322e055f8197e432abd1 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:48:19 -0700 Subject: [PATCH] feat(miner-governor): the real create->review->gate->submit attempt pipeline (#2337) Adds runMinerAttempt: the missing link between #2333's iterate-loop orchestrator and an actual, executed open_pr write. Composes, in order: runIterateLoop (create -> score -> self-review -> decide) -> on handoff, checkSubmissionFreshness (#3007) -> prepareOpenPrSubmission (#2336/#2337) -> the Governor chokepoint (#2340, which itself already composes kill-switch, dry-run, rate-limit, budget caps, non- convergence, self-reputation-throttle, and self-plagiarism into one precedence ladder -- confirmed by reading chokepoint.ts directly rather than assuming, which is why this calls it exactly once instead of also separately invoking governor-open-pr.js's self-plagiarism check) -> on allowed:true, builds the real open_pr command via the now-shared buildOpenPrSpec (moved to the engine in the prior PR) and executes it. Worktree lifecycle is deliberately NOT this module's job: runIterateLoop already takes a plain workingDirectory string, agnostic about where it came from. Allocating one is the caller's job, via the already-built slot allocator (worktree-allocator.js, #4297) -- this composes the create/review/gate/submit sequence #2337 is actually about, not worktree allocation policy, which is separate, already-solved scope. Two known, deliberate gaps, surfaced rather than papered over: - runSlopAssessment has no production implementation anywhere in this package. The real slop scorer (src/signals/slop.ts, 518 lines, 5 sibling src/signals/** dependencies) is far larger and more interconnected than local-write-tools.ts was, so extracting it is separate, substantial scope -- this function requires a real one be injected rather than silently stubbing a result that would either always pass (unsafe) or always fail (useless). - The governor's cross-attempt state (rate-limit buckets, budget-cap usage, convergence input, reputation history, self-plagiarism recent-submissions) has no persistence wiring anywhere in this package either, confirmed by reading governor-write-rate-limit.js directly: every existing governor-*.js wrapper is a pure in/out transform over caller-supplied state, with nothing persisting the returned value between calls. Durable cross-attempt tracking is portfolio-loop-level scope, not a single attempt's. The CLI dispatch wiring (bin/gittensory-miner.js, real worktree allocation, a real subprocess exec for the final command) is tracked as a separate follow-up -- this PR is the fully real, fully tested orchestrator those pieces will call. --- .../gittensory-miner/lib/attempt-runner.d.ts | 51 ++++ .../gittensory-miner/lib/attempt-runner.js | 156 +++++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-attempt-runner.test.ts | 247 ++++++++++++++++++ 4 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/attempt-runner.d.ts create mode 100644 packages/gittensory-miner/lib/attempt-runner.js create mode 100644 test/unit/miner-attempt-runner.test.ts diff --git a/packages/gittensory-miner/lib/attempt-runner.d.ts b/packages/gittensory-miner/lib/attempt-runner.d.ts new file mode 100644 index 000000000..4a3276242 --- /dev/null +++ b/packages/gittensory-miner/lib/attempt-runner.d.ts @@ -0,0 +1,51 @@ +import type { + CodingAgentDriver, + GovernorChokepointInput, + GovernorDecision, + IterateLoopInput, + IterateLoopResult, + LocalWriteActionSpec, +} from "@jsonbored/gittensory-engine"; +import type { HarnessSubmissionDecision, HarnessSubmissionEventLedger } from "./harness-submission-trigger.js"; +import type { SubmissionFreshnessClaimLedger, LiveIssueSnapshot, FreshnessAbortReason } from "./submission-freshness-check.js"; + +export const ATTEMPT_OUTCOMES: readonly ["abandon", "stale", "blocked", "governed", "submitted"]; + +export type AttemptGovernorContext = Omit; + +export type AttemptInput = { + loopInput: IterateLoopInput; + issueNumber: number; + minerLogin: string; + base: string; + killSwitchScope: "global" | "repo" | "none"; + slopThreshold: "clean" | "low" | "elevated" | "high"; + submissionMode: "observe" | "enforce"; + maxConsecutiveGateBlocks?: number; + draft?: boolean; + governor: AttemptGovernorContext; +}; + +export type AttemptDeps = { + driver: CodingAgentDriver; + runSlopAssessment: (input: unknown) => unknown; + appendAttemptLogEvent: (event: unknown) => void; + claimLedger: SubmissionFreshnessClaimLedger; + fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; + eventLedger: HarnessSubmissionEventLedger; + /** Injected governor-ledger append (mirrors evaluateGovernorChokepointGate's own `options.append`); omitted + * falls back to that function's own default (the real default governor ledger). */ + governorLedgerAppend?: (event: unknown) => unknown; + sessionStartMs?: number; + nowMs: number; + executeLocalWrite: (spec: LocalWriteActionSpec) => Promise; +}; + +export type AttemptResult = + | { outcome: "abandon"; loopResult: IterateLoopResult } + | { outcome: "stale"; reason: FreshnessAbortReason; loopResult: IterateLoopResult } + | { outcome: "blocked"; decision: HarnessSubmissionDecision; loopResult: IterateLoopResult } + | { outcome: "governed"; decision: GovernorDecision; loopResult: IterateLoopResult } + | { outcome: "submitted"; spec: LocalWriteActionSpec; execResult: unknown; loopResult: IterateLoopResult }; + +export function runMinerAttempt(input: AttemptInput, deps: AttemptDeps): Promise; diff --git a/packages/gittensory-miner/lib/attempt-runner.js b/packages/gittensory-miner/lib/attempt-runner.js new file mode 100644 index 000000000..e349bb66c --- /dev/null +++ b/packages/gittensory-miner/lib/attempt-runner.js @@ -0,0 +1,156 @@ +import { buildOpenPrSpec } from "@jsonbored/gittensory-engine"; +import { runIterateLoop } from "@jsonbored/gittensory-engine"; +import { checkSubmissionFreshness } from "./submission-freshness-check.js"; +import { evaluateGovernorChokepointGate } from "./governor-chokepoint.js"; +import { prepareOpenPrSubmission } from "./harness-submission-trigger.js"; + +// The real driving-loop entrypoint (#2337): the missing link between #2333's iterate-loop orchestrator and an +// actual, executed open_pr write. Composes, in order: runIterateLoop (create -> score -> self-review -> decide, +// #2333) -> on handoff, checkSubmissionFreshness (#3007) -> prepareOpenPrSubmission (#2336/#2337) -> the +// Governor chokepoint (#2340, which itself composes kill-switch, dry-run, rate-limit, budget caps, non- +// convergence, self-reputation-throttle, and self-plagiarism -- see chokepoint.ts's own module doc comment for +// the exact precedence ladder) -> on allowed:true, builds the REAL open_pr command via the now-shared +// buildOpenPrSpec (@jsonbored/gittensory-engine, moved from root src/mcp/local-write-tools.ts) and executes it. +// +// WORKTREE LIFECYCLE IS NOT THIS MODULE'S JOB: runIterateLoop already takes a plain `workingDirectory` string +// (packages/gittensory-engine/src/miner/iterate-loop.ts's own IterateLoopInput), deliberately agnostic about +// where it came from. Allocating one is the caller's job, via the already-built slot allocator +// (worktree-allocator.js, #4297) -- this module composes the create/review/gate/submit sequence #2337 is +// actually about, not worktree allocation policy, which is a separate, already-solved concern. +// +// KNOWN, DELIBERATE GAPS (not silently papered over -- both were injected-but-unwired seams before this module +// existed, and remain so here): +// - `deps.runSlopAssessment` has no production implementation anywhere in this package. The real slop scorer +// (src/signals/slop.ts, 518 lines, 5 sibling src/signals/** dependencies) is far larger and more +// interconnected than local-write-tools.ts was, so extracting it is separate, substantial scope -- this +// function requires a real one be injected rather than silently stubbing a result that would either always +// pass (unsafe) or always fail (useless). +// - `input.governor`'s cross-attempt state (rate-limit buckets, budget-cap usage, convergence input, +// reputation history, self-plagiarism recent-submissions) has no persistence wiring anywhere in this +// package either -- every existing governor-*.js wrapper is a pure in/out transform over caller-supplied +// state (confirmed by reading governor-write-rate-limit.js: it returns an updated bucket store, but nothing +// persists it). A caller with no prior history should pass honest empty/zero defaults, not fabricated ones; +// durable cross-attempt tracking is portfolio-loop-level scope (the outer "process the queue" loop this +// issue does not build), not a single attempt's. + +/** True once the loop reaches handoff AND every downstream gate (freshness, submission, governor) allows. */ +export const ATTEMPT_OUTCOMES = Object.freeze(["abandon", "stale", "blocked", "governed", "submitted"]); + +function assertFn(value, name) { + if (typeof value !== "function") throw new Error(`invalid_${name}`); +} + +function assertDeps(deps) { + if (!deps || typeof deps !== "object") throw new Error("invalid_attempt_deps"); + assertFn(deps.runSlopAssessment, "run_slop_assessment"); + assertFn(deps.appendAttemptLogEvent, "append_attempt_log_event"); + assertFn(deps.fetchLiveIssueSnapshot, "fetch_live_issue_snapshot"); + assertFn(deps.executeLocalWrite, "execute_local_write"); + if (!deps.driver || typeof deps.driver.run !== "function") throw new Error("invalid_driver"); + if (!deps.claimLedger || typeof deps.claimLedger.listClaims !== "function") throw new Error("invalid_claim_ledger"); + if (!deps.eventLedger || typeof deps.eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); + if (typeof deps.nowMs !== "number" || !Number.isFinite(deps.nowMs)) throw new Error("invalid_now_ms"); +} + +function assertInput(input) { + if (!input || typeof input !== "object") throw new Error("invalid_attempt_input"); + if (!input.loopInput || typeof input.loopInput !== "object") throw new Error("invalid_loop_input"); + if (!Number.isInteger(input.issueNumber) || input.issueNumber < 1) throw new Error("invalid_issue_number"); + if (typeof input.minerLogin !== "string" || !input.minerLogin.trim()) throw new Error("invalid_miner_login"); + if (typeof input.base !== "string" || !input.base.trim()) throw new Error("invalid_base"); + if (!["global", "repo", "none"].includes(input.killSwitchScope)) throw new Error("invalid_kill_switch_scope"); + if (!["clean", "low", "elevated", "high"].includes(input.slopThreshold)) throw new Error("invalid_slop_threshold"); + if (!["observe", "enforce"].includes(input.submissionMode)) throw new Error("invalid_submission_mode"); + if (!input.governor || typeof input.governor !== "object") throw new Error("invalid_governor_context"); +} + +/** + * Run one full attempt end to end: iterate-loop -> (on handoff) freshness -> submission-gate -> Governor + * chokepoint -> (on allowed:true) build + execute the real open_pr command. Fails closed (throws) on malformed + * input/deps, mirroring every sibling module in this pipeline. + * + * @param {{ + * loopInput: import("@jsonbored/gittensory-engine").IterateLoopInput, + * issueNumber: number, + * minerLogin: string, + * base: string, + * killSwitchScope: "global"|"repo"|"none", + * slopThreshold: "clean"|"low"|"elevated"|"high", + * submissionMode: "observe"|"enforce", + * maxConsecutiveGateBlocks?: number, + * draft?: boolean, + * governor: Omit, + * }} input + * @param {{ + * driver: import("@jsonbored/gittensory-engine").CodingAgentDriver, + * runSlopAssessment: Function, + * appendAttemptLogEvent: Function, + * claimLedger: object, + * fetchLiveIssueSnapshot: Function, + * eventLedger: object, + * sessionStartMs?: number, + * nowMs: number, + * executeLocalWrite: (spec: import("@jsonbored/gittensory-engine").LocalWriteActionSpec) => Promise, + * }} deps + */ +export async function runMinerAttempt(input, deps) { + assertInput(input); + assertDeps(deps); + + const loopResult = await runIterateLoop(input.loopInput, { + driver: deps.driver, + runSlopAssessment: deps.runSlopAssessment, + appendAttemptLogEvent: deps.appendAttemptLogEvent, + }); + + if (loopResult.outcome === "abandon") { + return { outcome: "abandon", loopResult }; + } + + const handoffPacket = loopResult.handoffPacket; + + const freshness = await checkSubmissionFreshness( + { repoFullName: input.loopInput.repoFullName, issueNumber: input.issueNumber, minerLogin: input.minerLogin }, + { claimLedger: deps.claimLedger, fetchLiveIssueSnapshot: deps.fetchLiveIssueSnapshot, eventLedger: deps.eventLedger }, + ); + if (!freshness.fresh) { + return { outcome: "stale", reason: freshness.reason, loopResult }; + } + + const submission = await prepareOpenPrSubmission( + { + killSwitchScope: input.killSwitchScope, + repoFullName: input.loopInput.repoFullName, + handoffPacket, + slopThreshold: input.slopThreshold, + mode: input.submissionMode, + maxConsecutiveGateBlocks: input.maxConsecutiveGateBlocks, + base: input.base, + title: input.loopInput.title, + body: input.loopInput.body ?? "", + draft: input.draft, + }, + { eventLedger: deps.eventLedger, sessionStartMs: deps.sessionStartMs }, + ); + if (!submission.ready) { + return { outcome: "blocked", decision: submission.decision, loopResult }; + } + + const governed = evaluateGovernorChokepointGate( + { + actionClass: "open_pr", + repoFullName: input.loopInput.repoFullName, + nowMs: deps.nowMs, + wouldBeAction: submission.openPrInput, + ...input.governor, + }, + deps.governorLedgerAppend ? { append: deps.governorLedgerAppend } : {}, + ); + if (!governed.decision.allowed) { + return { outcome: "governed", decision: governed.decision, loopResult }; + } + + const spec = buildOpenPrSpec(submission.openPrInput); + const execResult = await deps.executeLocalWrite(spec); + return { outcome: "submitted", spec, execResult, loopResult }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 92c6ce731..1ba07e4d9 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/miner-attempt-runner.test.ts b/test/unit/miner-attempt-runner.test.ts new file mode 100644 index 000000000..8c4941da2 --- /dev/null +++ b/test/unit/miner-attempt-runner.test.ts @@ -0,0 +1,247 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { runMinerAttempt } from "../../packages/gittensory-miner/lib/attempt-runner.js"; +import { initEventLedger } from "../../packages/gittensory-miner/lib/event-ledger.js"; +import { initGovernorLedger } from "../../packages/gittensory-miner/lib/governor-ledger.js"; +import { parseFocusManifest, type CodingAgentDriver, type CodingAgentDriverResult } from "../../packages/gittensory-engine/src/index"; + +const roots: string[] = []; +const closers: Array<{ close(): void }> = []; + +function tempEventLedger() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-attempt-runner-events-")); + roots.push(root); + const ledger = initEventLedger(join(root, "db.sqlite3")); + closers.push(ledger); + return ledger; +} + +function tempGovernorLedger() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-attempt-runner-governor-")); + roots.push(root); + const ledger = initGovernorLedger(join(root, "governor-ledger.sqlite3")); + closers.push(ledger); + return ledger; +} + +afterEach(() => { + for (const closer of closers.splice(0)) closer.close(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +// ── IterateLoopInput fixtures, mirroring packages/gittensory-engine/test/iterate-loop.test.ts's own ────────── + +const REPO = { fullName: "acme/widgets", owner: "acme", name: "widgets", isInstalled: true, isRegistered: true, isPrivate: false }; + +function openIssue(number: number, title: string) { + return { repoFullName: "acme/widgets", number, title, state: "open" as const, labels: [], linkedPrs: [] }; +} + +const noopSlop = { slopRisk: 0, band: "clean" as const, findings: [] }; + +function baseReviewContext(overrides: Record = {}) { + return { + manifest: parseFocusManifest({ gate: { duplicates: "block", linkedIssue: "advisory" } }), + repo: REPO, + issues: [openIssue(7, "Uploads should retry on 5xx")], + pullRequests: [], + ...overrides, + }; +} + +function passingLoopInput(overrides: Record = {}) { + return { + attemptId: "attempt-1", + workingDirectory: "/tmp/attempt-1", + acceptanceCriteriaPath: "/tmp/attempt-1/acceptance-criteria.json", + instructions: "Add retry to the upload client", + mode: "live" as const, + maxIterations: 3, + maxTurnsPerIteration: 20, + repoFullName: "acme/widgets", + contributorLogin: "miner-bot", + title: "Add retry to the upload client", + body: "Closes #7", + linkedIssues: [7], + branchRef: "miner/attempt-1", + reviewContext: baseReviewContext(), + rejectionSignaled: false, + ...overrides, + }; +} + +function driverReturning(result: CodingAgentDriverResult): CodingAgentDriver { + return { async run() { return result; } }; +} + +function okDriverResult(changedFiles: string[] = ["src/upload.ts"], turnsUsed = 5): CodingAgentDriverResult { + return { ok: true, changedFiles, summary: "added retry logic", turnsUsed }; +} + +// ── Governor "everything allows" fixture, mirroring test/unit/miner-governor-chokepoint.test.ts's own ──────── + +function allowingGovernorContext(overrides: Record = {}) { + return { + killSwitchGlobal: false, + killSwitchRepoPaused: false, + liveModeGlobalOptIn: true, + liveModeRepoOptIn: undefined, + rateLimitBuckets: { global: {}, perRepo: {} }, + rateLimitBackoffAttempts: {}, + capUsage: { budgetSpent: 0, turnsTaken: 0, elapsedMs: 0 }, + capLimits: { budget: 100, turns: 100, elapsedMs: 1_000_000 }, + convergenceInput: { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false }, + ...overrides, + }; +} + +function baseDeps(overrides: Record = {}) { + const eventLedger = tempEventLedger(); + const governorLedger = tempGovernorLedger(); + return { + driver: driverReturning(okDriverResult()), + runSlopAssessment: () => noopSlop, + appendAttemptLogEvent: () => undefined, + claimLedger: { listClaims: () => [{ repoFullName: "acme/widgets", issueNumber: 7, status: "active" }] }, + fetchLiveIssueSnapshot: async () => ({ state: "open" as const, referencingPrs: [] }), + eventLedger, + governorLedgerAppend: (event: unknown) => governorLedger.appendGovernorEvent(event as never), + nowMs: 10_000, + executeLocalWrite: async () => ({ ranAt: 10_000 }), + ...overrides, + }; +} + +function baseAttemptInput(overrides: Record = {}) { + return { + loopInput: passingLoopInput(), + issueNumber: 7, + minerLogin: "miner-bot", + base: "main", + killSwitchScope: "none" as const, + slopThreshold: "low" as const, + submissionMode: "enforce" as const, + draft: false, + governor: allowingGovernorContext(), + ...overrides, + }; +} + +describe("runMinerAttempt (#2337) — the real create->review->gate->submit pipeline", () => { + it("full happy path: handoff -> fresh -> ready -> allowed -> builds and executes the real open_pr command", async () => { + const deps = baseDeps(); + const result = await runMinerAttempt(baseAttemptInput(), deps); + + expect(result.outcome).toBe("submitted"); + if (result.outcome !== "submitted") throw new Error("expected submitted"); + expect(result.spec.action).toBe("open_pr"); + expect(result.spec.command).toContain("gh pr create"); + expect(result.spec.command).toContain("'acme/widgets'"); + expect(result.spec.command).toContain("'miner/attempt-1'"); + expect(result.execResult).toEqual({ ranAt: 10_000 }); + expect(result.loopResult.outcome).toBe("handoff"); + }); + + it("defaults the open_pr body to an empty string when the loop input never set one", async () => { + const deps = baseDeps(); + const result = await runMinerAttempt(baseAttemptInput({ loopInput: passingLoopInput({ body: undefined }) }), deps); + + expect(result.outcome).toBe("submitted"); + if (result.outcome !== "submitted") throw new Error("expected submitted"); + expect(result.spec.inputs.body).toBe(""); + }); + + it("abandon: the loop never reaches a candidate worth submitting -- no downstream gate is even consulted", async () => { + const claimLedgerListClaims = vi.fn(); + const deps = baseDeps({ claimLedger: { listClaims: claimLedgerListClaims } }); + const result = await runMinerAttempt(baseAttemptInput({ loopInput: passingLoopInput({ maxIterations: 0 }) }), deps); + + expect(result.outcome).toBe("abandon"); + expect(claimLedgerListClaims).not.toHaveBeenCalled(); + }); + + it("stale: a superseded claim aborts before the submission-gate or governor ever run", async () => { + const deps = baseDeps({ claimLedger: { listClaims: () => [{ repoFullName: "acme/widgets", issueNumber: 7, status: "released" }] } }); + const result = await runMinerAttempt(baseAttemptInput(), deps); + + expect(result.outcome).toBe("stale"); + if (result.outcome !== "stale") throw new Error("expected stale"); + expect(result.reason).toBe("claim_superseded"); + }); + + it("blocked: the submission-gate itself declines (e.g. global kill-switch) before the governor ever runs", async () => { + const deps = baseDeps(); + const result = await runMinerAttempt(baseAttemptInput({ killSwitchScope: "global" }), deps); + + expect(result.outcome).toBe("blocked"); + if (result.outcome !== "blocked") throw new Error("expected blocked"); + expect(result.decision.allow).toBe(false); + expect(result.decision.reasons).toContain("global_kill_switch_active"); + }); + + it("governed: the submission-gate says ready, but the Governor chokepoint denies (e.g. dry-run mode)", async () => { + const deps = baseDeps(); + const result = await runMinerAttempt(baseAttemptInput({ governor: allowingGovernorContext({ liveModeGlobalOptIn: false }) }), deps); + + expect(result.outcome).toBe("governed"); + if (result.outcome !== "governed") throw new Error("expected governed"); + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("dry_run"); + }); + + it("governed: the Governor's own kill-switch stage denies even though the submission-gate itself said ready", async () => { + const deps = baseDeps(); + const result = await runMinerAttempt(baseAttemptInput({ governor: allowingGovernorContext({ killSwitchGlobal: true }) }), deps); + + expect(result.outcome).toBe("governed"); + if (result.outcome !== "governed") throw new Error("expected governed"); + expect(result.decision.stage).toBe("kill_switch"); + }); + + it("falls back to the real default governor-ledger append when governorLedgerAppend is omitted", async () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-attempt-runner-default-governor-")); + roots.push(root); + vi.stubEnv("GITTENSORY_MINER_GOVERNOR_LEDGER_DB", join(root, "default-governor.sqlite3")); + const deps = baseDeps(); + delete (deps as { governorLedgerAppend?: unknown }).governorLedgerAppend; + + const result = await runMinerAttempt(baseAttemptInput(), deps); + + expect(result.outcome).toBe("submitted"); + vi.unstubAllEnvs(); + }); + + it("fails closed on malformed input", async () => { + const deps = baseDeps(); + await expect(runMinerAttempt(null as never, deps)).rejects.toThrow("invalid_attempt_input"); + await expect(runMinerAttempt({} as never, deps)).rejects.toThrow("invalid_loop_input"); + await expect(runMinerAttempt(baseAttemptInput({ issueNumber: 0 }), deps)).rejects.toThrow("invalid_issue_number"); + await expect(runMinerAttempt(baseAttemptInput({ minerLogin: "" }), deps)).rejects.toThrow("invalid_miner_login"); + await expect(runMinerAttempt(baseAttemptInput({ base: "" }), deps)).rejects.toThrow("invalid_base"); + await expect(runMinerAttempt(baseAttemptInput({ killSwitchScope: "bogus" }), deps)).rejects.toThrow("invalid_kill_switch_scope"); + await expect(runMinerAttempt(baseAttemptInput({ slopThreshold: "bogus" }), deps)).rejects.toThrow("invalid_slop_threshold"); + await expect(runMinerAttempt(baseAttemptInput({ submissionMode: "bogus" }), deps)).rejects.toThrow("invalid_submission_mode"); + await expect(runMinerAttempt(baseAttemptInput({ governor: null }), deps)).rejects.toThrow("invalid_governor_context"); + }); + + it("fails closed on malformed or missing deps", async () => { + const input = baseAttemptInput(); + const full = baseDeps(); + await expect(runMinerAttempt(input, null as never)).rejects.toThrow("invalid_attempt_deps"); + await expect(runMinerAttempt(input, { ...full, runSlopAssessment: undefined } as never)).rejects.toThrow("invalid_run_slop_assessment"); + await expect(runMinerAttempt(input, { ...full, appendAttemptLogEvent: undefined } as never)).rejects.toThrow("invalid_append_attempt_log_event"); + await expect(runMinerAttempt(input, { ...full, fetchLiveIssueSnapshot: undefined } as never)).rejects.toThrow("invalid_fetch_live_issue_snapshot"); + await expect(runMinerAttempt(input, { ...full, executeLocalWrite: undefined } as never)).rejects.toThrow("invalid_execute_local_write"); + await expect(runMinerAttempt(input, { ...full, driver: undefined } as never)).rejects.toThrow("invalid_driver"); + await expect(runMinerAttempt(input, { ...full, claimLedger: undefined } as never)).rejects.toThrow("invalid_claim_ledger"); + await expect(runMinerAttempt(input, { ...full, eventLedger: undefined } as never)).rejects.toThrow("invalid_event_ledger"); + await expect(runMinerAttempt(input, { ...full, nowMs: Number.NaN } as never)).rejects.toThrow("invalid_now_ms"); + }); +});