From 9c4d41fc8dbd1f73b63fa39e55f40712b413f92f Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:25:22 -0700 Subject: [PATCH] feat(miner): build the autonomous supervising loop Closes #5135 The final piece of Wave 3.5's Miner AMS epic (#5130): a real discover -> claim -> attempt -> observe -> re-enter loop, composing every existing primitive (runDiscover, runAttempt, the run-loop boundary gate, loop-reentry, loop-closure) into an actual repeat- until-halted CLI command. No daemon/watch pattern existed anywhere in this package before this change. New pieces: - lib/loop-cli.js: `gittensory-miner loop` -- fails closed if governor state can't be loaded; checks the kill switch and a real per-repo policy-aware run-loop boundary gate before every claim; runs a real attempt via runAttempt's onResult hook; on a submitted outcome, polls the real PR disposition and records it; tracks real in-memory convergence history and persists real GovernorCapUsage (turnsTaken from runMinerAttempt's own totalTurnsUsed, elapsedMs from wall-clock) via governor-state.js's saveCapUsage -- previously uncalled anywhere. A permanent AI-usage-policy block marks its item done instead of requeuing it forever; any other non-submitted outcome requeues, and a genuinely stuck item halts the whole run via real non-convergence detection rather than looping forever. - lib/pr-disposition-poller.js: polls a PR's real merge/close disposition (distinct from ci-poller.js's check-run polling) and classifies it into loop-reentry's merged/disengaged/other vocabulary -- the missing piece pr-outcome.js's store had no real caller for. - attempt-cli.js: surfaces runMinerAttempt's real loopResult turn usage (totalTurnsUsed/iterationsUsed) through options.onResult, so the loop can save genuine cap usage instead of a fabricated number. Documented, deliberate gaps (not silently papered over): convergence and cap-usage history are scoped to this loop process's own lifetime (cap usage itself persists across restarts; per-issue convergence counters do not -- a durable version needs attempt-log.js to grow a repo+issue index, a separate schema change); the loop's kill-switch and boundary checks are global-scope only, matching runAttempt's own existing gap (a per-repo `.gittensory-miner.yml` pause resolver now exists via #5255 but isn't wired into either call site yet). --- .../gittensory-miner/bin/gittensory-miner.js | 7 + .../gittensory-miner/lib/attempt-cli.d.ts | 40 +- packages/gittensory-miner/lib/attempt-cli.js | 10 + packages/gittensory-miner/lib/cli.js | 3 + packages/gittensory-miner/lib/loop-cli.d.ts | 61 +++ packages/gittensory-miner/lib/loop-cli.js | 455 ++++++++++++++++++ .../lib/pr-disposition-poller.d.ts | 26 + .../lib/pr-disposition-poller.js | 163 +++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-attempt-cli.test.ts | 45 +- test/unit/miner-loop-cli.test.ts | 389 +++++++++++++++ test/unit/miner-pr-disposition-poller.test.ts | 151 ++++++ 12 files changed, 1348 insertions(+), 4 deletions(-) create mode 100644 packages/gittensory-miner/lib/loop-cli.d.ts create mode 100644 packages/gittensory-miner/lib/loop-cli.js create mode 100644 packages/gittensory-miner/lib/pr-disposition-poller.d.ts create mode 100644 packages/gittensory-miner/lib/pr-disposition-poller.js create mode 100644 test/unit/miner-loop-cli.test.ts create mode 100644 test/unit/miner-pr-disposition-poller.test.ts diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 771c240143..04789f0a87 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -6,6 +6,7 @@ import { runDiscover } from "../lib/discover-cli.js"; import { runFeasibilityCli } from "../lib/feasibility-cli.js"; import { runGovernorCli } from "../lib/governor-ledger-cli.js"; import { runLedgerCli } from "../lib/event-ledger-cli.js"; +import { runLoop } from "../lib/loop-cli.js"; import { runManagePoll } from "../lib/manage-poll.js"; import { runManageStatus } from "../lib/manage-status.js"; import { runPlanCli } from "../lib/plan-store-cli.js"; @@ -133,6 +134,12 @@ if (cliArgs[0] === "attempt") { process.exit(exitCode); } +if (cliArgs[0] === "loop") { + const exitCode = await runLoop(cliArgs.slice(1)); + await awaitOpportunisticUpdateCheck(updateCheck); + process.exit(exitCode); +} + const exitCode = runCli(cliArgs, { packageName }); await awaitOpportunisticUpdateCheck(updateCheck); process.exit(exitCode); diff --git a/packages/gittensory-miner/lib/attempt-cli.d.ts b/packages/gittensory-miner/lib/attempt-cli.d.ts index 955df591b2..f047a08c69 100644 --- a/packages/gittensory-miner/lib/attempt-cli.d.ts +++ b/packages/gittensory-miner/lib/attempt-cli.d.ts @@ -1,5 +1,5 @@ -import type { CodingAgentExecutionMode } from "@jsonbored/gittensory-engine"; -import type { AttemptDeps, runMinerAttempt } from "./attempt-runner.js"; +import type { CodingAgentExecutionMode, FeasibilityVerdict, LocalWriteActionSpec } from "@jsonbored/gittensory-engine"; +import type { AttemptDeps, AttemptResult as RunMinerAttemptResult, runMinerAttempt } from "./attempt-runner.js"; import type { ClaimLedger } from "./claim-ledger.js"; import type { EventLedger } from "./event-ledger.js"; import type { AttemptLog } from "./attempt-log.js"; @@ -12,6 +12,39 @@ import type { buildCodingTaskSpec } from "./coding-task-spec.js"; import type { resolveAmsPolicy } from "./ams-policy.js"; import type { checkMinerKillSwitch } from "./governor-kill-switch.js"; +type CommonAttemptResultFields = { + repoFullName: string; + issueNumber: number; + minerLogin: string; + base: string; + mode: CodingAgentExecutionMode; + attemptId: string; +}; + +/** The result runAttempt reports at every real return point, threaded to `options.onResult` (in addition to + * 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: "blocked_rejection_signaled"; reason: string }) + | (CommonAttemptResultFields & { outcome: "blocked_worktree_preparation_failed"; reason: string }) + | (CommonAttemptResultFields & { + outcome: "blocked_infeasible"; + reason: string; + verdict: FeasibilityVerdict; + avoidReasons: string[]; + raiseReasons: string[]; + }) + | (CommonAttemptResultFields & { + outcome: `attempt_${RunMinerAttemptResult["outcome"]}`; + submissionMode: "observe" | "enforce"; + totalTurnsUsed: number; + iterationsUsed: number; + reason?: string; + decision?: unknown; + spec?: LocalWriteActionSpec; + execResult?: unknown; + }); + export type ParsedAttemptArgs = | { error: string } | { repoFullName: string; issueNumber: number; minerLogin: string; base: string; live: boolean; json: boolean }; @@ -43,6 +76,9 @@ export type RunAttemptOptions = { resolveAmsPolicy?: typeof resolveAmsPolicy; checkMinerKillSwitch?: typeof checkMinerKillSwitch; runMinerAttempt?: typeof runMinerAttempt; + /** Invoked with the real structured result at every return point, in addition to (never instead of) the + * plain exit-code return -- the loop orchestrator's real hook into what actually happened. */ + onResult?: (result: AttemptCliResult) => void; }; export function runAttempt(args: string[], options?: RunAttemptOptions): Promise; diff --git a/packages/gittensory-miner/lib/attempt-cli.js b/packages/gittensory-miner/lib/attempt-cli.js index 2aaa2a53a7..c337839870 100644 --- a/packages/gittensory-miner/lib/attempt-cli.js +++ b/packages/gittensory-miner/lib/attempt-cli.js @@ -207,6 +207,7 @@ export async function runAttempt(args, options = {}) { `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's AI-usage policy bans automated/AI-authored contributions.`, ); } + options.onResult?.(rejectedResult); return 5; } @@ -259,6 +260,7 @@ export async function runAttempt(args, options = {}) { } else { console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: real worktree preparation failed: ${reason}`); } + options.onResult?.(worktreeFailureResult); return 6; } @@ -325,6 +327,7 @@ export async function runAttempt(args, options = {}) { `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: feasibility verdict "${codingTaskSpec.verdict}" (${[...codingTaskSpec.feasibility.avoidReasons, ...codingTaskSpec.feasibility.raiseReasons].join(", ")}).`, ); } + options.onResult?.(infeasibleResult); return 4; } @@ -371,9 +374,15 @@ export async function runAttempt(args, options = {}) { mode, attemptId, submissionMode: amsPolicy.spec.submissionMode, + // Every runMinerAttempt outcome carries a real loopResult (#5135's loop needs its genuine turn-usage to + // save real GovernorCapUsage via governor-state.js's saveCapUsage -- nothing else in the codebase calls + // it yet). Surfaced flat rather than the whole loopResult object, matching this result's own shallow shape. + totalTurnsUsed: result.loopResult.totalTurnsUsed, + iterationsUsed: result.loopResult.iterationsUsed, ...("reason" in result ? { reason: result.reason } : {}), ...("decision" in result ? { decision: result.decision } : {}), ...("spec" in result ? { spec: result.spec } : {}), + ...("execResult" in result ? { execResult: result.execResult } : {}), }; if (parsed.json) { @@ -381,6 +390,7 @@ export async function runAttempt(args, options = {}) { } else { console.log(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} finished with outcome: ${result.outcome}.`); } + options.onResult?.(finalResult); switch (result.outcome) { case "submitted": diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index b78ba5c887..df54c686ab 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -22,6 +22,9 @@ export function printHelp(input) { " 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]", + " 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", " gittensory-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--json]", diff --git a/packages/gittensory-miner/lib/loop-cli.d.ts b/packages/gittensory-miner/lib/loop-cli.d.ts new file mode 100644 index 0000000000..18f748355d --- /dev/null +++ b/packages/gittensory-miner/lib/loop-cli.d.ts @@ -0,0 +1,61 @@ +import type { AttemptCliResult } from "./attempt-cli.js"; +import type { PortfolioQueueStore } from "./portfolio-queue.js"; +import type { GovernorState } from "./governor-state.js"; +import type { EventLedger } from "./event-ledger.js"; +import type { GovernorLedger } from "./governor-ledger.js"; +import type { RunStateStore } from "./run-state.js"; +import type { PollPrDispositionOptions } from "./pr-disposition-poller.js"; + +export type ParsedLoopArgs = + | { error: string } + | { + targets: string[]; + search: string | null; + minerLogin: string; + base: string; + live: boolean; + maxCycles: number | undefined; + cycleDelayMs: number; + json: boolean; + }; + +export function parseLoopArgs(args: string[]): ParsedLoopArgs; + +export type LoopCycleSummary = { + cycle: number; + outcome: "idle_queue_empty" | "halted" | "attempted" | "skipped_malformed_identifier"; + reason?: string; + repoFullName?: string; + identifier?: string; + attemptOutcome?: AttemptCliResult["outcome"] | "attempt_error"; + reentryOutcome?: "merged" | "disengaged" | "other"; + prNumber?: number | null; + reentered?: boolean; + reasons?: string[]; +}; + +export type RunLoopOptions = { + env?: Record; + nowMs?: number; + githubToken?: string; + apiBaseUrl?: string; + sleepFn?: (delayMs: number) => Promise; + openGovernorState?: () => GovernorState; + initEventLedger?: () => EventLedger; + initGovernorLedger?: () => GovernorLedger; + initPortfolioQueue?: () => PortfolioQueueStore; + initRunStateStore?: () => RunStateStore; + runDiscover?: (args: string[], options?: Record) => Promise; + runAttempt?: (args: string[], options?: Record) => Promise; + resolveAmsPolicy?: (repoFullName: string, options?: Record) => Promise<{ spec: Record; source: string; warnings: string[] }>; + checkMinerKillSwitch?: (input?: { env?: Record; repoPaused?: boolean }) => { scope: "global" | "repo" | "none"; active: boolean }; + evaluateRunLoopBoundaryGate?: (input: unknown, options?: unknown) => { verdict: { reason: string }; canClaimNext: boolean }; + pollPrDisposition?: (repoFullName: string, prNumber: number, options?: PollPrDispositionOptions) => Promise<{ state: "open" | "closed"; merged: boolean; closedAt: string | null; attempts: number }>; + recordPrOutcomeSnapshot?: (input: unknown, options?: unknown) => unknown; + buildLoopClosureSummary?: (sources: unknown, options?: unknown) => { sinceSeq: number | null; lastSeq: number }; + attemptLoopReentry?: (candidate: unknown, deps: unknown) => { decision: { reenter: boolean; reasons: string[] }; dequeued: { repoFullName: string; identifier: string; priority: number; status: string; enqueuedAt: string } | null }; + attemptOptions?: Record; + prDispositionOptions?: PollPrDispositionOptions; +}; + +export function runLoop(args: string[], options?: RunLoopOptions): Promise; diff --git a/packages/gittensory-miner/lib/loop-cli.js b/packages/gittensory-miner/lib/loop-cli.js new file mode 100644 index 0000000000..cc8a282359 --- /dev/null +++ b/packages/gittensory-miner/lib/loop-cli.js @@ -0,0 +1,455 @@ +// The autonomous supervising loop (#5135, Wave 3.5): the missing daemon/watch layer over the one-shot +// `discover`/`attempt` subcommands. Every existing piece it composes -- runDiscover, runAttempt, +// evaluateRunLoopBoundaryGate, attemptLoopReentry, buildLoopClosureSummary, governor-state.js -- already +// existed; this is the first caller that actually chains them into a real repeat-until-halted run. +// +// STRUCTURE (one cycle): kill-switch check -> real-per-repo-policy-aware run-loop boundary gate (before +// claiming) -> real runAttempt -> real PR-disposition poll (pr-disposition-poller.js, on a submitted outcome) +// -> real loop-closure summary -> real attemptLoopReentry decision. `attemptLoopReentry`'s own dequeue is the +// AUTHORITATIVE claim for every cycle after the first (its own doc: "if allowed -- dequeues the next +// candidate") -- this loop does not ALSO call portfolioQueue.dequeueNext() on a successful reentry, which +// would silently double-claim (the reentry's own claim would then leak as a permanently 'in_progress', never- +// attempted row). A manual dequeueNext() is used only to prime the very first cycle (no prior outcome exists +// yet to reenter from) and to refill after an empty queue. +// +// REAL, NOT FABRICATED: this loop is the first production caller of governor-state.js's `saveCapUsage` +// (turnsTaken from runMinerAttempt's own real `loopResult.totalTurnsUsed`, elapsedMs from real wall-clock +// measurement) and of a genuine per-identifier convergence history (attempts/consecutiveFailures/reenqueues +// tracked in this process's own memory across its own cycles) -- both were previously honest zero/placeholder +// literals (see attempt-input-builder.js's own header) because a ONE-SHOT `attempt` CLI invocation has no +// cross-call history to draw on. A long-running loop genuinely does. +// +// DOCUMENTED GAP: convergence/cap-usage history is IN-MEMORY, scoped to this loop process's own lifetime (cap +// usage itself persists across restarts via governor-state.js; per-identifier convergence counters do not -- +// a durable version needs attempt-log.js to grow a repo+issue index, the same separate schema change +// attempt-input-builder.js's header already flags as out of scope here). + +import { checkMinerKillSwitch } from "./governor-kill-switch.js"; +import { evaluateRunLoopBoundaryGate } from "./governor-run-halt.js"; +import { openGovernorState } from "./governor-state.js"; +import { initGovernorLedger } from "./governor-ledger.js"; +import { initEventLedger } from "./event-ledger.js"; +import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import { initRunStateStore } from "./run-state.js"; +import { runDiscover } from "./discover-cli.js"; +import { runAttempt } from "./attempt-cli.js"; +import { resolveAmsPolicy } from "./ams-policy.js"; +import { pollPrDisposition, classifyPrDisposition } from "./pr-disposition-poller.js"; +import { recordPrOutcomeSnapshot } from "./pr-outcome.js"; +import { buildLoopClosureSummary } from "./loop-closure.js"; +import { attemptLoopReentry } from "./loop-reentry.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]"; +const DEFAULT_CYCLE_DELAY_MS = 60_000; +const ISSUE_IDENTIFIER_PATTERN = /^issue:(\d+)$/; + +function parseRepoTarget(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) return null; + return `${owner}/${repo}`; +} + +function normalizeOptionalPositiveInt(value, label) { + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue) || parsedValue < 0) { + throw new Error(`${label} must be a non-negative integer: ${value}`); + } + return parsedValue; +} + +export function parseLoopArgs(args) { + const options = { json: false, minerLogin: null, base: "main", live: false, search: null, maxCycles: undefined, cycleDelayMs: DEFAULT_CYCLE_DELAY_MS }; + const targets = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--live") { + options.live = true; + continue; + } + if (token === "--search") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; + options.search = value; + index += 1; + continue; + } + if (token === "--miner-login") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; + options.minerLogin = value; + index += 1; + continue; + } + if (token === "--base") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; + options.base = value; + index += 1; + continue; + } + if (token === "--max-cycles") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; + try { + options.maxCycles = normalizeOptionalPositiveInt(value, "--max-cycles"); + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + index += 1; + continue; + } + if (token === "--cycle-delay-ms") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; + try { + options.cycleDelayMs = normalizeOptionalPositiveInt(value, "--cycle-delay-ms"); + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + index += 1; + continue; + } + if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; + const target = parseRepoTarget(token); + if (!target) return { error: `Repository must be in owner/repo form: ${token}` }; + targets.push(target); + } + + if (options.search === null && targets.length === 0) return { error: LOOP_USAGE }; + if (options.search !== null && targets.length > 0) return { error: "Pass either repository targets or --search, not both." }; + if (!options.minerLogin) return { error: `--miner-login is required. ${LOOP_USAGE}` }; + + return { + targets, + search: options.search, + minerLogin: options.minerLogin, + base: options.base, + live: options.live, + maxCycles: options.maxCycles, + cycleDelayMs: options.cycleDelayMs, + json: options.json, + }; +} + +function discoverArgv(parsed) { + return parsed.search !== null ? ["--search", parsed.search] : [...parsed.targets]; +} + +function parseIssueNumberFromIdentifier(identifier) { + const match = typeof identifier === "string" ? identifier.match(ISSUE_IDENTIFIER_PATTERN) : null; + return match ? Number(match[1]) : null; +} + +/** `gh pr create` (local-write-tools.ts's `buildOpenPrSpec` -- no `--json` flag) prints the created PR's own + * URL to stdout on success; this is `gh`'s real, documented, stable CLI behavior, not an invented contract. + * Scoped to the exact target repo so an unrelated URL elsewhere in stdout/stderr noise can never match. */ +function parsePrNumberFromExecResult(execResult, repoFullName) { + if (!execResult || execResult.timedOut || execResult.code !== 0 || typeof execResult.stdout !== "string") { + return null; + } + const escapedRepo = repoFullName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = execResult.stdout.match(new RegExp(`github\\.com/${escapedRepo}/pull/(\\d+)`)); + if (!match) return null; + const prNumber = Number(match[1]); + return Number.isInteger(prNumber) && prNumber > 0 ? prNumber : null; +} + +function convergenceKey(repoFullName, identifier) { + return `${repoFullName}:${identifier}`; +} + +function zeroConvergence() { + return { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false }; +} + +/** + * Run one full discover -> claim -> attempt -> observe -> reenter cycle repeatedly until a kill-switch trips, + * the run-loop boundary gate halts (non-convergence or a real budget/turn/elapsed cap), re-entry is declined, + * or `--max-cycles` is reached. Fails closed: refuses to start at all if governor state cannot be loaded. + * + * @param {string[]} args + * @param {{ + * env?: Record, + * nowMs?: number, + * githubToken?: string, + * apiBaseUrl?: string, + * sleepFn?: (delayMs: number) => Promise, + * openGovernorState?: typeof openGovernorState, + * initEventLedger?: typeof initEventLedger, + * initGovernorLedger?: typeof initGovernorLedger, + * initPortfolioQueue?: () => import("./portfolio-queue.js").PortfolioQueueStore, + * initRunStateStore?: typeof initRunStateStore, + * runDiscover?: typeof runDiscover, + * runAttempt?: typeof runAttempt, + * resolveAmsPolicy?: typeof resolveAmsPolicy, + * checkMinerKillSwitch?: typeof checkMinerKillSwitch, + * evaluateRunLoopBoundaryGate?: typeof evaluateRunLoopBoundaryGate, + * pollPrDisposition?: typeof pollPrDisposition, + * recordPrOutcomeSnapshot?: typeof recordPrOutcomeSnapshot, + * buildLoopClosureSummary?: typeof buildLoopClosureSummary, + * attemptLoopReentry?: typeof attemptLoopReentry, + * attemptOptions?: Record, + * prDispositionOptions?: Record, + * }} [options] + * @returns {Promise} + */ +export async function runLoop(args, options = {}) { + const parsed = parseLoopArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + const env = options.env ?? process.env; + const sleepFn = options.sleepFn ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))); + const nowMsFn = () => options.nowMs ?? Date.now(); + const sessionStartMs = nowMsFn(); + + let governorState; + try { + governorState = (options.openGovernorState ?? openGovernorState)(); + } catch (error) { + console.error( + `Loop refuses to start: governor state cannot be loaded: ${error instanceof Error ? error.message : String(error)}`, + ); + return 3; + } + + const eventLedger = (options.initEventLedger ?? initEventLedger)(); + const governorLedger = (options.initGovernorLedger ?? initGovernorLedger)(); + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + const runState = (options.initRunStateStore ?? initRunStateStore)(); + + const runDiscoverFn = options.runDiscover ?? runDiscover; + const runAttemptFn = options.runAttempt ?? runAttempt; + const resolveAmsPolicyFn = options.resolveAmsPolicy ?? resolveAmsPolicy; + const checkKillSwitchFn = options.checkMinerKillSwitch ?? checkMinerKillSwitch; + const evaluateBoundaryGateFn = options.evaluateRunLoopBoundaryGate ?? evaluateRunLoopBoundaryGate; + const pollPrDispositionFn = options.pollPrDisposition ?? pollPrDisposition; + const recordPrOutcomeSnapshotFn = options.recordPrOutcomeSnapshot ?? recordPrOutcomeSnapshot; + const buildLoopClosureSummaryFn = options.buildLoopClosureSummary ?? buildLoopClosureSummary; + const attemptLoopReentryFn = options.attemptLoopReentry ?? attemptLoopReentry; + + async function runDiscoveryOnce() { + await runDiscoverFn(discoverArgv(parsed), { + initPortfolioQueue: () => portfolioQueue, + githubToken: options.githubToken, + apiBaseUrl: options.apiBaseUrl, + nowMs: nowMsFn(), + }); + } + + let usage = governorState.loadCapUsage(); + const convergenceHistory = new Map(); + const cycles = []; + let sinceSeq = eventLedger.readEvents({}).at(-1)?.seq ?? 0; + let haltReason = null; + + try { + // Checked BEFORE any work at all -- including the very first discovery call -- so an already-active kill + // switch halts the loop without ever touching GitHub or the queue. + const initialKillSwitch = checkKillSwitchFn({ env }); + let claimed = null; + if (initialKillSwitch.active) { + haltReason = `kill_switch_${initialKillSwitch.scope}`; + cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); + } else { + await runDiscoveryOnce(); + claimed = portfolioQueue.dequeueNext(); + } + + let cycleIndex = haltReason !== null ? 1 : 0; + while (haltReason === null && (parsed.maxCycles === undefined || cycleIndex < parsed.maxCycles)) { + cycleIndex += 1; + + const killSwitch = checkKillSwitchFn({ env }); + if (killSwitch.active) { + haltReason = `kill_switch_${killSwitch.scope}`; + cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason }); + break; + } + + if (!claimed) { + cycles.push({ cycle: cycleIndex, outcome: "idle_queue_empty" }); + await sleepFn(parsed.cycleDelayMs); + await runDiscoveryOnce(); + claimed = portfolioQueue.dequeueNext(); + continue; + } + + const issueNumber = parseIssueNumberFromIdentifier(claimed.identifier); + if (issueNumber === null) { + // Never produced by enqueueRankedDiscovery in practice (always "issue:N") -- fail soft rather than + // crash the whole run: this exact item can never be attempted, so it will never resolve on retry. + portfolioQueue.markDone(claimed.repoFullName, claimed.identifier); + cycles.push({ cycle: cycleIndex, outcome: "skipped_malformed_identifier", identifier: claimed.identifier }); + claimed = portfolioQueue.dequeueNext(); + continue; + } + + const key = convergenceKey(claimed.repoFullName, claimed.identifier); + const amsPolicy = await resolveAmsPolicyFn(claimed.repoFullName, { env }); + const convergenceInput = convergenceHistory.get(key) ?? zeroConvergence(); + + const boundary = evaluateBoundaryGateFn( + { + runHalted: false, + usage, + limits: amsPolicy.spec.capLimits ?? DEFAULT_AMS_POLICY_SPEC.capLimits, + convergence: convergenceInput, + convergenceThresholds: amsPolicy.spec.convergenceThresholds ?? DEFAULT_AMS_POLICY_SPEC.convergenceThresholds, + inFlightItem: { repoFullName: claimed.repoFullName, identifier: claimed.identifier }, + markFailed: (repoFullName, identifier) => portfolioQueue.markFailed(repoFullName, identifier), + }, + { append: (event) => governorLedger.appendGovernorEvent(event) }, + ); + + if (!boundary.canClaimNext) { + haltReason = `boundary_${boundary.verdict.reason}`; + cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason, repoFullName: claimed.repoFullName, identifier: claimed.identifier }); + break; + } + + convergenceInput.attempts += 1; + convergenceHistory.set(key, convergenceInput); + + const cycleStartMs = nowMsFn(); + let lastResult = null; + const attemptArgv = [ + claimed.repoFullName, + String(issueNumber), + "--miner-login", + parsed.minerLogin, + "--base", + parsed.base, + ...(parsed.live ? ["--live"] : []), + ]; + await runAttemptFn(attemptArgv, { + ...(options.attemptOptions ?? {}), + env, + onResult: (result) => { + lastResult = result; + }, + }); + const cycleElapsedMs = nowMsFn() - cycleStartMs; + + usage = { + budgetSpent: usage.budgetSpent, + turnsTaken: usage.turnsTaken + (lastResult?.totalTurnsUsed ?? 0), + elapsedMs: usage.elapsedMs + cycleElapsedMs, + }; + governorState.saveCapUsage(usage); + + const attemptOutcome = lastResult?.outcome ?? "attempt_error"; + const submitted = attemptOutcome === "attempt_submitted"; + // A repo-wide AI-usage-policy ban will never resolve on retry -- stop re-queuing it (matches + // rejection-signal.js's own "this repo bans automated contributions" semantics). Every other blocked/ + // abandoned/stale/governed outcome MAY resolve on a later retry (transient infra, contention, a + // different iteration budget) and is requeued -- a genuinely stuck item is caught by non-convergence + // (reenqueues threshold) rather than silently retried forever. + const permanentBlock = attemptOutcome === "blocked_rejection_signaled"; + + if (submitted) { + portfolioQueue.markDone(claimed.repoFullName, claimed.identifier); + convergenceInput.reachedDone = true; + convergenceInput.consecutiveFailures = 0; + } else if (permanentBlock) { + portfolioQueue.markDone(claimed.repoFullName, claimed.identifier); + convergenceInput.consecutiveFailures += 1; + } else { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier); + convergenceInput.consecutiveFailures += 1; + convergenceInput.reenqueues += 1; + } + convergenceHistory.set(key, convergenceInput); + + let reentryOutcome = "other"; + let prNumber = null; + let prDisposition = null; + if (submitted) { + prNumber = parsePrNumberFromExecResult(lastResult?.execResult, claimed.repoFullName); + if (prNumber !== null) { + prDisposition = await pollPrDispositionFn(claimed.repoFullName, prNumber, options.prDispositionOptions ?? {}); + if (prDisposition.state === "closed") { + recordPrOutcomeSnapshotFn( + { + repoFullName: claimed.repoFullName, + prNumber, + decision: prDisposition.merged ? "merged" : "closed", + closedAt: prDisposition.closedAt, + }, + { eventLedger }, + ); + reentryOutcome = classifyPrDisposition(prDisposition); + } + } + } + + const loopSummary = buildLoopClosureSummaryFn( + { eventLedger, portfolioQueue, runState }, + { sinceSeq, repoFullName: claimed.repoFullName }, + ); + sinceSeq = loopSummary.lastSeq; + + const reentry = attemptLoopReentryFn( + { killSwitchScope: killSwitch.scope, repoFullName: claimed.repoFullName, outcome: reentryOutcome }, + { eventLedger, portfolioQueue, runState, nowMs: nowMsFn(), sessionStartMs, loopSummary }, + ); + + cycles.push({ + cycle: cycleIndex, + outcome: "attempted", + repoFullName: claimed.repoFullName, + identifier: claimed.identifier, + attemptOutcome, + reentryOutcome, + prNumber, + reentered: reentry.decision.reenter, + reasons: reentry.decision.reasons, + }); + + if (!reentry.decision.reenter) { + haltReason = `reentry_declined:${reentry.decision.reasons.join(",")}`; + break; + } + + if (reentry.dequeued) { + claimed = reentry.dequeued; + await sleepFn(parsed.cycleDelayMs); + } else { + await sleepFn(parsed.cycleDelayMs); + await runDiscoveryOnce(); + claimed = portfolioQueue.dequeueNext(); + } + } + + if (haltReason === null && parsed.maxCycles !== undefined) { + haltReason = "max_cycles_reached"; + } + + const summary = { haltReason, cyclesRun: cycles.length, cycles }; + if (parsed.json) { + console.log(JSON.stringify(summary, null, 2)); + } else { + console.log(`Loop finished after ${cycles.length} cycle(s): ${haltReason ?? "unknown"}.`); + } + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } finally { + governorState.close(); + eventLedger.close(); + governorLedger.close(); + portfolioQueue.close(); + runState.close(); + } +} diff --git a/packages/gittensory-miner/lib/pr-disposition-poller.d.ts b/packages/gittensory-miner/lib/pr-disposition-poller.d.ts new file mode 100644 index 0000000000..11a81799a7 --- /dev/null +++ b/packages/gittensory-miner/lib/pr-disposition-poller.d.ts @@ -0,0 +1,26 @@ +export type PrDisposition = { + state: "open" | "closed"; + merged: boolean; + closedAt: string | null; + attempts: number; +}; + +export type PollPrDispositionOptions = { + apiBaseUrl?: string; + fetchFn?: typeof fetch; + githubToken?: string; + maxAttempts?: number; + minIntervalMs?: number; + maxIntervalMs?: number; + sleepFn?: (delayMs: number) => Promise; +}; + +export function pollPrDisposition( + repoFullName: string, + prNumber: number, + options?: PollPrDispositionOptions, +): Promise; + +export function classifyPrDisposition( + disposition: Pick, +): "merged" | "disengaged" | "other"; diff --git a/packages/gittensory-miner/lib/pr-disposition-poller.js b/packages/gittensory-miner/lib/pr-disposition-poller.js new file mode 100644 index 0000000000..1b6d302036 --- /dev/null +++ b/packages/gittensory-miner/lib/pr-disposition-poller.js @@ -0,0 +1,163 @@ +// Real PR-disposition poller (#5135, Wave 3.5 -- the autonomous loop). ci-poller.js already polls a PR's CI +// check-runs, but that answers a DIFFERENT question ("did the checks pass") from what the supervising loop +// needs at cycle-close time ("did the PR itself get merged or closed"). Nothing in this package answered that +// second question before this file: pr-outcome.js already has a real store for the classification +// (recordPrOutcomeSnapshot/readPrOutcomes), but every existing caller of it was a test -- this is the real +// GitHub fetch that produces the classification pr-outcome.js's writer expects. +// +// Deliberately its own module, not folded into ci-poller.js: the two pollers ask genuinely different +// questions (check-run conclusion vs. PR merge/close disposition) with different terminal conditions (a +// check-run poll's "pending" means "wait for the SAME head commit's checks to finish"; a disposition poll's +// "open" means "wait for a human to actually merge or close the PR", a potentially much longer, unbounded +// wait) -- composing them into one poller would conflate two different backoff/timeout policies. + +const defaultApiBaseUrl = "https://api.github.com"; +const defaultMinIntervalMs = 60_000; +const defaultMaxIntervalMs = 5 * 60_000; +const defaultMaxAttempts = 1; +const githubApiVersion = "2022-11-28"; + +function normalizeApiBaseUrl(value) { + if (value === undefined) return defaultApiBaseUrl; + if (typeof value !== "string" || !value.trim()) return defaultApiBaseUrl; + let parsed; + try { + parsed = new URL(value.trim()); + } catch { + throw new Error("invalid_api_base_url"); + } + if (parsed.protocol !== "https:" || parsed.hostname !== "api.github.com") { + throw new Error("invalid_api_base_url"); + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/+$/, ""); +} + +function normalizePositiveInt(value, fallback, min, max) { + if (!Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.floor(value))); +} + +function normalizeOptions(options = {}) { + return { + apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl), + fetchFn: options.fetchFn ?? fetch, + githubToken: typeof options.githubToken === "string" ? options.githubToken.trim() : "", + maxAttempts: normalizePositiveInt(options.maxAttempts, defaultMaxAttempts, 1, 20), + minIntervalMs: normalizePositiveInt(options.minIntervalMs, defaultMinIntervalMs, 1, 60 * 60_000), + maxIntervalMs: normalizePositiveInt(options.maxIntervalMs, defaultMaxIntervalMs, 1, 60 * 60_000), + sleepFn: + options.sleepFn ?? + ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))), + }; +} + +function parseRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner?.trim() || !repo?.trim() || extra !== undefined) { + throw new Error("invalid_repo_full_name"); + } + return { owner: owner.trim(), repo: repo.trim() }; +} + +function normalizePullNumber(value) { + if (!Number.isInteger(value) || value <= 0) throw new Error("invalid_pr_number"); + return value; +} + +function githubHeaders(githubToken) { + const headers = { + accept: "application/vnd.github+json", + "user-agent": "gittensory-miner", + "x-github-api-version": githubApiVersion, + }; + if (githubToken) headers.authorization = `Bearer ${githubToken}`; + return headers; +} + +function repoPath(target, suffix) { + return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; +} + +function apiUrl(apiBaseUrl, path) { + return `${apiBaseUrl}${path}`; +} + +function githubError(response, payload) { + const code = `github_${response.status}`; + const githubMessage = + typeof payload?.message === "string" && payload.message.trim() ? payload.message : null; + const message = githubMessage ? `${code}: ${githubMessage}` : code; + return Object.assign(new Error(message), { code, githubMessage }); +} + +async function fetchPullRequest(target, prNumber, options) { + const response = await options.fetchFn(apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)), { + method: "GET", + headers: githubHeaders(options.githubToken), + }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw githubError(response, payload); + return payload; +} + +/** GitHub's own vocabulary is `state: "open"|"closed"` plus a separate `merged: boolean` -- "closed and not + * merged" is the disengaged case. A still-open PR is never terminal for this poller's purposes. */ +function normalizeDisposition(payload) { + const state = payload?.state === "closed" ? "closed" : "open"; + const merged = Boolean(payload?.merged); + const closedAt = typeof payload?.closed_at === "string" ? payload.closed_at : null; + return { state, merged, closedAt }; +} + +function backoffDelayMs(attemptIndex, options) { + const exponent = Math.min(10, Math.max(0, attemptIndex)); + return Math.min(options.maxIntervalMs, options.minIntervalMs * 2 ** exponent); +} + +/** + * Poll a real PR's own merge/close disposition (distinct from its CI check-run conclusion, ci-poller.js's + * concern) with exponential backoff, until it reaches a terminal `state: "closed"` or `maxAttempts` is + * exhausted -- whichever comes first. A still-`"open"` PR after the last attempt is returned as-is, not an + * error: an unattended loop cycle should treat "still open" as "not yet resolved", not fail. + * + * @param {string} repoFullName + * @param {number} prNumber + * @param {{ + * apiBaseUrl?: string, fetchFn?: typeof fetch, githubToken?: string, maxAttempts?: number, + * minIntervalMs?: number, maxIntervalMs?: number, sleepFn?: (delayMs: number) => Promise, + * }} [options] + * @returns {Promise<{ state: "open"|"closed", merged: boolean, closedAt: string|null, attempts: number }>} + */ +export async function pollPrDisposition(repoFullName, prNumber, options = {}) { + const target = parseRepoFullName(repoFullName); + const normalizedPrNumber = normalizePullNumber(prNumber); + const normalizedOptions = normalizeOptions(options); + + let latest = { state: "open", merged: false, closedAt: null, attempts: 0 }; + for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) { + const payload = await fetchPullRequest(target, normalizedPrNumber, normalizedOptions); + latest = { ...normalizeDisposition(payload), attempts: attempt + 1 }; + if (latest.state === "closed") return latest; + if (attempt === normalizedOptions.maxAttempts - 1) return latest; + await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions)); + } + return latest; +} + +/** + * Classify a real, terminal PR disposition into loop-reentry.js's own `candidate.outcome` vocabulary + * (`"merged"|"disengaged"|"other"`). A still-open disposition (not yet resolved) classifies as `"other"` -- + * the same bucket a runMinerAttempt outcome that never opened a PR at all falls into (nothing to re-enter on + * yet, in either case). + * + * @param {{ state: "open"|"closed", merged: boolean }} disposition + * @returns {"merged"|"disengaged"|"other"} + */ +export function classifyPrDisposition(disposition) { + if (disposition.state !== "closed") return "other"; + return disposition.merged ? "merged" : "disengaged"; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 3af716b07f..a8e5720051 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -33,7 +33,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.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-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.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/execute-local-write.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-persisted.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-state.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/live-issue-snapshot.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/miner-goal-spec.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/portfolio-queue-expiry.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.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-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.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 bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.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-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.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/execute-local-write.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-persisted.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-state.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/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.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/miner-goal-spec.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/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.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-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.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-cli.test.ts b/test/unit/miner-attempt-cli.test.ts index f80943d64c..88a2b25c72 100644 --- a/test/unit/miner-attempt-cli.test.ts +++ b/test/unit/miner-attempt-cli.test.ts @@ -243,7 +243,7 @@ describe("runAttempt (#5132)", () => { outcome: "submitted", spec: { command: "gh pr create", cwd: worktreeResult.worktreePath, timeoutMs: 1000 }, execResult: { code: 0 }, - loopResult: { outcome: "handoff" }, + loopResult: { outcome: "handoff", totalTurnsUsed: 3, iterationsUsed: 2 }, }); const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { @@ -269,7 +269,10 @@ describe("runAttempt (#5132)", () => { mode: "dry_run", attemptId: "fixed-attempt-id", submissionMode: "observe", + totalTurnsUsed: 3, + iterationsUsed: 2, spec: { command: "gh pr create", cwd: worktreeResult.worktreePath, timeoutMs: 1000 }, + execResult: { code: 0 }, }); // The worktree slot was acquired for real and then released, not left dangling. @@ -656,4 +659,44 @@ describe("runAttempt (#5132)", () => { linkedIssues: [7], }); }); + + it("REGRESSION: options.onResult is called with the real structured result at every return point, alongside the unchanged plain exit code", async () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + const onResult = vi.fn(); + + // blocked_rejection_signaled path + const rejectedLedgers = tempLedgers(); + const rejectedExit = await runAttempt(["acme/widgets", "7", "--miner-login", "alice"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => rejectedLedgers.allocator, + openClaimLedger: () => rejectedLedgers.claimLedger, + initEventLedger: () => rejectedLedgers.eventLedger, + initAttemptLog: () => rejectedLedgers.attemptLog, + initGovernorLedger: () => rejectedLedgers.governorLedger, + resolveRejectionSignaled: async () => true, + onResult, + }); + expect(rejectedExit).toBe(5); + expect(onResult).toHaveBeenLastCalledWith(expect.objectContaining({ outcome: "blocked_rejection_signaled" })); + + // attempt_submitted path (real final result) -- a separate set of real ledgers, since runAttempt closes + // whatever it's given in its own `finally` block. + onResult.mockClear(); + const submittedLedgers = tempLedgers(); + const submittedExit = await runAttempt(["acme/widgets", "7", "--miner-login", "alice"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => submittedLedgers.allocator, + openClaimLedger: () => submittedLedgers.claimLedger, + initEventLedger: () => submittedLedgers.eventLedger, + initAttemptLog: () => submittedLedgers.attemptLog, + initGovernorLedger: () => submittedLedgers.governorLedger, + ...readyPipelineOptions({ + runMinerAttempt: async () => ({ outcome: "submitted", spec: { command: "gh pr create", cwd: "/fake", timeoutMs: 1 }, execResult: { code: 0 }, loopResult: {} }), + }), + onResult, + }); + expect(submittedExit).toBe(0); + expect(onResult).toHaveBeenLastCalledWith(expect.objectContaining({ outcome: "attempt_submitted", spec: expect.objectContaining({ command: "gh pr create" }) })); + }); }); diff --git a/test/unit/miner-loop-cli.test.ts b/test/unit/miner-loop-cli.test.ts new file mode 100644 index 0000000000..2df176330d --- /dev/null +++ b/test/unit/miner-loop-cli.test.ts @@ -0,0 +1,389 @@ +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 { parseLoopArgs, runLoop } from "../../packages/gittensory-miner/lib/loop-cli.js"; +import { initEventLedger } from "../../packages/gittensory-miner/lib/event-ledger.js"; +import { initGovernorLedger } from "../../packages/gittensory-miner/lib/governor-ledger.js"; +import { initPortfolioQueueStore } from "../../packages/gittensory-miner/lib/portfolio-queue.js"; +import { initRunStateStore } from "../../packages/gittensory-miner/lib/run-state.js"; +import { openGovernorState } from "../../packages/gittensory-miner/lib/governor-state.js"; +import { DEFAULT_AMS_POLICY_SPEC } from "../../packages/gittensory-engine/src/index"; + +const roots: string[] = []; +// Fresh, separate connections opened AFTER a runLoop call to inspect real persisted state -- runLoop's own +// `finally` always closes the store handles it was given, so re-reading through the SAME handle afterward +// fails ("statement has been finalized"). A fresh connection to the same on-disk file sees the same data. +const postRunClosers: Array<{ close(): void }> = []; + +function tempPath(prefix: string) { + const root = mkdtempSync(join(tmpdir(), `gittensory-miner-${prefix}-`)); + roots.push(root); + return join(root, "db.sqlite3"); +} + +// runLoop's own `finally` block always closes every store it's handed (success or error path), mirroring +// runAttempt's own DI contract -- registering these in a shared closer list here would double-close (the +// underlying SQLite handle throws "database is not open" / "statement has been finalized" on a second close). +function tempStores() { + const eventLedgerPath = tempPath("loop-cli-events"); + const governorLedgerPath = tempPath("loop-cli-governor-ledger"); + const portfolioQueuePath = tempPath("loop-cli-queue"); + const runStatePath = tempPath("loop-cli-runstate"); + const governorStatePath = tempPath("loop-cli-governor-state"); + return { + eventLedger: initEventLedger(eventLedgerPath), + governorLedger: initGovernorLedger(governorLedgerPath), + portfolioQueue: initPortfolioQueueStore(portfolioQueuePath), + runState: initRunStateStore(runStatePath), + governorState: openGovernorState(governorStatePath), + paths: { eventLedgerPath, governorLedgerPath, portfolioQueuePath, runStatePath, governorStatePath }, + }; +} + +/** Open a fresh connection to inspect state persisted by a completed runLoop call. */ +function reopenAfterRun(paths: ReturnType["paths"]) { + const eventLedger = initEventLedger(paths.eventLedgerPath); + const governorLedger = initGovernorLedger(paths.governorLedgerPath); + const portfolioQueue = initPortfolioQueueStore(paths.portfolioQueuePath); + const governorState = openGovernorState(paths.governorStatePath); + postRunClosers.push(eventLedger, governorLedger, portfolioQueue, governorState); + return { eventLedger, governorLedger, portfolioQueue, governorState }; +} + +/** A no-op discover stub that primes the shared queue with one fixed candidate the first time it's called on + * an empty queue, then does nothing on later calls -- markFailed/reentry already keep re-surfacing the same + * claimed item without needing fresh discovery every cycle. */ +function primeOnceDiscover(portfolioQueue: ReturnType, item: { repoFullName: string; identifier: string }) { + return vi.fn(async () => { + if (portfolioQueue.listQueue().length === 0) portfolioQueue.enqueue(item); + return 0; + }); +} + +function readyLoopOptions(overrides: Record = {}) { + return { + resolveAmsPolicy: async () => ({ spec: DEFAULT_AMS_POLICY_SPEC, source: "default" as const, warnings: [] }), + checkMinerKillSwitch: () => ({ scope: "none" as const, active: false }), + sleepFn: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +afterEach(() => { + for (const closer of postRunClosers.splice(0)) closer.close(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("parseLoopArgs (#5135)", () => { + it("parses repo targets with the required miner-login and every optional flag", () => { + expect( + parseLoopArgs([ + "acme/widgets", + "acme/other", + "--miner-login", + "alice", + "--base", + "develop", + "--live", + "--max-cycles", + "5", + "--cycle-delay-ms", + "1000", + "--json", + ]), + ).toEqual({ + targets: ["acme/widgets", "acme/other"], + search: null, + minerLogin: "alice", + base: "develop", + live: true, + maxCycles: 5, + cycleDelayMs: 1000, + json: true, + }); + }); + + it("parses a --search query in place of repo targets", () => { + expect(parseLoopArgs(["--search", "label:good-first-issue", "--miner-login", "alice"])).toEqual({ + targets: [], + search: "label:good-first-issue", + minerLogin: "alice", + base: "main", + live: false, + maxCycles: undefined, + cycleDelayMs: 60_000, + json: false, + }); + }); + + it("requires --miner-login", () => { + expect(parseLoopArgs(["acme/widgets"])).toEqual({ error: expect.stringContaining("--miner-login is required") }); + }); + + it("rejects mixing repo targets and --search", () => { + expect(parseLoopArgs(["acme/widgets", "--search", "x", "--miner-login", "alice"])).toEqual({ + error: "Pass either repository targets or --search, not both.", + }); + }); + + it("requires at least one target or --search", () => { + expect(parseLoopArgs(["--miner-login", "alice"])).toEqual({ error: expect.stringContaining("Usage:") }); + }); + + it("rejects a malformed repo target", () => { + expect(parseLoopArgs(["not-a-repo", "--miner-login", "alice"])).toEqual({ + error: "Repository must be in owner/repo form: not-a-repo", + }); + }); + + it("rejects a non-integer or negative --max-cycles / --cycle-delay-ms", () => { + expect(parseLoopArgs(["acme/widgets", "--miner-login", "alice", "--max-cycles", "abc"])).toHaveProperty("error"); + expect(parseLoopArgs(["acme/widgets", "--miner-login", "alice", "--max-cycles", "-1"])).toHaveProperty("error"); + expect(parseLoopArgs(["acme/widgets", "--miner-login", "alice", "--cycle-delay-ms", "abc"])).toHaveProperty("error"); + }); + + it("rejects an unknown flag", () => { + expect(parseLoopArgs(["acme/widgets", "--miner-login", "alice", "--bogus"])).toEqual({ + error: "Unknown option: --bogus", + }); + }); +}); + +describe("runLoop (#5135)", () => { + it("fails closed: refuses to start when governor state cannot be loaded", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice"], { + openGovernorState: () => { + throw new Error("corrupt_governor_state_db"); + }, + }); + expect(exitCode).toBe(3); + expect(error).toHaveBeenCalledWith(expect.stringContaining("governor state cannot be loaded")); + }); + + 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); + const runDiscoverSpy = vi.fn(); + const runAttemptSpy = vi.fn(); + + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + ...readyLoopOptions({ checkMinerKillSwitch: () => ({ scope: "global" as const, active: true }) }), + }); + + expect(exitCode).toBe(0); + expect(runDiscoverSpy).not.toHaveBeenCalled(); + expect(runAttemptSpy).not.toHaveBeenCalled(); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.haltReason).toBe("kill_switch_global"); + expect(printed.cycles).toEqual([{ cycle: 1, outcome: "halted", reason: "kill_switch_global" }]); + }); + + it("REGRESSION: runs a full cycle end to end -- claims, attempts, polls real PR disposition, records the outcome, and re-enters", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState, paths } = tempStores(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const item = { repoFullName: "acme/widgets", identifier: "issue:7" }; + const runDiscoverSpy = primeOnceDiscover(portfolioQueue, item); + const runAttemptSpy = vi.fn(async (_args: string[], options?: Record) => { + (options?.onResult as ((result: unknown) => void) | undefined)?.({ + outcome: "attempt_submitted", + repoFullName: "acme/widgets", + issueNumber: 7, + minerLogin: "alice", + base: "main", + mode: "dry_run", + attemptId: "loop-attempt-1", + submissionMode: "observe", + totalTurnsUsed: 4, + iterationsUsed: 1, + execResult: { action: "open_pr", stdout: "https://github.com/acme/widgets/pull/123\n", stderr: "", code: 0, timedOut: false }, + }); + return 0; + }); + const pollPrDispositionSpy = vi.fn().mockResolvedValue({ state: "closed", merged: true, closedAt: "2026-07-12T00:00:00Z", attempts: 1 }); + + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "2", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + pollPrDisposition: pollPrDispositionSpy, + ...readyLoopOptions(), + }); + + expect(exitCode).toBe(0); + expect(runAttemptSpy).toHaveBeenCalledTimes(1); + const [attemptArgv] = runAttemptSpy.mock.calls[0]!; + expect(attemptArgv).toEqual(["acme/widgets", "7", "--miner-login", "alice", "--base", "main"]); + + expect(pollPrDispositionSpy).toHaveBeenCalledWith("acme/widgets", 123, expect.anything()); + + const after = reopenAfterRun(paths); + + // recordPrOutcomeSnapshot (real, not mocked) actually persisted the merged decision to the shared ledger. + const prOutcomeEvents = after.eventLedger.readEvents({}).filter((e) => e.type === "pr_outcome"); + expect(prOutcomeEvents).toHaveLength(1); + expect(prOutcomeEvents[0]?.payload).toMatchObject({ prNumber: 123, decision: "merged" }); + + // The claimed item resolved to done (real success), not left in_progress or requeued. + expect(after.portfolioQueue.listQueue()).toEqual([expect.objectContaining({ identifier: "issue:7", status: "done" })]); + + // Real governor cap usage was saved using runAttempt's own real totalTurnsUsed, not fabricated. + expect(after.governorState.loadCapUsage().turnsTaken).toBe(4); + + // Re-entry actually fired (a real loop_reentry_decision event, reentered on a merged outcome). + const reentryEvents = after.eventLedger.readEvents({}).filter((e) => e.type === "loop_reentry_decision"); + expect(reentryEvents).toHaveLength(1); + expect(reentryEvents[0]?.payload).toMatchObject({ reentered: true, outcome: "merged" }); + + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.cycles[0]).toMatchObject({ outcome: "attempted", attemptOutcome: "attempt_submitted", reentryOutcome: "merged", prNumber: 123 }); + }); + + it("REGRESSION: a repeatedly-blocked (non-permanent) outcome requeues the item and eventually halts on real non-convergence, not forever", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState, paths } = tempStores(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const item = { repoFullName: "acme/widgets", identifier: "issue:9" }; + const runDiscoverSpy = primeOnceDiscover(portfolioQueue, item); + const runAttemptSpy = vi.fn(async (_args: string[], options?: Record) => { + (options?.onResult as ((result: unknown) => void) | undefined)?.({ + outcome: "attempt_blocked", + repoFullName: "acme/widgets", + issueNumber: 9, + minerLogin: "alice", + base: "main", + mode: "dry_run", + attemptId: `loop-attempt-${Date.now()}`, + submissionMode: "observe", + totalTurnsUsed: 1, + iterationsUsed: 1, + decision: { allowed: false }, + }); + return 9; + }); + + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "10", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + ...readyLoopOptions(), + }); + + expect(exitCode).toBe(0); + // DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS.maxReenqueues is 3: the item is attempted 3 times (reenqueues + // reaching 1, 2, 3), then the 4th cycle's boundary check halts BEFORE attempting a 4th time. + expect(runAttemptSpy).toHaveBeenCalledTimes(3); + const after = reopenAfterRun(paths); + const governorEvents = after.governorLedger.readGovernorEvents({}); + expect(governorEvents.some((e) => e.reason === "non_convergence_detected")).toBe(true); + // The run-loop boundary gate released the in-flight item back to 'queued' on the fresh halt. + expect(after.portfolioQueue.listQueue()).toHaveLength(1); + expect(after.portfolioQueue.listQueue()[0]).toMatchObject({ status: "queued" }); + }); + + it("REGRESSION: a permanent (AI-usage-policy) block marks the item done instead of re-queuing it forever", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState, paths } = tempStores(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const item = { repoFullName: "acme/widgets", identifier: "issue:11" }; + const runDiscoverSpy = primeOnceDiscover(portfolioQueue, item); + const runAttemptSpy = vi.fn(async (_args: string[], options?: Record) => { + (options?.onResult as ((result: unknown) => void) | undefined)?.({ + outcome: "blocked_rejection_signaled", + reason: "ai_usage_policy_ban", + repoFullName: "acme/widgets", + issueNumber: 11, + minerLogin: "alice", + base: "main", + mode: "dry_run", + attemptId: "loop-attempt-permanent", + }); + return 5; + }); + + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "3", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + ...readyLoopOptions(), + }); + + expect(exitCode).toBe(0); + // Attempted exactly once -- a permanent block is marked done, not requeued, so cycles 2-3 are idle. + expect(runAttemptSpy).toHaveBeenCalledTimes(1); + expect(reopenAfterRun(paths).portfolioQueue.listQueue()).toEqual([ + expect.objectContaining({ identifier: "issue:11", status: "done" }), + ]); + }); + + it("respects --max-cycles even when the queue never has anything to claim", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const runDiscoverSpy = vi.fn().mockResolvedValue(0); + const runAttemptSpy = vi.fn(); + const sleepFn = vi.fn().mockResolvedValue(undefined); + + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "3", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + ...readyLoopOptions({ sleepFn }), + }); + + expect(exitCode).toBe(0); + expect(runAttemptSpy).not.toHaveBeenCalled(); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.haltReason).toBe("max_cycles_reached"); + expect(printed.cycles.every((c: { outcome: string }) => c.outcome === "idle_queue_empty")).toBe(true); + expect(printed.cycles).toHaveLength(3); + }); + + it("closes every store it opened, even when an unexpected error is thrown mid-cycle", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + const closeSpies = [eventLedger, governorLedger, portfolioQueue, runState, governorState].map((store) => vi.spyOn(store, "close")); + + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: async () => { + throw new Error("network_unreachable"); + }, + ...readyLoopOptions(), + }); + + expect(exitCode).toBe(2); + for (const spy of closeSpies) expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit/miner-pr-disposition-poller.test.ts b/test/unit/miner-pr-disposition-poller.test.ts new file mode 100644 index 0000000000..68754d3c40 --- /dev/null +++ b/test/unit/miner-pr-disposition-poller.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from "vitest"; +import { classifyPrDisposition, pollPrDisposition } from "../../packages/gittensory-miner/lib/pr-disposition-poller.js"; + +const API = "https://api.github.com"; + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return Response.json(body, init); +} + +function prResponse(overrides: Record = {}) { + return jsonResponse({ state: "open", merged: false, closed_at: null, ...overrides }); +} + +describe("PR disposition poller (#5135)", () => { + it("fetches a real PR's disposition with a read-only authenticated GET request", async () => { + const fetchFn = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => prResponse({ state: "open" })); + + const result = await pollPrDisposition("acme/widgets", 42, { + apiBaseUrl: API, + githubToken: "github-token", + fetchFn, + }); + + expect(result).toEqual({ state: "open", merged: false, closedAt: null, attempts: 1 }); + expect(fetchFn).toHaveBeenCalledTimes(1); + const [url, init] = fetchFn.mock.calls[0]!; + expect(String(url)).toBe(`${API}/repos/acme/widgets/pulls/42`); + expect(init?.method).toBe("GET"); + expect((init?.headers as Record).authorization).toBe("Bearer github-token"); + }); + + it("returns terminal merged disposition immediately, without further polling", async () => { + const fetchFn = vi.fn(async () => + prResponse({ state: "closed", merged: true, closed_at: "2026-07-12T00:00:00Z" }), + ); + + const result = await pollPrDisposition("acme/widgets", 7, { apiBaseUrl: API, fetchFn, maxAttempts: 5 }); + + expect(result).toEqual({ state: "closed", merged: true, closedAt: "2026-07-12T00:00:00Z", attempts: 1 }); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("returns terminal closed-unmerged (disengaged) disposition immediately", async () => { + const fetchFn = vi.fn(async () => + prResponse({ state: "closed", merged: false, closed_at: "2026-07-12T00:00:00Z" }), + ); + + const result = await pollPrDisposition("acme/widgets", 8, { apiBaseUrl: API, fetchFn }); + + expect(result).toEqual({ state: "closed", merged: false, closedAt: "2026-07-12T00:00:00Z", attempts: 1 }); + }); + + it("uses the default GitHub API base URL when apiBaseUrl is omitted", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + expect(String(input)).toBe("https://api.github.com/repos/acme/widgets/pulls/9"); + return prResponse({ state: "closed", merged: true }); + }); + + await expect(pollPrDisposition("acme/widgets", 9, { fetchFn })).resolves.toMatchObject({ merged: true }); + }); + + it("rejects untrusted apiBaseUrl values before any token-bearing request", async () => { + const fetchFn = vi.fn(); + for (const apiBaseUrl of [ + "http://api.github.com", + "https://evil.example", + "https://api.github.com.evil.example", + "not a url", + ]) { + await expect(pollPrDisposition("acme/widgets", 42, { apiBaseUrl, fetchFn })).rejects.toThrow( + "invalid_api_base_url", + ); + } + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("backs off between polls while the PR stays open, until it reaches a terminal disposition", async () => { + const sleeps: number[] = []; + const fetchFn = vi + .fn() + .mockResolvedValueOnce(prResponse({ state: "open" })) + .mockResolvedValueOnce(prResponse({ state: "open" })) + .mockResolvedValueOnce(prResponse({ state: "closed", merged: true, closed_at: "2026-07-12T01:00:00Z" })); + + const result = await pollPrDisposition("acme/widgets", 10, { + apiBaseUrl: API, + fetchFn, + maxAttempts: 3, + minIntervalMs: 100, + maxIntervalMs: 150, + sleepFn: async (delayMs: number) => { + sleeps.push(delayMs); + }, + }); + + expect(result).toEqual({ state: "closed", merged: true, closedAt: "2026-07-12T01:00:00Z", attempts: 3 }); + expect(sleeps).toEqual([100, 150]); + expect(fetchFn).toHaveBeenCalledTimes(3); + }); + + it("returns the last-observed open disposition once maxAttempts is exhausted, without throwing", async () => { + const fetchFn = vi.fn(async () => prResponse({ state: "open" })); + + const result = await pollPrDisposition("acme/widgets", 11, { + apiBaseUrl: API, + fetchFn, + maxAttempts: 2, + sleepFn: vi.fn(), + }); + + expect(result).toEqual({ state: "open", merged: false, closedAt: null, attempts: 2 }); + }); + + it("validates repo and PR number input before fetching", async () => { + const fetchFn = vi.fn(); + await expect(pollPrDisposition("missing-slash", 1, { apiBaseUrl: API, fetchFn })).rejects.toThrow( + "invalid_repo_full_name", + ); + await expect(pollPrDisposition("acme/widgets", 0, { apiBaseUrl: API, fetchFn })).rejects.toThrow( + "invalid_pr_number", + ); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("surfaces a GitHub error response as a deterministic error", async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(jsonResponse({ message: "not found" }, { status: 404 })); + await expect(pollPrDisposition("acme/widgets", 12, { apiBaseUrl: API, fetchFn })).rejects.toThrow( + "github_404: not found", + ); + }); + + it("treats a malformed state as still-open rather than throwing", async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(jsonResponse({})); + const result = await pollPrDisposition("acme/widgets", 13, { apiBaseUrl: API, fetchFn }); + expect(result).toEqual({ state: "open", merged: false, closedAt: null, attempts: 1 }); + }); +}); + +describe("classifyPrDisposition (#5135)", () => { + it("classifies a merged PR as merged", () => { + expect(classifyPrDisposition({ state: "closed", merged: true })).toBe("merged"); + }); + + it("classifies a closed-unmerged PR as disengaged", () => { + expect(classifyPrDisposition({ state: "closed", merged: false })).toBe("disengaged"); + }); + + it("classifies a still-open PR as other", () => { + expect(classifyPrDisposition({ state: "open", merged: false })).toBe("other"); + }); +});