From 63b7a8610dde57320e6cf62e701174d2224ca3ce Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Sun, 19 Jul 2026 18:27:37 +0000 Subject: [PATCH] chore(miner): migrate batch 3.3 CLI command modules to TypeScript Convert portfolio-queue-cli, loop-cli, governor-metrics-cli, discover-cli, governor-pause-cli, and attempt-cli from plain .js + hand-maintained .d.ts to real TypeScript under the existing in-place tsc emit pipeline, with zero behavior change. Closes #7307 Co-authored-by: Cursor --- packages/loopover-miner/lib/attempt-cli.d.ts | 207 +-- packages/loopover-miner/lib/attempt-cli.js | 1265 ++++++++--------- packages/loopover-miner/lib/attempt-cli.ts | 866 +++++++++++ packages/loopover-miner/lib/discover-cli.d.ts | 203 ++- packages/loopover-miner/lib/discover-cli.js | 738 +++++----- packages/loopover-miner/lib/discover-cli.ts | 607 ++++++++ .../lib/governor-metrics-cli.d.ts | 21 +- .../lib/governor-metrics-cli.js | 207 ++- .../lib/governor-metrics-cli.ts | 181 +++ .../lib/governor-pause-cli.d.ts | 44 +- .../loopover-miner/lib/governor-pause-cli.js | 270 ++-- .../loopover-miner/lib/governor-pause-cli.ts | 186 +++ packages/loopover-miner/lib/loop-cli.d.ts | 154 +- packages/loopover-miner/lib/loop-cli.js | 970 ++++++------- packages/loopover-miner/lib/loop-cli.ts | 652 +++++++++ .../lib/portfolio-queue-cli.d.ts | 209 +-- .../loopover-miner/lib/portfolio-queue-cli.js | 939 ++++++------ .../loopover-miner/lib/portfolio-queue-cli.ts | 639 +++++++++ 18 files changed, 5711 insertions(+), 2647 deletions(-) create mode 100644 packages/loopover-miner/lib/attempt-cli.ts create mode 100644 packages/loopover-miner/lib/discover-cli.ts create mode 100644 packages/loopover-miner/lib/governor-metrics-cli.ts create mode 100644 packages/loopover-miner/lib/governor-pause-cli.ts create mode 100644 packages/loopover-miner/lib/loop-cli.ts create mode 100644 packages/loopover-miner/lib/portfolio-queue-cli.ts diff --git a/packages/loopover-miner/lib/attempt-cli.d.ts b/packages/loopover-miner/lib/attempt-cli.d.ts index 75cbfa47e9..da9eda8923 100644 --- a/packages/loopover-miner/lib/attempt-cli.d.ts +++ b/packages/loopover-miner/lib/attempt-cli.d.ts @@ -1,108 +1,125 @@ import type { CodingAgentExecutionMode, FeasibilityVerdict, LocalWriteActionSpec } from "@loopover/engine"; -import type { AttemptDeps, AttemptResult as RunMinerAttemptResult, runMinerAttempt } from "./attempt-runner.js"; import type { ClaimLedger } from "./claim-ledger.js"; +import type { ClaimConflictResult, resolveClaimConflict as ResolveClaimConflictFn } from "./claim-conflict-resolver.js"; import type { EventLedger } from "./event-ledger.js"; import type { AttemptLog } from "./attempt-log.js"; import type { GovernorLedger } from "./governor-ledger.js"; import type { WorktreeAllocator } from "./worktree-allocator.js"; -import type { resolveRejectionSignaled } from "./rejection-signal.js"; -import type { SelfReviewContextFetch, fetchSelfReviewContext } from "./self-review-context.js"; -import type { cleanupAttemptWorktree, prepareAttemptWorktree } from "./attempt-worktree.js"; -import type { buildCodingTaskSpec } from "./coding-task-spec.js"; -import type { resolveAmsPolicy } from "./ams-policy.js"; -import type { checkMinerKillSwitch } from "./governor-kill-switch.js"; -import type { resolveMinerGoalSpec } from "./miner-goal-spec.js"; -import type { ClaimConflictResult, resolveClaimConflict } from "./claim-conflict-resolver.js"; -import type { recordOwnSubmission } from "./governor-state.js"; -import type { getAttemptHistory } from "./portfolio-queue.js"; -import type { submitSoftClaim } from "./discovery-index-client.js"; - +import type { resolveRejectionSignaled as ResolveRejectionSignaledFn } from "./rejection-signal.js"; +import type { cleanupAttemptWorktree as CleanupAttemptWorktreeFn, prepareAttemptWorktree as PrepareAttemptWorktreeFn } from "./attempt-worktree.js"; +import type { SelfReviewContextFetch, fetchSelfReviewContext as FetchSelfReviewContextFn } from "./self-review-context.js"; +import type { buildCodingTaskSpec as BuildCodingTaskSpecFn } from "./coding-task-spec.js"; +import type { resolveAmsPolicy as ResolveAmsPolicyFn } from "./ams-policy.js"; +import type { checkMinerKillSwitch as CheckMinerKillSwitchFn } from "./governor-kill-switch.js"; +import type { getAttemptHistory as GetAttemptHistoryFn } from "./portfolio-queue.js"; +import type { recordOwnSubmission as RecordOwnSubmissionFn } from "./governor-state.js"; +import type { AttemptDeps, AttemptResult as RunMinerAttemptResult, runMinerAttempt as RunMinerAttemptFn } from "./attempt-runner.js"; +import type { submitSoftClaim as SubmitSoftClaimFn } from "./discovery-index-client.js"; +import type { resolveMinerGoalSpec as ResolveMinerGoalSpecFn } from "./miner-goal-spec.js"; type CommonAttemptResultFields = { - repoFullName: string; - issueNumber: number; - minerLogin: string; - base: string; - mode: CodingAgentExecutionMode; - attemptId: string; + 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/loopover-miner.js's own * `process.exit(exitCode)` usage never breaks) -- the loop orchestrator's real caller for this data. */ -export type AttemptCliResult = - | (CommonAttemptResultFields & { outcome: "dry_run" }) - | (CommonAttemptResultFields & { outcome: "blocked_rejection_signaled"; reason: string }) - | (CommonAttemptResultFields & { outcome: "blocked_worktree_preparation_failed"; reason: string }) - | (CommonAttemptResultFields & { - outcome: "blocked_infeasible"; - reason: string; - verdict: FeasibilityVerdict; - avoidReasons: string[]; - raiseReasons: string[]; - }) - | (CommonAttemptResultFields & { - outcome: `attempt_${RunMinerAttemptResult["outcome"]}`; - submissionMode: "observe" | "enforce"; - totalTurnsUsed: number; - totalCostUsd: number; - totalTokensUsed: number; - iterationsUsed: number; - reason?: string; - decision?: unknown; - spec?: LocalWriteActionSpec; - execResult?: unknown; - claimConflict?: ClaimConflictResult; - }); - -export type ParsedAttemptArgs = - | { error: string } - | { - repoFullName: string; - issueNumber: number; - minerLogin: string; - base: string; - live: boolean; - dryRun: boolean; - json: boolean; - }; - -export function parseAttemptArgs(args: string[]): ParsedAttemptArgs; - -export function buildAttemptDeps( - env: Record, - ledgers: { claimLedger: ClaimLedger; eventLedger: EventLedger; attemptLog: AttemptLog; governorLedger: GovernorLedger; nowMs: number }, -): AttemptDeps; - +export type AttemptCliResult = (CommonAttemptResultFields & { + outcome: "dry_run"; +}) | (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; + totalCostUsd: number; + totalTokensUsed: number; + iterationsUsed: number; + reason?: string; + decision?: unknown; + spec?: LocalWriteActionSpec; + execResult?: unknown; + claimConflict?: ClaimConflictResult; +}); +export type ParsedAttemptArgs = { + error: string; +} | { + repoFullName: string; + issueNumber: number; + minerLogin: string; + base: string; + live: boolean; + dryRun: boolean; + json: boolean; +}; export type RunAttemptOptions = { - env?: Record; - nowMs?: number; - attemptId?: string; - resolveCodingAgentModeFromConfig?: (config: { env?: Record }) => CodingAgentExecutionMode; - openWorktreeAllocator?: () => WorktreeAllocator; - openClaimLedger?: () => ClaimLedger; - initEventLedger?: () => EventLedger; - initAttemptLog?: () => AttemptLog; - initGovernorLedger?: () => GovernorLedger; - buildAttemptDeps?: typeof buildAttemptDeps; - resolveRejectionSignaled?: typeof resolveRejectionSignaled; - fetchImpl?: SelfReviewContextFetch; - prepareAttemptWorktree?: typeof prepareAttemptWorktree; - cleanupAttemptWorktree?: typeof cleanupAttemptWorktree; - fetchSelfReviewContext?: typeof fetchSelfReviewContext; - buildCodingTaskSpec?: typeof buildCodingTaskSpec; - resolveAmsPolicy?: typeof resolveAmsPolicy; - checkMinerKillSwitch?: typeof checkMinerKillSwitch; - resolveMinerGoalSpec?: typeof resolveMinerGoalSpec; - runMinerAttempt?: typeof runMinerAttempt; - resolveClaimConflict?: typeof resolveClaimConflict; - recordOwnSubmission?: typeof recordOwnSubmission; - getAttemptHistory?: typeof getAttemptHistory; - /** Hosted soft-claim coordination at work-start/work-end, when the plane is enabled (#7168). Defaults to - * discovery-index-client.js's own submitSoftClaim. */ - submitSoftClaim?: typeof submitSoftClaim; - /** 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; + env?: Record; + nowMs?: number; + attemptId?: string; + resolveCodingAgentModeFromConfig?: (config: { + env?: Record; + }) => CodingAgentExecutionMode; + openWorktreeAllocator?: () => WorktreeAllocator; + openClaimLedger?: () => ClaimLedger; + initEventLedger?: () => EventLedger; + initAttemptLog?: () => AttemptLog; + initGovernorLedger?: () => GovernorLedger; + buildAttemptDeps?: typeof buildAttemptDeps; + resolveRejectionSignaled?: typeof ResolveRejectionSignaledFn; + fetchImpl?: SelfReviewContextFetch; + prepareAttemptWorktree?: typeof PrepareAttemptWorktreeFn; + cleanupAttemptWorktree?: typeof CleanupAttemptWorktreeFn; + fetchSelfReviewContext?: typeof FetchSelfReviewContextFn; + buildCodingTaskSpec?: typeof BuildCodingTaskSpecFn; + resolveAmsPolicy?: typeof ResolveAmsPolicyFn; + checkMinerKillSwitch?: typeof CheckMinerKillSwitchFn; + resolveMinerGoalSpec?: typeof ResolveMinerGoalSpecFn; + runMinerAttempt?: typeof RunMinerAttemptFn; + resolveClaimConflict?: typeof ResolveClaimConflictFn; + recordOwnSubmission?: typeof RecordOwnSubmissionFn; + getAttemptHistory?: typeof GetAttemptHistoryFn; + /** Hosted soft-claim coordination at work-start/work-end, when the plane is enabled (#7168). Defaults to + * discovery-index-client.js's own submitSoftClaim. */ + submitSoftClaim?: typeof SubmitSoftClaimFn; + /** 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; +export declare function parseAttemptArgs(args: string[]): ParsedAttemptArgs; +/** + * Assemble a real AttemptDeps object: every field wired to a genuine implementation (the #5131 driver, the + * #5133 slop assessor, the four real ledgers passed in, and the fetchLiveIssueSnapshot/executeLocalWrite + * built alongside this file). Throws if the coding-agent driver is unconfigured (fails closed, matching + * constructProductionCodingAgentDriver's own contract) -- callers should report that clearly rather than + * silently falling back to a driver that could never run. + */ +export declare function buildAttemptDeps(env: Record, ledgers: { + claimLedger: ClaimLedger; + eventLedger: EventLedger; + attemptLog: AttemptLog; + governorLedger: GovernorLedger; + nowMs: number; +}): AttemptDeps; +/** + * Run the `attempt` CLI subcommand end to end: resolveRejectionSignaled (before consuming a worktree slot) -> + * acquire a concurrency slot -> assemble real AttemptDeps -> prepare a REAL git worktree -> fetch a real + * SelfReviewContext -> build a real coding-task spec (blocks on an infeasible verdict) -> resolve the real + * AmsPolicySpec execution policy -> assemble the real IterateLoopInput + Governor context -> call + * runMinerAttempt for real. The worktree is cleaned up (or retained, per the real outcome) in `finally`. + * See this file's header for the documented gaps (real convergence history). + */ +export declare function runAttempt(args: string[], options?: RunAttemptOptions): Promise; +export {}; diff --git a/packages/loopover-miner/lib/attempt-cli.js b/packages/loopover-miner/lib/attempt-cli.js index 6346bff91f..2d6e8b1d4e 100644 --- a/packages/loopover-miner/lib/attempt-cli.js +++ b/packages/loopover-miner/lib/attempt-cli.js @@ -12,7 +12,6 @@ // governor.selfPlagiarismCandidate/selfPlagiarismRecentSubmissions are omitted (chokepoint.ts's own design treats // that as "skip that stage entirely"). governor.convergenceInput is now a real per-issue portfolio-queue.js read // (#5654) and governor.reputationHistory a real per-repo governor-state.js read (#5675), not placeholders. - import { fingerprintFromChangedFiles, resolveCodingAgentModeFromConfig, resolveFirstConfiguredCodingAgentDriverName } from "@loopover/engine"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { constructProductionCodingAgentDriver } from "./coding-agent-construction.js"; @@ -41,115 +40,112 @@ import { loadReputationHistory, recordOwnSubmission } from "./governor-state.js" import { runMinerAttempt } from "./attempt-runner.js"; import { resolveGitHubToken } from "./github-token-resolution.js"; import { isDiscoveryPlaneEnabled, submitSoftClaim } from "./discovery-index-client.js"; - -const ATTEMPT_USAGE = - "Usage: loopover-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]"; - +const ATTEMPT_USAGE = "Usage: loopover-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]"; function parseRepoTarget(value) { - const trimmed = typeof value === "string" ? value.trim() : ""; - const [owner, repo, extra] = trimmed.split("/"); - if (!owner || !repo || extra !== undefined) return null; - if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return null; - return `${owner}/${repo}`; + const trimmed = typeof value === "string" ? value.trim() : ""; + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) + return null; + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) + return null; + return `${owner}/${repo}`; } - export function parseAttemptArgs(args) { - const options = { json: false, minerLogin: null, base: "main", live: false, dryRun: false }; - const positional = []; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; - } - // Opt-in only: resolveCodingAgentModeFromConfig's own default (no agentDryRun override) is "live", not - // "dry_run" -- so #5132's "dry-run is default" acceptance criteria (#2342) has to be enforced HERE, by - // requiring an explicit --live flag before this command will ever request live mode. - if (token === "--live") { - options.live = true; - continue; - } - // #4847: distinct from --live's absence above -- --live only ever gated the coding-agent DRIVER's mode, - // but a non---live run still opened every store and made real worktree/claim/ledger writes. --dry-run - // short-circuits BEFORE any of that infrastructure is even opened, guaranteeing zero writes rather than - // merely skipping the driver. - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - if (token === "--miner-login") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE }; - options.minerLogin = value; - index += 1; - continue; + const options = { json: false, minerLogin: null, base: "main", live: false, dryRun: false }; + const positional = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + // Opt-in only: resolveCodingAgentModeFromConfig's own default (no agentDryRun override) is "live", not + // "dry_run" -- so #5132's "dry-run is default" acceptance criteria (#2342) has to be enforced HERE, by + // requiring an explicit --live flag before this command will ever request live mode. + if (token === "--live") { + options.live = true; + continue; + } + // #4847: distinct from --live's absence above -- --live only ever gated the coding-agent DRIVER's mode, + // but a non---live run still opened every store and made real worktree/claim/ledger writes. --dry-run + // short-circuits BEFORE any of that infrastructure is even opened, guaranteeing zero writes rather than + // merely skipping the driver. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--miner-login") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: ATTEMPT_USAGE }; + options.minerLogin = value; + index += 1; + continue; + } + if (token === "--base") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: ATTEMPT_USAGE }; + options.base = value; + index += 1; + continue; + } + if (token.startsWith("-")) + return { error: `Unknown option: ${token}` }; + positional.push(token); } - if (token === "--base") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE }; - options.base = value; - index += 1; - continue; + if (positional.length !== 2) + return { error: ATTEMPT_USAGE }; + const repoFullName = parseRepoTarget(positional[0]); + if (!repoFullName) + return { error: `Repository must be in owner/repo form: ${positional[0]}` }; + const issueNumber = Number(positional[1]); + if (!Number.isInteger(issueNumber) || issueNumber < 1) { + return { error: `Issue number must be a positive integer: ${positional[1]}` }; } - if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; - positional.push(token); - } - - if (positional.length !== 2) return { error: ATTEMPT_USAGE }; - const repoFullName = parseRepoTarget(positional[0]); - if (!repoFullName) return { error: `Repository must be in owner/repo form: ${positional[0]}` }; - const issueNumber = Number(positional[1]); - if (!Number.isInteger(issueNumber) || issueNumber < 1) { - return { error: `Issue number must be a positive integer: ${positional[1]}` }; - } - if (!options.minerLogin) return { error: `--miner-login is required. ${ATTEMPT_USAGE}` }; - - return { - repoFullName, - issueNumber, - minerLogin: options.minerLogin, - base: options.base, - live: options.live, - dryRun: options.dryRun, - json: options.json, - }; + if (!options.minerLogin) + return { error: `--miner-login is required. ${ATTEMPT_USAGE}` }; + return { + repoFullName, + issueNumber, + minerLogin: options.minerLogin, + base: options.base, + live: options.live, + dryRun: options.dryRun, + json: options.json, + }; } - /** * Assemble a real AttemptDeps object: every field wired to a genuine implementation (the #5131 driver, the * #5133 slop assessor, the four real ledgers passed in, and the fetchLiveIssueSnapshot/executeLocalWrite * built alongside this file). Throws if the coding-agent driver is unconfigured (fails closed, matching * constructProductionCodingAgentDriver's own contract) -- callers should report that clearly rather than * silently falling back to a driver that could never run. - * - * @param {Record} env - * @param {{ - * claimLedger: import("./claim-ledger.js").ClaimLedger, - * eventLedger: import("./event-ledger.js").EventLedger, - * attemptLog: import("./attempt-log.js").AttemptLog, - * governorLedger: import("./governor-ledger.js").GovernorLedger, - * nowMs: number, - * }} ledgers - * @returns {import("./attempt-runner.js").AttemptDeps} */ export function buildAttemptDeps(env, ledgers) { - return { - driver: constructProductionCodingAgentDriver(env), - runSlopAssessment: (input) => runSlopAssessment(input), - appendAttemptLogEvent: (event) => ledgers.attemptLog.appendAttemptLogEvent(event), - claimLedger: ledgers.claimLedger, - // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the - // authenticated `loopover-mcp login` session -- cached in memory, so repeat calls within this process - // don't repeatedly hit the session-fetch endpoint after the first successful resolution. - fetchLiveIssueSnapshot: async (repoFullName, issueNumber) => fetchLiveIssueSnapshot(repoFullName, issueNumber, { githubToken: await resolveGitHubToken(env) }), - eventLedger: ledgers.eventLedger, - governorLedgerAppend: (event) => ledgers.governorLedger.appendGovernorEvent(event), - nowMs: ledgers.nowMs, - executeLocalWrite: (spec) => executeLocalWrite(spec), - }; + // AttemptDeps' claimLedger/callback parameter types are looser structural stubs than the real ledgers + // (pre-existing .d.ts drift on attempt-runner); cast preserves the same runtime wiring the .js had. + return { + driver: constructProductionCodingAgentDriver(env), + runSlopAssessment: (input) => runSlopAssessment(input), + appendAttemptLogEvent: (event) => { + ledgers.attemptLog.appendAttemptLogEvent(event); + }, + claimLedger: ledgers.claimLedger, + // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the + // authenticated `loopover-mcp login` session -- cached in memory, so repeat calls within this process + // don't repeatedly hit the session-fetch endpoint after the first successful resolution. + fetchLiveIssueSnapshot: async (repoFullName, issueNumber) => { + // resolveGitHubToken returns string | null; exactOptionalPropertyTypes forbids explicit undefined. + const githubToken = await resolveGitHubToken(env); + return fetchLiveIssueSnapshot(repoFullName, issueNumber, githubToken !== null ? { githubToken } : {}); + }, + eventLedger: ledgers.eventLedger, + governorLedgerAppend: (event) => ledgers.governorLedger.appendGovernorEvent(event), + nowMs: ledgers.nowMs, + executeLocalWrite: (spec) => executeLocalWrite(spec), + }; } - /** * Run the `attempt` CLI subcommand end to end: resolveRejectionSignaled (before consuming a worktree slot) -> * acquire a concurrency slot -> assemble real AttemptDeps -> prepare a REAL git worktree -> fetch a real @@ -159,573 +155,544 @@ export function buildAttemptDeps(env, ledgers) { * See this file's header for the documented gaps (real convergence history). */ export async function runAttempt(args, options = {}) { - const parsed = parseAttemptArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - const env = options.env ?? process.env; - const nowMs = options.nowMs ?? Date.now(); - const resolveMode = options.resolveCodingAgentModeFromConfig ?? resolveCodingAgentModeFromConfig; - const mode = resolveMode({ env, agentDryRun: !parsed.live }); - - if (mode === "paused") { - return reportCliFailure( - parsed.json, - `Coding-agent execution is globally paused (MINER_CODING_AGENT_PAUSED). Not running attempt for ${parsed.repoFullName}#${parsed.issueNumber}.`, - 3, - ); - } - - const attemptId = options.attemptId ?? `${parsed.repoFullName.replace("/", "_")}-${parsed.issueNumber}-${nowMs}`; - - // #4847: reports what a real run would do and returns BEFORE any store (allocator/claim/event/attempt-log/ - // governor ledger) is even opened, so this is a provable zero-write path -- not just "opened but didn't - // write to" the local stores, and nowhere near the real worktree clone, claim, or coding-agent driver. - if (parsed.dryRun) { - const dryRunResult = { - outcome: "dry_run", - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - console.log( - `DRY RUN: would attempt ${parsed.repoFullName}#${parsed.issueNumber} for ${parsed.minerLogin} (mode: ${mode}, base: ${parsed.base}). No worktree, claim, or ledger writes were made.`, - ); - } - options.onResult?.(dryRunResult); - return 0; - } - - let allocator = null; - let claimLedger = null; - let eventLedger = null; - let attemptLog = null; - let governorLedger = null; - let allocation = null; - let worktreeResult = null; - let claimedIssue = false; - let claimRecord = null; - - try { - allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)(); - claimLedger = (options.openClaimLedger ?? openClaimLedger)(); - eventLedger = (options.initEventLedger ?? initEventLedger)(); - attemptLog = (options.initAttemptLog ?? initAttemptLog)(); - governorLedger = (options.initGovernorLedger ?? initGovernorLedger)(); - - // Checked before acquiring a worktree slot: a rejection-signaled repo should never consume one. - // resolveRejectionSignaled resolves both documented triggers (#5132 policy ban, #5655 own-rejection - // history) and returns a trigger-specific reason string for accurate audit-trail labeling. - const resolveRejection = options.resolveRejectionSignaled ?? resolveRejectionSignaled; - const rejectionSignal = await resolveRejection(parsed.repoFullName, { fetchImpl: options.fetchImpl }); - if (rejectionSignal) { - const reason = - rejectionSignal === true ? REJECTION_REASON_AI_USAGE_POLICY_BAN : rejectionSignal; - attemptLog.appendAttemptLogEvent({ - eventType: "attempt_aborted", - attemptId, - actionClass: "open_pr", - mode, - reason, - payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, - }); - eventLedger.appendEvent({ - type: "attempt_blocked", - repoFullName: parsed.repoFullName, - payload: { issueNumber: parsed.issueNumber, reason }, - }); - const rejectedResult = { - outcome: "blocked_rejection_signaled", - reason, - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - }; - if (parsed.json) { - console.log(JSON.stringify(rejectedResult, null, 2)); - } else { - console.error( - reason === REJECTION_REASON_OWN_SUBMISSION_REJECTED - ? `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this miner was previously rejected on this repo.` - : `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's AI-usage policy bans automated/AI-authored contributions.`, - ); - } - options.onResult?.(rejectedResult); - return 5; + const parsed = parseAttemptArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); } - - allocation = allocator.acquire(attemptId, parsed.repoFullName); - - let deps; - try { - const buildDeps = options.buildAttemptDeps ?? buildAttemptDeps; - deps = buildDeps(env, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs }); - } catch (error) { - const reason = describeCliError(error); - return reportCliFailure( - parsed.json, - `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: ${reason}`, - 3, - ); + const env = options.env ?? process.env; + const nowMs = options.nowMs ?? Date.now(); + const resolveMode = options.resolveCodingAgentModeFromConfig ?? resolveCodingAgentModeFromConfig; + // resolveCodingAgentModeFromConfig accepts agentDryRun at runtime; RunAttemptOptions injectable omits it (.d.ts drift). + const mode = resolveMode({ env, agentDryRun: !parsed.live }); + if (mode === "paused") { + return reportCliFailure(parsed.json, `Coding-agent execution is globally paused (MINER_CODING_AGENT_PAUSED). Not running attempt for ${parsed.repoFullName}#${parsed.issueNumber}.`, 3); } - - // Real worktree preparation (repo-clone.js + attempt-worktree.js, #5237): the allocator above only - // reserves a concurrency SLOT (worktree-allocator.js's own `slot-N` placeholder dirs never receive real - // git content) -- this is the step that actually clones/fetches the target repo and creates a real - // `git worktree` for this attempt. Its own path, NOT the allocator's slot path, is the real - // workingDirectory a future runMinerAttempt call must use. - const prepareWorktree = options.prepareAttemptWorktree ?? prepareAttemptWorktree; - worktreeResult = await prepareWorktree(parsed.repoFullName, attemptId, { baseBranch: parsed.base, env }); - if (!worktreeResult.ok) { - const reason = worktreeResult.error; - attemptLog.appendAttemptLogEvent({ - eventType: "attempt_aborted", - attemptId, - actionClass: "open_pr", - mode, - reason, - payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, - }); - eventLedger.appendEvent({ - type: "attempt_blocked", - repoFullName: parsed.repoFullName, - payload: { issueNumber: parsed.issueNumber, reason }, - }); - const worktreeFailureResult = { - outcome: "blocked_worktree_preparation_failed", - reason, - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - }; - if (parsed.json) { - console.log(JSON.stringify(worktreeFailureResult, null, 2)); - } else { - console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: real worktree preparation failed: ${reason}`); - } - options.onResult?.(worktreeFailureResult); - return 6; - } - - // Real SelfReviewContext (#5145): issue/PR/manifest data at live-gate fidelity for the target repo. - const fetchReviewContext = options.fetchSelfReviewContext ?? fetchSelfReviewContext; - const reviewContext = await fetchReviewContext(parsed.repoFullName, { - githubToken: await resolveGitHubToken(env), - contributorLogin: parsed.minerLogin, - linkedIssues: [parsed.issueNumber], - }); - - // The target issue's own real record, when present in the fetched context. When absent (e.g. already - // closed, or genuinely not found), buildCodingTaskSpec's own feasibility check reports target_not_found - // and this placeholder's empty title/body are never surfaced anywhere -- not fabricated content, just an - // inert shape for a verdict that immediately blocks. - const targetIssue = reviewContext.issues.find((candidate) => candidate.number === parsed.issueNumber) ?? { - number: parsed.issueNumber, - title: "", - body: null, - labels: [], - }; - - const buildTaskSpec = options.buildCodingTaskSpec ?? buildCodingTaskSpec; - const codingTaskSpec = buildTaskSpec({ - repoFullName: parsed.repoFullName, - issue: targetIssue, - context: { issues: reviewContext.issues, pullRequests: reviewContext.pullRequests }, - claimLedger, - workingDirectory: worktreeResult.worktreePath, - }); - - if (!codingTaskSpec.ready) { - const reason = `infeasible_${codingTaskSpec.verdict}`; - attemptLog.appendAttemptLogEvent({ - eventType: "attempt_aborted", - attemptId, - actionClass: "open_pr", - mode, - reason, - payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, feasibility: codingTaskSpec.feasibility }, - }); - eventLedger.appendEvent({ - type: "attempt_blocked", - repoFullName: parsed.repoFullName, - payload: { issueNumber: parsed.issueNumber, reason }, - }); - const infeasibleResult = { - outcome: "blocked_infeasible", - reason, - verdict: codingTaskSpec.verdict, - avoidReasons: codingTaskSpec.feasibility.avoidReasons, - raiseReasons: codingTaskSpec.feasibility.raiseReasons, - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - }; - if (parsed.json) { - console.log(JSON.stringify(infeasibleResult, null, 2)); - } else { - console.error( - `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: feasibility verdict "${codingTaskSpec.verdict}" (${[...codingTaskSpec.feasibility.avoidReasons, ...codingTaskSpec.feasibility.raiseReasons].join(", ")}).`, - ); - } - options.onResult?.(infeasibleResult); - return 4; - } - - const amsPolicy = await (options.resolveAmsPolicy ?? resolveAmsPolicy)(parsed.repoFullName, { env }); - - // Real per-repo pause (#5392): read straight from the already-cloned worktree's own .loopover-miner.yml - // (resolveMinerGoalSpec never throws -- a missing/malformed file degrades to killSwitch.paused: false, so - // this can't fail this attempt on its own). Threaded into BOTH checkMinerKillSwitch (killSwitchScope, used - // by the freshness/submission gate) and the governor context (killSwitchRepoPaused, used by the Governor - // chokepoint) -- the same two places the GLOBAL kill switch already reaches. - const resolveGoalSpec = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; - const minerGoalSpec = resolveGoalSpec(worktreeResult.repoPath); - const repoPaused = minerGoalSpec.spec.killSwitch.paused; - - const checkKillSwitch = options.checkMinerKillSwitch ?? checkMinerKillSwitch; - const recordKillTransition = options.recordMinerKillSwitchTransition ?? recordMinerKillSwitchTransition; - let killSwitchScope = checkKillSwitch({ env, repoPaused }).scope; - let previousKillSwitchScope = killSwitchScope; - - const resolveLiveKillSwitch = () => { - // Re-read the YAML flag each probe so an on-disk unpause/pause is reflected mid-attempt (#5670). - const liveRepoPaused = resolveGoalSpec(worktreeResult.repoPath).spec.killSwitch.paused; - const live = checkKillSwitch({ env, repoPaused: liveRepoPaused }); - if (live.scope !== previousKillSwitchScope) { - try { - recordKillTransition({ + const attemptId = options.attemptId ?? `${parsed.repoFullName.replace("/", "_")}-${parsed.issueNumber}-${nowMs}`; + // #4847: reports what a real run would do and returns BEFORE any store (allocator/claim/event/attempt-log/ + // governor ledger) is even opened, so this is a provable zero-write path -- not just "opened but didn't + // write to" the local stores, and nowhere near the real worktree clone, claim, or coding-agent driver. + if (parsed.dryRun) { + const dryRunResult = { + outcome: "dry_run", repoFullName: parsed.repoFullName, - actionClass: "attempt", - previousScope: previousKillSwitchScope, - scope: live.scope, - }); - } catch (error) { - // Ledger append must never crash an aborting attempt (kept), but was previously silent -- a - // kill-switch flip mid-attempt (a compliance-relevant event) could vanish with no record (#6011). - captureMinerError(error, { kind: "kill_switch_transition_record_failed", repoFullName: parsed.repoFullName, scope: live.scope }); + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); } - previousKillSwitchScope = live.scope; - } - killSwitchScope = live.scope; - return live; - }; - - const shouldAbort = () => { - const live = resolveLiveKillSwitch(); - if (!live.active) return false; - return { - abort: true, - reason: `Kill-switch (${live.scope}) engaged mid-attempt; abandoning without starting another driver iteration.`, - }; - }; - - const loopInput = buildAttemptLoopInput({ - codingTaskSpec, - reviewContext, - worktreePath: worktreeResult.worktreePath, - attemptId, - mode, - repoFullName: parsed.repoFullName, - minerLogin: parsed.minerLogin, - rejectionSignaled: false, - amsPolicySpec: amsPolicy.spec, - branchRef: worktreeResult.branchName, - }); - - // Real per-issue attempt history (#5654): portfolio-queue.js's own claim/reclaim/requeue/done counters, - // keyed the same way opportunity-fanout.js enqueues issue-shaped candidates (`issue:`). No - // apiBaseUrl: this file has no multi-forge host context of its own today, so this reads (and every - // pre-#5563 single-forge caller already reads) the github.com default. - const readAttemptHistory = options.getAttemptHistory ?? getAttemptHistory; - const convergenceInput = readAttemptHistory(parsed.repoFullName, `issue:${parsed.issueNumber}`); - // Real per-repo reputation history (#5675): the miner's own decided/unfavorable outcome streak for this repo, - // read from governor-state.js so the chokepoint's self-reputation throttle sees real data instead of nothing. - const readReputationHistory = options.loadReputationHistory ?? loadReputationHistory; - const reputationHistory = readReputationHistory(parsed.repoFullName); - const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused, convergenceInput, reputationHistory); - - // Real maxConcurrentClaims enforcement (#6758): the repo's .loopover-miner.yml cap is honored ATOMICALLY by - // the ledger's count-and-claim, not by a listActiveClaims pre-check here. The old check-then-act split -- read - // the count in this file, then record the claim in a separate claimLedger call -- let two sibling miner - // processes racing the same repo both pass a stale sub-cap count and both claim, exceeding the cap. - // claimIssueWithinCap fuses the count and the insert into one transaction; the loser gets `claimed: false` - // and is reported below rather than silently dropped. This is also the real soft-claim (#5393): once it - // returns claimed, a sibling process sees it via claimLedger.listActiveClaims while this attempt is in - // flight, it is released in `finally` on every terminal outcome (mirroring the worktree allocation slot's - // acquire-then-always-release), and its claimedAt feeds the post-submission conflict check further down (#4848). - const claimResult = claimLedger.claimIssueWithinCap( - parsed.repoFullName, - parsed.issueNumber, - `attempt:${attemptId}`, - undefined, - minerGoalSpec.spec.maxConcurrentClaims, - ); - if (!claimResult.claimed) { - const reason = "max_concurrent_claims_exceeded"; - attemptLog.appendAttemptLogEvent({ - eventType: "attempt_aborted", - attemptId, - actionClass: "open_pr", - mode, - reason, - payload: { - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, - activeClaimCount: claimResult.activeClaimCount, - }, - }); - eventLedger.appendEvent({ - type: "attempt_blocked", - repoFullName: parsed.repoFullName, - payload: { issueNumber: parsed.issueNumber, reason }, - }); - const blockedResult = { - outcome: "blocked_max_concurrent_claims", - reason, - maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, - activeClaimCount: claimResult.activeClaimCount, - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - }; - if (parsed.json) { - console.log(JSON.stringify(blockedResult, null, 2)); - } else { - console.error( - `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's maxConcurrentClaims cap (${minerGoalSpec.spec.maxConcurrentClaims}) is already met (${claimResult.activeClaimCount} active claim(s)).`, - ); - } - options.onResult?.(blockedResult); - return 11; - } - - claimRecord = claimResult.claim; - claimedIssue = true; - // Hosted soft-claim coordination (#7168), opt-in via LOOPOVER_MINER_DISCOVERY_PLANE -- gated HERE at the - // call site (not left to submitSoftClaim's own internal check alone) so a disabled plane costs zero calls, - // matching discover-cli.js's supplementWithDiscoveryIndex gating; a caller-injected options.submitSoftClaim - // (tests, or a future programmatic caller) can't accidentally bypass the opt-in this way either. Awaited - // (not fire-and-forget) so a sibling instance racing the same issue is genuinely less likely to start - // duplicate work in the window before this attempt's claim reaches the shared index -- the whole point of - // coordinating BEFORE work begins, not after. - if (isDiscoveryPlaneEnabled(env)) { - const submitClaim = options.submitSoftClaim ?? submitSoftClaim; - await submitClaim(claimRecord, { env }); + else { + console.log(`DRY RUN: would attempt ${parsed.repoFullName}#${parsed.issueNumber} for ${parsed.minerLogin} (mode: ${mode}, base: ${parsed.base}). No worktree, claim, or ledger writes were made.`); + } + options.onResult?.(dryRunResult); + return 0; } - - const runAttemptPipeline = options.runMinerAttempt ?? runMinerAttempt; - let result; + let allocator = null; + let claimLedger = null; + let eventLedger = null; + let attemptLog = null; + let governorLedger = null; + let allocation = null; + let worktreeResult = null; + let claimedIssue = false; + let claimRecord = null; try { - result = await runAttemptPipeline( - { - loopInput, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - killSwitchScope, - slopThreshold: amsPolicy.spec.slopThreshold, - submissionMode: amsPolicy.spec.submissionMode, - governor, - }, - { - ...deps, - shouldAbort, - resolveKillSwitchScope: () => resolveLiveKillSwitch().scope, - }, - ); - } catch (error) { - // A real attempt that CRASHED is exactly the case that most needs its worktree kept for post-mortem - // inspection, so record the failure explicitly before unwinding. Without this, `attemptOk` stayed - // `undefined` and the finally block's `?? true` default (meant for the earlier blocked paths that never - // ran anything in the worktree) deleted it -- inverting shouldRetainWorktree's documented policy. - worktreeResult.attemptOk = false; - throw error; - } - - worktreeResult.attemptOk = result.outcome === "submitted"; - - // Real claim-conflict resolution (#4848): only meaningful once a real PR exists, so this only ever runs - // on a real "submitted" outcome. checkSubmissionFreshness (inside runMinerAttempt) already caught the - // common pre-submission case; this closes the narrower TOCTOU window where two miners raced past that - // check almost simultaneously -- see claim-conflict-resolver.js's own header for why the adjudicator - // can only run POST-submission (it needs a real PR number on both sides of the election). - let claimConflict; - if (result.outcome === "submitted") { - const selfPrNumber = parsePrNumberFromExecResult(result.execResult, parsed.repoFullName); - if (selfPrNumber !== null) { - const resolveConflict = options.resolveClaimConflict ?? resolveClaimConflict; - claimConflict = await resolveConflict( - { + allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)(); + claimLedger = (options.openClaimLedger ?? openClaimLedger)(); + eventLedger = (options.initEventLedger ?? initEventLedger)(); + attemptLog = (options.initAttemptLog ?? initAttemptLog)(); + governorLedger = (options.initGovernorLedger ?? initGovernorLedger)(); + // Checked before acquiring a worktree slot: a rejection-signaled repo should never consume one. + // resolveRejectionSignaled resolves both documented triggers (#5132 policy ban, #5655 own-rejection + // history) and returns a trigger-specific reason string for accurate audit-trail labeling. + const resolveRejection = options.resolveRejectionSignaled ?? resolveRejectionSignaled; + // Pass fetchImpl through even when unset (same shape the .js always produced); cast for + // exactOptionalPropertyTypes vs RejectionSignaledOptions (pre-existing optional-prop drift). + const rejectionSignal = await resolveRejection(parsed.repoFullName, { + fetchImpl: options.fetchImpl, + }); + if (rejectionSignal) { + const reason = rejectionSignal === true ? REJECTION_REASON_AI_USAGE_POLICY_BAN : rejectionSignal; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const rejectedResult = { + outcome: "blocked_rejection_signaled", + reason, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(rejectedResult, null, 2)); + } + else { + console.error(reason === REJECTION_REASON_OWN_SUBMISSION_REJECTED + ? `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this miner was previously rejected on this repo.` + : `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's AI-usage policy bans automated/AI-authored contributions.`); + } + options.onResult?.(rejectedResult); + return 5; + } + allocation = allocator.acquire(attemptId, parsed.repoFullName); + let deps; + try { + const buildDeps = options.buildAttemptDeps ?? buildAttemptDeps; + deps = buildDeps(env, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs }); + } + catch (error) { + const reason = describeCliError(error); + return reportCliFailure(parsed.json, `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: ${reason}`, 3); + } + // Real worktree preparation (repo-clone.js + attempt-worktree.js, #5237): the allocator above only + // reserves a concurrency SLOT (worktree-allocator.js's own `slot-N` placeholder dirs never receive real + // git content) -- this is the step that actually clones/fetches the target repo and creates a real + // `git worktree` for this attempt. Its own path, NOT the allocator's slot path, is the real + // workingDirectory a future runMinerAttempt call must use. + const prepareWorktree = options.prepareAttemptWorktree ?? prepareAttemptWorktree; + worktreeResult = await prepareWorktree(parsed.repoFullName, attemptId, { baseBranch: parsed.base, env }); + if (!worktreeResult.ok) { + const reason = worktreeResult.error; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const worktreeFailureResult = { + outcome: "blocked_worktree_preparation_failed", + reason, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(worktreeFailureResult, null, 2)); + } + else { + console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: real worktree preparation failed: ${reason}`); + } + options.onResult?.(worktreeFailureResult); + return 6; + } + // Real SelfReviewContext (#5145): issue/PR/manifest data at live-gate fidelity for the target repo. + const fetchReviewContext = options.fetchSelfReviewContext ?? fetchSelfReviewContext; + const reviewGithubToken = await resolveGitHubToken(env); + const reviewContext = await fetchReviewContext(parsed.repoFullName, { + ...(reviewGithubToken !== null ? { githubToken: reviewGithubToken } : {}), + contributorLogin: parsed.minerLogin, + linkedIssues: [parsed.issueNumber], + }); + // The target issue's own real record, when present in the fetched context. When absent (e.g. already + // closed, or genuinely not found), buildCodingTaskSpec's own feasibility check reports target_not_found + // and this placeholder's empty title/body are never surfaced anywhere -- not fabricated content, just an + // inert shape for a verdict that immediately blocks. + const targetIssue = reviewContext.issues.find((candidate) => candidate.number === parsed.issueNumber) ?? { + number: parsed.issueNumber, + title: "", + body: null, + labels: [], + }; + const buildTaskSpec = options.buildCodingTaskSpec ?? buildCodingTaskSpec; + // CodingTaskClaimLedger's listClaims filter types status as plain string (pre-existing .d.ts drift). + const codingTaskSpec = buildTaskSpec({ + repoFullName: parsed.repoFullName, + issue: targetIssue, + context: { issues: reviewContext.issues, pullRequests: reviewContext.pullRequests }, + claimLedger: claimLedger, + workingDirectory: worktreeResult.worktreePath, + }); + if (!codingTaskSpec.ready) { + const reason = `infeasible_${codingTaskSpec.verdict}`; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, feasibility: codingTaskSpec.feasibility }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const infeasibleResult = { + outcome: "blocked_infeasible", + reason, + verdict: codingTaskSpec.verdict, + avoidReasons: codingTaskSpec.feasibility.avoidReasons, + raiseReasons: codingTaskSpec.feasibility.raiseReasons, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(infeasibleResult, null, 2)); + } + else { + console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: feasibility verdict "${codingTaskSpec.verdict}" (${[...codingTaskSpec.feasibility.avoidReasons, ...codingTaskSpec.feasibility.raiseReasons].join(", ")}).`); + } + options.onResult?.(infeasibleResult); + return 4; + } + const amsPolicy = await (options.resolveAmsPolicy ?? resolveAmsPolicy)(parsed.repoFullName, { env }); + // Real per-repo pause (#5392): read straight from the already-cloned worktree's own .loopover-miner.yml + // (resolveMinerGoalSpec never throws -- a missing/malformed file degrades to killSwitch.paused: false, so + // this can't fail this attempt on its own). Threaded into BOTH checkMinerKillSwitch (killSwitchScope, used + // by the freshness/submission gate) and the governor context (killSwitchRepoPaused, used by the Governor + // chokepoint) -- the same two places the GLOBAL kill switch already reaches. + const resolveGoalSpec = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; + const minerGoalSpec = resolveGoalSpec(worktreeResult.repoPath); + const repoPaused = minerGoalSpec.spec.killSwitch.paused; + const checkKillSwitch = options.checkMinerKillSwitch ?? checkMinerKillSwitch; + // recordMinerKillSwitchTransition is used at runtime but omitted from RunAttemptOptions (.d.ts drift). + const recordKillTransition = options + .recordMinerKillSwitchTransition ?? recordMinerKillSwitchTransition; + let killSwitchScope = checkKillSwitch({ env, repoPaused }).scope; + let previousKillSwitchScope = killSwitchScope; + // Captured after the ok-check above so the mid-attempt kill-switch probe can't see a null worktreeResult. + const preparedWorktree = worktreeResult; + const resolveLiveKillSwitch = () => { + // Re-read the YAML flag each probe so an on-disk unpause/pause is reflected mid-attempt (#5670). + const liveRepoPaused = resolveGoalSpec(preparedWorktree.repoPath).spec.killSwitch.paused; + const live = checkKillSwitch({ env, repoPaused: liveRepoPaused }); + if (live.scope !== previousKillSwitchScope) { + try { + recordKillTransition({ + repoFullName: parsed.repoFullName, + actionClass: "attempt", + previousScope: previousKillSwitchScope, + scope: live.scope, + }); + } + catch (error) { + // Ledger append must never crash an aborting attempt (kept), but was previously silent -- a + // kill-switch flip mid-attempt (a compliance-relevant event) could vanish with no record (#6011). + captureMinerError(error, { kind: "kill_switch_transition_record_failed", repoFullName: parsed.repoFullName, scope: live.scope }); + } + previousKillSwitchScope = live.scope; + } + killSwitchScope = live.scope; + return live; + }; + const shouldAbort = () => { + const live = resolveLiveKillSwitch(); + if (!live.active) + return false; + return { + abort: true, + reason: `Kill-switch (${live.scope}) engaged mid-attempt; abandoning without starting another driver iteration.`, + }; + }; + const loopInput = buildAttemptLoopInput({ + codingTaskSpec, + reviewContext, + worktreePath: worktreeResult.worktreePath, + attemptId, + mode, repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - selfPrNumber, - selfClaimedAt: claimRecord.claimedAt, minerLogin: parsed.minerLogin, - }, - { fetchLiveIssueSnapshot: deps.fetchLiveIssueSnapshot, executeLocalWrite: deps.executeLocalWrite }, - ); - } - - // Real own-submission history (#5655 follow-up): governor-state.js's recordOwnSubmission/ - // listRecentOwnSubmissions store (#5134) existed and was already READ by resolveOwnRejectionHistory - // (#5655), but nothing ever WROTE to it -- attempt-runner.js's own header names this exact gap - // ("real persistence primitives... but isn't auto-loaded here yet"). Left unfixed, that trigger is a - // silent no-op in every real deployment: an empty table always resolves "no prior submissions found." - // The fingerprint is the real changed-files set from the loop's own handoff packet (never fabricated) -- - // omitted (not recorded as an empty placeholder) when the packet reports no changed files at all. A - // logging failure must never fail an otherwise-successful attempt, matching the summary-event write below. - const changedFiles = result.loopResult.handoffPacket?.changedFiles?.map((file) => file.path) ?? []; - const fingerprint = fingerprintFromChangedFiles(changedFiles); - if (fingerprint) { + rejectionSignaled: false, + amsPolicySpec: amsPolicy.spec, + branchRef: worktreeResult.branchName, + }); + // Real per-issue attempt history (#5654): portfolio-queue.js's own claim/reclaim/requeue/done counters, + // keyed the same way opportunity-fanout.js enqueues issue-shaped candidates (`issue:`). No + // apiBaseUrl: this file has no multi-forge host context of its own today, so this reads (and every + // pre-#5563 single-forge caller already reads) the github.com default. + const readAttemptHistory = options.getAttemptHistory ?? getAttemptHistory; + const convergenceInput = readAttemptHistory(parsed.repoFullName, `issue:${parsed.issueNumber}`); + // Real per-repo reputation history (#5675): the miner's own decided/unfavorable outcome streak for this repo, + // read from governor-state.js so the chokepoint's self-reputation throttle sees real data instead of nothing. + // loadReputationHistory is used at runtime but omitted from RunAttemptOptions (.d.ts drift). + const readReputationHistory = options.loadReputationHistory ?? + loadReputationHistory; + const reputationHistory = readReputationHistory(parsed.repoFullName); + const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused, convergenceInput, reputationHistory); + // Real maxConcurrentClaims enforcement (#6758): the repo's .loopover-miner.yml cap is honored ATOMICALLY by + // the ledger's count-and-claim, not by a listActiveClaims pre-check here. The old check-then-act split -- read + // the count in this file, then record the claim in a separate claimLedger call -- let two sibling miner + // processes racing the same repo both pass a stale sub-cap count and both claim, exceeding the cap. + // claimIssueWithinCap fuses the count and the insert into one transaction; the loser gets `claimed: false` + // and is reported below rather than silently dropped. This is also the real soft-claim (#5393): once it + // returns claimed, a sibling process sees it via claimLedger.listActiveClaims while this attempt is in + // flight, it is released in `finally` on every terminal outcome (mirroring the worktree allocation slot's + // acquire-then-always-release), and its claimedAt feeds the post-submission conflict check further down (#4848). + const claimResult = claimLedger.claimIssueWithinCap(parsed.repoFullName, parsed.issueNumber, `attempt:${attemptId}`, undefined, minerGoalSpec.spec.maxConcurrentClaims); + if (!claimResult.claimed) { + const reason = "max_concurrent_claims_exceeded"; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, + activeClaimCount: claimResult.activeClaimCount, + }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const blockedResult = { + outcome: "blocked_max_concurrent_claims", + reason, + maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, + activeClaimCount: claimResult.activeClaimCount, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(blockedResult, null, 2)); + } + else { + console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's maxConcurrentClaims cap (${minerGoalSpec.spec.maxConcurrentClaims}) is already met (${claimResult.activeClaimCount} active claim(s)).`); + } + // blocked_max_concurrent_claims is a real runtime outcome omitted from AttemptCliResult (.d.ts drift). + options.onResult?.(blockedResult); + return 11; + } + claimRecord = claimResult.claim; + claimedIssue = true; + // Hosted soft-claim coordination (#7168), opt-in via LOOPOVER_MINER_DISCOVERY_PLANE -- gated HERE at the + // call site (not left to submitSoftClaim's own internal check alone) so a disabled plane costs zero calls, + // matching discover-cli.js's supplementWithDiscoveryIndex gating; a caller-injected options.submitSoftClaim + // (tests, or a future programmatic caller) can't accidentally bypass the opt-in this way either. Awaited + // (not fire-and-forget) so a sibling instance racing the same issue is genuinely less likely to start + // duplicate work in the window before this attempt's claim reaches the shared index -- the whole point of + // coordinating BEFORE work begins, not after. + if (isDiscoveryPlaneEnabled(env)) { + const submitClaim = options.submitSoftClaim ?? submitSoftClaim; + await submitClaim(claimRecord, { env }); + } + const runAttemptPipeline = options.runMinerAttempt ?? runMinerAttempt; + let result; try { - const record = options.recordOwnSubmission ?? recordOwnSubmission; - record({ + result = await runAttemptPipeline({ + loopInput, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + killSwitchScope, + slopThreshold: amsPolicy.spec.slopThreshold, + submissionMode: amsPolicy.spec.submissionMode, + governor, + }, { + ...deps, + shouldAbort, + resolveKillSwitchScope: () => resolveLiveKillSwitch().scope, + }); + } + catch (error) { + // A real attempt that CRASHED is exactly the case that most needs its worktree kept for post-mortem + // inspection, so record the failure explicitly before unwinding. Without this, `attemptOk` stayed + // `undefined` and the finally block's `?? true` default (meant for the earlier blocked paths that never + // ran anything in the worktree) deleted it -- inverting shouldRetainWorktree's documented policy. + worktreeResult.attemptOk = false; + throw error; + } + worktreeResult.attemptOk = result.outcome === "submitted"; + // Real claim-conflict resolution (#4848): only meaningful once a real PR exists, so this only ever runs + // on a real "submitted" outcome. checkSubmissionFreshness (inside runMinerAttempt) already caught the + // common pre-submission case; this closes the narrower TOCTOU window where two miners raced past that + // check almost simultaneously -- see claim-conflict-resolver.js's own header for why the adjudicator + // can only run POST-submission (it needs a real PR number on both sides of the election). + let claimConflict; + if (result.outcome === "submitted") { + const selfPrNumber = parsePrNumberFromExecResult(result.execResult, parsed.repoFullName); + if (selfPrNumber !== null) { + const resolveConflict = options.resolveClaimConflict ?? resolveClaimConflict; + claimConflict = await resolveConflict({ + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + selfPrNumber, + selfClaimedAt: claimRecord.claimedAt, + minerLogin: parsed.minerLogin, + }, { fetchLiveIssueSnapshot: deps.fetchLiveIssueSnapshot, executeLocalWrite: deps.executeLocalWrite }); + } + // Real own-submission history (#5655 follow-up): governor-state.js's recordOwnSubmission/ + // listRecentOwnSubmissions store (#5134) existed and was already READ by resolveOwnRejectionHistory + // (#5655), but nothing ever WROTE to it -- attempt-runner.js's own header names this exact gap + // ("real persistence primitives... but isn't auto-loaded here yet"). Left unfixed, that trigger is a + // silent no-op in every real deployment: an empty table always resolves "no prior submissions found." + // The fingerprint is the real changed-files set from the loop's own handoff packet (never fabricated) -- + // omitted (not recorded as an empty placeholder) when the packet reports no changed files at all. A + // logging failure must never fail an otherwise-successful attempt, matching the summary-event write below. + const changedFiles = result.loopResult.handoffPacket?.changedFiles?.map((file) => file.path) ?? []; + const fingerprint = fingerprintFromChangedFiles(changedFiles); + if (fingerprint) { + try { + const record = options.recordOwnSubmission ?? recordOwnSubmission; + record({ + repoFullName: parsed.repoFullName, + fingerprint, + submittedAt: new Date(nowMs).toISOString(), + pullRequestNumber: selfPrNumber, + issueNumber: parsed.issueNumber, + }); + } + catch (error) { + // A logging failure must never fail an otherwise-successful attempt (kept), but was previously + // silent -- if this write fails AFTER a real PR has already opened, future self-plagiarism checks go + // permanently blind to this exact submission with nobody told (#6011). + captureMinerError(error, { kind: "record_own_submission_failed", repoFullName: parsed.repoFullName, pullRequestNumber: selfPrNumber }); + } + } + } + const finalResult = { + outcome: `attempt_${result.outcome}`, repoFullName: parsed.repoFullName, - fingerprint, - submittedAt: new Date(nowMs).toISOString(), - pullRequestNumber: selfPrNumber, issueNumber: parsed.issueNumber, - }); - } catch (error) { - // A logging failure must never fail an otherwise-successful attempt (kept), but was previously - // silent -- if this write fails AFTER a real PR has already opened, future self-plagiarism checks go - // permanently blind to this exact submission with nobody told (#6011). - captureMinerError(error, { kind: "record_own_submission_failed", repoFullName: parsed.repoFullName, pullRequestNumber: selfPrNumber }); + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + submissionMode: amsPolicy.spec.submissionMode, + // Every runMinerAttempt outcome carries a real loopResult (#5135's loop needs its genuine turn-usage and + // cost 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. costUsd is real only for the agent-sdk provider (its own SDK result message reports + // total_cost_usd); CLI-subprocess providers (claude-cli/codex-cli) report no cost signal today, so this + // is 0 for those -- an honest absence, not a fabricated number. + totalTurnsUsed: result.loopResult.totalTurnsUsed, + totalCostUsd: result.loopResult.totalCostUsd, + // Real accumulated tokens (#5653) -- read from finalMeterTotals rather than a flat totalTokensUsed field + // (IterateLoopResult has no such flat field, unlike turns/cost). 0 when no driver reported a token signal + // on any iteration this attempt ran, never fabricated. + totalTokensUsed: result.loopResult.finalMeterTotals.tokens, + iterationsUsed: result.loopResult.iterationsUsed, + ...(result.outcome === "abandon" && result.loopResult.finalDecision?.abandonReason + ? { abandonReason: result.loopResult.finalDecision.abandonReason } + : {}), + ...("reason" in result ? { reason: result.reason } : {}), + ...("decision" in result ? { decision: result.decision } : {}), + ...("spec" in result ? { spec: result.spec } : {}), + ...("execResult" in result ? { execResult: result.execResult } : {}), + // Present only on a real "submitted" outcome whose PR number was recoverable from execResult -- omitted + // (not fabricated as "checked: false") on every other outcome, and on a submitted outcome where the new + // PR's number genuinely couldn't be parsed (an honest gap, not silently swallowed). + ...(claimConflict !== undefined ? { claimConflict } : {}), + }; + // One summary row per completed attempt (#5185), for the Grafana per-provider usage dashboard the redacted + // AMS reporting export exposes -- distinct from the per-iteration attempt_started/attempt_tool_edit/... trail + // iterate-loop.ts already writes. No fallback for an unconfigured provider: buildAttemptDeps already fails + // closed (throws) on the same env before a worktree is even allocated, so reaching this point guarantees + // resolveFirstConfiguredCodingAgentDriverName(env) resolves a real name. costUsd/tokensUsed are both real, + // driver-reported accumulated totals (#5653) -- 0 when no iteration's driver reported a signal, never + // fabricated. A logging failure must never fail an otherwise-successful attempt -- mirrors iterate-loop.ts's + // own safeAppendAttemptLogEvent non-fatal handling. + try { + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_outcome_summary", + attemptId, + actionClass: finalResult.outcome, + mode, + reason: `attempt finished with outcome: ${result.outcome}`, + provider: resolveFirstConfiguredCodingAgentDriverName(env), + costUsd: finalResult.totalCostUsd, + tokensUsed: finalResult.totalTokensUsed, + }); + } + catch (error) { + // A logging failure must never fail an otherwise-successful attempt (kept), but was previously silent -- + // per docs/observability.md this row feeds the Grafana per-provider cost/usage dashboard, so a failure + // here silently drops the attempt from operator-facing metrics with nobody told (#6011). + captureMinerError(error, { kind: "attempt_outcome_summary_append_failed", attemptId, repoFullName: parsed.repoFullName }); + } + if (parsed.json) { + console.log(JSON.stringify(finalResult, null, 2)); + } + else { + console.log(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} finished with outcome: ${result.outcome}.`); + } + options.onResult?.(finalResult); + switch (result.outcome) { + case "submitted": + return 0; + case "abandon": + return 7; + case "stale": + return 8; + case "blocked": + return 9; + case "governed": + return 10; + default: + return 2; } - } - } - - const finalResult = { - outcome: `attempt_${result.outcome}`, - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - submissionMode: amsPolicy.spec.submissionMode, - // Every runMinerAttempt outcome carries a real loopResult (#5135's loop needs its genuine turn-usage and - // cost 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. costUsd is real only for the agent-sdk provider (its own SDK result message reports - // total_cost_usd); CLI-subprocess providers (claude-cli/codex-cli) report no cost signal today, so this - // is 0 for those -- an honest absence, not a fabricated number. - totalTurnsUsed: result.loopResult.totalTurnsUsed, - totalCostUsd: result.loopResult.totalCostUsd, - // Real accumulated tokens (#5653) -- read from finalMeterTotals rather than a flat totalTokensUsed field - // (IterateLoopResult has no such flat field, unlike turns/cost). 0 when no driver reported a token signal - // on any iteration this attempt ran, never fabricated. - totalTokensUsed: result.loopResult.finalMeterTotals.tokens, - iterationsUsed: result.loopResult.iterationsUsed, - ...(result.outcome === "abandon" && result.loopResult.finalDecision?.abandonReason - ? { abandonReason: result.loopResult.finalDecision.abandonReason } - : {}), - ...("reason" in result ? { reason: result.reason } : {}), - ...("decision" in result ? { decision: result.decision } : {}), - ...("spec" in result ? { spec: result.spec } : {}), - ...("execResult" in result ? { execResult: result.execResult } : {}), - // Present only on a real "submitted" outcome whose PR number was recoverable from execResult -- omitted - // (not fabricated as "checked: false") on every other outcome, and on a submitted outcome where the new - // PR's number genuinely couldn't be parsed (an honest gap, not silently swallowed). - ...(claimConflict !== undefined ? { claimConflict } : {}), - }; - - // One summary row per completed attempt (#5185), for the Grafana per-provider usage dashboard the redacted - // AMS reporting export exposes -- distinct from the per-iteration attempt_started/attempt_tool_edit/... trail - // iterate-loop.ts already writes. No fallback for an unconfigured provider: buildAttemptDeps already fails - // closed (throws) on the same env before a worktree is even allocated, so reaching this point guarantees - // resolveFirstConfiguredCodingAgentDriverName(env) resolves a real name. costUsd/tokensUsed are both real, - // driver-reported accumulated totals (#5653) -- 0 when no iteration's driver reported a signal, never - // fabricated. A logging failure must never fail an otherwise-successful attempt -- mirrors iterate-loop.ts's - // own safeAppendAttemptLogEvent non-fatal handling. - try { - attemptLog.appendAttemptLogEvent({ - eventType: "attempt_outcome_summary", - attemptId, - actionClass: finalResult.outcome, - mode, - reason: `attempt finished with outcome: ${result.outcome}`, - provider: resolveFirstConfiguredCodingAgentDriverName(env), - costUsd: finalResult.totalCostUsd, - tokensUsed: finalResult.totalTokensUsed, - }); - } catch (error) { - // A logging failure must never fail an otherwise-successful attempt (kept), but was previously silent -- - // per docs/observability.md this row feeds the Grafana per-provider cost/usage dashboard, so a failure - // here silently drops the attempt from operator-facing metrics with nobody told (#6011). - captureMinerError(error, { kind: "attempt_outcome_summary_append_failed", attemptId, repoFullName: parsed.repoFullName }); - } - - if (parsed.json) { - console.log(JSON.stringify(finalResult, null, 2)); - } else { - console.log(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} finished with outcome: ${result.outcome}.`); - } - options.onResult?.(finalResult); - - switch (result.outcome) { - case "submitted": - return 0; - case "abandon": - return 7; - case "stale": - return 8; - case "blocked": - return 9; - case "governed": - return 10; - default: - return 2; } - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } finally { - // worktreeResult.attemptOk is set to the REAL runMinerAttempt outcome (submitted = true) once that call - // happens, and explicitly to `false` when that call THROWS -- a crashed attempt is precisely what needs a - // retained worktree to postmortem, so it must never fall through to the `?? true` default below. Every - // earlier blocked path (rejection/worktree-prep-failure/infeasible) never sets it, since nothing ran in - // the worktree to postmortem -- those are the cases that default to `true` (nothing to retain), matching - // cleanupAttemptWorktree's own retention policy (a failed REAL attempt is what gets retained). - if (worktreeResult?.ok) { - const cleanupWorktree = options.cleanupAttemptWorktree ?? cleanupAttemptWorktree; - await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, worktreeResult.attemptOk ?? true); + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); } - // Every terminal outcome past the claim point (submitted/abandon/stale/blocked/governed, or an - // unexpected throw) releases the soft-claim -- a claim that outlives its own attempt process would - // wrongly tell a sibling miner this issue is still in flight. - if (claimedIssue && claimLedger) claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber); - // Paired hosted release (#7168): same call-site opt-in gate as the claim submission above. Only fires when - // the initial claim submission actually ran (claimRecord is only set once claimedIssue is), so a run that - // never reached the claim point (e.g. blocked_max_concurrent_claims) has nothing to release remotely. - if (claimedIssue && claimRecord && isDiscoveryPlaneEnabled(env)) { - const submitClaim = options.submitSoftClaim ?? submitSoftClaim; - await submitClaim({ ...claimRecord, status: "released" }, { env }); + finally { + // worktreeResult.attemptOk is set to the REAL runMinerAttempt outcome (submitted = true) once that call + // happens, and explicitly to `false` when that call THROWS -- a crashed attempt is precisely what needs a + // retained worktree to postmortem, so it must never fall through to the `?? true` default below. Every + // earlier blocked path (rejection/worktree-prep-failure/infeasible) never sets it, since nothing ran in + // the worktree to postmortem -- those are the cases that default to `true` (nothing to retain), matching + // cleanupAttemptWorktree's own retention policy (a failed REAL attempt is what gets retained). + if (worktreeResult?.ok) { + const cleanupWorktree = options.cleanupAttemptWorktree ?? cleanupAttemptWorktree; + await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, worktreeResult.attemptOk ?? true); + } + // Every terminal outcome past the claim point (submitted/abandon/stale/blocked/governed, or an + // unexpected throw) releases the soft-claim -- a claim that outlives its own attempt process would + // wrongly tell a sibling miner this issue is still in flight. + if (claimedIssue && claimLedger) + claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber); + // Paired hosted release (#7168): same call-site opt-in gate as the claim submission above. Only fires when + // the initial claim submission actually ran (claimRecord is only set once claimedIssue is), so a run that + // never reached the claim point (e.g. blocked_max_concurrent_claims) has nothing to release remotely. + if (claimedIssue && claimRecord && isDiscoveryPlaneEnabled(env)) { + const submitClaim = options.submitSoftClaim ?? submitSoftClaim; + await submitClaim({ ...claimRecord, status: "released" }, { env }); + } + if (allocation && allocator) + allocator.release(attemptId); + allocator?.close(); + claimLedger?.close(); + eventLedger?.close(); + attemptLog?.close(); + governorLedger?.close(); } - if (allocation && allocator) allocator.release(attemptId); - allocator?.close(); - claimLedger?.close(); - eventLedger?.close(); - attemptLog?.close(); - governorLedger?.close(); - } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXR0ZW1wdC1jbGkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJhdHRlbXB0LWNsaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxvSEFBb0g7QUFDcEgscUdBQXFHO0FBQ3JHLDBHQUEwRztBQUMxRyw2R0FBNkc7QUFDN0csOEdBQThHO0FBQzlHLDJHQUEyRztBQUMzRyxxR0FBcUc7QUFDckcsMkZBQTJGO0FBQzNGLHFGQUFxRjtBQUNyRixFQUFFO0FBQ0YsMEdBQTBHO0FBQzFHLGtIQUFrSDtBQUNsSCxpSEFBaUg7QUFDakgsMkdBQTJHO0FBRTNHLE9BQU8sRUFBRSwyQkFBMkIsRUFBRSxnQ0FBZ0MsRUFBRSwyQ0FBMkMsRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBRTlJLE9BQU8sRUFBRSxZQUFZLEVBQUUsZ0JBQWdCLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxnQkFBZ0IsQ0FBQztBQUNsRixPQUFPLEVBQUUsb0NBQW9DLEVBQUUsTUFBTSxnQ0FBZ0MsQ0FBQztBQUN0RixPQUFPLEVBQUUsaUJBQWlCLEVBQUUsTUFBTSxzQkFBc0IsQ0FBQztBQUN6RCxPQUFPLEVBQUUsc0JBQXNCLEVBQUUsTUFBTSwwQkFBMEIsQ0FBQztBQUNsRSxPQUFPLEVBQUUsaUJBQWlCLEVBQUUsTUFBTSwwQkFBMEIsQ0FBQztBQUM3RCxPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sbUJBQW1CLENBQUM7QUFFcEQsT0FBTyxFQUFFLG9CQUFvQixFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDNUQsT0FBTyxFQUFFLG9CQUFvQixFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFFcEUsT0FBTyxFQUFFLDJCQUEyQixFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDbkUsT0FBTyxFQUFFLGVBQWUsRUFBRSxNQUFNLG1CQUFtQixDQUFDO0FBRXBELE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUVsRCxPQUFPLEVBQUUsa0JBQWtCLEVBQUUsTUFBTSxzQkFB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/attempt-cli.ts b/packages/loopover-miner/lib/attempt-cli.ts new file mode 100644 index 0000000000..ac2f5d9848 --- /dev/null +++ b/packages/loopover-miner/lib/attempt-cli.ts @@ -0,0 +1,866 @@ +// CLI dispatch for the real attempt pipeline (#5132, Wave 3.5 -- the final assembly). Wires bin/loopover-miner.js's +// `attempt` subcommand to real infrastructure end to end: worktree allocation + real git preparation +// (worktree-allocator.js + attempt-worktree.js), the four ledgers (claim/event/attempt-log/governor), the +// real coding-agent driver (#5131) and slop assessor (#5133), a live SelfReviewContext fetch (#5145), a real +// coding-task spec (#5239), the operator's AmsPolicySpec execution policy (#5249), rejectionSignaled (#5241), +// a real runMinerAttempt call -- the first point in this epic where a real coding agent actually runs, not +// just checks-and-reports-blocked -- and, only on a real "submitted" outcome, a real post-submission +// claim-conflict resolution (#4848, claim-conflict-resolver.js) for the narrow race window +// checkSubmissionFreshness cannot see (two miners submitting almost simultaneously). +// +// KNOWN, DOCUMENTED GAPS (not fabricated -- see attempt-input-builder.js's own header for the full list): +// governor.selfPlagiarismCandidate/selfPlagiarismRecentSubmissions are omitted (chokepoint.ts's own design treats +// that as "skip that stage entirely"). governor.convergenceInput is now a real per-issue portfolio-queue.js read +// (#5654) and governor.reputationHistory a real per-repo governor-state.js read (#5675), not placeholders. + +import { fingerprintFromChangedFiles, resolveCodingAgentModeFromConfig, resolveFirstConfiguredCodingAgentDriverName } from "@loopover/engine"; +import type { CodingAgentExecutionMode, FeasibilityVerdict, LocalWriteActionSpec } from "@loopover/engine"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { constructProductionCodingAgentDriver } from "./coding-agent-construction.js"; +import { runSlopAssessment } from "./slop-assessment.js"; +import { fetchLiveIssueSnapshot } from "./live-issue-snapshot.js"; +import { executeLocalWrite } from "./execute-local-write.js"; +import { openClaimLedger } from "./claim-ledger.js"; +import type { ClaimEntry, ClaimLedger } from "./claim-ledger.js"; +import { resolveMinerGoalSpec } from "./miner-goal-spec.js"; +import { resolveClaimConflict } from "./claim-conflict-resolver.js"; +import type { ClaimConflictResult, resolveClaimConflict as ResolveClaimConflictFn } from "./claim-conflict-resolver.js"; +import { parsePrNumberFromExecResult } from "./pr-number-parse.js"; +import { initEventLedger } from "./event-ledger.js"; +import type { EventLedger } from "./event-ledger.js"; +import { initAttemptLog } from "./attempt-log.js"; +import type { AttemptLog } from "./attempt-log.js"; +import { initGovernorLedger } from "./governor-ledger.js"; +import type { GovernorLedger } from "./governor-ledger.js"; +import { openWorktreeAllocator } from "./worktree-allocator.js"; +import type { WorktreeAllocation, WorktreeAllocator } from "./worktree-allocator.js"; +import { isValidRepoSegment } from "./repo-clone.js"; +import { REJECTION_REASON_AI_USAGE_POLICY_BAN, REJECTION_REASON_OWN_SUBMISSION_REJECTED, resolveRejectionSignaled } from "./rejection-signal.js"; +import type { resolveRejectionSignaled as ResolveRejectionSignaledFn } from "./rejection-signal.js"; +import { cleanupAttemptWorktree, prepareAttemptWorktree } from "./attempt-worktree.js"; +import type { + cleanupAttemptWorktree as CleanupAttemptWorktreeFn, + prepareAttemptWorktree as PrepareAttemptWorktreeFn, + PrepareAttemptWorktreeResult, +} from "./attempt-worktree.js"; +import { fetchSelfReviewContext } from "./self-review-context.js"; +import type { SelfReviewContextFetch, fetchSelfReviewContext as FetchSelfReviewContextFn } from "./self-review-context.js"; +import { buildCodingTaskSpec } from "./coding-task-spec.js"; +import type { buildCodingTaskSpec as BuildCodingTaskSpecFn } from "./coding-task-spec.js"; +import { resolveAmsPolicy } from "./ams-policy.js"; +import type { resolveAmsPolicy as ResolveAmsPolicyFn } from "./ams-policy.js"; +import { checkMinerKillSwitch, recordMinerKillSwitchTransition } from "./governor-kill-switch.js"; +import type { checkMinerKillSwitch as CheckMinerKillSwitchFn } from "./governor-kill-switch.js"; +import { captureMinerError } from "./sentry.js"; +import { buildAttemptGovernorContext, buildAttemptLoopInput } from "./attempt-input-builder.js"; +import { getAttemptHistory } from "./portfolio-queue.js"; +import type { getAttemptHistory as GetAttemptHistoryFn } from "./portfolio-queue.js"; +import { loadReputationHistory, recordOwnSubmission } from "./governor-state.js"; +import type { recordOwnSubmission as RecordOwnSubmissionFn } from "./governor-state.js"; +import { runMinerAttempt } from "./attempt-runner.js"; +import type { AttemptDeps, AttemptResult as RunMinerAttemptResult, runMinerAttempt as RunMinerAttemptFn } from "./attempt-runner.js"; +import { resolveGitHubToken } from "./github-token-resolution.js"; +import { isDiscoveryPlaneEnabled, submitSoftClaim } from "./discovery-index-client.js"; +import type { submitSoftClaim as SubmitSoftClaimFn } from "./discovery-index-client.js"; +import type { resolveMinerGoalSpec as ResolveMinerGoalSpecFn } from "./miner-goal-spec.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/loopover-miner.js's own + * `process.exit(exitCode)` usage never breaks) -- the loop orchestrator's real caller for this data. */ +export type AttemptCliResult = + | (CommonAttemptResultFields & { outcome: "dry_run" }) + | (CommonAttemptResultFields & { outcome: "blocked_rejection_signaled"; reason: string }) + | (CommonAttemptResultFields & { outcome: "blocked_worktree_preparation_failed"; reason: string }) + | (CommonAttemptResultFields & { + outcome: "blocked_infeasible"; + reason: string; + verdict: FeasibilityVerdict; + avoidReasons: string[]; + raiseReasons: string[]; + }) + | (CommonAttemptResultFields & { + outcome: `attempt_${RunMinerAttemptResult["outcome"]}`; + submissionMode: "observe" | "enforce"; + totalTurnsUsed: number; + totalCostUsd: number; + totalTokensUsed: number; + iterationsUsed: number; + reason?: string; + decision?: unknown; + spec?: LocalWriteActionSpec; + execResult?: unknown; + claimConflict?: ClaimConflictResult; + }); + +export type ParsedAttemptArgs = + | { error: string } + | { + repoFullName: string; + issueNumber: number; + minerLogin: string; + base: string; + live: boolean; + dryRun: boolean; + json: boolean; + }; + +export type RunAttemptOptions = { + env?: Record; + nowMs?: number; + attemptId?: string; + resolveCodingAgentModeFromConfig?: (config: { env?: Record }) => CodingAgentExecutionMode; + openWorktreeAllocator?: () => WorktreeAllocator; + openClaimLedger?: () => ClaimLedger; + initEventLedger?: () => EventLedger; + initAttemptLog?: () => AttemptLog; + initGovernorLedger?: () => GovernorLedger; + buildAttemptDeps?: typeof buildAttemptDeps; + resolveRejectionSignaled?: typeof ResolveRejectionSignaledFn; + fetchImpl?: SelfReviewContextFetch; + prepareAttemptWorktree?: typeof PrepareAttemptWorktreeFn; + cleanupAttemptWorktree?: typeof CleanupAttemptWorktreeFn; + fetchSelfReviewContext?: typeof FetchSelfReviewContextFn; + buildCodingTaskSpec?: typeof BuildCodingTaskSpecFn; + resolveAmsPolicy?: typeof ResolveAmsPolicyFn; + checkMinerKillSwitch?: typeof CheckMinerKillSwitchFn; + resolveMinerGoalSpec?: typeof ResolveMinerGoalSpecFn; + runMinerAttempt?: typeof RunMinerAttemptFn; + resolveClaimConflict?: typeof ResolveClaimConflictFn; + recordOwnSubmission?: typeof RecordOwnSubmissionFn; + getAttemptHistory?: typeof GetAttemptHistoryFn; + /** Hosted soft-claim coordination at work-start/work-end, when the plane is enabled (#7168). Defaults to + * discovery-index-client.js's own submitSoftClaim. */ + submitSoftClaim?: typeof SubmitSoftClaimFn; + /** 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; +}; + +const ATTEMPT_USAGE = + "Usage: loopover-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]"; + +function parseRepoTarget(value: unknown): string | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) return null; + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return null; + return `${owner}/${repo}`; +} + +export function parseAttemptArgs(args: string[]): ParsedAttemptArgs { + const options: { + json: boolean; + minerLogin: string | null; + base: string; + live: boolean; + dryRun: boolean; + } = { json: false, minerLogin: null, base: "main", live: false, dryRun: false }; + const positional: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + // Opt-in only: resolveCodingAgentModeFromConfig's own default (no agentDryRun override) is "live", not + // "dry_run" -- so #5132's "dry-run is default" acceptance criteria (#2342) has to be enforced HERE, by + // requiring an explicit --live flag before this command will ever request live mode. + if (token === "--live") { + options.live = true; + continue; + } + // #4847: distinct from --live's absence above -- --live only ever gated the coding-agent DRIVER's mode, + // but a non---live run still opened every store and made real worktree/claim/ledger writes. --dry-run + // short-circuits BEFORE any of that infrastructure is even opened, guaranteeing zero writes rather than + // merely skipping the driver. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--miner-login") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE }; + options.minerLogin = value; + index += 1; + continue; + } + if (token === "--base") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE }; + options.base = value; + index += 1; + continue; + } + if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; + positional.push(token); + } + + if (positional.length !== 2) return { error: ATTEMPT_USAGE }; + const repoFullName = parseRepoTarget(positional[0]); + if (!repoFullName) return { error: `Repository must be in owner/repo form: ${positional[0]}` }; + const issueNumber = Number(positional[1]); + if (!Number.isInteger(issueNumber) || issueNumber < 1) { + return { error: `Issue number must be a positive integer: ${positional[1]}` }; + } + if (!options.minerLogin) return { error: `--miner-login is required. ${ATTEMPT_USAGE}` }; + + return { + repoFullName, + issueNumber, + minerLogin: options.minerLogin, + base: options.base, + live: options.live, + dryRun: options.dryRun, + json: options.json, + }; +} + +/** + * Assemble a real AttemptDeps object: every field wired to a genuine implementation (the #5131 driver, the + * #5133 slop assessor, the four real ledgers passed in, and the fetchLiveIssueSnapshot/executeLocalWrite + * built alongside this file). Throws if the coding-agent driver is unconfigured (fails closed, matching + * constructProductionCodingAgentDriver's own contract) -- callers should report that clearly rather than + * silently falling back to a driver that could never run. + */ +export function buildAttemptDeps( + env: Record, + ledgers: { claimLedger: ClaimLedger; eventLedger: EventLedger; attemptLog: AttemptLog; governorLedger: GovernorLedger; nowMs: number }, +): AttemptDeps { + // AttemptDeps' claimLedger/callback parameter types are looser structural stubs than the real ledgers + // (pre-existing .d.ts drift on attempt-runner); cast preserves the same runtime wiring the .js had. + return { + driver: constructProductionCodingAgentDriver(env), + runSlopAssessment: (input) => runSlopAssessment(input as Parameters[0]), + appendAttemptLogEvent: (event) => { + ledgers.attemptLog.appendAttemptLogEvent(event as Parameters[0]); + }, + claimLedger: ledgers.claimLedger as AttemptDeps["claimLedger"], + // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the + // authenticated `loopover-mcp login` session -- cached in memory, so repeat calls within this process + // don't repeatedly hit the session-fetch endpoint after the first successful resolution. + fetchLiveIssueSnapshot: async (repoFullName: string, issueNumber: number) => { + // resolveGitHubToken returns string | null; exactOptionalPropertyTypes forbids explicit undefined. + const githubToken = await resolveGitHubToken(env as NodeJS.ProcessEnv); + return fetchLiveIssueSnapshot( + repoFullName, + issueNumber, + githubToken !== null ? { githubToken } : {}, + ); + }, + eventLedger: ledgers.eventLedger, + governorLedgerAppend: (event) => + ledgers.governorLedger.appendGovernorEvent(event as Parameters[0]), + nowMs: ledgers.nowMs, + executeLocalWrite: (spec) => executeLocalWrite(spec as Parameters[0]), + }; +} + +/** + * Run the `attempt` CLI subcommand end to end: resolveRejectionSignaled (before consuming a worktree slot) -> + * acquire a concurrency slot -> assemble real AttemptDeps -> prepare a REAL git worktree -> fetch a real + * SelfReviewContext -> build a real coding-task spec (blocks on an infeasible verdict) -> resolve the real + * AmsPolicySpec execution policy -> assemble the real IterateLoopInput + Governor context -> call + * runMinerAttempt for real. The worktree is cleaned up (or retained, per the real outcome) in `finally`. + * See this file's header for the documented gaps (real convergence history). + */ +export async function runAttempt(args: string[], options: RunAttemptOptions = {}): Promise { + const parsed = parseAttemptArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + const env = options.env ?? process.env; + const nowMs = options.nowMs ?? Date.now(); + const resolveMode = options.resolveCodingAgentModeFromConfig ?? resolveCodingAgentModeFromConfig; + // resolveCodingAgentModeFromConfig accepts agentDryRun at runtime; RunAttemptOptions injectable omits it (.d.ts drift). + const mode = resolveMode({ env, agentDryRun: !parsed.live } as { env?: Record }); + + if (mode === "paused") { + return reportCliFailure( + parsed.json, + `Coding-agent execution is globally paused (MINER_CODING_AGENT_PAUSED). Not running attempt for ${parsed.repoFullName}#${parsed.issueNumber}.`, + 3, + ); + } + + const attemptId = options.attemptId ?? `${parsed.repoFullName.replace("/", "_")}-${parsed.issueNumber}-${nowMs}`; + + // #4847: reports what a real run would do and returns BEFORE any store (allocator/claim/event/attempt-log/ + // governor ledger) is even opened, so this is a provable zero-write path -- not just "opened but didn't + // write to" the local stores, and nowhere near the real worktree clone, claim, or coding-agent driver. + if (parsed.dryRun) { + const dryRunResult = { + outcome: "dry_run", + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log( + `DRY RUN: would attempt ${parsed.repoFullName}#${parsed.issueNumber} for ${parsed.minerLogin} (mode: ${mode}, base: ${parsed.base}). No worktree, claim, or ledger writes were made.`, + ); + } + options.onResult?.(dryRunResult as AttemptCliResult); + return 0; + } + + let allocator: WorktreeAllocator | null = null; + let claimLedger: ClaimLedger | null = null; + let eventLedger: EventLedger | null = null; + let attemptLog: AttemptLog | null = null; + let governorLedger: GovernorLedger | null = null; + let allocation: WorktreeAllocation | null = null; + let worktreeResult: (PrepareAttemptWorktreeResult & { attemptOk?: boolean }) | null = null; + let claimedIssue = false; + let claimRecord: ClaimEntry | null = null; + + try { + allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)(); + claimLedger = (options.openClaimLedger ?? openClaimLedger)(); + eventLedger = (options.initEventLedger ?? initEventLedger)(); + attemptLog = (options.initAttemptLog ?? initAttemptLog)(); + governorLedger = (options.initGovernorLedger ?? initGovernorLedger)(); + + // Checked before acquiring a worktree slot: a rejection-signaled repo should never consume one. + // resolveRejectionSignaled resolves both documented triggers (#5132 policy ban, #5655 own-rejection + // history) and returns a trigger-specific reason string for accurate audit-trail labeling. + const resolveRejection = options.resolveRejectionSignaled ?? resolveRejectionSignaled; + // Pass fetchImpl through even when unset (same shape the .js always produced); cast for + // exactOptionalPropertyTypes vs RejectionSignaledOptions (pre-existing optional-prop drift). + const rejectionSignal = await resolveRejection(parsed.repoFullName, { + fetchImpl: options.fetchImpl, + } as Parameters[1]); + if (rejectionSignal) { + const reason = + rejectionSignal === true ? REJECTION_REASON_AI_USAGE_POLICY_BAN : rejectionSignal; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const rejectedResult = { + outcome: "blocked_rejection_signaled", + reason, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(rejectedResult, null, 2)); + } else { + console.error( + reason === REJECTION_REASON_OWN_SUBMISSION_REJECTED + ? `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this miner was previously rejected on this repo.` + : `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's AI-usage policy bans automated/AI-authored contributions.`, + ); + } + options.onResult?.(rejectedResult as AttemptCliResult); + return 5; + } + + allocation = allocator.acquire(attemptId, parsed.repoFullName); + + let deps; + try { + const buildDeps = options.buildAttemptDeps ?? buildAttemptDeps; + deps = buildDeps(env, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs }); + } catch (error) { + const reason = describeCliError(error); + return reportCliFailure( + parsed.json, + `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: ${reason}`, + 3, + ); + } + + // Real worktree preparation (repo-clone.js + attempt-worktree.js, #5237): the allocator above only + // reserves a concurrency SLOT (worktree-allocator.js's own `slot-N` placeholder dirs never receive real + // git content) -- this is the step that actually clones/fetches the target repo and creates a real + // `git worktree` for this attempt. Its own path, NOT the allocator's slot path, is the real + // workingDirectory a future runMinerAttempt call must use. + const prepareWorktree = options.prepareAttemptWorktree ?? prepareAttemptWorktree; + worktreeResult = await prepareWorktree(parsed.repoFullName, attemptId, { baseBranch: parsed.base, env }); + if (!worktreeResult.ok) { + const reason = worktreeResult.error; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const worktreeFailureResult = { + outcome: "blocked_worktree_preparation_failed", + reason, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(worktreeFailureResult, null, 2)); + } else { + console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: real worktree preparation failed: ${reason}`); + } + options.onResult?.(worktreeFailureResult as AttemptCliResult); + return 6; + } + + // Real SelfReviewContext (#5145): issue/PR/manifest data at live-gate fidelity for the target repo. + const fetchReviewContext = options.fetchSelfReviewContext ?? fetchSelfReviewContext; + const reviewGithubToken = await resolveGitHubToken(env as NodeJS.ProcessEnv); + const reviewContext = await fetchReviewContext(parsed.repoFullName, { + ...(reviewGithubToken !== null ? { githubToken: reviewGithubToken } : {}), + contributorLogin: parsed.minerLogin, + linkedIssues: [parsed.issueNumber], + }); + + // The target issue's own real record, when present in the fetched context. When absent (e.g. already + // closed, or genuinely not found), buildCodingTaskSpec's own feasibility check reports target_not_found + // and this placeholder's empty title/body are never surfaced anywhere -- not fabricated content, just an + // inert shape for a verdict that immediately blocks. + const targetIssue = reviewContext.issues.find((candidate) => candidate.number === parsed.issueNumber) ?? { + number: parsed.issueNumber, + title: "", + body: null, + labels: [], + }; + + const buildTaskSpec = options.buildCodingTaskSpec ?? buildCodingTaskSpec; + // CodingTaskClaimLedger's listClaims filter types status as plain string (pre-existing .d.ts drift). + const codingTaskSpec = buildTaskSpec({ + repoFullName: parsed.repoFullName, + issue: targetIssue, + context: { issues: reviewContext.issues, pullRequests: reviewContext.pullRequests }, + claimLedger: claimLedger as Parameters[0]["claimLedger"], + workingDirectory: worktreeResult.worktreePath, + }); + + if (!codingTaskSpec.ready) { + const reason = `infeasible_${codingTaskSpec.verdict}`; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, feasibility: codingTaskSpec.feasibility }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const infeasibleResult = { + outcome: "blocked_infeasible", + reason, + verdict: codingTaskSpec.verdict, + avoidReasons: codingTaskSpec.feasibility.avoidReasons, + raiseReasons: codingTaskSpec.feasibility.raiseReasons, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(infeasibleResult, null, 2)); + } else { + console.error( + `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: feasibility verdict "${codingTaskSpec.verdict}" (${[...codingTaskSpec.feasibility.avoidReasons, ...codingTaskSpec.feasibility.raiseReasons].join(", ")}).`, + ); + } + options.onResult?.(infeasibleResult as AttemptCliResult); + return 4; + } + + const amsPolicy = await (options.resolveAmsPolicy ?? resolveAmsPolicy)(parsed.repoFullName, { env }); + + // Real per-repo pause (#5392): read straight from the already-cloned worktree's own .loopover-miner.yml + // (resolveMinerGoalSpec never throws -- a missing/malformed file degrades to killSwitch.paused: false, so + // this can't fail this attempt on its own). Threaded into BOTH checkMinerKillSwitch (killSwitchScope, used + // by the freshness/submission gate) and the governor context (killSwitchRepoPaused, used by the Governor + // chokepoint) -- the same two places the GLOBAL kill switch already reaches. + const resolveGoalSpec = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; + const minerGoalSpec = resolveGoalSpec(worktreeResult.repoPath); + const repoPaused = minerGoalSpec.spec.killSwitch.paused; + + const checkKillSwitch = options.checkMinerKillSwitch ?? checkMinerKillSwitch; + // recordMinerKillSwitchTransition is used at runtime but omitted from RunAttemptOptions (.d.ts drift). + const recordKillTransition = + (options as RunAttemptOptions & { recordMinerKillSwitchTransition?: typeof recordMinerKillSwitchTransition }) + .recordMinerKillSwitchTransition ?? recordMinerKillSwitchTransition; + let killSwitchScope = checkKillSwitch({ env, repoPaused }).scope; + let previousKillSwitchScope = killSwitchScope; + + // Captured after the ok-check above so the mid-attempt kill-switch probe can't see a null worktreeResult. + const preparedWorktree = worktreeResult; + const resolveLiveKillSwitch = () => { + // Re-read the YAML flag each probe so an on-disk unpause/pause is reflected mid-attempt (#5670). + const liveRepoPaused = resolveGoalSpec(preparedWorktree.repoPath).spec.killSwitch.paused; + const live = checkKillSwitch({ env, repoPaused: liveRepoPaused }); + if (live.scope !== previousKillSwitchScope) { + try { + recordKillTransition({ + repoFullName: parsed.repoFullName, + actionClass: "attempt", + previousScope: previousKillSwitchScope, + scope: live.scope, + }); + } catch (error) { + // Ledger append must never crash an aborting attempt (kept), but was previously silent -- a + // kill-switch flip mid-attempt (a compliance-relevant event) could vanish with no record (#6011). + captureMinerError(error, { kind: "kill_switch_transition_record_failed", repoFullName: parsed.repoFullName, scope: live.scope }); + } + previousKillSwitchScope = live.scope; + } + killSwitchScope = live.scope; + return live; + }; + + const shouldAbort = () => { + const live = resolveLiveKillSwitch(); + if (!live.active) return false; + return { + abort: true, + reason: `Kill-switch (${live.scope}) engaged mid-attempt; abandoning without starting another driver iteration.`, + }; + }; + + const loopInput = buildAttemptLoopInput({ + codingTaskSpec, + reviewContext, + worktreePath: worktreeResult.worktreePath, + attemptId, + mode, + repoFullName: parsed.repoFullName, + minerLogin: parsed.minerLogin, + rejectionSignaled: false, + amsPolicySpec: amsPolicy.spec, + branchRef: worktreeResult.branchName, + }); + + // Real per-issue attempt history (#5654): portfolio-queue.js's own claim/reclaim/requeue/done counters, + // keyed the same way opportunity-fanout.js enqueues issue-shaped candidates (`issue:`). No + // apiBaseUrl: this file has no multi-forge host context of its own today, so this reads (and every + // pre-#5563 single-forge caller already reads) the github.com default. + const readAttemptHistory = options.getAttemptHistory ?? getAttemptHistory; + const convergenceInput = readAttemptHistory(parsed.repoFullName, `issue:${parsed.issueNumber}`); + // Real per-repo reputation history (#5675): the miner's own decided/unfavorable outcome streak for this repo, + // read from governor-state.js so the chokepoint's self-reputation throttle sees real data instead of nothing. + // loadReputationHistory is used at runtime but omitted from RunAttemptOptions (.d.ts drift). + const readReputationHistory = + (options as RunAttemptOptions & { loadReputationHistory?: typeof loadReputationHistory }).loadReputationHistory ?? + loadReputationHistory; + const reputationHistory = readReputationHistory(parsed.repoFullName); + const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused, convergenceInput, reputationHistory); + + // Real maxConcurrentClaims enforcement (#6758): the repo's .loopover-miner.yml cap is honored ATOMICALLY by + // the ledger's count-and-claim, not by a listActiveClaims pre-check here. The old check-then-act split -- read + // the count in this file, then record the claim in a separate claimLedger call -- let two sibling miner + // processes racing the same repo both pass a stale sub-cap count and both claim, exceeding the cap. + // claimIssueWithinCap fuses the count and the insert into one transaction; the loser gets `claimed: false` + // and is reported below rather than silently dropped. This is also the real soft-claim (#5393): once it + // returns claimed, a sibling process sees it via claimLedger.listActiveClaims while this attempt is in + // flight, it is released in `finally` on every terminal outcome (mirroring the worktree allocation slot's + // acquire-then-always-release), and its claimedAt feeds the post-submission conflict check further down (#4848). + const claimResult = claimLedger.claimIssueWithinCap( + parsed.repoFullName, + parsed.issueNumber, + `attempt:${attemptId}`, + undefined, + minerGoalSpec.spec.maxConcurrentClaims, + ); + if (!claimResult.claimed) { + const reason = "max_concurrent_claims_exceeded"; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, + activeClaimCount: claimResult.activeClaimCount, + }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const blockedResult = { + outcome: "blocked_max_concurrent_claims", + reason, + maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, + activeClaimCount: claimResult.activeClaimCount, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(blockedResult, null, 2)); + } else { + console.error( + `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's maxConcurrentClaims cap (${minerGoalSpec.spec.maxConcurrentClaims}) is already met (${claimResult.activeClaimCount} active claim(s)).`, + ); + } + // blocked_max_concurrent_claims is a real runtime outcome omitted from AttemptCliResult (.d.ts drift). + options.onResult?.(blockedResult as AttemptCliResult); + return 11; + } + + claimRecord = claimResult.claim; + claimedIssue = true; + // Hosted soft-claim coordination (#7168), opt-in via LOOPOVER_MINER_DISCOVERY_PLANE -- gated HERE at the + // call site (not left to submitSoftClaim's own internal check alone) so a disabled plane costs zero calls, + // matching discover-cli.js's supplementWithDiscoveryIndex gating; a caller-injected options.submitSoftClaim + // (tests, or a future programmatic caller) can't accidentally bypass the opt-in this way either. Awaited + // (not fire-and-forget) so a sibling instance racing the same issue is genuinely less likely to start + // duplicate work in the window before this attempt's claim reaches the shared index -- the whole point of + // coordinating BEFORE work begins, not after. + if (isDiscoveryPlaneEnabled(env)) { + const submitClaim = options.submitSoftClaim ?? submitSoftClaim; + await submitClaim(claimRecord as Parameters[0], { env }); + } + + const runAttemptPipeline = options.runMinerAttempt ?? runMinerAttempt; + let result; + try { + result = await runAttemptPipeline( + { + loopInput, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + killSwitchScope, + slopThreshold: amsPolicy.spec.slopThreshold, + submissionMode: amsPolicy.spec.submissionMode, + governor, + }, + { + ...deps, + shouldAbort, + resolveKillSwitchScope: () => resolveLiveKillSwitch().scope, + }, + ); + } catch (error) { + // A real attempt that CRASHED is exactly the case that most needs its worktree kept for post-mortem + // inspection, so record the failure explicitly before unwinding. Without this, `attemptOk` stayed + // `undefined` and the finally block's `?? true` default (meant for the earlier blocked paths that never + // ran anything in the worktree) deleted it -- inverting shouldRetainWorktree's documented policy. + worktreeResult.attemptOk = false; + throw error; + } + + worktreeResult.attemptOk = result.outcome === "submitted"; + + // Real claim-conflict resolution (#4848): only meaningful once a real PR exists, so this only ever runs + // on a real "submitted" outcome. checkSubmissionFreshness (inside runMinerAttempt) already caught the + // common pre-submission case; this closes the narrower TOCTOU window where two miners raced past that + // check almost simultaneously -- see claim-conflict-resolver.js's own header for why the adjudicator + // can only run POST-submission (it needs a real PR number on both sides of the election). + let claimConflict: ClaimConflictResult | undefined; + if (result.outcome === "submitted") { + const selfPrNumber = parsePrNumberFromExecResult( + result.execResult as Parameters[0], + parsed.repoFullName, + ); + if (selfPrNumber !== null) { + const resolveConflict = options.resolveClaimConflict ?? resolveClaimConflict; + claimConflict = await resolveConflict( + { + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + selfPrNumber, + selfClaimedAt: claimRecord.claimedAt, + minerLogin: parsed.minerLogin, + }, + { fetchLiveIssueSnapshot: deps.fetchLiveIssueSnapshot, executeLocalWrite: deps.executeLocalWrite }, + ); + } + + // Real own-submission history (#5655 follow-up): governor-state.js's recordOwnSubmission/ + // listRecentOwnSubmissions store (#5134) existed and was already READ by resolveOwnRejectionHistory + // (#5655), but nothing ever WROTE to it -- attempt-runner.js's own header names this exact gap + // ("real persistence primitives... but isn't auto-loaded here yet"). Left unfixed, that trigger is a + // silent no-op in every real deployment: an empty table always resolves "no prior submissions found." + // The fingerprint is the real changed-files set from the loop's own handoff packet (never fabricated) -- + // omitted (not recorded as an empty placeholder) when the packet reports no changed files at all. A + // logging failure must never fail an otherwise-successful attempt, matching the summary-event write below. + const changedFiles = result.loopResult.handoffPacket?.changedFiles?.map((file: { path: string }) => file.path) ?? []; + const fingerprint = fingerprintFromChangedFiles(changedFiles); + if (fingerprint) { + try { + const record = options.recordOwnSubmission ?? recordOwnSubmission; + record({ + repoFullName: parsed.repoFullName, + fingerprint, + submittedAt: new Date(nowMs).toISOString(), + pullRequestNumber: selfPrNumber, + issueNumber: parsed.issueNumber, + }); + } catch (error) { + // A logging failure must never fail an otherwise-successful attempt (kept), but was previously + // silent -- if this write fails AFTER a real PR has already opened, future self-plagiarism checks go + // permanently blind to this exact submission with nobody told (#6011). + captureMinerError(error, { kind: "record_own_submission_failed", repoFullName: parsed.repoFullName, pullRequestNumber: selfPrNumber }); + } + } + } + + const finalResult = { + outcome: `attempt_${result.outcome}`, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + submissionMode: amsPolicy.spec.submissionMode, + // Every runMinerAttempt outcome carries a real loopResult (#5135's loop needs its genuine turn-usage and + // cost 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. costUsd is real only for the agent-sdk provider (its own SDK result message reports + // total_cost_usd); CLI-subprocess providers (claude-cli/codex-cli) report no cost signal today, so this + // is 0 for those -- an honest absence, not a fabricated number. + totalTurnsUsed: result.loopResult.totalTurnsUsed, + totalCostUsd: result.loopResult.totalCostUsd, + // Real accumulated tokens (#5653) -- read from finalMeterTotals rather than a flat totalTokensUsed field + // (IterateLoopResult has no such flat field, unlike turns/cost). 0 when no driver reported a token signal + // on any iteration this attempt ran, never fabricated. + totalTokensUsed: result.loopResult.finalMeterTotals.tokens, + iterationsUsed: result.loopResult.iterationsUsed, + ...(result.outcome === "abandon" && result.loopResult.finalDecision?.abandonReason + ? { abandonReason: result.loopResult.finalDecision.abandonReason } + : {}), + ...("reason" in result ? { reason: result.reason } : {}), + ...("decision" in result ? { decision: result.decision } : {}), + ...("spec" in result ? { spec: result.spec } : {}), + ...("execResult" in result ? { execResult: result.execResult } : {}), + // Present only on a real "submitted" outcome whose PR number was recoverable from execResult -- omitted + // (not fabricated as "checked: false") on every other outcome, and on a submitted outcome where the new + // PR's number genuinely couldn't be parsed (an honest gap, not silently swallowed). + ...(claimConflict !== undefined ? { claimConflict } : {}), + }; + + // One summary row per completed attempt (#5185), for the Grafana per-provider usage dashboard the redacted + // AMS reporting export exposes -- distinct from the per-iteration attempt_started/attempt_tool_edit/... trail + // iterate-loop.ts already writes. No fallback for an unconfigured provider: buildAttemptDeps already fails + // closed (throws) on the same env before a worktree is even allocated, so reaching this point guarantees + // resolveFirstConfiguredCodingAgentDriverName(env) resolves a real name. costUsd/tokensUsed are both real, + // driver-reported accumulated totals (#5653) -- 0 when no iteration's driver reported a signal, never + // fabricated. A logging failure must never fail an otherwise-successful attempt -- mirrors iterate-loop.ts's + // own safeAppendAttemptLogEvent non-fatal handling. + try { + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_outcome_summary", + attemptId, + actionClass: finalResult.outcome, + mode, + reason: `attempt finished with outcome: ${result.outcome}`, + provider: resolveFirstConfiguredCodingAgentDriverName(env), + costUsd: finalResult.totalCostUsd, + tokensUsed: finalResult.totalTokensUsed, + }); + } catch (error) { + // A logging failure must never fail an otherwise-successful attempt (kept), but was previously silent -- + // per docs/observability.md this row feeds the Grafana per-provider cost/usage dashboard, so a failure + // here silently drops the attempt from operator-facing metrics with nobody told (#6011). + captureMinerError(error, { kind: "attempt_outcome_summary_append_failed", attemptId, repoFullName: parsed.repoFullName }); + } + + if (parsed.json) { + console.log(JSON.stringify(finalResult, null, 2)); + } else { + console.log(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} finished with outcome: ${result.outcome}.`); + } + options.onResult?.(finalResult as AttemptCliResult); + + switch (result.outcome) { + case "submitted": + return 0; + case "abandon": + return 7; + case "stale": + return 8; + case "blocked": + return 9; + case "governed": + return 10; + default: + return 2; + } + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } finally { + // worktreeResult.attemptOk is set to the REAL runMinerAttempt outcome (submitted = true) once that call + // happens, and explicitly to `false` when that call THROWS -- a crashed attempt is precisely what needs a + // retained worktree to postmortem, so it must never fall through to the `?? true` default below. Every + // earlier blocked path (rejection/worktree-prep-failure/infeasible) never sets it, since nothing ran in + // the worktree to postmortem -- those are the cases that default to `true` (nothing to retain), matching + // cleanupAttemptWorktree's own retention policy (a failed REAL attempt is what gets retained). + if (worktreeResult?.ok) { + const cleanupWorktree = options.cleanupAttemptWorktree ?? cleanupAttemptWorktree; + await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, worktreeResult.attemptOk ?? true); + } + // Every terminal outcome past the claim point (submitted/abandon/stale/blocked/governed, or an + // unexpected throw) releases the soft-claim -- a claim that outlives its own attempt process would + // wrongly tell a sibling miner this issue is still in flight. + if (claimedIssue && claimLedger) claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber); + // Paired hosted release (#7168): same call-site opt-in gate as the claim submission above. Only fires when + // the initial claim submission actually ran (claimRecord is only set once claimedIssue is), so a run that + // never reached the claim point (e.g. blocked_max_concurrent_claims) has nothing to release remotely. + if (claimedIssue && claimRecord && isDiscoveryPlaneEnabled(env)) { + const submitClaim = options.submitSoftClaim ?? submitSoftClaim; + await submitClaim({ ...claimRecord, status: "released" } as Parameters[0], { env }); + } + if (allocation && allocator) allocator.release(attemptId); + allocator?.close(); + claimLedger?.close(); + eventLedger?.close(); + attemptLog?.close(); + governorLedger?.close(); + } +} diff --git a/packages/loopover-miner/lib/discover-cli.d.ts b/packages/loopover-miner/lib/discover-cli.d.ts index 155ff2b6a8..e72237e64b 100644 --- a/packages/loopover-miner/lib/discover-cli.d.ts +++ b/packages/loopover-miner/lib/discover-cli.d.ts @@ -1,135 +1,108 @@ import type { ForgeConfig } from "./forge-config.js"; -import type { - CandidateIssueWarning, - FanoutOptions, - FanoutTarget, - RawCandidateIssue, -} from "./opportunity-fanout.js"; -import type { - RankCandidateIssuesOptions, - RankedCandidateIssue, - RankedCandidateSummary, -} from "./opportunity-ranker.js"; +import type { CandidateIssueWarning, FanoutOptions, FanoutTarget, RawCandidateIssue } from "./opportunity-fanout.js"; +import type { RankCandidateIssuesOptions, RankedCandidateIssue, RankedCandidateSummary } from "./opportunity-ranker.js"; import type { PolicyDocCacheStore } from "./policy-doc-cache.js"; import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js"; import type { EnqueueRankedDiscoverySummary } from "./portfolio-discovery.js"; import type { PortfolioQueueStore } from "./portfolio-queue.js"; import type { RankedCandidatesStore } from "./ranked-candidates.js"; -import type { queryDiscoveryIndex } from "./discovery-index-client.js"; - -export type ParsedDiscoverArgs = - | { - targets: FanoutTarget[]; - search: string | null; - dryRun: boolean; - json: boolean; - /** Present only when `--api-base-url` is supplied (#4784); threads the tenant's forge host to the fan-out. */ - apiBaseUrl?: string; - /** Present only when `--token-env` is supplied (#4784); names the credential env var to read. */ - tokenEnv?: string; - } - | { error: string }; - +import type { queryDiscoveryIndex as QueryDiscoveryIndexFn } from "./discovery-index-client.js"; +export type ParsedDiscoverArgs = { + targets: FanoutTarget[]; + search: string | null; + dryRun: boolean; + json: boolean; + /** Present only when `--api-base-url` is supplied (#4784); threads the tenant's forge host to the fan-out. */ + apiBaseUrl?: string; + /** Present only when `--token-env` is supplied (#4784); names the credential env var to read. */ + tokenEnv?: string; +} | { + error: string; +}; /** The subset of `CandidateIssueSummary` runDiscover actually reads. It surfaces the rate-limit telemetry (#4837), * so a fake must supply it. A real `fetchCandidateIssuesWithSummary` result satisfies this, since it is a superset. */ export type DiscoverFanOutSummary = { - issues: RawCandidateIssue[]; - warnings: CandidateIssueWarning[]; - rateLimitRemaining: number | null; - rateLimitResetAt: string | null; + issues: RawCandidateIssue[]; + warnings: CandidateIssueWarning[]; + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; }; - /** The subset of a ranked entry that `renderDiscoverSummary` reads for its top-candidates listing. */ -export type DiscoverRankedEntry = Pick< - RankedCandidateIssue, - "repoFullName" | "issueNumber" | "title" | "rankScore" ->; - +export type DiscoverRankedEntry = Pick; export type DiscoverResult = { - fanOutCount: number; - warnings: CandidateIssueWarning[]; - rateLimitRemaining: number | null; - rateLimitResetAt: string | null; - ranked: DiscoverRankedEntry[]; - /** Candidates the eligibility filter dropped, each with the repo/issue and the reason (#6798). */ - excluded?: Array<{ - repoFullName: string; - issueNumber: number; - reason: string; - }>; - /** True when ranking fell back to the built-in default goal spec because no per-tenant spec was supplied (#4784). */ - usedDefaultGoalSpec?: boolean; - enqueueSummary: EnqueueRankedDiscoverySummary; + fanOutCount: number; + warnings: CandidateIssueWarning[]; + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; + ranked: DiscoverRankedEntry[]; + /** Candidates the eligibility filter dropped, each with the repo/issue and the reason (#6798). */ + excluded?: Array<{ + repoFullName: string; + issueNumber: number; + reason: string; + }>; + /** True when ranking fell back to the built-in default goal spec because no per-tenant spec was supplied (#4784). */ + usedDefaultGoalSpec?: boolean; + enqueueSummary: EnqueueRankedDiscoverySummary; }; - export type RunDiscoverOptions = { - /** Read for the discovery-index opt-in gate (#7168) -- defaults to `process.env`. */ - env?: Record; - githubToken?: string; - apiBaseUrl?: string; - /** Per-tenant credential env var name (#4784); defaults to GITHUB_TOKEN. Overridden by a `--token-env` flag. */ - tokenEnv?: string; - /** Per-tenant forge knobs beyond the host (#4784), forwarded to the fan-out. */ - forge?: Partial; - nowMs?: number; - /** Per-tenant goal specs threaded to the ranker so lane fit uses the tenant's conventions, not the defaults (#4784). */ - goalSpecsByRepo?: RankCandidateIssuesOptions["goalSpecsByRepo"]; - goalSpecContentByRepo?: RankCandidateIssuesOptions["goalSpecContentByRepo"]; - initPortfolioQueue?: () => PortfolioQueueStore; - initPolicyDocCache?: () => PolicyDocCacheStore; - initPolicyVerdictCache?: () => PolicyVerdictCacheStore; - initRankedCandidatesStore?: () => RankedCandidatesStore; - fetchCandidateIssuesWithSummary?: ( - targets: FanoutTarget[], - githubToken: string, - options?: FanoutOptions, - ) => Promise; - searchCandidateIssuesWithSummary?: ( - searchQuery: string, - githubToken: string, - options?: FanoutOptions, - ) => Promise; - rankCandidateIssuesWithSummary?: ( - candidates: RawCandidateIssue[], - options?: RankCandidateIssuesOptions, - ) => RankedCandidateSummary; - enqueueRankedDiscovery?: ( - rankedIssues: RankedCandidateIssue[], - options: { queueStore: PortfolioQueueStore }, - ) => EnqueueRankedDiscoverySummary; - /** Supplements the local fan-out with hosted discovery-index results for the same scope, when the plane is - * enabled (#7168). Defaults to discovery-index-client.js's own queryDiscoveryIndex. */ - queryDiscoveryIndex?: typeof queryDiscoveryIndex; - /** Invoked with the real structured result at each success return point (dry-run and full-run), in addition - * to (never instead of) the plain exit-code return -- mirrors `RunAttemptOptions.onResult`. Never fires on a - * parse-error/unexpected-error `reportCliFailure` branch, matching runAttempt's own asymmetry (#6522). */ - onResult?: (result: DiscoverResult) => void; - /** Resolve each candidate repo's ContributionProfile for eligibility filtering (#6798). Defaults to - * resolveContributionProfilesForDiscover; injectable so tests avoid the network. */ - resolveContributionProfiles?: ( - repoFullNames: string[], - ctx: { githubToken?: string; apiBaseUrl?: string; nowMs?: number }, - ) => Promise>; + /** Read for the discovery-index opt-in gate (#7168) -- defaults to `process.env`. */ + env?: Record; + githubToken?: string; + apiBaseUrl?: string; + /** Per-tenant credential env var name (#4784); defaults to GITHUB_TOKEN. Overridden by a `--token-env` flag. */ + tokenEnv?: string; + /** Per-tenant forge knobs beyond the host (#4784), forwarded to the fan-out. */ + forge?: Partial; + nowMs?: number; + /** Per-tenant goal specs threaded to the ranker so lane fit uses the tenant's conventions, not the defaults (#4784). */ + goalSpecsByRepo?: RankCandidateIssuesOptions["goalSpecsByRepo"]; + goalSpecContentByRepo?: RankCandidateIssuesOptions["goalSpecContentByRepo"]; + initPortfolioQueue?: () => PortfolioQueueStore; + initPolicyDocCache?: () => PolicyDocCacheStore; + initPolicyVerdictCache?: () => PolicyVerdictCacheStore; + initRankedCandidatesStore?: () => RankedCandidatesStore; + fetchCandidateIssuesWithSummary?: (targets: FanoutTarget[], githubToken: string, options?: FanoutOptions) => Promise; + searchCandidateIssuesWithSummary?: (searchQuery: string, githubToken: string, options?: FanoutOptions) => Promise; + rankCandidateIssuesWithSummary?: (candidates: RawCandidateIssue[], options?: RankCandidateIssuesOptions) => RankedCandidateSummary; + enqueueRankedDiscovery?: (rankedIssues: RankedCandidateIssue[], options: { + queueStore: PortfolioQueueStore; + }) => EnqueueRankedDiscoverySummary; + /** Supplements the local fan-out with hosted discovery-index results for the same scope, when the plane is + * enabled (#7168). Defaults to discovery-index-client.js's own queryDiscoveryIndex. */ + queryDiscoveryIndex?: typeof QueryDiscoveryIndexFn; + /** Invoked with the real structured result at each success return point (dry-run and full-run), in addition + * to (never instead of) the plain exit-code return -- mirrors `RunAttemptOptions.onResult`. Never fires on a + * parse-error/unexpected-error `reportCliFailure` branch, matching runAttempt's own asymmetry (#6522). */ + onResult?: (result: DiscoverResult) => void; + /** Resolve each candidate repo's ContributionProfile for eligibility filtering (#6798). Defaults to + * resolveContributionProfilesForDiscover; injectable so tests avoid the network. */ + resolveContributionProfiles?: (repoFullNames: string[], ctx: { + githubToken?: string; + apiBaseUrl?: string; + nowMs?: number; + }) => Promise>; }; - -export function resolveContributionProfilesForDiscover( - repoFullNames: string[], - ctx?: { +export declare function sanitizeDiscoverDisplayText(value: unknown): string; +export declare function parseDiscoverArgs(args: string[]): ParsedDiscoverArgs; +export declare function renderDiscoverSummary(result: DiscoverResult): string; +/** + * Default per-repo ContributionProfile resolver (#6798): reads the local cache and, on a miss/stale entry, + * extracts a fresh profile and caches it. Returns a Map keyed by repoFullName. + * + * WITHOUT a github token this returns an empty map and does no network work at all — AMS can't reliably read a + * repo's label taxonomy/docs unauthenticated (rate limits), so it safe-defaults to no eligibility filtering. + * That also keeps callers that don't supply a token (the common CLI path, and every test) hermetic. + * + * @param {string[]} repoFullNames unique repos among the fanned-out candidates + * @param {{ githubToken?: string, apiBaseUrl?: string, nowMs?: number, initCache?: typeof initContributionProfileCache, extract?: typeof extractContributionProfile }} ctx + * @returns {Promise>} + */ +export declare function resolveContributionProfilesForDiscover(repoFullNames: string[], ctx?: { githubToken?: string; apiBaseUrl?: string; nowMs?: number; initCache?: unknown; extract?: unknown; - }, -): Promise>; - -export function parseDiscoverArgs(args: string[]): ParsedDiscoverArgs; - -export function sanitizeDiscoverDisplayText(value: unknown): string; - -export function renderDiscoverSummary(result: DiscoverResult): string; - -export function runDiscover( - args: string[], - options?: RunDiscoverOptions, -): Promise; +}): Promise>; +export declare function runDiscover(args: string[], options?: RunDiscoverOptions): Promise; diff --git a/packages/loopover-miner/lib/discover-cli.js b/packages/loopover-miner/lib/discover-cli.js index d24881d42f..754730f595 100644 --- a/packages/loopover-miner/lib/discover-cli.js +++ b/packages/loopover-miner/lib/discover-cli.js @@ -1,10 +1,7 @@ /** `discover` CLI command (#4247): wires the existing fanout -> rank -> enqueue pipeline together so a miner * can actually run it. Every piece already exists and is independently tested; this module only composes them. */ import { resolveForgeConfig } from "./forge-config.js"; -import { - fetchCandidateIssuesWithSummary, - searchCandidateIssuesWithSummary, -} from "./opportunity-fanout.js"; +import { fetchCandidateIssuesWithSummary, searchCandidateIssuesWithSummary, } from "./opportunity-fanout.js"; import { rankCandidateIssuesWithSummary } from "./opportunity-ranker.js"; import { initPolicyDocCacheStore } from "./policy-doc-cache.js"; import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js"; @@ -16,31 +13,25 @@ import { initContributionProfileCache } from "./contribution-profile-cache.js"; import { filterCandidatesByProfiles } from "./contribution-profile-filter.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { isDiscoveryPlaneEnabled, queryDiscoveryIndex, recordDiscoveryTelemetry } from "./discovery-index-client.js"; - -const DISCOVER_USAGE = - "Usage: loopover-miner discover [...] | --search [--dry-run] [--json] [--api-base-url ] [--token-env ]"; - +const DISCOVER_USAGE = "Usage: loopover-miner discover [...] | --search [--dry-run] [--json] [--api-base-url ] [--token-env ]"; const MAX_DISCOVER_TITLE_DISPLAY_LENGTH = 240; const OSC_SEQUENCE_PATTERN = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g; const ANSI_ESCAPE_PATTERN = /\u001b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g; const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/g; const BIDI_CONTROL_PATTERN = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; - export function sanitizeDiscoverDisplayText(value) { - return String(value ?? "") - .replace(OSC_SEQUENCE_PATTERN, "") - .replace(ANSI_ESCAPE_PATTERN, "") - .replace(CONTROL_CHARACTER_PATTERN, " ") - .replace(BIDI_CONTROL_PATTERN, "") - .replace(/\s+/g, " ") - .trim() - .slice(0, MAX_DISCOVER_TITLE_DISPLAY_LENGTH); + return String(value ?? "") + .replace(OSC_SEQUENCE_PATTERN, "") + .replace(ANSI_ESCAPE_PATTERN, "") + .replace(CONTROL_CHARACTER_PATTERN, " ") + .replace(BIDI_CONTROL_PATTERN, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_DISCOVER_TITLE_DISPLAY_LENGTH); } - function dedupeKey(repoFullName, issueNumber) { - return `${repoFullName.toLowerCase()}#${issueNumber}`; + return `${repoFullName.toLowerCase()}#${issueNumber}`; } - /** * Supplements `fanOut.issues` with hosted discovery-index results for the same scope (#7168) -- a complete * no-op (returns `fanOut` unchanged) unless the plane is enabled, so a run with the flag unset behaves exactly @@ -51,139 +42,138 @@ function dedupeKey(repoFullName, issueNumber) { * filter.js's assignee-exclusion rule treats that identically to "no assignees on this issue". */ async function supplementWithDiscoveryIndex(fanOut, queryScope, options) { - const env = options.env ?? process.env; - if (!isDiscoveryPlaneEnabled(env)) return fanOut; - const queryIndex = options.queryDiscoveryIndex ?? queryDiscoveryIndex; - const response = await queryIndex(queryScope, { env }); - recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env }); - if (response.candidates.length === 0) return fanOut; - - const seen = new Set(fanOut.issues.map((issue) => dedupeKey(issue.repoFullName, issue.issueNumber))); - const supplemented = response.candidates - .filter((candidate) => !seen.has(dedupeKey(candidate.repoFullName, candidate.issueNumber))) - .map((candidate) => ({ ...candidate, assignees: [] })); - if (supplemented.length === 0) return fanOut; - return { ...fanOut, issues: [...fanOut.issues, ...supplemented] }; + const env = options.env ?? process.env; + if (!isDiscoveryPlaneEnabled(env)) + return fanOut; + const queryIndex = options.queryDiscoveryIndex ?? queryDiscoveryIndex; + const response = await queryIndex(queryScope, { env }); + recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env }); + if (response.candidates.length === 0) + return fanOut; + const seen = new Set(fanOut.issues.map((issue) => dedupeKey(issue.repoFullName, issue.issueNumber))); + const supplemented = response.candidates + .filter((candidate) => !seen.has(dedupeKey(candidate.repoFullName, candidate.issueNumber))) + // DiscoveryIndexCandidate is a near-superset of RawCandidateIssue; assignees is absent from the hosted + // contract (#7168) so we annotate [] — cast preserves pre-existing runtime shape rather than re-mapping. + .map((candidate) => ({ ...candidate, assignees: [], labels: [...candidate.labels] })); + if (supplemented.length === 0) + return fanOut; + return { ...fanOut, issues: [...fanOut.issues, ...supplemented] }; } - 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 }; + const trimmed = typeof value === "string" ? value.trim() : ""; + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) + return null; + return { owner, repo }; } - export function parseDiscoverArgs(args) { - // `--api-base-url` and `--token-env` (#4784) thread the tenant's forge host and credential env var into the - // fan-out; they are kept off the parsed result unless supplied, so callers that pass neither see the exact - // pre-#4784 `{ targets, search, json }` shape. - const options = { json: false, dryRun: false, search: null, apiBaseUrl: null, tokenEnv: null }; - const targets = []; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; + // `--api-base-url` and `--token-env` (#4784) thread the tenant's forge host and credential env var into the + // fan-out; they are kept off the parsed result unless supplied, so callers that pass neither see the exact + // pre-#4784 `{ targets, search, json }` shape. + const options = { json: false, dryRun: false, search: null, apiBaseUrl: null, tokenEnv: null }; + const targets = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + // #4847: fetches + ranks exactly as a real run, but skips opening any local store and makes zero writes. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--search") { + const query = args[index + 1]; + if (!query || query.startsWith("-")) + return { error: DISCOVER_USAGE }; + options.search = query; + index += 1; + continue; + } + if (token === "--api-base-url") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: DISCOVER_USAGE }; + options.apiBaseUrl = value; + index += 1; + continue; + } + if (token === "--token-env") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: DISCOVER_USAGE }; + options.tokenEnv = value; + 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); } - // #4847: fetches + ranks exactly as a real run, but skips opening any local store and makes zero writes. - if (token === "--dry-run") { - options.dryRun = true; - continue; + if (options.search === null && targets.length === 0) { + return { error: DISCOVER_USAGE }; } - if (token === "--search") { - const query = args[index + 1]; - if (!query || query.startsWith("-")) return { error: DISCOVER_USAGE }; - options.search = query; - index += 1; - continue; + if (options.search !== null && targets.length > 0) { + return { error: "Pass either repository targets or --search, not both." }; } - if (token === "--api-base-url") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE }; - options.apiBaseUrl = value; - index += 1; - continue; - } - if (token === "--token-env") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE }; - options.tokenEnv = value; - 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: DISCOVER_USAGE }; - } - if (options.search !== null && targets.length > 0) { - return { error: "Pass either repository targets or --search, not both." }; - } - - return { - targets, - search: options.search, - dryRun: options.dryRun, - json: options.json, - ...(options.apiBaseUrl !== null ? { apiBaseUrl: options.apiBaseUrl } : {}), - ...(options.tokenEnv !== null ? { tokenEnv: options.tokenEnv } : {}), - }; + return { + targets, + search: options.search, + dryRun: options.dryRun, + json: options.json, + ...(options.apiBaseUrl !== null ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.tokenEnv !== null ? { tokenEnv: options.tokenEnv } : {}), + }; } - // The rate-limit line surfaces the telemetry the fanout already records (#4837) so an operator sees how close a // `discover` run is to being throttled without running a separate command. `unknown` covers the no-fetch/no-header // case where the fanout captured no remaining count. function renderRateLimitLine(result) { - const remaining = result.rateLimitRemaining === null ? "unknown" : String(result.rateLimitRemaining); - const resetSuffix = result.rateLimitResetAt === null ? "" : ` (resets ${result.rateLimitResetAt})`; - return `rate-limit remaining: ${remaining}${resetSuffix}`; + const remaining = result.rateLimitRemaining === null ? "unknown" : String(result.rateLimitRemaining); + const resetSuffix = result.rateLimitResetAt === null ? "" : ` (resets ${result.rateLimitResetAt})`; + return `rate-limit remaining: ${remaining}${resetSuffix}`; } - export function renderDiscoverSummary(result) { - const lines = [ - `fanned out: ${result.fanOutCount} candidate issue(s)`, - `ai-policy warnings: ${result.warnings.length}`, - `ranked: ${result.ranked.length}`, - `enqueued: ${result.enqueueSummary.enqueued}`, - renderRateLimitLine(result), - ]; - if (result.enqueueSummary.skippedBelowMinRank > 0) { - lines.push(`skipped (below min rank): ${result.enqueueSummary.skippedBelowMinRank}`); - } - // #6798: surface what the eligibility filter dropped and why, so a human sees AMS's inference. - const excluded = result.excluded ?? []; - if (excluded.length > 0) { - lines.push(`excluded (eligibility): ${excluded.length}`); - for (const entry of excluded.slice(0, 10)) { - lines.push(` ${entry.repoFullName}#${entry.issueNumber} ${entry.reason}`); + const lines = [ + `fanned out: ${result.fanOutCount} candidate issue(s)`, + `ai-policy warnings: ${result.warnings.length}`, + `ranked: ${result.ranked.length}`, + `enqueued: ${result.enqueueSummary.enqueued}`, + renderRateLimitLine(result), + ]; + if (result.enqueueSummary.skippedBelowMinRank > 0) { + lines.push(`skipped (below min rank): ${result.enqueueSummary.skippedBelowMinRank}`); + } + // #6798: surface what the eligibility filter dropped and why, so a human sees AMS's inference. + const excluded = result.excluded ?? []; + if (excluded.length > 0) { + lines.push(`excluded (eligibility): ${excluded.length}`); + for (const entry of excluded.slice(0, 10)) { + lines.push(` ${entry.repoFullName}#${entry.issueNumber} ${entry.reason}`); + } + } + // Make the fall-back to loopover's built-in rubric explicit instead of silent (#4784): when no per-tenant goal + // spec is supplied, lane fit reflects loopover's defaults, not the target repo's own conventions. + if (result.usedDefaultGoalSpec) { + lines.push("note: ranked with the built-in default goal spec (no per-tenant .loopover-miner.yml supplied)"); + } + if (result.ranked.length === 0) { + lines.push("", "no candidates found."); + return lines.join("\n"); + } + lines.push("", "top candidates:"); + for (const entry of result.ranked.slice(0, 10)) { + const title = sanitizeDiscoverDisplayText(entry.title); + lines.push(` ${entry.repoFullName}#${entry.issueNumber} score=${entry.rankScore.toFixed(4)} ${title}`); } - } - // Make the fall-back to loopover's built-in rubric explicit instead of silent (#4784): when no per-tenant goal - // spec is supplied, lane fit reflects loopover's defaults, not the target repo's own conventions. - if (result.usedDefaultGoalSpec) { - lines.push( - "note: ranked with the built-in default goal spec (no per-tenant .loopover-miner.yml supplied)", - ); - } - if (result.ranked.length === 0) { - lines.push("", "no candidates found."); return lines.join("\n"); - } - lines.push("", "top candidates:"); - for (const entry of result.ranked.slice(0, 10)) { - const title = sanitizeDiscoverDisplayText(entry.title); - lines.push(` ${entry.repoFullName}#${entry.issueNumber} score=${entry.rankScore.toFixed(4)} ${title}`); - } - return lines.join("\n"); } - /** * Default per-repo ContributionProfile resolver (#6798): reads the local cache and, on a miss/stale entry, * extracts a fresh profile and caches it. Returns a Map keyed by repoFullName. @@ -197,233 +187,269 @@ export function renderDiscoverSummary(result) { * @returns {Promise>} */ export async function resolveContributionProfilesForDiscover(repoFullNames, ctx = {}) { - const profiles = new Map(); - if (!ctx.githubToken) return profiles; - const initCache = ctx.initCache ?? initContributionProfileCache; - const extract = ctx.extract ?? extractContributionProfile; - const cache = initCache(); - try { - for (const repoFullName of repoFullNames) { - const cached = cache.get(repoFullName, ctx.nowMs); - if (cached && !cached.stale) { - profiles.set(repoFullName, cached.profile); - continue; - } - const profile = await extract(repoFullName, { githubToken: ctx.githubToken, apiBaseUrl: ctx.apiBaseUrl }); - cache.put(profile, ctx.nowMs); - profiles.set(repoFullName, profile); + const profiles = new Map(); + if (!ctx.githubToken) + return profiles; + const initCache = ctx.initCache ?? initContributionProfileCache; + const extract = ctx.extract ?? extractContributionProfile; + const cache = initCache(); + try { + for (const repoFullName of repoFullNames) { + const cached = cache.get(repoFullName, ctx.nowMs); + if (cached && !cached.stale) { + profiles.set(repoFullName, cached.profile); + continue; + } + const profile = await extract(repoFullName, { + githubToken: ctx.githubToken, + // exactOptionalPropertyTypes: omit apiBaseUrl when unset (pre-existing optional-prop shape). + ...(ctx.apiBaseUrl !== undefined ? { apiBaseUrl: ctx.apiBaseUrl } : {}), + }); + cache.put(profile, ctx.nowMs); + profiles.set(repoFullName, profile); + } } - } finally { - cache.close(); - } - return profiles; + finally { + cache.close(); + } + return profiles; } - export async function runDiscover(args, options = {}) { - const parsed = parseDiscoverArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - // Credential env var is per-tenant (#4784): a `--token-env FORGE_PAT` flag (or `options.tokenEnv`) reads a - // non-`GITHUB_TOKEN` variable so a non-github.com forge's token is reachable. The default falls through to the - // forge adapter's own `tokenEnvVar` (github.com's `GITHUB_TOKEN`), so there's a single source of truth for the - // default credential env instead of a second hardcoded literal that could drift from `DEFAULT_FORGE_CONFIG`. - const tokenEnv = parsed.tokenEnv ?? options.tokenEnv ?? resolveForgeConfig(options.forge).tokenEnvVar; - const githubToken = options.githubToken ?? process.env[tokenEnv] ?? ""; - // A `--api-base-url` flag (or `options.apiBaseUrl`) surfaces the fan-out's existing forge-host override at the CLI - // (#4784); `options.forge` carries any remaining per-tenant forge knobs for a programmatic caller. - const apiBaseUrl = parsed.apiBaseUrl ?? options.apiBaseUrl; - const fetchTargets = options.fetchCandidateIssuesWithSummary ?? fetchCandidateIssuesWithSummary; - const searchTargets = options.searchCandidateIssuesWithSummary ?? searchCandidateIssuesWithSummary; - const rankIssues = options.rankCandidateIssuesWithSummary ?? rankCandidateIssuesWithSummary; - const enqueue = options.enqueueRankedDiscovery ?? enqueueRankedDiscovery; - // Eligibility filtering (#6798): resolve each candidate repo's ContributionProfile and drop candidates the - // repo's own conventions would reject, BEFORE ranking. Safe by default -- see resolveContributionProfilesForDiscover. - const resolveProfiles = options.resolveContributionProfiles ?? resolveContributionProfilesForDiscover; - // Same scope this run already asks GitHub about (#7168) -- the discovery-index supplement, when enabled, - // asks the shared hosted index about the identical targets/search rather than a different query entirely. - const discoveryQueryScope = - parsed.search !== null - ? { repos: [], orgs: [], searchTerms: [parsed.search] } - : { repos: parsed.targets.map((target) => `${target.owner}/${target.repo}`), orgs: [], searchTerms: [] }; - - // #4847: fetch + rank are read-only GitHub GETs and pure local computation, so a dry run still does them for - // real (that's the useful "what would this discover?" output) -- but it never opens any local store (portfolio - // queue, policy-doc cache, policy-verdict cache), since opening a not-yet-existing SQLite store file is itself - // a write. The ranked issues are fed through a no-op queue stub so enqueueRankedDiscovery's own classification - // logic (valid/invalid, below-min-rank) still runs for real, just without ever touching the real queue. - if (parsed.dryRun) { - const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache: null, policyVerdictCache: null }; + const parsed = parseDiscoverArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + // Credential env var is per-tenant (#4784): a `--token-env FORGE_PAT` flag (or `options.tokenEnv`) reads a + // non-`GITHUB_TOKEN` variable so a non-github.com forge's token is reachable. The default falls through to the + // forge adapter's own `tokenEnvVar` (github.com's `GITHUB_TOKEN`), so there's a single source of truth for the + // default credential env instead of a second hardcoded literal that could drift from `DEFAULT_FORGE_CONFIG`. + const tokenEnv = parsed.tokenEnv ?? options.tokenEnv ?? resolveForgeConfig(options.forge).tokenEnvVar; + const githubToken = options.githubToken ?? process.env[tokenEnv] ?? ""; + // A `--api-base-url` flag (or `options.apiBaseUrl`) surfaces the fan-out's existing forge-host override at the CLI + // (#4784); `options.forge` carries any remaining per-tenant forge knobs for a programmatic caller. + const apiBaseUrl = parsed.apiBaseUrl ?? options.apiBaseUrl; + const fetchTargets = options.fetchCandidateIssuesWithSummary ?? fetchCandidateIssuesWithSummary; + const searchTargets = options.searchCandidateIssuesWithSummary ?? searchCandidateIssuesWithSummary; + const rankIssues = options.rankCandidateIssuesWithSummary ?? rankCandidateIssuesWithSummary; + const enqueue = options.enqueueRankedDiscovery ?? enqueueRankedDiscovery; + // Eligibility filtering (#6798): resolve each candidate repo's ContributionProfile and drop candidates the + // repo's own conventions would reject, BEFORE ranking. Safe by default -- see resolveContributionProfilesForDiscover. + const resolveProfiles = options.resolveContributionProfiles ?? resolveContributionProfilesForDiscover; + // Same scope this run already asks GitHub about (#7168) -- the discovery-index supplement, when enabled, + // asks the shared hosted index about the identical targets/search rather than a different query entirely. + const discoveryQueryScope = parsed.search !== null + ? { repos: [], orgs: [], searchTerms: [parsed.search] } + : { repos: parsed.targets.map((target) => `${target.owner}/${target.repo}`), orgs: [], searchTerms: [] }; + // #4847: fetch + rank are read-only GitHub GETs and pure local computation, so a dry run still does them for + // real (that's the useful "what would this discover?" output) -- but it never opens any local store (portfolio + // queue, policy-doc cache, policy-verdict cache), since opening a not-yet-existing SQLite store file is itself + // a write. The ranked issues are fed through a no-op queue stub so enqueueRankedDiscovery's own classification + // logic (valid/invalid, below-min-rank) still runs for real, just without ever touching the real queue. + if (parsed.dryRun) { + // exactOptionalPropertyTypes: cast through FanoutOptions — apiBaseUrl/forge may be unset at runtime. + const fanOutOptions = { + apiBaseUrl, + forge: options.forge, + policyDocCache: null, + policyVerdictCache: null, + }; + try { + let fanOut = parsed.search !== null + ? await searchTargets(parsed.search, githubToken, fanOutOptions) + : await fetchTargets(parsed.targets, githubToken, fanOutOptions); + fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); + // #6798: same eligibility filter as the real path, so a dry run shows the exact candidate set a real run + // would enqueue (and the same excluded set), rather than an unfiltered preview. + const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; + const profilesByRepo = await resolveProfiles(repoFullNames, { + githubToken, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + }); + // RunDiscoverOptions.resolveContributionProfiles is typed as Map (pre-existing .d.ts); + // the filter expects ContributionProfile values — same runtime objects. + const { kept, excluded } = filterCandidatesByProfiles(fanOut.issues, profilesByRepo); + const rankedSummary = rankIssues(kept, { + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + ...(options.goalSpecsByRepo !== undefined ? { goalSpecsByRepo: options.goalSpecsByRepo } : {}), + ...(options.goalSpecContentByRepo !== undefined + ? { goalSpecContentByRepo: options.goalSpecContentByRepo } + : {}), + }); + const noopQueueStore = { enqueue: () => { } }; + const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: noopQueueStore }); + const result = { + outcome: "dry_run", + fanOutCount: fanOut.issues.length, + warnings: fanOut.warnings, + rateLimitRemaining: fanOut.rateLimitRemaining, + rateLimitResetAt: fanOut.rateLimitResetAt, + ranked: rankedSummary.issues, + excluded: excluded.map((entry) => ({ + repoFullName: entry.candidate.repoFullName, + issueNumber: entry.candidate.issueNumber, + reason: entry.reason, + })), + usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, + enqueueSummary, + }; + // Structured-outcome hook (#6522), mirroring runAttempt's onResult convention: fires only at a real + // structured success point (never the reportCliFailure branches), in addition to -- never instead of -- + // the plain exit-code return, so a non-CLI caller (the /api/discover route) can read the result. + // Dry-run result adds `outcome: "dry_run"` at runtime; DiscoverResult/.d.ts omits it — pre-existing drift. + options.onResult?.(result); + if (parsed.json) { + console.log(JSON.stringify(result, null, 2)); + } + else { + console.log(renderDiscoverSummary(result)); + console.log("\nDRY RUN: no portfolio-queue write was made."); + } + return 0; + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + } + const ownsPortfolioQueue = options.initPortfolioQueue === undefined; + let portfolioQueue; try { - let fanOut = - parsed.search !== null - ? await searchTargets(parsed.search, githubToken, fanOutOptions) - : await fetchTargets(parsed.targets, githubToken, fanOutOptions); - fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); - // #6798: same eligibility filter as the real path, so a dry run shows the exact candidate set a real run - // would enqueue (and the same excluded set), rather than an unfiltered preview. - const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; - const profilesByRepo = await resolveProfiles(repoFullNames, { githubToken, apiBaseUrl, nowMs: options.nowMs }); - const { kept, excluded } = filterCandidatesByProfiles(fanOut.issues, profilesByRepo); - const rankedSummary = rankIssues(kept, { - nowMs: options.nowMs, - goalSpecsByRepo: options.goalSpecsByRepo, - goalSpecContentByRepo: options.goalSpecContentByRepo, - }); - const noopQueueStore = { enqueue: () => {} }; - const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: noopQueueStore }); - const result = { - outcome: "dry_run", - fanOutCount: fanOut.issues.length, - warnings: fanOut.warnings, - rateLimitRemaining: fanOut.rateLimitRemaining, - rateLimitResetAt: fanOut.rateLimitResetAt, - ranked: rankedSummary.issues, - excluded: excluded.map((entry) => ({ - repoFullName: entry.candidate.repoFullName, - issueNumber: entry.candidate.issueNumber, - reason: entry.reason, - })), - usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, - enqueueSummary, - }; - // Structured-outcome hook (#6522), mirroring runAttempt's onResult convention: fires only at a real - // structured success point (never the reportCliFailure branches), in addition to -- never instead of -- - // the plain exit-code return, so a non-CLI caller (the /api/discover route) can read the result. - options.onResult?.(result); - if (parsed.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(renderDiscoverSummary(result)); - console.log("\nDRY RUN: no portfolio-queue write was made."); - } - return 0; - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); + portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); } - } - - const ownsPortfolioQueue = options.initPortfolioQueue === undefined; - let portfolioQueue; - try { - portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } - - // Local ETag cache so a repeated discover revalidates each repo's policy docs with a conditional GET instead of - // re-downloading them (#4842). Opened inside its OWN try/catch, separate from the portfolio queue above: the - // queue is required infrastructure (discovery genuinely cannot enqueue anything without it, so a real open - // failure should abort the run), but the policy-doc cache is a pure performance optimization -- a corrupt or - // unwritable cache DB must degrade to "no cache" (every doc fetched in full, exactly as before #4842) rather - // than fail discovery outright. - let policyDocCache = null; - let ownsPolicyDocCache = false; - try { - ownsPolicyDocCache = options.initPolicyDocCache === undefined; - policyDocCache = (options.initPolicyDocCache ?? initPolicyDocCacheStore)(); - } catch { - policyDocCache = null; - ownsPolicyDocCache = false; - } - - // Persisted cache of resolved policy verdicts (#4843), same "own try/catch, degrade to null" discipline as the - // doc cache above and for the same reason: purely a performance optimization the feature is inert without, so a - // corrupt/unwritable cache DB must never abort a run. - let policyVerdictCache = null; - let ownsPolicyVerdictCache = false; - try { - ownsPolicyVerdictCache = options.initPolicyVerdictCache === undefined; - policyVerdictCache = (options.initPolicyVerdictCache ?? initPolicyVerdictCacheStore)(); - } catch { - policyVerdictCache = null; - ownsPolicyVerdictCache = false; - } - - // Snapshot of this run's full ranked output (#4859 prerequisite), so a local HTTP endpoint (and eventually the - // miner-ui/browser-extension live-fetch it's meant for) can serve the same per-issue breakdown `--json` prints, - // without the operator re-running discover or hand-pasting its output. Same "own try/catch, degrade to null" - // discipline as the two caches above: a corrupt/unwritable snapshot store must never abort discovery's actual - // job (fan out, rank, enqueue). Unlike the caches, this store is a WRITE target, not a read optimization -- the - // save call itself gets its own try/catch below for the same reason. - let rankedCandidatesStore = null; - let ownsRankedCandidatesStore = false; - try { - ownsRankedCandidatesStore = options.initRankedCandidatesStore === undefined; - rankedCandidatesStore = (options.initRankedCandidatesStore ?? initRankedCandidatesStore)(); - } catch { - rankedCandidatesStore = null; - ownsRankedCandidatesStore = false; - } - const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache, policyVerdictCache }; - - try { - let fanOut = - parsed.search !== null - ? await searchTargets(parsed.search, githubToken, fanOutOptions) - : await fetchTargets(parsed.targets, githubToken, fanOutOptions); - fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); - - // Eligibility filter (#6798): drop candidates a target repo's own conventions would reject, before ranking. - // A repo with no trustworthy eligibility profile keeps every candidate (filterCandidatesByProfiles' safe - // default), so this never silently skips real work on a repo whose conventions AMS couldn't read. - const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; - const profilesByRepo = await resolveProfiles(repoFullNames, { githubToken, apiBaseUrl, nowMs: options.nowMs }); - const { kept, excluded } = filterCandidatesByProfiles(fanOut.issues, profilesByRepo); - - // Pass any caller-supplied per-tenant goal specs through to the ranker so lane fit uses the tenant's - // conventions instead of silently falling back to loopover's defaults (#4784); the fallback is surfaced via - // `usedDefaultGoalSpec` below rather than hidden. - const rankedSummary = rankIssues(kept, { - nowMs: options.nowMs, - goalSpecsByRepo: options.goalSpecsByRepo, - goalSpecContentByRepo: options.goalSpecContentByRepo, - }); - const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: portfolioQueue, apiBaseUrl }); - + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + // Local ETag cache so a repeated discover revalidates each repo's policy docs with a conditional GET instead of + // re-downloading them (#4842). Opened inside its OWN try/catch, separate from the portfolio queue above: the + // queue is required infrastructure (discovery genuinely cannot enqueue anything without it, so a real open + // failure should abort the run), but the policy-doc cache is a pure performance optimization -- a corrupt or + // unwritable cache DB must degrade to "no cache" (every doc fetched in full, exactly as before #4842) rather + // than fail discovery outright. + let policyDocCache = null; + let ownsPolicyDocCache = false; + try { + ownsPolicyDocCache = options.initPolicyDocCache === undefined; + policyDocCache = (options.initPolicyDocCache ?? initPolicyDocCacheStore)(); + } + catch { + policyDocCache = null; + ownsPolicyDocCache = false; + } + // Persisted cache of resolved policy verdicts (#4843), same "own try/catch, degrade to null" discipline as the + // doc cache above and for the same reason: purely a performance optimization the feature is inert without, so a + // corrupt/unwritable cache DB must never abort a run. + let policyVerdictCache = null; + let ownsPolicyVerdictCache = false; try { - // Optional chaining rather than an `if (rankedCandidatesStore)` guard: a null store (open failed above) - // short-circuits to a no-op read, so the same try/catch below also covers the open-failed case without a - // second explicit branch. - rankedCandidatesStore?.saveRankedCandidates(rankedSummary.issues, options.nowMs); - } catch { - // Non-fatal: the ranked-candidates snapshot is a nice-to-have for the local HTTP endpoint, not a - // requirement for discover's own job (fan out, rank, enqueue), which already succeeded above. + ownsPolicyVerdictCache = options.initPolicyVerdictCache === undefined; + policyVerdictCache = (options.initPolicyVerdictCache ?? initPolicyVerdictCacheStore)(); + } + catch { + policyVerdictCache = null; + ownsPolicyVerdictCache = false; } - - const result = { - fanOutCount: fanOut.issues.length, - warnings: fanOut.warnings, - rateLimitRemaining: fanOut.rateLimitRemaining, - rateLimitResetAt: fanOut.rateLimitResetAt, - ranked: rankedSummary.issues, - // #6798: candidates the eligibility filter dropped, each with the repo + issue + reason, so a human sees - // what AMS inferred and why a candidate was skipped. Empty when no profile was trustworthy enough to filter. - excluded: excluded.map((entry) => ({ - repoFullName: entry.candidate.repoFullName, - issueNumber: entry.candidate.issueNumber, - reason: entry.reason, - })), - usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, - enqueueSummary, + // Snapshot of this run's full ranked output (#4859 prerequisite), so a local HTTP endpoint (and eventually the + // miner-ui/browser-extension live-fetch it's meant for) can serve the same per-issue breakdown `--json` prints, + // without the operator re-running discover or hand-pasting its output. Same "own try/catch, degrade to null" + // discipline as the two caches above: a corrupt/unwritable snapshot store must never abort discovery's actual + // job (fan out, rank, enqueue). Unlike the caches, this store is a WRITE target, not a read optimization -- the + // save call itself gets its own try/catch below for the same reason. + let rankedCandidatesStore = null; + let ownsRankedCandidatesStore = false; + try { + ownsRankedCandidatesStore = options.initRankedCandidatesStore === undefined; + rankedCandidatesStore = (options.initRankedCandidatesStore ?? initRankedCandidatesStore)(); + } + catch { + rankedCandidatesStore = null; + ownsRankedCandidatesStore = false; + } + const fanOutOptions = { + apiBaseUrl, + forge: options.forge, + policyDocCache, + policyVerdictCache, }; - - // Structured-outcome hook (#6522) for the full-run success point -- same convention as the dry-run branch - // above and as runAttempt's onResult: real result only, additive to the unchanged exit-code return. - options.onResult?.(result); - if (parsed.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(renderDiscoverSummary(result)); + try { + let fanOut = parsed.search !== null + ? await searchTargets(parsed.search, githubToken, fanOutOptions) + : await fetchTargets(parsed.targets, githubToken, fanOutOptions); + fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); + // Eligibility filter (#6798): drop candidates a target repo's own conventions would reject, before ranking. + // A repo with no trustworthy eligibility profile keeps every candidate (filterCandidatesByProfiles' safe + // default), so this never silently skips real work on a repo whose conventions AMS couldn't read. + const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; + const profilesByRepo = await resolveProfiles(repoFullNames, { + githubToken, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + }); + // RunDiscoverOptions.resolveContributionProfiles is typed as Map (pre-existing .d.ts); + // the filter expects ContributionProfile values — same runtime objects. + const { kept, excluded } = filterCandidatesByProfiles(fanOut.issues, profilesByRepo); + // Pass any caller-supplied per-tenant goal specs through to the ranker so lane fit uses the tenant's + // conventions instead of silently falling back to loopover's defaults (#4784); the fallback is surfaced via + // `usedDefaultGoalSpec` below rather than hidden. + const rankedSummary = rankIssues(kept, { + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + ...(options.goalSpecsByRepo !== undefined ? { goalSpecsByRepo: options.goalSpecsByRepo } : {}), + ...(options.goalSpecContentByRepo !== undefined + ? { goalSpecContentByRepo: options.goalSpecContentByRepo } + : {}), + }); + const enqueueSummary = enqueue(rankedSummary.issues, { + queueStore: portfolioQueue, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + }); + try { + // Optional chaining rather than an `if (rankedCandidatesStore)` guard: a null store (open failed above) + // short-circuits to a no-op read, so the same try/catch below also covers the open-failed case without a + // second explicit branch. + rankedCandidatesStore?.saveRankedCandidates(rankedSummary.issues, options.nowMs); + } + catch { + // Non-fatal: the ranked-candidates snapshot is a nice-to-have for the local HTTP endpoint, not a + // requirement for discover's own job (fan out, rank, enqueue), which already succeeded above. + } + const result = { + fanOutCount: fanOut.issues.length, + warnings: fanOut.warnings, + rateLimitRemaining: fanOut.rateLimitRemaining, + rateLimitResetAt: fanOut.rateLimitResetAt, + ranked: rankedSummary.issues, + // #6798: candidates the eligibility filter dropped, each with the repo + issue + reason, so a human sees + // what AMS inferred and why a candidate was skipped. Empty when no profile was trustworthy enough to filter. + excluded: excluded.map((entry) => ({ + repoFullName: entry.candidate.repoFullName, + issueNumber: entry.candidate.issueNumber, + reason: entry.reason, + })), + usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, + enqueueSummary, + }; + // Structured-outcome hook (#6522) for the full-run success point -- same convention as the dry-run branch + // above and as runAttempt's onResult: real result only, additive to the unchanged exit-code return. + options.onResult?.(result); + if (parsed.json) { + console.log(JSON.stringify(result, null, 2)); + } + else { + console.log(renderDiscoverSummary(result)); + } + return 0; + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + finally { + if (ownsPortfolioQueue && portfolioQueue) + portfolioQueue.close(); + if (ownsPolicyDocCache && policyDocCache) + policyDocCache.close(); + if (ownsPolicyVerdictCache && policyVerdictCache) + policyVerdictCache.close(); + if (ownsRankedCandidatesStore && rankedCandidatesStore) + rankedCandidatesStore.close(); } - return 0; - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } finally { - if (ownsPortfolioQueue && portfolioQueue) portfolioQueue.close(); - if (ownsPolicyDocCache && policyDocCache) policyDocCache.close(); - if (ownsPolicyVerdictCache && policyVerdictCache) policyVerdictCache.close(); - if (ownsRankedCandidatesStore && rankedCandidatesStore) rankedCandidatesStore.close(); - } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGlzY292ZXItY2xpLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiZGlzY292ZXItY2xpLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBO2tIQUNrSDtBQUNsSCxPQUFPLEVBQUUsa0JBQWtCLEVBQUUsTUFBTSxtQkFBbUIsQ0FBQztBQUV2RCxPQUFPLEVBQ0wsK0JBQStCLEVBQy9CLGdDQUFnQyxHQUNqQyxNQUFNLHlCQUF5QixDQUFDO0FBT2pDLE9BQU8sRUFBRSw4QkFBOEIsRUFBRSxNQUFNLHlCQUF5QixDQUFDO0FBTXpFLE9BQU8sRUFBRSx1QkFBdUIsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBRWhFLE9BQU8sRUFBRSwyQkFBMkIsRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBRXhFLE9BQU8sRUFBRSxzQkFBc0IsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBRWxFLE9BQU8sRUFBRSx1QkFBdUIsRUFBRSxNQUFNLHNCQUFzQixDQUFDO0FBRS9ELE9BQU8sRUFBRSx5QkFBeUIsRUFBRSxNQUFNLHdCQUF3QixDQUFDO0FBRW5FLE9BQU8sRUFBRSwwQkFBMEIsRUFBRSxNQUFNLG1DQUFtQyxDQUFDO0FBQy9FLE9BQU8sRUFBRSw0QkFBNEIsRUFBRSxNQUFNLGlDQUFpQyxDQUFDO0FBQy9FLE9BQU8sRUFBRSwwQkFBMEIsRUFBRSxNQUFNLGtDQUFrQyxDQUFDO0FBRTlFLE9BQU8sRUFBRSxZQUFZLEVBQUUsZ0JBQWdCLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxnQkFBZ0IsQ0FBQztBQUNsRixPQUFPLEVBQUUsdUJBQXVCLEVBQUUsbUJBQW1CLEVBQUUsd0JBQXdCLEVBQUUsTUFBTSw2QkFBNkIsQ0FBQztBQW9HckgsTUFBTSxjQUFjLEdBQ2xCLGtKQUFrSixDQUFDO0FBRXJKLE1BQU0saUNBQWlDLEdBQUcsR0FBRyxDQUFDO0FBQzlDLE1BQU0sb0JBQW9CLEdBQUcsc0NBQXNDLENBQUM7QUFDcEUsTUFBTSxtQkFBbUIsR0FBRyxzQ0FBc0MsQ0FBQztBQUNuRSxNQUFNLHlCQUF5QixHQUFHLCtCQUErQixDQUFDO0FBQ2xFLE1BQU0sb0JBQW9CLEdBQUcsMkNBQTJDLENBQUM7QUFFekUsTUFBTSxVQUFVLDJCQUEyQixDQUFDLEtBQWM7SUFDeEQsT0FBTyxNQUFNLENBQUMsS0FBSyxJQUFJLEVBQUUsQ0FBQztTQUN2QixPQUFPLENBQUMsb0JBQW9CLEVBQUUsRUFBRSxDQUFDO1NBQ2pDLE9BQU8sQ0FBQyxtQkFBbUIsRUFBRSxFQUFFLENBQUM7U0FDaEMsT0FBTyxDQUFDLHlCQUF5QixFQUFFLEdBQUcsQ0FBQztTQUN2QyxPQUFPLENBQUMsb0JBQW9CLEVBQUUsRUFBRSxDQUFDO1NBQ2pDLE9BQU8sQ0FBQyxNQUFNLEVBQUUsR0FBRyxDQUFDO1NBQ3BCLElBQUksRUFBRTtTQUNOLEtBQUssQ0FBQyxDQUFDLEVBQUUsaUNBQWlDLENBQUMsQ0FBQztBQUNqRCxDQUFDO0FBRUQsU0FBUyxTQUFTLENBQUMsWUFBb0IsRUFBRSxXQUFtQjtJQUMxRCxPQUFPLEdBQUcsWUFBWSxDQUFDLFdBQVcsRUFBRSxJQUFJLFdBQVcsRUFBRSxDQUFDO0FBQ3hELENBQUM7QUFFRDs7Ozs7Ozs7R0FRRztBQUNILEtBQUssVUFBVSw0QkFBNEIsQ0FDekMsTUFBNkIsRUFDN0IsVUFBd0MsRUFDeEMsT0FBMkI7SUFFM0IsTUFBTSxHQUFHLEdBQUcsT0FBTyxDQUFDLEdBQUcsSUFBSSxPQUFPLENBQUMsR0FBRyxDQUFDO0lBQ3ZDLElBQUksQ0FBQyx1QkFBdUIsQ0FBQyxHQUFHLENBQUM7UUFBRSxPQUFPLE1BQU0sQ0FBQztJQUNqRCxNQUFNLFVBQVUsR0FBRyxPQUFPLENBQUMsbUJBQW1CLElBQUksbUJBQW1CLENBQUM7SUFDdEUsTUFBTSxRQUFRLEdBQUcsTUFBTSxVQUFVLENBQUMsVUFBVSxFQUFFLEVBQUUsR0FBRyxFQUFFLENBQUMsQ0FBQztJQUN2RCx3QkFBd0IsQ0FBQyxnQkFBZ0IsRUFBRSxRQUFRLENBQUMsVUFBVSxDQUFDLE1BQU0sR0FBRyxDQUFDLENBQUMsQ0FBQyxDQUFDLGNBQWMsQ0FBQyxDQUFDLENBQUMsT0FBTyxFQUFFLEVBQUUsR0FBRyxFQUFFLENBQUMsQ0FBQztJQUMvRyxJQUFJLFFBQVEsQ0FBQyxVQUFVLENBQUMsTUFBTSxLQUFLLENBQUM7UUFBRSxPQUFPLE1BQU0sQ0FBQztJQUVwRCxNQUFNLElBQUksR0FBRyxJQUFJLEdBQUcsQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsU0FBUyxDQUFDLEtBQUssQ0FBQyxZQUFZLEVBQUUsS0FBSyxDQUFDLFdBQVcsQ0FBQyxDQUFDLENBQUMsQ0FBQztJQUNyRyxNQUFNLFlBQVksR0FBRyxRQUFRLENBQUMsVUFBVTtTQUNyQyxNQUFNLENBQUMsQ0FBQyxTQUFTLEVBQUUsRUFBRSxDQUFDLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxTQUFTLENBQUMsU0FBUyxDQUFDLFlBQVksRUFBRSxTQUFTLENBQUMsV0FBVyxDQUFDLENBQUMsQ0FBQztRQUMzRix1R0FBdUc7UUFDdkcseUdBQXlHO1NBQ3hHLEdBQUcsQ0FBQyxDQUFDLFNBQVMsRUFBRSxFQUFFLENBQUMsQ0FBQyxFQUFFLEdBQUcsU0FBUyxFQUFFLFNBQVMsRUFBRSxFQUFFLEVBQUUsTUFBTSxFQUFFLENBQUMsR0FBRyxTQUFTLENBQUMsTUFBTSxDQUFDLEVBQUUsQ0FBc0IsQ0FBQyxDQUFDO0lBQzdHLElBQUksWUFBWSxDQUFDLE1BQU0sS0FBSyxDQUFDO1FBQUUsT0FBTyxNQUFNLENBQUM7SUFDN0MsT0FBTyxFQUFFLEdBQUcsTUFBTSxFQUFFLE1BQU0sRUFBRSxDQUFDLEdBQUcsTUFBTSxDQUFDLE1BQU0sRUFBRSxHQUFHLFlBQVksQ0FBQyxFQUFFLENBQUM7QUFDcEUsQ0FBQztBQUVELFNBQVMsZUFBZSxDQUFDLEtBQWM7SUFDckMsTUFBTSxPQUFPLEdBQUcsT0FBTyxLQUFLLEtBQUssUUFBUSxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsSUFBSSxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztJQUM5RCxNQUFNLENBQUMsS0FBSyxFQUFFLElBQUksRUFBRSxLQUFLLENBQUMsR0FBRyxPQUFPLENBQUMsS0FBSyxDQUFDLEdBQUcsQ0FBQyxDQUFDO0lBQ2hELElBQUksQ0FBQyxLQUFLLElBQUksQ0FBQyxJQUFJLElBQUksS0FBSyxLQUFLLFNBQVM7UUFBRSxPQUFPLElBQUksQ0FBQztJQUN4RCxPQUFPLEVBQUUsS0FBSyxFQUFFLElBQUksRUFBRSxDQUFDO0FBQ3pCLENBQUM7QUFFRCxNQUFNLFVBQVUsaUJBQWlCLENBQUMsSUFBYztJQUM5Qyw0R0FBNEc7SUFDNUcsMkdBQTJHO0lBQzNHLCtDQUErQztJQUMvQyxNQUFNLE9BQU8sR0FNVCxFQUFFLElBQUksRUFBRSxLQUFLLEVBQUUsTUFBTSxFQUFFLEtBQUssRUFBRSxNQUFNLEVBQUUsSUFBSSxFQUFFLFVBQVUsRUFBRSxJQUFJLEVBQUUsUUFBUSxFQUFFLElBQUksRUFBRSxDQUFDO0lBQ25GLE1BQU0sT0FBTyxHQUFtQixFQUFFLENBQUM7SUFFbkMsS0FBSyxJQUFJLEtBQUssR0FBRyxDQUFDLEVBQUUsS0FBSyxHQUFHLElBQUksQ0FBQyxNQUFNLEVBQUUsS0FBSyxJQUFJLENBQUMsRUFBRSxDQUFDO1FBQ3BELE1BQU0sS0FBSyxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUUsQ0FBQztRQUMzQixJQUFJLEtBQUssS0FBSyxRQUFRLEVBQUUsQ0FBQztZQUN2QixPQUFPLENBQUMsSUFBSSxHQUFHLElBQUksQ0FBQztZQUNwQixTQUFTO1FBQ1gsQ0FBQztRQUNELHlHQUF5RztRQUN6RyxJQUFJLEtBQUssS0FBSyxXQUFXLEVBQUUsQ0FBQztZQUMxQixPQUFPLENBQUMsTUFBTSxHQUFHLElBQUksQ0FBQztZQUN0QixTQUFTO1FBQ1gsQ0FBQztRQUNELElBQUksS0FBSyxLQUFLLFVBQVUsRUFBRSxDQUFDO1lBQ3pCLE1BQU0sS0FBSyxHQUFHLElBQUksQ0FBQyxLQUFLLEdBQUcsQ0FBQyxDQUFDLENBQUM7WUFDOUIsSUFBSSxDQUFDLEtBQUssSUFBSSxLQUFLLENBQUMsVUFBVSxDQUFDLEdBQUcsQ0FBQztnQkFBRSxPQUFPLEVBQUUsS0FBSyxFQUFFLGNBQWMsRUFBRSxDQUFDO1lBQ3RFLE9BQU8sQ0FBQyxNQUFNLEdBQUcsS0FBSyxDQUFDO1lBQ3ZCLEtBQUssSUFBSSxDQUFDLENBQUM7WUFDWCxTQUFTO1FBQ1gsQ0FBQztRQUNELElBQUksS0FBSyxLQUFLLGdCQUFnQixFQUFFLENBQUM7WUFDL0IsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQztZQUM5QixJQUFJLENBQUMsS0FBSyxJQUFJLEtBQUssQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFDO2dCQUFFLE9BQU8sRUFBRSxLQUFLLEVBQUUsY0FBYyxFQUFFLENBQUM7WUFDdEUsT0FBTyxDQUFDLFVBQVUsR0FBRyxLQUFLLENBQUM7WUFDM0IsS0FBSyxJQUFJLENBQUMsQ0FBQztZQUNYLFNBQVM7UUFDWCxDQUFDO1FBQ0QsSUFBSSxLQUFLLEtBQUssYUFBYSxFQUFFLENBQUM7WUFDNUIsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQztZQUM5QixJQUFJLENBQUMsS0FBSyxJQUFJLEtBQUssQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFDO2dCQUFFLE9BQU8sRUFBRSxLQUFLLEVBQUUsY0FBYyxFQUFFLENBQUM7WUFDdEUsT0FBTyxDQUFDLFFBQVEsR0FBRyxLQUFLLENBQUM7WUFDekIsS0FBSyxJQUFJLENBQUMsQ0FBQztZQUNYLFNBQVM7UUFDWCxDQUFDO1FBQ0QsSUFBSSxLQUFLLENBQUMsVUFBVSxDQUFDLEdBQUcsQ0FBQyxFQUFFLENBQUM7WUFDMUIsT0FBTyxFQUFFLEtBQUssRUFBRSxtQkFBbUIsS0FBSyxFQUFFLEVBQUUsQ0FBQztRQUMvQyxDQUFDO1FBQ0QsTUFBTSxNQUFNLEdBQUcsZUFBZSxDQUFDLEtBQUssQ0FBQyxDQUFDO1FBQ3RDLElBQUksQ0FBQyxNQUFNO1lBQUUsT0FBTyxFQUFFLEtBQUssRUFBRSwwQ0FBMEMsS0FBSyxFQUFFLEVBQUUsQ0FBQztRQUNqRixPQUFPLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxDQUFDO0lBQ3ZCLENBQUM7SUFFRCxJQUFJLE9BQU8sQ0FBQyxNQUFNLEtBQUssSUFBSSxJQUFJLE9BQU8sQ0FBQyxNQUFNLEtBQUssQ0FBQyxFQUFFLENBQUM7UUFDcEQsT0FBTyxFQUFFLEtBQUssRUFBRSxjQUFjLEVBQUUsQ0FBQztJQUNuQyxDQUFDO0lBQ0QsSUFBSSxPQUFPLENBQUMsTUFBTSxLQUFLLElBQUksSUFBSSxPQUFPLENBQUMsTUFBTSxHQUFHLENBQUMsRUFBRSxDQUFDO1FBQ2xELE9BQU8sRUFBRSxLQUFLLEVBQUUsdURBQXVELEVBQUUsQ0FBQztJQUM1RSxDQUFDO0lBRUQsT0FBTztRQUNMLE9BQU87UUFDUCxNQUFNLEVBQUUsT0FBTyxDQUFDLE1BQU07UUFDdEIsTUFBTSxFQUFFLE9BQU8sQ0FBQyxNQUFNO1FBQ3RCLElBQUksRUFBRSxPQUFPLENBQUMsSUFBSTtRQUNsQixHQUFHLENBQUMsT0FBTyxDQUFDLFVBQVUsS0FBSyxJQUFJLENBQUMsQ0FBQyxDQUFDLEVBQUUsVUFBVSxFQUFFLE9BQU8sQ0FBQyxVQUFVLEVBQUUsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDO1FBQzFFLEdBQUcsQ0FBQyxPQUFPLENBQUMsUUFBUSxLQUFLLElBQUksQ0FBQyxDQUFDLENBQUMsRUFBRSxRQUFRLEVBQUUsT0FBTyxDQUFDLFFBQVEsRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7S0FDckUsQ0FBQztBQUNKLENBQUM7QUFFRCxnSEFBZ0g7QUFDaEgsbUhBQW1IO0FBQ25ILHFEQUFxRDtBQUNyRCxTQUFTLG1CQUFtQixDQUFDLE1BQXVFO0lBQ2xHLE1BQU0sU0FBUyxHQUFHLE1BQU0sQ0FBQyxrQkFBa0IsS0FBSyxJQUFJLENBQUMsQ0FBQyxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQyxrQkFBa0IsQ0FBQyxDQUFDO0lBQ3JHLE1BQU0sV0FBVyxHQUFHLE1BQU0sQ0FBQyxnQkFBZ0IsS0FBSyxJQUFJLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDLENBQUMsWUFBWSxNQUFNLENBQUMsZ0JBQWdCLEdBQUcsQ0FBQztJQUNuRyxPQUFPLHlCQUF5QixTQUFTLEdBQUcsV0FBVyxFQUFFLENBQUM7QUFDNUQsQ0FBQztBQUVELE1BQU0sVUFBVSxxQkFBcUIsQ0FBQyxNQUFzQjtJQUMxRCxNQUFNLEtBQUssR0FBRztRQUNaLGVBQWUsTUFBTSxDQUFDLFdBQVcscUJBQXFCO1FBQ3RELHVCQUF1QixNQUFNLENBQUMsUUFBUSxDQUFDLE1BQU0sRUFBRTtRQUMvQyxXQUFXLE1BQU0sQ0FBQyxNQUFNLENBQUMsTUFBTSxFQUFFO1FBQ2pDLGFBQWEsTUFBTSxDQUFDLGNBQWMsQ0FBQyxRQUFRLEVBQUU7UUFDN0MsbUJBQW1CLENBQUMsTUFBTSxDQUFDO0tBQzVCLENBQUM7SUFDRixJQUFJLE1BQU0sQ0FBQyxjQUFjLENBQUMsbUJBQW1CLEdBQUcsQ0FBQyxFQUFFLENBQUM7UUFDbEQsS0FBSyxDQUFDLElBQUksQ0FBQyw2QkFBNkIsTUFBTSxDQUFDLGNBQWMsQ0FBQyxtQkFBbUIsRUFBRSxDQUFDLENBQUM7SUFDdkYsQ0FBQztJQUNELCtGQUErRjtJQUMvRixNQUFNLFFBQVEsR0FBRyxNQUFNLENBQUMsUUFBUSxJQUFJLEVBQUUsQ0FBQztJQUN2QyxJQUFJLFFBQVEsQ0FBQyxNQUFNLEdBQUcsQ0FBQyxFQUFFLENBQUM7UUFDeEIsS0FBSyxDQUFDLElBQUksQ0FBQywyQkFBMkIsUUFBUSxDQUFDLE1BQU0sRUFBRSxDQUFDLENBQUM7UUFDekQsS0FBSyxNQUFNLEtBQUssSUFBSSxRQUFRLENBQUMsS0FBSyxDQUFDLENBQUMsRUFBRSxFQUFFLENBQUMsRUFBRSxDQUFDO1lBQzFDLEtBQUssQ0FBQyxJQUFJLENBQUMsS0FBSyxLQUFLLENBQUMsWUFBWSxJQUFJLEtBQUssQ0FBQyxXQUFXLEtBQUssS0FBSyxDQUFDLE1BQU0sRUFBRSxDQUFDLENBQUM7UUFDOUUsQ0FBQztJQUNILENBQUM7SUFDRCwrR0FBK0c7SUFDL0csa0dBQWtHO0lBQ2xHLElBQUksTUFBTSxDQUFDLG1CQUFtQixFQUFFLENBQUM7UUFDL0IsS0FBSyxDQUFDLElBQUksQ0FDUiwrRkFBK0YsQ0FDaEcsQ0FBQztJQUNKLENBQUM7SUFDRCxJQUFJLE1BQU0sQ0FBQyxNQUFNLENBQUMsTUFBTSxLQUFLLENBQUMsRUFBRSxDQUFDO1FBQy9CLEtBQUssQ0FBQyxJQUFJLENBQUMsRUFBRSxFQUFFLHNCQUFzQixDQUFDLENBQUM7UUFDdkMsT0FBTyxLQUFLLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDO0lBQzFCLENBQUM7SUFDRCxLQUFLLENBQUMsSUFBSSxDQUFDLEVBQUUsRUFBRSxpQkFBaUIsQ0FBQyxDQUFDO0lBQ2xDLEtBQUssTUFBTSxLQUFLLElBQUksTUFBTSxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQyxFQUFFLEVBQUUsQ0FBQyxFQUFFLENBQUM7UUFDL0MsTUFBTSxLQUFLLEdBQUcsMkJBQTJCLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDO1FBQ3ZELEtBQUssQ0FBQyxJQUFJLENBQUMsS0FBSyxLQUFLLENBQUMsWUFBWSxJQUFJLEtBQUssQ0FBQyxXQUFXLFdBQVcsS0FBSyxDQUFDLFNBQVMsQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLEtBQUssS0FBSyxFQUFFLENBQUMsQ0FBQztJQUM1RyxDQUFDO0lBQ0QsT0FBTyxLQUFLLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDO0FBQzFCLENBQUM7QUFFRDs7Ozs7Ozs7Ozs7R0FXRztBQUNILE1BQU0sQ0FBQyxLQUFLLFVBQVUsc0NBQXNDLENBQzFELGFBQXVCLEVBQ3ZCLE1BTUksRUFBRTtJQUVOLE1BQU0sUUFBUSxHQUFHLElBQUksR0FBRyxFQUFFLENBQUM7SUFDM0IsSUFBSSxDQUFDLEdBQUcsQ0FBQyxXQUFXO1FBQUUsT0FBTyxRQUFRLENBQUM7SUFDdEMsTUFBTSxTQUFTLEdBQUksR0FBRyxDQUFDLFNBQTZELElBQUksNEJBQTRCLENBQUM7SUFDckgsTUFBTSxPQUFPLEdBQUksR0FBRyxDQUFDLE9BQXlELElBQUksMEJBQTBCLENBQUM7SUFDN0csTUFBTSxLQUFLLEdBQUcsU0FBUyxFQUFFLENBQUM7SUFDMUIsSUFBSSxDQUFDO1FBQ0gsS0FBSyxNQUFNLFlBQVksSUFBSSxhQUFhLEVBQUUsQ0FBQztZQUN6QyxNQUFNLE1BQU0sR0FBRyxLQUFLLENBQUMsR0FBRyxDQUFDLFlBQVksRUFBRSxHQUFHLENBQUMsS0FBSyxDQUFDLENBQUM7WUFDbEQsSUFBSSxNQUFNLElBQUksQ0FBQyxNQUFNLENBQUMsS0FBSyxFQUFFLENBQUM7Z0JBQzVCLFFBQVEsQ0FBQyxHQUFHLENBQUMsWUFBWSxFQUFFLE1BQU0sQ0FBQyxPQUFPLENBQUMsQ0FBQztnQkFDM0MsU0FBUztZQUNYLENBQUM7WUFDRCxNQUFNLE9BQU8sR0FBRyxNQUFNLE9BQU8sQ0FBQyxZQUFZLEVBQUU7Z0JBQzFDLFdBQVcsRUFBRSxHQUFHLENBQUMsV0FBVztnQkFDNUIsNkZBQTZGO2dCQUM3RixHQUFHLENBQUMsR0FBRyxDQUFDLFVBQVUsS0FBSyxTQUFTLENBQUMsQ0FBQyxDQUFDLEVBQUUsVUFBVSxFQUFFLEdBQUcsQ0FBQyxVQUFVLEVBQUUsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDO2FBQ3BCLENBQUMsQ0FBQztZQUN2RCxLQUFLLENBQUMsR0FBRyxDQUFDLE9BQU8sRUFBRSxHQUFHLENBQUMsS0FBSyxDQUFDLENBQUM7WUFDOUIsUUFBUSxDQUFDLEdBQUcsQ0FBQyxZQUFZLEVBQUUsT0FBTyxDQUFDLENBQUM7UUFDdEMsQ0FBQztJQUNILENBQUM7WUFBUyxDQUFDO1FBQ1QsS0FBSyxDQUFDLEtBQUssRUFBRSxDQUFDO0lBQ2hCLENBQUM7SUFDRCxPQUFPLFFBQVEsQ0FBQztBQUNsQixDQUFDO0FBRUQsTUFBTSxDQUFDLEtBQUssVUFBVSxXQUFXLENBQUMsSUFBYyxFQUFFLFVBQThCLEVBQUU7SUFDaEYsTUFBTSxNQUFNLEdBQUcsaUJBQWlCLENBQUMsSUFBSSxDQUFDLENBQUM7SUFDdkMsSUFBSSxPQUFPLElBQUksTUFBTSxFQUFFLENBQUM7UUFDdEIsT0FBTyxnQkFBZ0IsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLEVBQUUsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDO0lBQzVELENBQUM7SUFFRCwyR0FBMkc7SUFDM0csK0dBQStHO0lBQy9HLCtHQUErRztJQUMvRyw2R0FBNkc7SUFDN0csTUFBTSxRQUFRLEdBQUcsTUFBTSxDQUFDLFFBQVEsSUFBSSxPQUFPLENBQUMsUUFBUSxJQUFJLGtCQUFrQixDQUFDLE9BQU8sQ0FBQyxLQUFLLENBQUMsQ0FBQyxXQUFXLENBQUM7SUFDdEcsTUFBTSxXQUFXLEdBQUcsT0FBTyxDQUFDLFdBQVcsSUFBSSxPQUFPLENBQUMsR0FBRyxDQUFDLFFBQVEsQ0FBQyxJQUFJLEVBQUUsQ0FBQztJQUN2RSxtSEFBbUg7SUFDbkgsbUdBQW1HO0lBQ25HLE1BQU0sVUFBVSxHQUFHLE1BQU0sQ0FBQyxVQUFVLElBQUksT0FBTyxDQUFDLFVBQVUsQ0FBQztJQUMzRCxNQUFNLFlBQVksR0FBRyxPQUFPLENBQUMsK0JBQStCLElBQUksK0JBQStCLENBQUM7SUFDaEcsTUFBTSxhQUFhLEdBQUcsT0FBTyxDQUFDLGdDQUFnQyxJQUFJLGdDQUFnQyxDQUFDO0lBQ25HLE1BQU0sVUFBVSxHQUFHLE9BQU8sQ0FBQyw4QkFBOEIsSUFBSSw4QkFBOEIsQ0FBQztJQUM1RixNQUFNLE9BQU8sR0FBRyxPQUFPLENBQUMsc0JBQXNCLElBQUksc0JBQXNCLENBQUM7SUFDekUsMkdBQTJHO0lBQzNHLHNIQUFzSDtJQUN0SCxNQUFNLGVBQWUsR0FBRyxPQUFPLENBQUMsMkJBQTJCLElBQUksc0NBQXNDLENBQUM7SUFDdEcseUdBQXlHO0lBQ3pHLDBHQUEwRztJQUMxRyxNQUFNLG1CQUFtQixHQUN2QixNQUFNLENBQUMsTUFBTSxLQUFLLElBQUk7UUFDcEIsQ0FBQyxDQUFDLEVBQUUsS0FBSyxFQUFFLEVBQUUsRUFBRSxJQUFJLEVBQUUsRUFBRSxFQUFFLFdBQVcsRUFBRSxDQUFDLE1BQU0sQ0FBQyxNQUFNLENBQUMsRUFBRTtRQUN2RCxDQUFDLENBQUMsRUFBRSxLQUFLLEVBQUUsTUFBTSxDQUFDLE9BQU8sQ0FBQyxHQUFHLENBQUMsQ0FBQyxNQUFNLEVBQUUsRUFBRSxDQUFDLEdBQUcsTUFBTSxDQUFDLEtBQUssSUFBSSxNQUFNLENBQUMsSUFBSSxFQUFFLENBQUMsRUFBRSxJQUFJLEVBQUUsRUFBRSxFQUFFLFdBQVcsRUFBRSxFQUFFLEVBQUUsQ0FBQztJQUU3Ryw2R0FBNkc7SUFDN0csK0dBQStHO0lBQy9HLCtHQUErRztJQUMvRywrR0FBK0c7SUFDL0csd0dBQXdHO0lBQ3hHLElBQUksTUFBTSxDQUFDLE1BQU0sRUFBRSxDQUFDO1FBQ2xCLHFHQUFxRztRQUNyRyxNQUFNLGFBQWEsR0FBRztZQUNwQixVQUFVO1lBQ1YsS0FBSyxFQUFFLE9BQU8sQ0FBQyxLQUFLO1lBQ3BCLGNBQWMsRUFBRSxJQUFJO1lBQ3BCLGtCQUFrQixFQUFFLElBQUk7U0FDUixDQUFDO1FBQ25CLElBQUksQ0FBQztZQUNILElBQUksTUFBTSxHQUNSLE1BQU0sQ0FBQyxNQUFNLEtBQUssSUFBSTtnQkFDcEIsQ0FBQyxDQUFDLE1BQU0sYUFBYSxDQUFDLE1BQU0sQ0FBQyxNQUFNLEVBQUUsV0FBVyxFQUFFLGFBQWEsQ0FBQztnQkFDaEUsQ0FBQyxDQUFDLE1BQU0sWUFBWSxDQUFDLE1BQU0sQ0FBQyxPQUFPLEVBQUUsV0FBVyxFQUFFLGFBQWEsQ0FBQyxDQUFDO1lBQ3JFLE1BQU0sR0FBRyxNQUFNLDRCQUE0QixDQUFDLE1BQU0sRUFBRSxtQkFBbUIsRUFBRSxPQUFPLENBQUMsQ0FBQztZQUNsRix5R0FBeUc7WUFDekcsZ0ZBQWdGO1lBQ2hGLE1BQU0sYUFBYSxHQUFHLENBQUMsR0FBRyxJQUFJLEdBQUcsQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsS0FBSyxDQUFDLFlBQVksQ0FBQyxDQUFDLENBQUMsQ0FBQztZQUNyRixNQUFNLGNBQWMsR0FBRyxNQUFNLGVBQWUsQ0FBQyxhQUFhLEVBQUU7Z0JBQzFELFdBQVc7Z0JBQ1gsR0FBRyxDQUFDLFVBQVUsS0FBSyxTQUFTLENBQUMsQ0FBQyxDQUFDLEVBQUUsVUFBVSxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztnQkFDbkQsR0FBRyxDQUFDLE9BQU8sQ0FBQyxLQUFLLEtBQUssU0FBUyxDQUFDLENBQUMsQ0FBQyxFQUFFLEtBQUssRUFBRSxPQUFPLENBQUMsS0FBSyxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQzthQUNqRSxDQUFDLENBQUM7WUFDSCx3R0FBd0c7WUFDeEcsd0VBQXdFO1lBQ3hFLE1BQU0sRUFBRSxJQUFJLEVBQUUsUUFBUSxFQUFFLEdBQUcsMEJBQTBCLENBQ25ELE1BQU0sQ0FBQyxNQUFNLEVBQ2IsY0FBa0QsQ0FDbkQsQ0FBQztZQUNGLE1BQU0sYUFBYSxHQUFHLFVBQVUsQ0FBQyxJQUFJLEVBQUU7Z0JBQ3JDLEdBQUcsQ0FBQyxPQUFPLENBQUMsS0FBSyxLQUFLLFNBQVMsQ0FBQyxDQUFDLENBQUMsRUFBRSxLQUFLLEVBQUUsT0FBTyxDQUFDLEtBQUssRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7Z0JBQ2hFLEdBQUcsQ0FBQyxPQUFPLENBQUMsZUFBZSxLQUFLLFNBQVMsQ0FBQyxDQUFDLENBQUMsRUFBRSxlQUFlLEVBQUUsT0FBTyxDQUFDLGVBQWUsRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7Z0JBQzlGLEdBQUcsQ0FBQyxPQUFPLENBQUMscUJBQXFCLEtBQUssU0FBUztvQkFDN0MsQ0FBQyxDQUFDLEVBQUUscUJBQXFCLEVBQUUsT0FBTyxDQUFDLHFCQUFxQixFQUFFO29CQUMxRCxDQUFDLENBQUMsRUFBRSxDQUFDO2FBQ1IsQ0FBQyxDQUFDO1lBQ0gsTUFBTSxjQUFjLEdBQUcsRUFBRSxPQUFPLEVBQUUsR0FBRyxFQUFFLEdBQUUsQ0FBQyxFQUFvQyxDQUFDO1lBQy9FLE1BQU0sY0FBYyxHQUFHLE9BQU8sQ0FBQyxhQUFhLENBQUMsTUFBTSxFQUFFLEVBQUUsVUFBVSxFQUFFLGNBQWMsRUFBRSxDQUFDLENBQUM7WUFDckYsTUFBTSxNQUFNLEdBQUc7Z0JBQ2IsT0FBTyxFQUFFLFNBQVM7Z0JBQ2xCLFdBQVcsRUFBRSxNQUFNLENBQUMsTUFBTSxDQUFDLE1BQU07Z0JBQ2pDLFFBQVEsRUFBRSxNQUFNLENBQUMsUUFBUTtnQkFDekIsa0JBQWtCLEVBQUUsTUFBTSxDQUFDLGtCQUFrQjtnQkFDN0MsZ0JBQWdCLEVBQUUsTUFBTSxDQUFDLGdCQUFnQjtnQkFDekMsTUFBTSxFQUFFLGFBQWEsQ0FBQyxNQUFNO2dCQUM1QixRQUFRLEVBQUUsUUFBUSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsQ0FBQztvQkFDakMsWUFBWSxFQUFFLEtBQUssQ0FBQyxTQUFTLENBQUMsWUFBWTtvQkFDMUMsV0FBVyxFQUFFLEtBQUssQ0FBQyxTQUFTLENBQUMsV0FBVztvQkFDeEMsTUFBTSxFQUFFLEtBQUssQ0FBQyxNQUFNO2lCQUNyQixDQUFDLENBQUM7Z0JBQ0gsbUJBQW1CLEVBQUUsYUFBYSxDQUFDLG1CQUFtQjtnQkFDdEQsY0FBYzthQUNmLENBQUM7WUFDRixvR0FBb0c7WUFDcEcsd0dBQXdHO1lBQ3hHLGlHQUFpRztZQUNqRywyR0FBMkc7WUFDM0csT0FBTyxDQUFDLFFBQVEsRUFBRSxDQUFDLE1BQXdCLENBQUMsQ0FBQztZQUM3QyxJQUFJLE1BQU0sQ0FBQyxJQUFJLEVBQUUsQ0FBQztnQkFDaEIsT0FBTyxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFDLE1BQU0sRUFBRSxJQUFJLEVBQUUsQ0FBQyxDQUFDLENBQUMsQ0FBQztZQUMvQyxDQUFDO2lCQUFNLENBQUM7Z0JBQ04sT0FBTyxDQUFDLEdBQUcsQ0FBQyxxQkFBcUIsQ0FBQyxNQUF3QixDQUFDLENBQUMsQ0FBQztnQkFDN0QsT0FBTyxDQUFDLEdBQUcsQ0FBQywrQ0FBK0MsQ0FBQyxDQUFDO1lBQy9ELENBQUM7WUFDRCxPQUFPLENBQUMsQ0FBQztRQUNYLENBQUM7UUFBQyxPQUFPLEtBQUssRUFBRSxDQUFDO1lBQ2YsT0FBTyxnQkFBZ0IsQ0FBQyxNQUFNLENBQUMsSUFBSSxFQUFFLGdCQUFnQixDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUM7UUFDaEUsQ0FBQztJQUNILENBQUM7SUFFRCxNQUFNLGtCQUFrQixHQUFHLE9BQU8sQ0FBQyxrQkFBa0IsS0FBSyxTQUFTLENBQUM7SUFDcEUsSUFBSSxjQUErQyxDQUFDO0lBQ3BELElBQUksQ0FBQztRQUNILGNBQWMsR0FBRyxDQUFDLE9BQU8sQ0FBQyxrQkFBa0IsSUFBSSx1QkFBdUIsQ0FBQyxFQUFFLENBQUM7SUFDN0UsQ0FBQztJQUFDLE9BQU8sS0FBSyxFQUFFLENBQUM7UUFDZixPQUFPLGdCQUFnQixDQUFDLE1BQU0sQ0FBQyxJQUFJLEVBQUUsZ0JBQWdCLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQztJQUNoRSxDQUFDO0lBRUQsZ0hBQWdIO0lBQ2hILDZHQUE2RztJQUM3RywyR0FBMkc7SUFDM0csNkdBQTZHO0lBQzdHLDZHQUE2RztJQUM3RyxnQ0FBZ0M7SUFDaEMsSUFBSSxjQUFjLEdBQStCLElBQUksQ0FBQztJQUN0RCxJQUFJLGtCQUFrQixHQUFHLEtBQUssQ0FBQztJQUMvQixJQUFJLENBQUM7UUFDSCxrQkFBa0IsR0FBRyxPQUFPLENBQUMsa0JBQWtCLEtBQUssU0FBUyxDQUFDO1FBQzlELGNBQWMsR0FBRyxDQUFDLE9BQU8sQ0FBQyxrQkFBa0IsSUFBSSx1QkFBdUIsQ0FBQyxFQUFFLENBQUM7SUFDN0UsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLGNBQWMsR0FBRyxJQUFJLENBQUM7UUFDdEIsa0JBQWtCLEdBQUcsS0FBSyxDQUFDO0lBQzdCLENBQUM7SUFFRCwrR0FBK0c7SUFDL0csZ0hBQWdIO0lBQ2hILHNEQUFzRDtJQUN0RCxJQUFJLGtCQUFrQixHQUFtQyxJQUFJLENBQUM7SUFDOUQsSUFBSSxzQkFBc0IsR0FBRyxLQUFLLENBQUM7SUFDbkMsSUFBSSxDQUFDO1FBQ0gsc0JBQXNCLEdBQUcsT0FBTyxDQUFDLHNCQUFzQixLQUFLLFNBQVMsQ0FBQztRQUN0RSxrQkFBa0IsR0FBRyxDQUFDLE9BQU8sQ0FBQyxzQkFBc0IsSUFBSSwyQkFBMkIsQ0FBQyxFQUFFLENBQUM7SUFDekYsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLGtCQUFrQixHQUFHLElBQUksQ0FBQztRQUMxQixzQkFBc0IsR0FBRyxLQUFLLENBQUM7SUFDakMsQ0FBQztJQUVELCtHQUErRztJQUMvRyxnSEFBZ0g7SUFDaEgsNkdBQTZHO0lBQzdHLDhHQUE4RztJQUM5RyxnSEFBZ0g7SUFDaEgscUVBQXFFO0lBQ3JFLElBQUkscUJBQXFCLEdBQWlDLElBQUksQ0FBQztJQUMvRCxJQUFJLHlCQUF5QixHQUFHLEtBQUssQ0FBQztJQUN0QyxJQUFJLENBQUM7UUFDSCx5QkFBeUIsR0FBRyxPQUFPLENBQUMseUJBQXlCLEtBQUssU0FBUyxDQUFDO1FBQzVFLHFCQUFxQixHQUFHLENBQUMsT0FBTyxDQUFDLHlCQUF5QixJQUFJLHlCQUF5QixDQUFDLEVBQUUsQ0FBQztJQUM3RixDQUFDO0lBQUMsTUFBTSxDQUFDO1FBQ1AscUJBQXFCLEdBQUcsSUFBSSxDQUFDO1FBQzdCLHlCQUF5QixHQUFHLEtBQUssQ0FBQztJQUNwQyxDQUFDO0lBQ0QsTUFBTSxhQUFhLEdBQUc7UUFDcEIsVUFBVTtRQUNWLEtBQUssRUFBRSxPQUFPLENBQUMsS0FBSztRQUNwQixjQUFjO1FBQ2Qsa0JBQWtCO0tBQ0YsQ0FBQztJQUVuQixJQUFJLENBQUM7UUFDSCxJQUFJLE1BQU0sR0FDUixNQUFNLENBQUMsTUFBTSxLQUFLLElBQUk7WUFDcEIsQ0FBQyxDQUFDLE1BQU0sYUFBYSxDQUFDLE1BQU0sQ0FBQyxNQUFNLEVBQUUsV0FBVyxFQUFFLGFBQWEsQ0FBQztZQUNoRSxDQUFDLENBQUMsTUFBTSxZQUFZLENBQUMsTUFBTSxDQUFDLE9BQU8sRUFBRSxXQUFXLEVBQUUsYUFBYSxDQUFDLENBQUM7UUFDckUsTUFBTSxHQUFHLE1BQU0sNEJBQTRCLENBQUMsTUFBTSxFQUFFLG1CQUFtQixFQUFFLE9BQU8sQ0FBQyxDQUFDO1FBRWxGLDRHQUE0RztRQUM1Ryx5R0FBeUc7UUFDekcsa0dBQWtHO1FBQ2xHLE1BQU0sYUFBYSxHQUFHLENBQUMsR0FBRyxJQUFJLEdBQUcsQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsS0FBSyxDQUFDLFlBQVksQ0FBQyxDQUFDLENBQUMsQ0FBQztRQUNyRixNQUFNLGNBQWMsR0FBRyxNQUFNLGVBQWUsQ0FBQyxhQUFhLEVBQUU7WUFDMUQsV0FBVztZQUNYLEdBQUcsQ0FBQyxVQUFVLEtBQUssU0FBUyxDQUFDLENBQUMsQ0FBQyxFQUFFLFVBQVUsRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7WUFDbkQsR0FBRyxDQUFDLE9BQU8sQ0FBQyxLQUFLLEtBQUssU0FBUyxDQUFDLENBQUMsQ0FBQyxFQUFFLEtBQUssRUFBRSxPQUFPLENBQUMsS0FBSyxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztTQUNqRSxDQUFDLENBQUM7UUFDSCx3R0FBd0c7UUFDeEcsd0VBQXdFO1FBQ3hFLE1BQU0sRUFBRSxJQUFJLEVBQUUsUUFBUSxFQUFFLEdBQUcsMEJBQTBCLENBQ25ELE1BQU0sQ0FBQyxNQUFNLEVBQ2IsY0FBa0QsQ0FDbkQsQ0FBQztRQUVGLHFHQUFxRztRQUNyRyw0R0FBNEc7UUFDNUcsa0RBQWtEO1FBQ2xELE1BQU0sYUFBYSxHQUFHLFVBQVUsQ0FBQyxJQUFJLEVBQUU7WUFDckMsR0FBRyxDQUFDLE9BQU8sQ0FBQyxLQUFLLEtBQUssU0FBUyxDQUFDLENBQUMsQ0FBQyxFQUFFLEtBQUssRUFBRSxPQUFPLENBQUMsS0FBSyxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztZQUNoRSxHQUFHLENBQUMsT0FBTyxDQUFDLGVBQWUsS0FBSyxTQUFTLENBQUMsQ0FBQyxDQUFDLEVBQUUsZUFBZSxFQUFFLE9BQU8sQ0FBQyxlQUFlLEVBQUUsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDO1lBQzlGLEdBQUcsQ0FBQyxPQUFPLENBQUMscUJBQXFCLEtBQUssU0FBUztnQkFDN0MsQ0FBQyxDQUFDLEVBQUUscUJBQXFCLEVBQUUsT0FBTyxDQUFDLHFCQUFxQixFQUFFO2dCQUMxRCxDQUFDLENBQUMsRUFBRSxDQUFDO1NBQ1IsQ0FBQyxDQUFDO1FBQ0gsTUFBTSxjQUFjLEdBQUcsT0FBTyxDQUFDLGFBQWEsQ0FBQyxNQUFNLEVBQUU7WUFDbkQsVUFBVSxFQUFFLGNBQWM7WUFDMUIsR0FBRyxDQUFDLFVBQVUsS0FBSyxTQUFTLENBQUMsQ0FBQyxDQUFDLEVBQUUsVUFBVSxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztTQUNwRCxDQUFDLENBQUM7UUFFSCxJQUFJLENBQUM7WUFDSCx3R0FBd0c7WUFDeEcseUdBQXlHO1lBQ3pHLDBCQUEwQjtZQUMxQixxQkFBcUIsRUFBRSxvQkFBb0IsQ0FBQyxhQUFhLENBQUMsTUFBTSxFQUFFLE9BQU8sQ0FBQyxLQUFLLENBQUMsQ0FBQztRQUNuRixDQUFDO1FBQUMsTUFBTSxDQUFDO1lBQ1AsaUdBQWlHO1lBQ2pHLDhGQUE4RjtRQUNoRyxDQUFDO1FBRUQsTUFBTSxNQUFNLEdBQUc7WUFDYixXQUFXLEVBQUUsTUFBTSxDQUFDLE1BQU0sQ0FBQyxNQUFNO1lBQ2pDLFFBQVEsRUFBRSxNQUFNLENBQUMsUUFBUTtZQUN6QixrQkFBa0IsRUFBRSxNQUFNLENBQUMsa0JBQWtCO1lBQzdDLGdCQUFnQixFQUFFLE1BQU0sQ0FBQyxnQkFBZ0I7WUFDekMsTUFBTSxFQUFFLGFBQWEsQ0FBQyxNQUFNO1lBQzVCLHlHQUF5RztZQUN6Ryw2R0FBNkc7WUFDN0csUUFBUSxFQUFFLFFBQVEsQ0FBQyxHQUFHLENBQUMsQ0FBQyxLQUFLLEVBQUUsRUFBRSxDQUFDLENBQUM7Z0JBQ2pDLFlBQVksRUFBRSxLQUFLLENBQUMsU0FBUyxDQUFDLFlBQVk7Z0JBQzFDLFdBQVcsRUFBRSxLQUFLLENBQUMsU0FBUyxDQUFDLFdBQVc7Z0JBQ3hDLE1BQU0sRUFBRSxLQUFLLENBQUMsTUFBTTthQUNyQixDQUFDLENBQUM7WUFDSCxtQkFBbUIsRUFBRSxhQUFhLENBQUMsbUJBQW1CO1lBQ3RELGNBQWM7U0FDZixDQUFDO1FBRUYsMEdBQTBHO1FBQzFHLG9HQUFvRztRQUNwRyxPQUFPLENBQUMsUUFBUSxFQUFFLENBQUMsTUFBTSxDQUFDLENBQUM7UUFDM0IsSUFBSSxNQUFNLENBQUMsSUFBSSxFQUFFLENBQUM7WUFDaEIsT0FBTyxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFDLE1BQU0sRUFBRSxJQUFJLEVBQUUsQ0FBQyxDQUFDLENBQUMsQ0FBQztRQUMvQyxDQUFDO2FBQU0sQ0FBQztZQUNOLE9BQU8sQ0FBQyxHQUFHLENBQUMscUJBQXFCLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQztRQUM3QyxDQUFDO1FBQ0QsT0FBTyxDQUFDLENBQUM7SUFDWCxDQUFDO0lBQUMsT0FBTyxLQUFLLEVBQUUsQ0FBQztRQUNmLE9BQU8sZ0JBQWdCLENBQUMsTUFBTSxDQUFDLElBQUksRUFBRSxnQkFBZ0IsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDO0lBQ2hFLENBQUM7WUFBUyxDQUFDO1FBQ1QsSUFBSSxrQkFBa0IsSUFBSSxjQUFjO1lBQUUsY0FBYyxDQUFDLEtBQUssRUFBRSxDQUFDO1FBQ2pFLElBQUksa0JBQWtCLElBQUksY0FBYztZQUFFLGNBQWMsQ0FBQyxLQUFLLEVBQUUsQ0FBQztRQUNqRSxJQUFJLHNCQUFzQixJQUFJLGtCQUFrQjtZQUFFLGtCQUFrQixDQUFDLEtBQUssRUFBRSxDQUFDO1FBQzdFLElBQUkseUJBQXlCLElBQUkscUJBQXFCO1lBQUUscUJBQXFCLENBQUMsS0FBSyxFQUFFLENBQUM7SUFDeEYsQ0FBQztBQUNILENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/discover-cli.ts b/packages/loopover-miner/lib/discover-cli.ts new file mode 100644 index 0000000000..3bafa4b360 --- /dev/null +++ b/packages/loopover-miner/lib/discover-cli.ts @@ -0,0 +1,607 @@ +/** `discover` CLI command (#4247): wires the existing fanout -> rank -> enqueue pipeline together so a miner + * can actually run it. Every piece already exists and is independently tested; this module only composes them. */ +import { resolveForgeConfig } from "./forge-config.js"; +import type { ForgeConfig } from "./forge-config.js"; +import { + fetchCandidateIssuesWithSummary, + searchCandidateIssuesWithSummary, +} from "./opportunity-fanout.js"; +import type { + CandidateIssueWarning, + FanoutOptions, + FanoutTarget, + RawCandidateIssue, +} from "./opportunity-fanout.js"; +import { rankCandidateIssuesWithSummary } from "./opportunity-ranker.js"; +import type { + RankCandidateIssuesOptions, + RankedCandidateIssue, + RankedCandidateSummary, +} from "./opportunity-ranker.js"; +import { initPolicyDocCacheStore } from "./policy-doc-cache.js"; +import type { PolicyDocCacheStore } from "./policy-doc-cache.js"; +import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js"; +import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js"; +import { enqueueRankedDiscovery } from "./portfolio-discovery.js"; +import type { EnqueueRankedDiscoverySummary } from "./portfolio-discovery.js"; +import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import type { PortfolioQueueStore } from "./portfolio-queue.js"; +import { initRankedCandidatesStore } from "./ranked-candidates.js"; +import type { RankedCandidatesStore } from "./ranked-candidates.js"; +import { extractContributionProfile } from "./contribution-profile-extract.js"; +import { initContributionProfileCache } from "./contribution-profile-cache.js"; +import { filterCandidatesByProfiles } from "./contribution-profile-filter.js"; +import type { ContributionProfile } from "./contribution-profile.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { isDiscoveryPlaneEnabled, queryDiscoveryIndex, recordDiscoveryTelemetry } from "./discovery-index-client.js"; +import type { queryDiscoveryIndex as QueryDiscoveryIndexFn } from "./discovery-index-client.js"; +import type { DiscoveryIndexQuery } from "@loopover/engine"; + + +export type ParsedDiscoverArgs = + | { + targets: FanoutTarget[]; + search: string | null; + dryRun: boolean; + json: boolean; + /** Present only when `--api-base-url` is supplied (#4784); threads the tenant's forge host to the fan-out. */ + apiBaseUrl?: string; + /** Present only when `--token-env` is supplied (#4784); names the credential env var to read. */ + tokenEnv?: string; + } + | { error: string }; + +/** The subset of `CandidateIssueSummary` runDiscover actually reads. It surfaces the rate-limit telemetry (#4837), + * so a fake must supply it. A real `fetchCandidateIssuesWithSummary` result satisfies this, since it is a superset. */ +export type DiscoverFanOutSummary = { + issues: RawCandidateIssue[]; + warnings: CandidateIssueWarning[]; + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; +}; + +/** The subset of a ranked entry that `renderDiscoverSummary` reads for its top-candidates listing. */ +export type DiscoverRankedEntry = Pick< + RankedCandidateIssue, + "repoFullName" | "issueNumber" | "title" | "rankScore" +>; + +export type DiscoverResult = { + fanOutCount: number; + warnings: CandidateIssueWarning[]; + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; + ranked: DiscoverRankedEntry[]; + /** Candidates the eligibility filter dropped, each with the repo/issue and the reason (#6798). */ + excluded?: Array<{ + repoFullName: string; + issueNumber: number; + reason: string; + }>; + /** True when ranking fell back to the built-in default goal spec because no per-tenant spec was supplied (#4784). */ + usedDefaultGoalSpec?: boolean; + enqueueSummary: EnqueueRankedDiscoverySummary; +}; + +export type RunDiscoverOptions = { + /** Read for the discovery-index opt-in gate (#7168) -- defaults to `process.env`. */ + env?: Record; + githubToken?: string; + apiBaseUrl?: string; + /** Per-tenant credential env var name (#4784); defaults to GITHUB_TOKEN. Overridden by a `--token-env` flag. */ + tokenEnv?: string; + /** Per-tenant forge knobs beyond the host (#4784), forwarded to the fan-out. */ + forge?: Partial; + nowMs?: number; + /** Per-tenant goal specs threaded to the ranker so lane fit uses the tenant's conventions, not the defaults (#4784). */ + goalSpecsByRepo?: RankCandidateIssuesOptions["goalSpecsByRepo"]; + goalSpecContentByRepo?: RankCandidateIssuesOptions["goalSpecContentByRepo"]; + initPortfolioQueue?: () => PortfolioQueueStore; + initPolicyDocCache?: () => PolicyDocCacheStore; + initPolicyVerdictCache?: () => PolicyVerdictCacheStore; + initRankedCandidatesStore?: () => RankedCandidatesStore; + fetchCandidateIssuesWithSummary?: ( + targets: FanoutTarget[], + githubToken: string, + options?: FanoutOptions, + ) => Promise; + searchCandidateIssuesWithSummary?: ( + searchQuery: string, + githubToken: string, + options?: FanoutOptions, + ) => Promise; + rankCandidateIssuesWithSummary?: ( + candidates: RawCandidateIssue[], + options?: RankCandidateIssuesOptions, + ) => RankedCandidateSummary; + enqueueRankedDiscovery?: ( + rankedIssues: RankedCandidateIssue[], + options: { queueStore: PortfolioQueueStore }, + ) => EnqueueRankedDiscoverySummary; + /** Supplements the local fan-out with hosted discovery-index results for the same scope, when the plane is + * enabled (#7168). Defaults to discovery-index-client.js's own queryDiscoveryIndex. */ + queryDiscoveryIndex?: typeof QueryDiscoveryIndexFn; + /** Invoked with the real structured result at each success return point (dry-run and full-run), in addition + * to (never instead of) the plain exit-code return -- mirrors `RunAttemptOptions.onResult`. Never fires on a + * parse-error/unexpected-error `reportCliFailure` branch, matching runAttempt's own asymmetry (#6522). */ + onResult?: (result: DiscoverResult) => void; + /** Resolve each candidate repo's ContributionProfile for eligibility filtering (#6798). Defaults to + * resolveContributionProfilesForDiscover; injectable so tests avoid the network. */ + resolveContributionProfiles?: ( + repoFullNames: string[], + ctx: { githubToken?: string; apiBaseUrl?: string; nowMs?: number }, + ) => Promise>; +}; + +const DISCOVER_USAGE = + "Usage: loopover-miner discover [...] | --search [--dry-run] [--json] [--api-base-url ] [--token-env ]"; + +const MAX_DISCOVER_TITLE_DISPLAY_LENGTH = 240; +const OSC_SEQUENCE_PATTERN = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g; +const ANSI_ESCAPE_PATTERN = /\u001b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/g; +const BIDI_CONTROL_PATTERN = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; + +export function sanitizeDiscoverDisplayText(value: unknown): string { + return String(value ?? "") + .replace(OSC_SEQUENCE_PATTERN, "") + .replace(ANSI_ESCAPE_PATTERN, "") + .replace(CONTROL_CHARACTER_PATTERN, " ") + .replace(BIDI_CONTROL_PATTERN, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_DISCOVER_TITLE_DISPLAY_LENGTH); +} + +function dedupeKey(repoFullName: string, issueNumber: number): string { + return `${repoFullName.toLowerCase()}#${issueNumber}`; +} + +/** + * Supplements `fanOut.issues` with hosted discovery-index results for the same scope (#7168) -- a complete + * no-op (returns `fanOut` unchanged) unless the plane is enabled, so a run with the flag unset behaves exactly + * as before this feature existed. Local results always win on a duplicate issue (the discovery-index candidate + * is dropped, not merged over it) -- this instance's own live fan-out is more current than a cached shared + * index entry. Discovery-index candidates lack `assignees` (not part of the public contract), so they're + * annotated with an empty array to match opportunity-fanout.js's own candidate shape; contribution-profile- + * filter.js's assignee-exclusion rule treats that identically to "no assignees on this issue". + */ +async function supplementWithDiscoveryIndex( + fanOut: DiscoverFanOutSummary, + queryScope: Partial, + options: RunDiscoverOptions, +): Promise { + const env = options.env ?? process.env; + if (!isDiscoveryPlaneEnabled(env)) return fanOut; + const queryIndex = options.queryDiscoveryIndex ?? queryDiscoveryIndex; + const response = await queryIndex(queryScope, { env }); + recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env }); + if (response.candidates.length === 0) return fanOut; + + const seen = new Set(fanOut.issues.map((issue) => dedupeKey(issue.repoFullName, issue.issueNumber))); + const supplemented = response.candidates + .filter((candidate) => !seen.has(dedupeKey(candidate.repoFullName, candidate.issueNumber))) + // DiscoveryIndexCandidate is a near-superset of RawCandidateIssue; assignees is absent from the hosted + // contract (#7168) so we annotate [] — cast preserves pre-existing runtime shape rather than re-mapping. + .map((candidate) => ({ ...candidate, assignees: [], labels: [...candidate.labels] }) as RawCandidateIssue); + if (supplemented.length === 0) return fanOut; + return { ...fanOut, issues: [...fanOut.issues, ...supplemented] }; +} + +function parseRepoTarget(value: unknown): FanoutTarget | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) return null; + return { owner, repo }; +} + +export function parseDiscoverArgs(args: string[]): ParsedDiscoverArgs { + // `--api-base-url` and `--token-env` (#4784) thread the tenant's forge host and credential env var into the + // fan-out; they are kept off the parsed result unless supplied, so callers that pass neither see the exact + // pre-#4784 `{ targets, search, json }` shape. + const options: { + json: boolean; + dryRun: boolean; + search: string | null; + apiBaseUrl: string | null; + tokenEnv: string | null; + } = { json: false, dryRun: false, search: null, apiBaseUrl: null, tokenEnv: null }; + const targets: FanoutTarget[] = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + // #4847: fetches + ranks exactly as a real run, but skips opening any local store and makes zero writes. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--search") { + const query = args[index + 1]; + if (!query || query.startsWith("-")) return { error: DISCOVER_USAGE }; + options.search = query; + index += 1; + continue; + } + if (token === "--api-base-url") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE }; + options.apiBaseUrl = value; + index += 1; + continue; + } + if (token === "--token-env") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE }; + options.tokenEnv = value; + 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: DISCOVER_USAGE }; + } + if (options.search !== null && targets.length > 0) { + return { error: "Pass either repository targets or --search, not both." }; + } + + return { + targets, + search: options.search, + dryRun: options.dryRun, + json: options.json, + ...(options.apiBaseUrl !== null ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.tokenEnv !== null ? { tokenEnv: options.tokenEnv } : {}), + }; +} + +// The rate-limit line surfaces the telemetry the fanout already records (#4837) so an operator sees how close a +// `discover` run is to being throttled without running a separate command. `unknown` covers the no-fetch/no-header +// case where the fanout captured no remaining count. +function renderRateLimitLine(result: Pick): string { + const remaining = result.rateLimitRemaining === null ? "unknown" : String(result.rateLimitRemaining); + const resetSuffix = result.rateLimitResetAt === null ? "" : ` (resets ${result.rateLimitResetAt})`; + return `rate-limit remaining: ${remaining}${resetSuffix}`; +} + +export function renderDiscoverSummary(result: DiscoverResult): string { + const lines = [ + `fanned out: ${result.fanOutCount} candidate issue(s)`, + `ai-policy warnings: ${result.warnings.length}`, + `ranked: ${result.ranked.length}`, + `enqueued: ${result.enqueueSummary.enqueued}`, + renderRateLimitLine(result), + ]; + if (result.enqueueSummary.skippedBelowMinRank > 0) { + lines.push(`skipped (below min rank): ${result.enqueueSummary.skippedBelowMinRank}`); + } + // #6798: surface what the eligibility filter dropped and why, so a human sees AMS's inference. + const excluded = result.excluded ?? []; + if (excluded.length > 0) { + lines.push(`excluded (eligibility): ${excluded.length}`); + for (const entry of excluded.slice(0, 10)) { + lines.push(` ${entry.repoFullName}#${entry.issueNumber} ${entry.reason}`); + } + } + // Make the fall-back to loopover's built-in rubric explicit instead of silent (#4784): when no per-tenant goal + // spec is supplied, lane fit reflects loopover's defaults, not the target repo's own conventions. + if (result.usedDefaultGoalSpec) { + lines.push( + "note: ranked with the built-in default goal spec (no per-tenant .loopover-miner.yml supplied)", + ); + } + if (result.ranked.length === 0) { + lines.push("", "no candidates found."); + return lines.join("\n"); + } + lines.push("", "top candidates:"); + for (const entry of result.ranked.slice(0, 10)) { + const title = sanitizeDiscoverDisplayText(entry.title); + lines.push(` ${entry.repoFullName}#${entry.issueNumber} score=${entry.rankScore.toFixed(4)} ${title}`); + } + return lines.join("\n"); +} + +/** + * Default per-repo ContributionProfile resolver (#6798): reads the local cache and, on a miss/stale entry, + * extracts a fresh profile and caches it. Returns a Map keyed by repoFullName. + * + * WITHOUT a github token this returns an empty map and does no network work at all — AMS can't reliably read a + * repo's label taxonomy/docs unauthenticated (rate limits), so it safe-defaults to no eligibility filtering. + * That also keeps callers that don't supply a token (the common CLI path, and every test) hermetic. + * + * @param {string[]} repoFullNames unique repos among the fanned-out candidates + * @param {{ githubToken?: string, apiBaseUrl?: string, nowMs?: number, initCache?: typeof initContributionProfileCache, extract?: typeof extractContributionProfile }} ctx + * @returns {Promise>} + */ +export async function resolveContributionProfilesForDiscover( + repoFullNames: string[], + ctx: { + githubToken?: string; + apiBaseUrl?: string; + nowMs?: number; + initCache?: unknown; + extract?: unknown; + } = {}, +): Promise> { + const profiles = new Map(); + if (!ctx.githubToken) return profiles; + const initCache = (ctx.initCache as typeof initContributionProfileCache | undefined) ?? initContributionProfileCache; + const extract = (ctx.extract as typeof extractContributionProfile | undefined) ?? extractContributionProfile; + const cache = initCache(); + try { + for (const repoFullName of repoFullNames) { + const cached = cache.get(repoFullName, ctx.nowMs); + if (cached && !cached.stale) { + profiles.set(repoFullName, cached.profile); + continue; + } + const profile = await extract(repoFullName, { + githubToken: ctx.githubToken, + // exactOptionalPropertyTypes: omit apiBaseUrl when unset (pre-existing optional-prop shape). + ...(ctx.apiBaseUrl !== undefined ? { apiBaseUrl: ctx.apiBaseUrl } : {}), + } as Parameters[1]); + cache.put(profile, ctx.nowMs); + profiles.set(repoFullName, profile); + } + } finally { + cache.close(); + } + return profiles; +} + +export async function runDiscover(args: string[], options: RunDiscoverOptions = {}): Promise { + const parsed = parseDiscoverArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + // Credential env var is per-tenant (#4784): a `--token-env FORGE_PAT` flag (or `options.tokenEnv`) reads a + // non-`GITHUB_TOKEN` variable so a non-github.com forge's token is reachable. The default falls through to the + // forge adapter's own `tokenEnvVar` (github.com's `GITHUB_TOKEN`), so there's a single source of truth for the + // default credential env instead of a second hardcoded literal that could drift from `DEFAULT_FORGE_CONFIG`. + const tokenEnv = parsed.tokenEnv ?? options.tokenEnv ?? resolveForgeConfig(options.forge).tokenEnvVar; + const githubToken = options.githubToken ?? process.env[tokenEnv] ?? ""; + // A `--api-base-url` flag (or `options.apiBaseUrl`) surfaces the fan-out's existing forge-host override at the CLI + // (#4784); `options.forge` carries any remaining per-tenant forge knobs for a programmatic caller. + const apiBaseUrl = parsed.apiBaseUrl ?? options.apiBaseUrl; + const fetchTargets = options.fetchCandidateIssuesWithSummary ?? fetchCandidateIssuesWithSummary; + const searchTargets = options.searchCandidateIssuesWithSummary ?? searchCandidateIssuesWithSummary; + const rankIssues = options.rankCandidateIssuesWithSummary ?? rankCandidateIssuesWithSummary; + const enqueue = options.enqueueRankedDiscovery ?? enqueueRankedDiscovery; + // Eligibility filtering (#6798): resolve each candidate repo's ContributionProfile and drop candidates the + // repo's own conventions would reject, BEFORE ranking. Safe by default -- see resolveContributionProfilesForDiscover. + const resolveProfiles = options.resolveContributionProfiles ?? resolveContributionProfilesForDiscover; + // Same scope this run already asks GitHub about (#7168) -- the discovery-index supplement, when enabled, + // asks the shared hosted index about the identical targets/search rather than a different query entirely. + const discoveryQueryScope = + parsed.search !== null + ? { repos: [], orgs: [], searchTerms: [parsed.search] } + : { repos: parsed.targets.map((target) => `${target.owner}/${target.repo}`), orgs: [], searchTerms: [] }; + + // #4847: fetch + rank are read-only GitHub GETs and pure local computation, so a dry run still does them for + // real (that's the useful "what would this discover?" output) -- but it never opens any local store (portfolio + // queue, policy-doc cache, policy-verdict cache), since opening a not-yet-existing SQLite store file is itself + // a write. The ranked issues are fed through a no-op queue stub so enqueueRankedDiscovery's own classification + // logic (valid/invalid, below-min-rank) still runs for real, just without ever touching the real queue. + if (parsed.dryRun) { + // exactOptionalPropertyTypes: cast through FanoutOptions — apiBaseUrl/forge may be unset at runtime. + const fanOutOptions = { + apiBaseUrl, + forge: options.forge, + policyDocCache: null, + policyVerdictCache: null, + } as FanoutOptions; + try { + let fanOut = + parsed.search !== null + ? await searchTargets(parsed.search, githubToken, fanOutOptions) + : await fetchTargets(parsed.targets, githubToken, fanOutOptions); + fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); + // #6798: same eligibility filter as the real path, so a dry run shows the exact candidate set a real run + // would enqueue (and the same excluded set), rather than an unfiltered preview. + const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; + const profilesByRepo = await resolveProfiles(repoFullNames, { + githubToken, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + }); + // RunDiscoverOptions.resolveContributionProfiles is typed as Map (pre-existing .d.ts); + // the filter expects ContributionProfile values — same runtime objects. + const { kept, excluded } = filterCandidatesByProfiles( + fanOut.issues, + profilesByRepo as Map, + ); + const rankedSummary = rankIssues(kept, { + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + ...(options.goalSpecsByRepo !== undefined ? { goalSpecsByRepo: options.goalSpecsByRepo } : {}), + ...(options.goalSpecContentByRepo !== undefined + ? { goalSpecContentByRepo: options.goalSpecContentByRepo } + : {}), + }); + const noopQueueStore = { enqueue: () => {} } as unknown as PortfolioQueueStore; + const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: noopQueueStore }); + const result = { + outcome: "dry_run", + fanOutCount: fanOut.issues.length, + warnings: fanOut.warnings, + rateLimitRemaining: fanOut.rateLimitRemaining, + rateLimitResetAt: fanOut.rateLimitResetAt, + ranked: rankedSummary.issues, + excluded: excluded.map((entry) => ({ + repoFullName: entry.candidate.repoFullName, + issueNumber: entry.candidate.issueNumber, + reason: entry.reason, + })), + usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, + enqueueSummary, + }; + // Structured-outcome hook (#6522), mirroring runAttempt's onResult convention: fires only at a real + // structured success point (never the reportCliFailure branches), in addition to -- never instead of -- + // the plain exit-code return, so a non-CLI caller (the /api/discover route) can read the result. + // Dry-run result adds `outcome: "dry_run"` at runtime; DiscoverResult/.d.ts omits it — pre-existing drift. + options.onResult?.(result as DiscoverResult); + if (parsed.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(renderDiscoverSummary(result as DiscoverResult)); + console.log("\nDRY RUN: no portfolio-queue write was made."); + } + return 0; + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + } + + const ownsPortfolioQueue = options.initPortfolioQueue === undefined; + let portfolioQueue: PortfolioQueueStore | undefined; + try { + portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + + // Local ETag cache so a repeated discover revalidates each repo's policy docs with a conditional GET instead of + // re-downloading them (#4842). Opened inside its OWN try/catch, separate from the portfolio queue above: the + // queue is required infrastructure (discovery genuinely cannot enqueue anything without it, so a real open + // failure should abort the run), but the policy-doc cache is a pure performance optimization -- a corrupt or + // unwritable cache DB must degrade to "no cache" (every doc fetched in full, exactly as before #4842) rather + // than fail discovery outright. + let policyDocCache: PolicyDocCacheStore | null = null; + let ownsPolicyDocCache = false; + try { + ownsPolicyDocCache = options.initPolicyDocCache === undefined; + policyDocCache = (options.initPolicyDocCache ?? initPolicyDocCacheStore)(); + } catch { + policyDocCache = null; + ownsPolicyDocCache = false; + } + + // Persisted cache of resolved policy verdicts (#4843), same "own try/catch, degrade to null" discipline as the + // doc cache above and for the same reason: purely a performance optimization the feature is inert without, so a + // corrupt/unwritable cache DB must never abort a run. + let policyVerdictCache: PolicyVerdictCacheStore | null = null; + let ownsPolicyVerdictCache = false; + try { + ownsPolicyVerdictCache = options.initPolicyVerdictCache === undefined; + policyVerdictCache = (options.initPolicyVerdictCache ?? initPolicyVerdictCacheStore)(); + } catch { + policyVerdictCache = null; + ownsPolicyVerdictCache = false; + } + + // Snapshot of this run's full ranked output (#4859 prerequisite), so a local HTTP endpoint (and eventually the + // miner-ui/browser-extension live-fetch it's meant for) can serve the same per-issue breakdown `--json` prints, + // without the operator re-running discover or hand-pasting its output. Same "own try/catch, degrade to null" + // discipline as the two caches above: a corrupt/unwritable snapshot store must never abort discovery's actual + // job (fan out, rank, enqueue). Unlike the caches, this store is a WRITE target, not a read optimization -- the + // save call itself gets its own try/catch below for the same reason. + let rankedCandidatesStore: RankedCandidatesStore | null = null; + let ownsRankedCandidatesStore = false; + try { + ownsRankedCandidatesStore = options.initRankedCandidatesStore === undefined; + rankedCandidatesStore = (options.initRankedCandidatesStore ?? initRankedCandidatesStore)(); + } catch { + rankedCandidatesStore = null; + ownsRankedCandidatesStore = false; + } + const fanOutOptions = { + apiBaseUrl, + forge: options.forge, + policyDocCache, + policyVerdictCache, + } as FanoutOptions; + + try { + let fanOut = + parsed.search !== null + ? await searchTargets(parsed.search, githubToken, fanOutOptions) + : await fetchTargets(parsed.targets, githubToken, fanOutOptions); + fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); + + // Eligibility filter (#6798): drop candidates a target repo's own conventions would reject, before ranking. + // A repo with no trustworthy eligibility profile keeps every candidate (filterCandidatesByProfiles' safe + // default), so this never silently skips real work on a repo whose conventions AMS couldn't read. + const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; + const profilesByRepo = await resolveProfiles(repoFullNames, { + githubToken, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + }); + // RunDiscoverOptions.resolveContributionProfiles is typed as Map (pre-existing .d.ts); + // the filter expects ContributionProfile values — same runtime objects. + const { kept, excluded } = filterCandidatesByProfiles( + fanOut.issues, + profilesByRepo as Map, + ); + + // Pass any caller-supplied per-tenant goal specs through to the ranker so lane fit uses the tenant's + // conventions instead of silently falling back to loopover's defaults (#4784); the fallback is surfaced via + // `usedDefaultGoalSpec` below rather than hidden. + const rankedSummary = rankIssues(kept, { + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + ...(options.goalSpecsByRepo !== undefined ? { goalSpecsByRepo: options.goalSpecsByRepo } : {}), + ...(options.goalSpecContentByRepo !== undefined + ? { goalSpecContentByRepo: options.goalSpecContentByRepo } + : {}), + }); + const enqueueSummary = enqueue(rankedSummary.issues, { + queueStore: portfolioQueue, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + }); + + try { + // Optional chaining rather than an `if (rankedCandidatesStore)` guard: a null store (open failed above) + // short-circuits to a no-op read, so the same try/catch below also covers the open-failed case without a + // second explicit branch. + rankedCandidatesStore?.saveRankedCandidates(rankedSummary.issues, options.nowMs); + } catch { + // Non-fatal: the ranked-candidates snapshot is a nice-to-have for the local HTTP endpoint, not a + // requirement for discover's own job (fan out, rank, enqueue), which already succeeded above. + } + + const result = { + fanOutCount: fanOut.issues.length, + warnings: fanOut.warnings, + rateLimitRemaining: fanOut.rateLimitRemaining, + rateLimitResetAt: fanOut.rateLimitResetAt, + ranked: rankedSummary.issues, + // #6798: candidates the eligibility filter dropped, each with the repo + issue + reason, so a human sees + // what AMS inferred and why a candidate was skipped. Empty when no profile was trustworthy enough to filter. + excluded: excluded.map((entry) => ({ + repoFullName: entry.candidate.repoFullName, + issueNumber: entry.candidate.issueNumber, + reason: entry.reason, + })), + usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, + enqueueSummary, + }; + + // Structured-outcome hook (#6522) for the full-run success point -- same convention as the dry-run branch + // above and as runAttempt's onResult: real result only, additive to the unchanged exit-code return. + options.onResult?.(result); + if (parsed.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(renderDiscoverSummary(result)); + } + return 0; + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } finally { + if (ownsPortfolioQueue && portfolioQueue) portfolioQueue.close(); + if (ownsPolicyDocCache && policyDocCache) policyDocCache.close(); + if (ownsPolicyVerdictCache && policyVerdictCache) policyVerdictCache.close(); + if (ownsRankedCandidatesStore && rankedCandidatesStore) rankedCandidatesStore.close(); + } +} diff --git a/packages/loopover-miner/lib/governor-metrics-cli.d.ts b/packages/loopover-miner/lib/governor-metrics-cli.d.ts index c12edd72c2..3667b4a9f6 100644 --- a/packages/loopover-miner/lib/governor-metrics-cli.d.ts +++ b/packages/loopover-miner/lib/governor-metrics-cli.d.ts @@ -1,16 +1,9 @@ import type { GovernorCapUsage } from "@loopover/engine"; import type { GovernorRateLimitState, GovernorState } from "./governor-state.js"; - -export const GOVERNOR_RATE_LIMIT_REMAINING_RATIO: string; -export const GOVERNOR_CAP_USAGE_RATIO: string; - -export function renderGovernorMetrics( - rateLimitState: GovernorRateLimitState, - capUsage: GovernorCapUsage, - nowMs: number, -): string; - -export function runGovernorMetrics( - args: string[], - options?: { openGovernorState?: () => GovernorState; nowMs?: number }, -): Promise; +export declare const GOVERNOR_RATE_LIMIT_REMAINING_RATIO = "loopover_miner_governor_rate_limit_remaining_ratio"; +export declare const GOVERNOR_CAP_USAGE_RATIO = "loopover_miner_governor_cap_usage_ratio"; +export declare function renderGovernorMetrics(rateLimitState: GovernorRateLimitState, capUsage: GovernorCapUsage, nowMs: number): string; +export declare function runGovernorMetrics(args: string[], options?: { + openGovernorState?: () => GovernorState; + nowMs?: number; +}): Promise; diff --git a/packages/loopover-miner/lib/governor-metrics-cli.js b/packages/loopover-miner/lib/governor-metrics-cli.js index 0138841b11..15ea149949 100644 --- a/packages/loopover-miner/lib/governor-metrics-cli.js +++ b/packages/loopover-miner/lib/governor-metrics-cli.js @@ -1,12 +1,6 @@ -import { - DEFAULT_AMS_POLICY_SPEC, - DEFAULT_WRITE_RATE_LIMIT_POLICIES, - evaluateGovernorCaps, - evaluateLocalRateLimit, -} from "@loopover/engine"; +import { DEFAULT_AMS_POLICY_SPEC, DEFAULT_WRITE_RATE_LIMIT_POLICIES, evaluateGovernorCaps, evaluateLocalRateLimit, } from "@loopover/engine"; import { openGovernorState } from "./governor-state.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; - // `governor metrics` (#5187): render the governor's persisted rate-limit + cap-usage state (#5134, // governor-state.js) as Prometheus text-exposition, so an operator's Alertmanager can page on rate-limit/ // budget pressure without hand-rolling a scrape. Strictly read-only, mirroring queue-cli.js's `queue metrics` @@ -23,32 +17,27 @@ import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js // per-repo capLimits override from a resolved `.loopover-miner.yml` has no matching per-repo usage row to // pair it with here. Using the fleet-wide DEFAULT_AMS_POLICY_SPEC.capLimits is the same approximation // loop-cli.js itself already makes for any repo without its own override. - const GOVERNOR_METRICS_USAGE = "Usage: loopover-miner governor metrics"; - export const GOVERNOR_RATE_LIMIT_REMAINING_RATIO = "loopover_miner_governor_rate_limit_remaining_ratio"; export const GOVERNOR_CAP_USAGE_RATIO = "loopover_miner_governor_cap_usage_ratio"; - /** HELP-text escaping — backslash + newline (mirrors miner-prediction-metrics.ts's escapeHelpText). */ function escapeMetricsHelpText(help) { - return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); + return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); } - /** Prometheus label-value escaping — backslash, double-quote, newline (mirrors event-ledger-cli.js's * escapeLabelValue). */ function escapeLabelValue(value) { - return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); } - /** buckets.perRepo is keyed by writeRateLimitRepoKey(actionClass, repoFullName) = "actionClass:repoFullName" * (write-rate-limit.ts). actionClass is a fixed identifier (never contains ":"), so splitting on the FIRST * colon recovers both parts even though repoFullName itself contains a "/". */ function splitPerRepoKey(key) { - const separatorIndex = key.indexOf(":"); - if (separatorIndex === -1) return { actionClass: key, repoFullName: "" }; - return { actionClass: key.slice(0, separatorIndex), repoFullName: key.slice(separatorIndex + 1) }; + const separatorIndex = key.indexOf(":"); + if (separatorIndex === -1) + return { actionClass: key, repoFullName: "" }; + return { actionClass: key.slice(0, separatorIndex), repoFullName: key.slice(separatorIndex + 1) }; } - // evaluateLocalRateLimit's own `remaining` field answers "how many MORE writes are allowed AFTER one more write // right now" (rate-limit.ts: `remaining = allowed ? limit - effectiveCount - 1 : 0`) -- it is NOT current // headroom. At count=2/limit=3 that field is already 0, identical to a fully exhausted count=3/limit=3 bucket, @@ -58,114 +47,100 @@ function splitPerRepoKey(key) { // has already passed the DEFAULT_WRITE_RATE_LIMIT_POLICIES lookup above, so decision.limit is always one of the // frozen, non-zero policy limits -- no zero-limit guard needed. function remainingRatio(decision) { - const headroom = decision.allowed ? decision.remaining + 1 : 0; - return headroom / decision.limit; + const headroom = decision.allowed ? decision.remaining + 1 : 0; + return headroom / decision.limit; } - function collectRateLimitRows(buckets, nowMs) { - const rows = []; - for (const [actionClass, bucket] of Object.entries(buckets.global)) { - const config = DEFAULT_WRITE_RATE_LIMIT_POLICIES.global[actionClass]; - if (!config) continue; - rows.push({ - scope: "global", - actionClass, - repoFullName: "", - ratio: remainingRatio(evaluateLocalRateLimit(bucket, config, nowMs)), + const rows = []; + for (const [actionClass, bucket] of Object.entries(buckets.global)) { + const config = DEFAULT_WRITE_RATE_LIMIT_POLICIES.global[actionClass]; + if (!config) + continue; + rows.push({ + scope: "global", + actionClass, + repoFullName: "", + ratio: remainingRatio(evaluateLocalRateLimit(bucket, config, nowMs)), + }); + } + for (const [key, bucket] of Object.entries(buckets.perRepo)) { + const { actionClass, repoFullName } = splitPerRepoKey(key); + const config = DEFAULT_WRITE_RATE_LIMIT_POLICIES.perRepo[actionClass]; + if (!config) + continue; + rows.push({ + scope: "per_repo", + actionClass, + repoFullName, + ratio: remainingRatio(evaluateLocalRateLimit(bucket, config, nowMs)), + }); + } + rows.sort((a, b) => { + if (a.scope !== b.scope) + return a.scope.localeCompare(b.scope); + if (a.actionClass !== b.actionClass) + return a.actionClass.localeCompare(b.actionClass); + return a.repoFullName.localeCompare(b.repoFullName); }); - } - for (const [key, bucket] of Object.entries(buckets.perRepo)) { - const { actionClass, repoFullName } = splitPerRepoKey(key); - const config = DEFAULT_WRITE_RATE_LIMIT_POLICIES.perRepo[actionClass]; - if (!config) continue; - rows.push({ - scope: "per_repo", - actionClass, - repoFullName, - ratio: remainingRatio(evaluateLocalRateLimit(bucket, config, nowMs)), - }); - } - rows.sort((a, b) => { - if (a.scope !== b.scope) return a.scope.localeCompare(b.scope); - if (a.actionClass !== b.actionClass) return a.actionClass.localeCompare(b.actionClass); - return a.repoFullName.localeCompare(b.repoFullName); - }); - return rows; + return rows; } - // DEFAULT_AMS_POLICY_SPEC.capLimits is a frozen, non-zero constant for every dimension -- no zero-limit guard // needed, mirroring remainingRatio()'s reasoning above. function collectCapUsageRows(capUsage) { - const report = evaluateGovernorCaps(capUsage, DEFAULT_AMS_POLICY_SPEC.capLimits); - return [ - { dimension: "budget", dimensionReport: report.budget }, - { dimension: "turns", dimensionReport: report.turns }, - { dimension: "elapsed_ms", dimensionReport: report.termination }, - ].map(({ dimension, dimensionReport }) => ({ - dimension, - ratio: dimensionReport.used / dimensionReport.limit, - })); + const report = evaluateGovernorCaps(capUsage, DEFAULT_AMS_POLICY_SPEC.capLimits); + return [ + { dimension: "budget", dimensionReport: report.budget }, + { dimension: "turns", dimensionReport: report.turns }, + { dimension: "elapsed_ms", dimensionReport: report.termination }, + ].map(({ dimension, dimensionReport }) => ({ + dimension, + ratio: dimensionReport.used / dimensionReport.limit, + })); } - -/** - * @param {import("./governor-state.js").GovernorRateLimitState} rateLimitState - * @param {import("@loopover/engine").GovernorCapUsage} capUsage - * @param {number} nowMs - */ export function renderGovernorMetrics(rateLimitState, capUsage, nowMs) { - const rateLimitRows = collectRateLimitRows(rateLimitState.buckets, nowMs); - const capRows = collectCapUsageRows(capUsage); - - const lines = [ - `# HELP ${GOVERNOR_RATE_LIMIT_REMAINING_RATIO} ${escapeMetricsHelpText( - "Remaining headroom in the governor's current write-rate-limit window, as a fraction of the configured limit (1 = empty bucket, 0 = exhausted). Evaluated against DEFAULT_WRITE_RATE_LIMIT_POLICIES.", - )}`, - `# TYPE ${GOVERNOR_RATE_LIMIT_REMAINING_RATIO} gauge`, - ]; - for (const row of rateLimitRows) { - const repoLabel = row.scope === "per_repo" ? `,repo="${escapeLabelValue(row.repoFullName)}"` : ""; - lines.push( - `${GOVERNOR_RATE_LIMIT_REMAINING_RATIO}{scope="${row.scope}",action_class="${escapeLabelValue(row.actionClass)}"${repoLabel}} ${row.ratio}`, - ); - } - - lines.push( - `# HELP ${GOVERNOR_CAP_USAGE_RATIO} ${escapeMetricsHelpText( - "The governor's persisted cumulative cap usage as a fraction of DEFAULT_AMS_POLICY_SPEC.capLimits (1 = ceiling reached). dimension is one of budget|turns|elapsed_ms.", - )}`, - ); - lines.push(`# TYPE ${GOVERNOR_CAP_USAGE_RATIO} gauge`); - for (const row of capRows) { - lines.push(`${GOVERNOR_CAP_USAGE_RATIO}{dimension="${row.dimension}"} ${row.ratio}`); - } - - return `${lines.join("\n")}\n`; + const rateLimitRows = collectRateLimitRows(rateLimitState.buckets, nowMs); + const capRows = collectCapUsageRows(capUsage); + const lines = [ + `# HELP ${GOVERNOR_RATE_LIMIT_REMAINING_RATIO} ${escapeMetricsHelpText("Remaining headroom in the governor's current write-rate-limit window, as a fraction of the configured limit (1 = empty bucket, 0 = exhausted). Evaluated against DEFAULT_WRITE_RATE_LIMIT_POLICIES.")}`, + `# TYPE ${GOVERNOR_RATE_LIMIT_REMAINING_RATIO} gauge`, + ]; + for (const row of rateLimitRows) { + const repoLabel = row.scope === "per_repo" ? `,repo="${escapeLabelValue(row.repoFullName)}"` : ""; + lines.push(`${GOVERNOR_RATE_LIMIT_REMAINING_RATIO}{scope="${row.scope}",action_class="${escapeLabelValue(row.actionClass)}"${repoLabel}} ${row.ratio}`); + } + lines.push(`# HELP ${GOVERNOR_CAP_USAGE_RATIO} ${escapeMetricsHelpText("The governor's persisted cumulative cap usage as a fraction of DEFAULT_AMS_POLICY_SPEC.capLimits (1 = ceiling reached). dimension is one of budget|turns|elapsed_ms.")}`); + lines.push(`# TYPE ${GOVERNOR_CAP_USAGE_RATIO} gauge`); + for (const row of capRows) { + lines.push(`${GOVERNOR_CAP_USAGE_RATIO}{dimension="${row.dimension}"} ${row.ratio}`); + } + return `${lines.join("\n")}\n`; } - -async function withGovernorState(options, run) { - const ownsGovernorState = options.openGovernorState === undefined; - const governorState = (options.openGovernorState ?? openGovernorState)(); - try { - return run(governorState); - } finally { - if (ownsGovernorState) governorState.close(); - } +function withGovernorState(options, run) { + const ownsGovernorState = options.openGovernorState === undefined; + const governorState = (options.openGovernorState ?? openGovernorState)(); + try { + return run(governorState); + } + finally { + if (ownsGovernorState) + governorState.close(); + } } - export async function runGovernorMetrics(args, options = {}) { - if (args.length > 0) { - return reportCliFailure(argsWantJson(args), GOVERNOR_METRICS_USAGE); - } - - try { - return await withGovernorState(options, (governorState) => { - const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); - const rateLimitState = governorState.loadRateLimitState(); - const capUsage = governorState.loadCapUsage(); - console.log(renderGovernorMetrics(rateLimitState, capUsage, nowMs).trimEnd()); - return 0; - }); - } catch (error) { - return reportCliFailure(argsWantJson(args), describeCliError(error)); - } + if (args.length > 0) { + return reportCliFailure(argsWantJson(args), GOVERNOR_METRICS_USAGE); + } + try { + return await withGovernorState(options, (governorState) => { + const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); + const rateLimitState = governorState.loadRateLimitState(); + const capUsage = governorState.loadCapUsage(); + console.log(renderGovernorMetrics(rateLimitState, capUsage, nowMs).trimEnd()); + return 0; + }); + } + catch (error) { + return reportCliFailure(argsWantJson(args), describeCliError(error)); + } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZ292ZXJub3ItbWV0cmljcy1jbGkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJnb3Zlcm5vci1tZXRyaWNzLWNsaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQ0wsdUJBQXVCLEVBQ3ZCLGlDQUFpQyxFQUNqQyxvQkFBb0IsRUFDcEIsc0JBQXNCLEdBQ3ZCLE1BQU0sa0JBQWtCLENBQUM7QUFFMUIsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0scUJBQXFCLENBQUM7QUFFeEQsT0FBTyxFQUFFLFlBQVksRUFBRSxnQkFBZ0IsRUFBRSxnQkFBZ0IsRUFBRSxNQUFNLGdCQUFnQixDQUFDO0FBRWxGLG1HQUFtRztBQUNuRywwR0FBMEc7QUFDMUcsOEdBQThHO0FBQzlHLDZHQUE2RztBQUM3Ryx1R0FBdUc7QUFDdkcsNkdBQTZHO0FBQzdHLCtHQUErRztBQUMvRyw4R0FBOEc7QUFDOUcsOEdBQThHO0FBQzlHLDBDQUEwQztBQUMxQyxFQUFFO0FBQ0YsOEdBQThHO0FBQzlHLDBHQUEwRztBQUMxRywwR0FBMEc7QUFDMUcsc0dBQXNHO0FBQ3RHLDBFQUEwRTtBQUUxRSxNQUFNLHNCQUFzQixHQUFHLHdDQUF3QyxDQUFDO0FBRXhFLE1BQU0sQ0FBQyxNQUFNLG1DQUFtQyxHQUFHLG9EQUFvRCxDQUFDO0FBQ3hHLE1BQU0sQ0FBQyxNQUFNLHdCQUF3QixHQUFHLHlDQUF5QyxDQUFDO0FBRWxGLHVHQUF1RztBQUN2RyxTQUFTLHFCQUFxQixDQUFDLElBQVk7SUFDekMsT0FBTyxJQUFJLENBQUMsT0FBTyxDQUFDLEtBQUssRUFBRSxNQUFNLENBQUMsQ0FBQyxPQUFPLENBQUMsS0FBSyxFQUFFLEtBQUssQ0FBQyxDQUFDO0FBQzNELENBQUM7QUFFRDt5QkFDeUI7QUFDekIsU0FBUyxnQkFBZ0IsQ0FBQyxLQUFhO0lBQ3JDLE9BQU8sS0FBSyxDQUFDLE9BQU8sQ0FBQyxLQUFLLEVBQUUsTUFBTSxDQUFDLENBQUMsT0FBTyxDQUFDLElB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/governor-metrics-cli.ts b/packages/loopover-miner/lib/governor-metrics-cli.ts new file mode 100644 index 0000000000..93211f7a11 --- /dev/null +++ b/packages/loopover-miner/lib/governor-metrics-cli.ts @@ -0,0 +1,181 @@ +import { + DEFAULT_AMS_POLICY_SPEC, + DEFAULT_WRITE_RATE_LIMIT_POLICIES, + evaluateGovernorCaps, + evaluateLocalRateLimit, +} from "@loopover/engine"; +import type { GovernorCapUsage } from "@loopover/engine"; +import { openGovernorState } from "./governor-state.js"; +import type { GovernorRateLimitState, GovernorState } from "./governor-state.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; + +// `governor metrics` (#5187): render the governor's persisted rate-limit + cap-usage state (#5134, +// governor-state.js) as Prometheus text-exposition, so an operator's Alertmanager can page on rate-limit/ +// budget pressure without hand-rolling a scrape. Strictly read-only, mirroring queue-cli.js's `queue metrics` +// (#5186) and event-ledger-cli.js's `ledger metrics` (#4841): opens the local governor-state store, composes +// its EXISTING loadRateLimitState()/loadCapUsage() with the engine's already-exported PURE calculators +// (evaluateLocalRateLimit, evaluateGovernorCaps) against the SAME defaults the production loop (loop-cli.js) +// already falls back to when no `.loopover-ams.yml` override is configured (DEFAULT_WRITE_RATE_LIMIT_POLICIES, +// DEFAULT_AMS_POLICY_SPEC.capLimits) -- it never invents a threshold of its own, and it does not gate, retry, +// mutate, or otherwise touch governor decision logic (governor-chokepoint.js/governor-chokepoint-persisted.js +// are completely untouched by this file). +// +// capLimits is intentionally NOT read per-repo: governor-state.js's capUsage row is a single global scalar (a +// run-scoped cumulative counter, not indexed by repo -- see governor-state.js's own header comment), so a +// per-repo capLimits override from a resolved `.loopover-miner.yml` has no matching per-repo usage row to +// pair it with here. Using the fleet-wide DEFAULT_AMS_POLICY_SPEC.capLimits is the same approximation +// loop-cli.js itself already makes for any repo without its own override. + +const GOVERNOR_METRICS_USAGE = "Usage: loopover-miner governor metrics"; + +export const GOVERNOR_RATE_LIMIT_REMAINING_RATIO = "loopover_miner_governor_rate_limit_remaining_ratio"; +export const GOVERNOR_CAP_USAGE_RATIO = "loopover_miner_governor_cap_usage_ratio"; + +/** HELP-text escaping — backslash + newline (mirrors miner-prediction-metrics.ts's escapeHelpText). */ +function escapeMetricsHelpText(help: string): string { + return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); +} + +/** Prometheus label-value escaping — backslash, double-quote, newline (mirrors event-ledger-cli.js's + * escapeLabelValue). */ +function escapeLabelValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); +} + +/** buckets.perRepo is keyed by writeRateLimitRepoKey(actionClass, repoFullName) = "actionClass:repoFullName" + * (write-rate-limit.ts). actionClass is a fixed identifier (never contains ":"), so splitting on the FIRST + * colon recovers both parts even though repoFullName itself contains a "/". */ +function splitPerRepoKey(key: string): { actionClass: string; repoFullName: string } { + const separatorIndex = key.indexOf(":"); + if (separatorIndex === -1) return { actionClass: key, repoFullName: "" }; + return { actionClass: key.slice(0, separatorIndex), repoFullName: key.slice(separatorIndex + 1) }; +} + +// evaluateLocalRateLimit's own `remaining` field answers "how many MORE writes are allowed AFTER one more write +// right now" (rate-limit.ts: `remaining = allowed ? limit - effectiveCount - 1 : 0`) -- it is NOT current +// headroom. At count=2/limit=3 that field is already 0, identical to a fully exhausted count=3/limit=3 bucket, +// even though the count=2 bucket still has one write available. Recover true current headroom algebraically +// instead: when allowed, decision.remaining + 1 is exactly limit - effectiveCount (undo the "-1 for this next +// write" the decision already applied); when not allowed, headroom is 0. Every actionClass this loop reaches +// has already passed the DEFAULT_WRITE_RATE_LIMIT_POLICIES lookup above, so decision.limit is always one of the +// frozen, non-zero policy limits -- no zero-limit guard needed. +function remainingRatio(decision: { allowed: boolean; remaining: number; limit: number }): number { + const headroom = decision.allowed ? decision.remaining + 1 : 0; + return headroom / decision.limit; +} + +function collectRateLimitRows( + buckets: GovernorRateLimitState["buckets"], + nowMs: number, +): Array<{ scope: string; actionClass: string; repoFullName: string; ratio: number }> { + const rows: Array<{ scope: string; actionClass: string; repoFullName: string; ratio: number }> = []; + for (const [actionClass, bucket] of Object.entries(buckets.global)) { + const config = DEFAULT_WRITE_RATE_LIMIT_POLICIES.global[actionClass]; + if (!config) continue; + rows.push({ + scope: "global", + actionClass, + repoFullName: "", + ratio: remainingRatio(evaluateLocalRateLimit(bucket, config, nowMs)), + }); + } + for (const [key, bucket] of Object.entries(buckets.perRepo)) { + const { actionClass, repoFullName } = splitPerRepoKey(key); + const config = DEFAULT_WRITE_RATE_LIMIT_POLICIES.perRepo[actionClass]; + if (!config) continue; + rows.push({ + scope: "per_repo", + actionClass, + repoFullName, + ratio: remainingRatio(evaluateLocalRateLimit(bucket, config, nowMs)), + }); + } + rows.sort((a, b) => { + if (a.scope !== b.scope) return a.scope.localeCompare(b.scope); + if (a.actionClass !== b.actionClass) return a.actionClass.localeCompare(b.actionClass); + return a.repoFullName.localeCompare(b.repoFullName); + }); + return rows; +} + +// DEFAULT_AMS_POLICY_SPEC.capLimits is a frozen, non-zero constant for every dimension -- no zero-limit guard +// needed, mirroring remainingRatio()'s reasoning above. +function collectCapUsageRows(capUsage: GovernorCapUsage): Array<{ dimension: string; ratio: number }> { + const report = evaluateGovernorCaps(capUsage, DEFAULT_AMS_POLICY_SPEC.capLimits); + return [ + { dimension: "budget", dimensionReport: report.budget }, + { dimension: "turns", dimensionReport: report.turns }, + { dimension: "elapsed_ms", dimensionReport: report.termination }, + ].map(({ dimension, dimensionReport }) => ({ + dimension, + ratio: dimensionReport.used / dimensionReport.limit, + })); +} + +export function renderGovernorMetrics( + rateLimitState: GovernorRateLimitState, + capUsage: GovernorCapUsage, + nowMs: number, +): string { + const rateLimitRows = collectRateLimitRows(rateLimitState.buckets, nowMs); + const capRows = collectCapUsageRows(capUsage); + + const lines = [ + `# HELP ${GOVERNOR_RATE_LIMIT_REMAINING_RATIO} ${escapeMetricsHelpText( + "Remaining headroom in the governor's current write-rate-limit window, as a fraction of the configured limit (1 = empty bucket, 0 = exhausted). Evaluated against DEFAULT_WRITE_RATE_LIMIT_POLICIES.", + )}`, + `# TYPE ${GOVERNOR_RATE_LIMIT_REMAINING_RATIO} gauge`, + ]; + for (const row of rateLimitRows) { + const repoLabel = row.scope === "per_repo" ? `,repo="${escapeLabelValue(row.repoFullName)}"` : ""; + lines.push( + `${GOVERNOR_RATE_LIMIT_REMAINING_RATIO}{scope="${row.scope}",action_class="${escapeLabelValue(row.actionClass)}"${repoLabel}} ${row.ratio}`, + ); + } + + lines.push( + `# HELP ${GOVERNOR_CAP_USAGE_RATIO} ${escapeMetricsHelpText( + "The governor's persisted cumulative cap usage as a fraction of DEFAULT_AMS_POLICY_SPEC.capLimits (1 = ceiling reached). dimension is one of budget|turns|elapsed_ms.", + )}`, + ); + lines.push(`# TYPE ${GOVERNOR_CAP_USAGE_RATIO} gauge`); + for (const row of capRows) { + lines.push(`${GOVERNOR_CAP_USAGE_RATIO}{dimension="${row.dimension}"} ${row.ratio}`); + } + + return `${lines.join("\n")}\n`; +} + +function withGovernorState( + options: { openGovernorState?: () => GovernorState }, + run: (governorState: GovernorState) => T, +): T { + const ownsGovernorState = options.openGovernorState === undefined; + const governorState = (options.openGovernorState ?? openGovernorState)(); + try { + return run(governorState); + } finally { + if (ownsGovernorState) governorState.close(); + } +} + +export async function runGovernorMetrics( + args: string[], + options: { openGovernorState?: () => GovernorState; nowMs?: number } = {}, +): Promise { + if (args.length > 0) { + return reportCliFailure(argsWantJson(args), GOVERNOR_METRICS_USAGE); + } + + try { + return await withGovernorState(options, (governorState) => { + const nowMs = Number.isFinite(options.nowMs) ? (options.nowMs as number) : Date.now(); + const rateLimitState = governorState.loadRateLimitState(); + const capUsage = governorState.loadCapUsage(); + console.log(renderGovernorMetrics(rateLimitState, capUsage, nowMs).trimEnd()); + return 0; + }); + } catch (error) { + return reportCliFailure(argsWantJson(args), describeCliError(error)); + } +} diff --git a/packages/loopover-miner/lib/governor-pause-cli.d.ts b/packages/loopover-miner/lib/governor-pause-cli.d.ts index a83bbccfcd..7677e385d4 100644 --- a/packages/loopover-miner/lib/governor-pause-cli.d.ts +++ b/packages/loopover-miner/lib/governor-pause-cli.d.ts @@ -1,23 +1,27 @@ import type { GovernorState } from "./governor-state.js"; - -export type ParsedGovernorPauseArgs = - | { json: boolean; dryRun: boolean; reason: string | null } - | { error: string }; - -export type ParsedGovernorResumeArgs = { json: boolean; dryRun: boolean } | { error: string }; - -export type ParsedGovernorNoArgsSubcommand = { json: boolean } | { error: string }; - +export type ParsedGovernorPauseArgs = { + json: boolean; + dryRun: boolean; + reason: string | null; +} | { + error: string; +}; +export type ParsedGovernorResumeArgs = { + json: boolean; + dryRun: boolean; +} | { + error: string; +}; +export type ParsedGovernorNoArgsSubcommand = { + json: boolean; +} | { + error: string; +}; export type GovernorPauseCliOptions = { - openGovernorState?: () => GovernorState; + openGovernorState?: () => GovernorState; }; - -export function parseGovernorPauseArgs(args: string[]): ParsedGovernorPauseArgs; - -export function parseGovernorResumeArgs(args: string[]): ParsedGovernorResumeArgs; - -export function runGovernorPause(args: string[], options?: GovernorPauseCliOptions): Promise; - -export function runGovernorResume(args: string[], options?: GovernorPauseCliOptions): Promise; - -export function runGovernorStatus(args: string[], options?: GovernorPauseCliOptions): Promise; +export declare function parseGovernorPauseArgs(args: string[]): ParsedGovernorPauseArgs; +export declare function parseGovernorResumeArgs(args: string[]): ParsedGovernorResumeArgs; +export declare function runGovernorPause(args: string[], options?: GovernorPauseCliOptions): Promise; +export declare function runGovernorResume(args: string[], options?: GovernorPauseCliOptions): Promise; +export declare function runGovernorStatus(args: string[], options?: GovernorPauseCliOptions): Promise; diff --git a/packages/loopover-miner/lib/governor-pause-cli.js b/packages/loopover-miner/lib/governor-pause-cli.js index aa55b5cee0..e3caf3c415 100644 --- a/packages/loopover-miner/lib/governor-pause-cli.js +++ b/packages/loopover-miner/lib/governor-pause-cli.js @@ -5,162 +5,162 @@ // path) -- this is the first genuinely operator/governor-writable stop/go control. Persisted on governor-state.js's // existing single-row scalar-state table, not a new store: a pause flag has no relational key of its own, the // same reasoning that table's other scalar fields (rate-limit buckets, cap usage) already rely on. - import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { openGovernorState } from "./governor-state.js"; - const GOVERNOR_PAUSE_USAGE = "Usage: loopover-miner governor pause [--reason ] [--dry-run] [--json]"; const GOVERNOR_RESUME_USAGE = "Usage: loopover-miner governor resume [--dry-run] [--json]"; const GOVERNOR_STATUS_USAGE = "Usage: loopover-miner governor status [--json]"; - export function parseGovernorPauseArgs(args) { - const options = { json: false, dryRun: false, reason: null }; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; + const options = { + json: false, + dryRun: false, + reason: null, + }; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + // #4847: reports what pausing would do and returns before writing to governor-state. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--reason") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: GOVERNOR_PAUSE_USAGE }; + options.reason = value; + index += 1; + continue; + } + return { error: `Unknown option: ${token}` }; } - // #4847: reports what pausing would do and returns before writing to governor-state. - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - if (token === "--reason") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: GOVERNOR_PAUSE_USAGE }; - options.reason = value; - index += 1; - continue; - } - return { error: `Unknown option: ${token}` }; - } - - return options; + return options; } - export function parseGovernorResumeArgs(args) { - const options = { json: false, dryRun: false }; - - for (const token of args) { - if (token === "--json") { - options.json = true; - continue; + const options = { json: false, dryRun: false }; + for (const token of args) { + if (token === "--json") { + options.json = true; + continue; + } + // #4847: reports what resuming would do and returns before writing to governor-state. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + return { error: GOVERNOR_RESUME_USAGE }; } - // #4847: reports what resuming would do and returns before writing to governor-state. - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - return { error: GOVERNOR_RESUME_USAGE }; - } - - return options; + return options; } - function parseNoArgsSubcommand(args, usage) { - if (args.length === 0) return { json: false }; - if (args.length === 1 && args[0] === "--json") return { json: true }; - return { error: usage }; + if (args.length === 0) + return { json: false }; + if (args.length === 1 && args[0] === "--json") + return { json: true }; + return { error: usage }; } - -async function withGovernorState(options, run) { - const ownsGovernorState = options.openGovernorState === undefined; - const governorState = (options.openGovernorState ?? openGovernorState)(); - try { - return run(governorState); - } finally { - if (ownsGovernorState) governorState.close(); - } +function withGovernorState(options, run) { + const ownsGovernorState = options.openGovernorState === undefined; + const governorState = (options.openGovernorState ?? openGovernorState)(); + try { + return run(governorState); + } + finally { + if (ownsGovernorState) + governorState.close(); + } } - function renderPauseState(pauseState) { - if (!pauseState.paused) return "governor is not paused"; - const reason = pauseState.reason ? ` (${pauseState.reason})` : ""; - return `governor is PAUSED since ${pauseState.pausedAt}${reason}`; + if (!pauseState.paused) + return "governor is not paused"; + const reason = pauseState.reason ? ` (${pauseState.reason})` : ""; + return `governor is PAUSED since ${pauseState.pausedAt}${reason}`; } - export async function runGovernorPause(args, options = {}) { - const parsed = parseGovernorPauseArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", paused: true, reason: parsed.reason }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult)); - } else { - const reason = parsed.reason ? ` (${parsed.reason})` : ""; - console.log(`DRY RUN: would pause the governor${reason}. No governor-state write was made.`); + const parsed = parseGovernorPauseArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", paused: true, reason: parsed.reason }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult)); + } + else { + const reason = parsed.reason ? ` (${parsed.reason})` : ""; + console.log(`DRY RUN: would pause the governor${reason}. No governor-state write was made.`); + } + return 0; + } + try { + return await withGovernorState(options, (governorState) => { + const pauseState = governorState.savePauseState({ paused: true, reason: parsed.reason }); + if (parsed.json) { + console.log(JSON.stringify(pauseState)); + } + else { + console.log(renderPauseState(pauseState)); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); } - return 0; - } - - try { - return await withGovernorState(options, (governorState) => { - const pauseState = governorState.savePauseState({ paused: true, reason: parsed.reason }); - if (parsed.json) { - console.log(JSON.stringify(pauseState)); - } else { - console.log(renderPauseState(pauseState)); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } } - export async function runGovernorResume(args, options = {}) { - const parsed = parseGovernorResumeArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", paused: false }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult)); - } else { - console.log("DRY RUN: would resume the governor. No governor-state write was made."); + const parsed = parseGovernorResumeArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", paused: false }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult)); + } + else { + console.log("DRY RUN: would resume the governor. No governor-state write was made."); + } + return 0; + } + try { + return await withGovernorState(options, (governorState) => { + const pauseState = governorState.savePauseState({ paused: false }); + if (parsed.json) { + console.log(JSON.stringify(pauseState)); + } + else { + console.log(renderPauseState(pauseState)); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); } - return 0; - } - - try { - return await withGovernorState(options, (governorState) => { - const pauseState = governorState.savePauseState({ paused: false }); - if (parsed.json) { - console.log(JSON.stringify(pauseState)); - } else { - console.log(renderPauseState(pauseState)); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } } - export async function runGovernorStatus(args, options = {}) { - const parsed = parseNoArgsSubcommand(args, GOVERNOR_STATUS_USAGE); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - try { - return await withGovernorState(options, (governorState) => { - const pauseState = governorState.loadPauseState(); - if (parsed.json) { - console.log(JSON.stringify(pauseState)); - } else { - console.log(renderPauseState(pauseState)); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } + const parsed = parseNoArgsSubcommand(args, GOVERNOR_STATUS_USAGE); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + try { + return await withGovernorState(options, (governorState) => { + const pauseState = governorState.loadPauseState(); + if (parsed.json) { + console.log(JSON.stringify(pauseState)); + } + else { + console.log(renderPauseState(pauseState)); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZ292ZXJub3ItcGF1c2UtY2xpLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiZ292ZXJub3ItcGF1c2UtY2xpLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLCtHQUErRztBQUMvRywrR0FBK0c7QUFDL0csaUhBQWlIO0FBQ2pILCtHQUErRztBQUMvRyxvSEFBb0g7QUFDcEgsOEdBQThHO0FBQzlHLG1HQUFtRztBQUVuRyxPQUFPLEVBQUUsWUFBWSxFQUFFLGdCQUFnQixFQUFFLGdCQUFnQixFQUFFLE1BQU0sZ0JBQWdCLENBQUM7QUFDbEYsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0scUJBQXFCLENBQUM7QUFHeEQsTUFBTSxvQkFBb0IsR0FBRyw2RUFBNkUsQ0FBQztBQUMzRyxNQUFNLHFCQUFxQixHQUFHLDREQUE0RCxDQUFDO0FBQzNGLE1BQU0scUJBQXFCLEdBQUcsZ0RBQWdELENBQUM7QUFjL0UsTUFBTSxVQUFVLHNCQUFzQixDQUFDLElBQWM7SUFDbkQsTUFBTSxPQUFPLEdBQThEO1FBQ3pFLElBQUksRUFBRSxLQUFLO1FBQ1gsTUFBTSxFQUFFLEtBQUs7UUFDYixNQUFNLEVBQUUsSUFBSTtLQUNiLENBQUM7SUFFRixLQUFLLElBQUksS0FBSyxHQUFHLENBQUMsRUFBRSxLQUFLLEdBQUcsSUFBSSxDQUFDLE1BQU0sRUFBRSxLQUFLLElBQUksQ0FBQyxFQUFFLENBQUM7UUFDcEQsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssQ0FBQyxDQUFDO1FBQzFCLElBQUksS0FBSyxLQUFLLFFBQVEsRUFBRSxDQUFDO1lBQ3ZCLE9BQU8sQ0FBQyxJQUFJLEdBQUcsSUFBSSxDQUFDO1lBQ3BCLFNBQVM7UUFDWCxDQUFDO1FBQ0QscUZBQXFGO1FBQ3JGLElBQUksS0FBSyxLQUFLLFdBQVcsRUFBRSxDQUFDO1lBQzFCLE9BQU8sQ0FBQyxNQUFNLEdBQUcsSUFBSSxDQUFDO1lBQ3RCLFNBQVM7UUFDWCxDQUFDO1FBQ0QsSUFBSSxLQUFL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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/governor-pause-cli.ts b/packages/loopover-miner/lib/governor-pause-cli.ts new file mode 100644 index 0000000000..0de8c1bf5b --- /dev/null +++ b/packages/loopover-miner/lib/governor-pause-cli.ts @@ -0,0 +1,186 @@ +// The governor pause/resume control surface (#4851): a real, persisted pause flag an operator (or, in a future +// wave, the governor itself) can toggle via this CLI, that loop-cli.js's iteration loop actually checks before +// each cycle. Distinct from governor-kill-switch.js (a read-only resolver over pre-existing env/YAML inputs this +// package never itself writes) and governor-run-halt.js (a one-way, run-scoped terminal breaker with no resume +// path) -- this is the first genuinely operator/governor-writable stop/go control. Persisted on governor-state.js's +// existing single-row scalar-state table, not a new store: a pause flag has no relational key of its own, the +// same reasoning that table's other scalar fields (rate-limit buckets, cap usage) already rely on. + +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { openGovernorState } from "./governor-state.js"; +import type { GovernorPauseState, GovernorState } from "./governor-state.js"; + +const GOVERNOR_PAUSE_USAGE = "Usage: loopover-miner governor pause [--reason ] [--dry-run] [--json]"; +const GOVERNOR_RESUME_USAGE = "Usage: loopover-miner governor resume [--dry-run] [--json]"; +const GOVERNOR_STATUS_USAGE = "Usage: loopover-miner governor status [--json]"; + +export type ParsedGovernorPauseArgs = + | { json: boolean; dryRun: boolean; reason: string | null } + | { error: string }; + +export type ParsedGovernorResumeArgs = { json: boolean; dryRun: boolean } | { error: string }; + +export type ParsedGovernorNoArgsSubcommand = { json: boolean } | { error: string }; + +export type GovernorPauseCliOptions = { + openGovernorState?: () => GovernorState; +}; + +export function parseGovernorPauseArgs(args: string[]): ParsedGovernorPauseArgs { + const options: { json: boolean; dryRun: boolean; reason: string | null } = { + json: false, + dryRun: false, + reason: null, + }; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + // #4847: reports what pausing would do and returns before writing to governor-state. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--reason") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: GOVERNOR_PAUSE_USAGE }; + options.reason = value; + index += 1; + continue; + } + return { error: `Unknown option: ${token}` }; + } + + return options; +} + +export function parseGovernorResumeArgs(args: string[]): ParsedGovernorResumeArgs { + const options: { json: boolean; dryRun: boolean } = { json: false, dryRun: false }; + + for (const token of args) { + if (token === "--json") { + options.json = true; + continue; + } + // #4847: reports what resuming would do and returns before writing to governor-state. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + return { error: GOVERNOR_RESUME_USAGE }; + } + + return options; +} + +function parseNoArgsSubcommand(args: string[], usage: string): ParsedGovernorNoArgsSubcommand { + if (args.length === 0) return { json: false }; + if (args.length === 1 && args[0] === "--json") return { json: true }; + return { error: usage }; +} + +function withGovernorState( + options: GovernorPauseCliOptions, + run: (governorState: GovernorState) => T, +): T { + const ownsGovernorState = options.openGovernorState === undefined; + const governorState = (options.openGovernorState ?? openGovernorState)(); + try { + return run(governorState); + } finally { + if (ownsGovernorState) governorState.close(); + } +} + +function renderPauseState(pauseState: GovernorPauseState): string { + if (!pauseState.paused) return "governor is not paused"; + const reason = pauseState.reason ? ` (${pauseState.reason})` : ""; + return `governor is PAUSED since ${pauseState.pausedAt}${reason}`; +} + +export async function runGovernorPause(args: string[], options: GovernorPauseCliOptions = {}): Promise { + const parsed = parseGovernorPauseArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", paused: true, reason: parsed.reason }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult)); + } else { + const reason = parsed.reason ? ` (${parsed.reason})` : ""; + console.log(`DRY RUN: would pause the governor${reason}. No governor-state write was made.`); + } + return 0; + } + + try { + return await withGovernorState(options, (governorState) => { + const pauseState = governorState.savePauseState({ paused: true, reason: parsed.reason }); + if (parsed.json) { + console.log(JSON.stringify(pauseState)); + } else { + console.log(renderPauseState(pauseState)); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +export async function runGovernorResume(args: string[], options: GovernorPauseCliOptions = {}): Promise { + const parsed = parseGovernorResumeArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", paused: false }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult)); + } else { + console.log("DRY RUN: would resume the governor. No governor-state write was made."); + } + return 0; + } + + try { + return await withGovernorState(options, (governorState) => { + const pauseState = governorState.savePauseState({ paused: false }); + if (parsed.json) { + console.log(JSON.stringify(pauseState)); + } else { + console.log(renderPauseState(pauseState)); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +export async function runGovernorStatus(args: string[], options: GovernorPauseCliOptions = {}): Promise { + const parsed = parseNoArgsSubcommand(args, GOVERNOR_STATUS_USAGE); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + try { + return await withGovernorState(options, (governorState) => { + const pauseState = governorState.loadPauseState(); + if (parsed.json) { + console.log(JSON.stringify(pauseState)); + } else { + console.log(renderPauseState(pauseState)); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} diff --git a/packages/loopover-miner/lib/loop-cli.d.ts b/packages/loopover-miner/lib/loop-cli.d.ts index 8ad9c178c8..68dfde88b0 100644 --- a/packages/loopover-miner/lib/loop-cli.d.ts +++ b/packages/loopover-miner/lib/loop-cli.d.ts @@ -1,66 +1,106 @@ -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 { EventLedger } from "./event-ledger.js"; +import type { PortfolioQueueStore } from "./portfolio-queue.js"; import type { RunStateStore } from "./run-state.js"; +import type { AttemptCliResult } from "./attempt-cli.js"; import type { PollPrDispositionOptions } from "./pr-disposition-poller.js"; import type { CheckRunConclusion, PollCheckRunsOptions } from "./ci-poller.js"; - -export type ParsedLoopArgs = - | { error: string } - | { - targets: string[]; - search: string | null; - minerLogin: string; - base: string; - live: boolean; - dryRun: boolean; - maxCycles: number | undefined; - cycleDelayMs: number; - json: boolean; - }; - -export function parseLoopArgs(args: string[]): ParsedLoopArgs; - +export type ParsedLoopArgs = { + error: string; +} | { + targets: string[]; + search: string | null; + minerLogin: string; + base: string; + live: boolean; + dryRun: boolean; + maxCycles: number | undefined; + cycleDelayMs: number; + json: boolean; +}; 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; - ciConclusion?: CheckRunConclusion | null; - reentered?: boolean; - reasons?: string[]; + 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; + ciConclusion?: CheckRunConclusion | 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 }>; - pollCheckRuns?: (repoFullName: string, prNumber: number, options?: PollCheckRunsOptions) => Promise<{ conclusion: CheckRunConclusion; checks: unknown[]; headSha: string; 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; - ciPollOptions?: PollCheckRunsOptions; + 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; + }>; + pollCheckRuns?: (repoFullName: string, prNumber: number, options?: PollCheckRunsOptions) => Promise<{ + conclusion: CheckRunConclusion; + checks: unknown[]; + headSha: string; + 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; + ciPollOptions?: PollCheckRunsOptions; }; - -export function runLoop(args: string[], options?: RunLoopOptions): Promise; +export declare function parseLoopArgs(args: string[]): ParsedLoopArgs; +/** + * 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. + */ +export declare function runLoop(args: string[], options?: RunLoopOptions): Promise; diff --git a/packages/loopover-miner/lib/loop-cli.js b/packages/loopover-miner/lib/loop-cli.js index 5133f62249..020e864ee9 100644 --- a/packages/loopover-miner/lib/loop-cli.js +++ b/packages/loopover-miner/lib/loop-cli.js @@ -21,7 +21,6 @@ // dequeueNext claim + markDone/markFailed calls below already maintain -- the same source a one-shot `attempt` // invocation reads (#5654), so both share one source of truth and the counters survive a loop-daemon restart // (crash/deploy/systemd bounce) instead of resetting with the process (#5677). - import { checkMinerKillSwitch } from "./governor-kill-switch.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { evaluateRunLoopBoundaryGate } from "./governor-run-halt.js"; @@ -42,549 +41,486 @@ import { attemptLoopReentry } from "./loop-reentry.js"; import { parsePrNumberFromExecResult } from "./pr-number-parse.js"; import { resolveGitHubToken } from "./github-token-resolution.js"; import { DEFAULT_AMS_POLICY_SPEC } from "@loopover/engine"; - -const LOOP_USAGE = - "Usage: loopover-miner loop [...] | --search --miner-login [--base ] [--live] [--dry-run] [--max-cycles ] [--cycle-delay-ms ] [--json]"; +const LOOP_USAGE = "Usage: loopover-miner loop [...] | --search --miner-login [--base ] [--live] [--dry-run] [--max-cycles ] [--cycle-delay-ms ] [--json]"; const DEFAULT_CYCLE_DELAY_MS = 60_000; const ISSUE_IDENTIFIER_PATTERN = /^issue:(\d+)$/; - 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}`; + 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; + 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, - dryRun: 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; - } - // #4847: see attempt-cli.js's own --dry-run comment -- distinct from --live's absence, this short-circuits - // BEFORE governor state or any other store is opened, guaranteeing zero discovery/queue/ledger writes. - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - if (token === "--search") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; - 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; + const options = { + json: false, + minerLogin: null, + base: "main", + live: false, + dryRun: false, + search: null, + maxCycles: undefined, + cycleDelayMs: DEFAULT_CYCLE_DELAY_MS, + }; + const targets = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--live") { + options.live = true; + continue; + } + // #4847: see attempt-cli.js's own --dry-run comment -- distinct from --live's absence, this short-circuits + // BEFORE governor state or any other store is opened, guaranteeing zero discovery/queue/ledger writes. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--search") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: LOOP_USAGE }; + 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 (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, - dryRun: options.dryRun, - maxCycles: options.maxCycles, - cycleDelayMs: options.cycleDelayMs, - json: options.json, - }; + 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, + dryRun: options.dryRun, + maxCycles: options.maxCycles, + cycleDelayMs: options.cycleDelayMs, + json: options.json, + }; } - function discoverArgv(parsed) { - return parsed.search !== null ? ["--search", parsed.search] : [...parsed.targets]; + 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; + const match = typeof identifier === "string" ? identifier.match(ISSUE_IDENTIFIER_PATTERN) : null; + return match ? Number(match[1]) : null; } - /** * 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, - * pollCheckRuns?: typeof pollCheckRuns, - * recordPrOutcomeSnapshot?: typeof recordPrOutcomeSnapshot, - * buildLoopClosureSummary?: typeof buildLoopClosureSummary, - * attemptLoopReentry?: typeof attemptLoopReentry, - * attemptOptions?: Record, - * prDispositionOptions?: Record, - * ciPollOptions?: Record, - * }} [options] - * @returns {Promise} */ export async function runLoop(args, options = {}) { - const parsed = parseLoopArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - 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(); - - // #4847: reports what a real loop invocation would target and returns BEFORE governor state or any other - // store (event/governor ledger, portfolio queue, run state) is opened -- a provable zero-write path, not just - // "opened but didn't write." The loop's own discovery call enqueues newly-found candidates into the LOCAL - // portfolio queue even before any attempt happens, so a faithful dry run cannot call it either. - if (parsed.dryRun) { - const dryRunResult = { - outcome: "dry_run", - targets: parsed.targets, - search: parsed.search, - minerLogin: parsed.minerLogin, - base: parsed.base, - live: parsed.live, - maxCycles: parsed.maxCycles ?? null, - }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - const target = parsed.search !== null ? `--search ${parsed.search}` : parsed.targets.join(", "); - console.log( - `DRY RUN: would run an autonomous loop against ${target} for ${parsed.minerLogin} (base: ${parsed.base}, live: ${parsed.live}). No discovery, queue, or ledger writes were made.`, - ); - } - return 0; - } - - let governorState; - try { - governorState = (options.openGovernorState ?? openGovernorState)(); - } catch (error) { - return reportCliFailure( - parsed.json, - `Loop refuses to start: governor state cannot be loaded: ${describeCliError(error)}`, - 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 pollCheckRunsFn = options.pollCheckRuns ?? pollCheckRuns; - const recordPrOutcomeSnapshotFn = options.recordPrOutcomeSnapshot ?? recordPrOutcomeSnapshot; - const buildLoopClosureSummaryFn = options.buildLoopClosureSummary ?? buildLoopClosureSummary; - const attemptLoopReentryFn = options.attemptLoopReentry ?? attemptLoopReentry; - - // Resolved ONCE, at the CLI-entrypoint layer, mirroring manage-poll.js's own runManagePoll (its - // recordManagePollSnapshot callee has no env fallback of its own either -- the top-level CLI function is - // where the GitHub token gets resolved, then threaded down explicitly to every real GitHub caller). - // pollPrDisposition (unlike runDiscover, which falls back to process.env.GITHUB_TOKEN internally) has NO - // such fallback -- an unresolved githubToken here would silently poll unauthenticated. - // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the - // authenticated `loopover-mcp login` session -- cached in memory for this process's lifetime. - const githubToken = options.githubToken ?? (await resolveGitHubToken(env)) ?? ""; - - async function runDiscoveryOnce() { - await runDiscoverFn(discoverArgv(parsed), { - initPortfolioQueue: () => portfolioQueue, - githubToken, - apiBaseUrl: options.apiBaseUrl, - nowMs: nowMsFn(), - }); - } - - let usage = governorState.loadCapUsage(); - 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 OR an already-active pause (#4851) halts the loop without ever touching GitHub or the queue. The - // pause flag is real, persisted, operator/governor-writable state on governorState (toggled via - // `loopover-miner governor pause`/`resume`) -- unlike the kill switch, a paused run resumes simply by being - // re-invoked: every piece of per-cycle state this loop reads (portfolioQueue, runState, governorState's own - // cap usage) is already durable, so clearing the flag and restarting continues exactly where it left off. - const initialKillSwitch = checkKillSwitchFn({ env }); - const initialPauseState = governorState.loadPauseState(); - let claimed = null; - if (initialKillSwitch.active) { - haltReason = `kill_switch_${initialKillSwitch.scope}`; - cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); - } else if (initialPauseState.paused) { - haltReason = "paused"; - cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); - } else { - await runDiscoveryOnce(); - claimed = portfolioQueue.dequeueNext(); + const parsed = parseLoopArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); } - - 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}`; - // Release the in-flight claim so left state is defined (#5670 / mirrors run-halt's markFailed). - if (claimed) { - portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + // Narrow for nested closures (TS resets control-flow narrowing inside nested functions). + const loopArgs = parsed; + 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(); + // #4847: reports what a real loop invocation would target and returns BEFORE governor state or any other + // store (event/governor ledger, portfolio queue, run state) is opened -- a provable zero-write path, not just + // "opened but didn't write." The loop's own discovery call enqueues newly-found candidates into the LOCAL + // portfolio queue even before any attempt happens, so a faithful dry run cannot call it either. + if (parsed.dryRun) { + const dryRunResult = { + outcome: "dry_run", + targets: parsed.targets, + search: parsed.search, + minerLogin: parsed.minerLogin, + base: parsed.base, + live: parsed.live, + maxCycles: parsed.maxCycles ?? null, + }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); } - cycles.push({ - cycle: cycleIndex, - outcome: "halted", - reason: haltReason, - ...(claimed - ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } - : {}), - }); - break; - } - - const pauseState = governorState.loadPauseState(); - if (pauseState.paused) { - haltReason = "paused"; - if (claimed) { - portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + else { + const target = parsed.search !== null ? `--search ${parsed.search}` : parsed.targets.join(", "); + console.log(`DRY RUN: would run an autonomous loop against ${target} for ${parsed.minerLogin} (base: ${parsed.base}, live: ${parsed.live}). No discovery, queue, or ledger writes were made.`); } - cycles.push({ - cycle: cycleIndex, - outcome: "halted", - reason: haltReason, - ...(claimed - ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } - : {}), - }); - 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, claimed.apiBaseUrl); - cycles.push({ cycle: cycleIndex, outcome: "skipped_malformed_identifier", identifier: claimed.identifier }); - claimed = portfolioQueue.dequeueNext(); - continue; - } - - const amsPolicy = await resolveAmsPolicyFn(claimed.repoFullName, { env }); - // Real, SQLite-persisted per-item convergence history (#5677): the dequeueNext claim above already recorded - // this attempt and the markDone/markFailed calls below record the outcome, so reading it back here shares one - // source of truth with attempt-cli.js (#5654) and survives a loop-daemon restart instead of resetting. - const convergenceInput = portfolioQueue.getAttemptHistory( - claimed.repoFullName, - claimed.identifier, - claimed.apiBaseUrl, - ); - - 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 }, - // Echoes claimed.apiBaseUrl (#5563), NOT the callback's own repoFullName/identifier alone -- two forge - // hosts can share an in-flight item with the same repo name+identifier. - markFailed: (repoFullName, identifier) => portfolioQueue.markFailed(repoFullName, identifier, claimed.apiBaseUrl), - }, - { 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; - } - - 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 = { - // Real for the agent-sdk provider (its own SDK result message reports total_cost_usd, wired through - // runMinerAttempt's real loopResult.totalCostUsd); the CLI-subprocess providers (claude-cli/codex-cli) - // report no cost signal today, so this contributes 0 for those runs -- an honest absence, not a - // fabricated number. A capLimits.budget dimension only ever meaningfully trips against agent-sdk spend. - budgetSpent: usage.budgetSpent + (lastResult?.totalCostUsd ?? 0), - 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"; - // Mid-attempt kill-switch abandon (#5670): stop the outer loop immediately instead of waiting for the - // next between-cycle probe, and treat the item like any other re-queued abandon via markFailed below. - const killSwitchAbandon = lastResult?.abandonReason === "kill_switch_engaged"; - - if (submitted || permanentBlock) { - // Both terminal -- a submitted PR is done, and a repo-wide AI-usage-policy ban never resolves on retry -- - // so neither is re-queued. markDone also clears the persisted consecutive-failure streak. - portfolioQueue.markDone(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); - } else { - // Any other blocked/abandoned/stale/governed outcome may resolve on a later retry, so requeue it; markFailed - // records the re-enqueue + consecutive failure the non-convergence detector reads on the next cycle. - portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); - } - - if (killSwitchAbandon) { - const liveKill = checkKillSwitchFn({ env }); - haltReason = liveKill.active ? `kill_switch_${liveKill.scope}` : "kill_switch_engaged"; - cycles.push({ - cycle: cycleIndex, - outcome: "halted", - reason: haltReason, - repoFullName: claimed.repoFullName, - identifier: claimed.identifier, - attemptOutcome, - }); - break; - } - - let reentryOutcome = "other"; - let prNumber = null; - let prDisposition = null; - let ciConclusion = null; - if (submitted) { - prNumber = parsePrNumberFromExecResult(lastResult?.execResult, claimed.repoFullName); - if (prNumber !== null) { - // Real CI-status observation (#5394): recorded BEFORE the disposition poll below, so a submitted - // PR's check-run state is captured even while it's still open, not just at its eventual merge/close. - // ci-poller.js's real GitHub check-run polling is a heuristic proxy for the gate verdict; the - // authoritative terminal merge/close outcome comes from pollPrDispositionFn below, sourced directly - // from GitHub's own PR state rather than a server-internal endpoint (#5450). - const ciStatus = await pollCheckRunsFn(claimed.repoFullName, prNumber, { - githubToken, - apiBaseUrl: options.apiBaseUrl, - ...(options.ciPollOptions ?? {}), - }); - ciConclusion = ciStatus.conclusion; - eventLedger.appendEvent({ - type: "ci_status_observed", - repoFullName: claimed.repoFullName, - payload: { prNumber, conclusion: ciStatus.conclusion, checkCount: ciStatus.checks.length, source: "ci-poller" }, - }); - - prDisposition = await pollPrDispositionFn(claimed.repoFullName, prNumber, { + return 0; + } + let governorState; + try { + governorState = (options.openGovernorState ?? openGovernorState)(); + } + catch (error) { + return reportCliFailure(parsed.json, `Loop refuses to start: governor state cannot be loaded: ${describeCliError(error)}`, 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 pollCheckRunsFn = options.pollCheckRuns ?? pollCheckRuns; + const recordPrOutcomeSnapshotFn = options.recordPrOutcomeSnapshot ?? recordPrOutcomeSnapshot; + const buildLoopClosureSummaryFn = options.buildLoopClosureSummary ?? buildLoopClosureSummary; + const attemptLoopReentryFn = options.attemptLoopReentry ?? attemptLoopReentry; + // Resolved ONCE, at the CLI-entrypoint layer, mirroring manage-poll.js's own runManagePoll (its + // recordManagePollSnapshot callee has no env fallback of its own either -- the top-level CLI function is + // where the GitHub token gets resolved, then threaded down explicitly to every real GitHub caller). + // pollPrDisposition (unlike runDiscover, which falls back to process.env.GITHUB_TOKEN internally) has NO + // such fallback -- an unresolved githubToken here would silently poll unauthenticated. + // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the + // authenticated `loopover-mcp login` session -- cached in memory for this process's lifetime. + const githubToken = options.githubToken ?? (await resolveGitHubToken(env)) ?? ""; + async function runDiscoveryOnce() { + await runDiscoverFn(discoverArgv(loopArgs), { + initPortfolioQueue: () => portfolioQueue, githubToken, - apiBaseUrl: options.apiBaseUrl, - ...(options.prDispositionOptions ?? {}), - }); - if (prDisposition.state === "closed") { - recordPrOutcomeSnapshotFn( - { + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + nowMs: nowMsFn(), + }); + } + let usage = governorState.loadCapUsage(); + 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 OR an already-active pause (#4851) halts the loop without ever touching GitHub or the queue. The + // pause flag is real, persisted, operator/governor-writable state on governorState (toggled via + // `loopover-miner governor pause`/`resume`) -- unlike the kill switch, a paused run resumes simply by being + // re-invoked: every piece of per-cycle state this loop reads (portfolioQueue, runState, governorState's own + // cap usage) is already durable, so clearing the flag and restarting continues exactly where it left off. + const initialKillSwitch = checkKillSwitchFn({ env }); + const initialPauseState = governorState.loadPauseState(); + let claimed = null; + if (initialKillSwitch.active) { + haltReason = `kill_switch_${initialKillSwitch.scope}`; + cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); + } + else if (initialPauseState.paused) { + haltReason = "paused"; + 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}`; + // Release the in-flight claim so left state is defined (#5670 / mirrors run-halt's markFailed). + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + ...(claimed + ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } + : {}), + }); + break; + } + const pauseState = governorState.loadPauseState(); + if (pauseState.paused) { + haltReason = "paused"; + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + ...(claimed + ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } + : {}), + }); + 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, claimed.apiBaseUrl); + cycles.push({ cycle: cycleIndex, outcome: "skipped_malformed_identifier", identifier: claimed.identifier }); + claimed = portfolioQueue.dequeueNext(); + continue; + } + // Capture for the boundary-gate markFailed callback (claimed is reassigned later in the loop). + const claimedEntry = claimed; + const amsPolicy = await resolveAmsPolicyFn(claimedEntry.repoFullName, { env }); + // Real, SQLite-persisted per-item convergence history (#5677): the dequeueNext claim above already recorded + // this attempt and the markDone/markFailed calls below record the outcome, so reading it back here shares one + // source of truth with attempt-cli.js (#5654) and survives a loop-daemon restart instead of resetting. + const convergenceInput = portfolioQueue.getAttemptHistory(claimedEntry.repoFullName, claimedEntry.identifier, claimedEntry.apiBaseUrl); + const boundary = evaluateBoundaryGateFn({ + runHalted: false, + usage, + // RunLoopOptions.resolveAmsPolicy types spec as Record (pre-existing .d.ts); + // real resolveAmsPolicy returns AmsPolicySpec — cast preserves runtime fallback behavior. + limits: amsPolicy.spec.capLimits ?? DEFAULT_AMS_POLICY_SPEC.capLimits, + convergence: convergenceInput, + convergenceThresholds: amsPolicy.spec.convergenceThresholds ?? + DEFAULT_AMS_POLICY_SPEC.convergenceThresholds, + inFlightItem: { repoFullName: claimedEntry.repoFullName, identifier: claimedEntry.identifier }, + // Echoes claimed.apiBaseUrl (#5563), NOT the callback's own repoFullName/identifier alone -- two forge + // hosts can share an in-flight item with the same repo name+identifier. + markFailed: (repoFullName, identifier) => portfolioQueue.markFailed(repoFullName, identifier, claimedEntry.apiBaseUrl), + }, { append: (event) => governorLedger.appendGovernorEvent(event) }); + if (!boundary.canClaimNext) { + haltReason = `boundary_${boundary.verdict.reason}`; + cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason, repoFullName: claimedEntry.repoFullName, identifier: claimedEntry.identifier }); + break; + } + const cycleStartMs = nowMsFn(); + // Local result bag: AttemptCliResult is a discriminant union; CFA after the onResult callback + // collapses typed bags to `never`, so keep this local untyped (runtime shape unchanged). + let lastResult = null; + const attemptArgv = [ + claimedEntry.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 = { + // Real for the agent-sdk provider (its own SDK result message reports total_cost_usd, wired through + // runMinerAttempt's real loopResult.totalCostUsd); the CLI-subprocess providers (claude-cli/codex-cli) + // report no cost signal today, so this contributes 0 for those runs -- an honest absence, not a + // fabricated number. A capLimits.budget dimension only ever meaningfully trips against agent-sdk spend. + budgetSpent: usage.budgetSpent + (lastResult?.totalCostUsd ?? 0), + 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"; + // Mid-attempt kill-switch abandon (#5670): stop the outer loop immediately instead of waiting for the + // next between-cycle probe, and treat the item like any other re-queued abandon via markFailed below. + const killSwitchAbandon = lastResult?.abandonReason === "kill_switch_engaged"; + if (submitted || permanentBlock) { + // Both terminal -- a submitted PR is done, and a repo-wide AI-usage-policy ban never resolves on retry -- + // so neither is re-queued. markDone also clears the persisted consecutive-failure streak. + portfolioQueue.markDone(claimedEntry.repoFullName, claimedEntry.identifier, claimedEntry.apiBaseUrl); + } + else { + // Any other blocked/abandoned/stale/governed outcome may resolve on a later retry, so requeue it; markFailed + // records the re-enqueue + consecutive failure the non-convergence detector reads on the next cycle. + portfolioQueue.markFailed(claimedEntry.repoFullName, claimedEntry.identifier, claimedEntry.apiBaseUrl); + } + if (killSwitchAbandon) { + const liveKill = checkKillSwitchFn({ env }); + haltReason = liveKill.active ? `kill_switch_${liveKill.scope}` : "kill_switch_engaged"; + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + repoFullName: claimedEntry.repoFullName, + identifier: claimedEntry.identifier, + attemptOutcome, + }); + break; + } + let reentryOutcome = "other"; + let prNumber = null; + let prDisposition = null; + let ciConclusion = null; + if (submitted) { + prNumber = parsePrNumberFromExecResult(lastResult?.execResult, claimedEntry.repoFullName); + if (prNumber !== null) { + // Real CI-status observation (#5394): recorded BEFORE the disposition poll below, so a submitted + // PR's check-run state is captured even while it's still open, not just at its eventual merge/close. + // ci-poller.js's real GitHub check-run polling is a heuristic proxy for the gate verdict; the + // authoritative terminal merge/close outcome comes from pollPrDispositionFn below, sourced directly + // from GitHub's own PR state rather than a server-internal endpoint (#5450). + const ciStatus = await pollCheckRunsFn(claimedEntry.repoFullName, prNumber, { + githubToken, + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.ciPollOptions ?? {}), + }); + ciConclusion = ciStatus.conclusion; + eventLedger.appendEvent({ + type: "ci_status_observed", + repoFullName: claimedEntry.repoFullName, + payload: { prNumber, conclusion: ciStatus.conclusion, checkCount: ciStatus.checks.length, source: "ci-poller" }, + }); + prDisposition = await pollPrDispositionFn(claimedEntry.repoFullName, prNumber, { + githubToken, + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.prDispositionOptions ?? {}), + }); + if (prDisposition.state === "closed") { + recordPrOutcomeSnapshotFn({ + repoFullName: claimedEntry.repoFullName, + prNumber, + decision: prDisposition.merged ? "merged" : "closed", + closedAt: prDisposition.closedAt, + }, { eventLedger }); + // Real per-repo reputation history (#5675): a resolved terminal outcome updates the decided/unfavorable + // counts the Governor's self-reputation throttle reads on this repo's next attempt. `decided` always; + // `unfavorable` only on a closed-without-merge (rejection-state-machine.js's isRejectedPr, matching + // #5655's own-rejection classification). Forge-scoped by claimed.apiBaseUrl (#5563), like every other + // governor-state write here. + const priorReputation = governorState.loadReputationHistory(claimed.repoFullName, claimed.apiBaseUrl); + governorState.saveReputationHistory(claimed.repoFullName, { + decided: priorReputation.decided + 1, + unfavorable: priorReputation.unfavorable + (isRejectedPr(prDisposition) ? 1 : 0), + }, claimed.apiBaseUrl); + 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, - decision: prDisposition.merged ? "merged" : "closed", - closedAt: prDisposition.closedAt, - }, - { eventLedger }, - ); - // Real per-repo reputation history (#5675): a resolved terminal outcome updates the decided/unfavorable - // counts the Governor's self-reputation throttle reads on this repo's next attempt. `decided` always; - // `unfavorable` only on a closed-without-merge (rejection-state-machine.js's isRejectedPr, matching - // #5655's own-rejection classification). Forge-scoped by claimed.apiBaseUrl (#5563), like every other - // governor-state write here. - const priorReputation = governorState.loadReputationHistory(claimed.repoFullName, claimed.apiBaseUrl); - governorState.saveReputationHistory( - claimed.repoFullName, - { - decided: priorReputation.decided + 1, - unfavorable: priorReputation.unfavorable + (isRejectedPr(prDisposition) ? 1 : 0), - }, - claimed.apiBaseUrl, - ); - reentryOutcome = classifyPrDisposition(prDisposition); - } + ciConclusion, + reentered: reentry.decision.reenter, + reasons: reentry.decision.reasons, + }); + if (!reentry.decision.reenter) { + haltReason = `reentry_declined:${reentry.decision.reasons.join(",")}`; + break; + } + if (reentry.dequeued) { + // attemptLoopReentry's injectable .d.ts types dequeued.status as string; QueueEntry wants QueueStatus. + 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"; + // The next cycle's item is primed (dequeued → 'in_progress') BEFORE the while-condition re-checks + // maxCycles -- both at the initial priming above and at each cycle's tail -- so exhausting maxCycles + // ends the run holding a claim no cycle ever processed. Release it, mirroring the kill-switch/pause + // halts (#5670): dequeueNext() only pulls 'queued' rows, so an unreleased claim is invisible to every + // future loop/attempt run until an out-of-band stale-lease sweep reclaims it. + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + } + 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"}.`); } - } - - 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, - ciConclusion, - 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(); - } + return 0; } - - if (haltReason === null && parsed.maxCycles !== undefined) { - haltReason = "max_cycles_reached"; - // The next cycle's item is primed (dequeued → 'in_progress') BEFORE the while-condition re-checks - // maxCycles -- both at the initial priming above and at each cycle's tail -- so exhausting maxCycles - // ends the run holding a claim no cycle ever processed. Release it, mirroring the kill-switch/pause - // halts (#5670): dequeueNext() only pulls 'queued' rows, so an unreleased claim is invisible to every - // future loop/attempt run until an out-of-band stale-lease sweep reclaims it. - if (claimed) { - portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); - } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); } - - 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"}.`); + finally { + governorState.close(); + eventLedger.close(); + governorLedger.close(); + portfolioQueue.close(); + runState.close(); } - return 0; - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } finally { - governorState.close(); - eventLedger.close(); - governorLedger.close(); - portfolioQueue.close(); - runState.close(); - } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibG9vcC1jbGkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJsb29wLWNsaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxzR0FBc0c7QUFDdEcsaUdBQWlHO0FBQ2pHLHlHQUF5RztBQUN6RyxtR0FBbUc7QUFDbkcsRUFBRTtBQUNGLHFHQUFxRztBQUNyRyx5R0FBeUc7QUFDekcscUZBQXFGO0FBQ3JGLDZHQUE2RztBQUM3RyxzREFBc0Q7QUFDdEQscUdBQXFHO0FBQ3JHLDBHQUEwRztBQUMxRyw4R0FBOEc7QUFDOUcsNkdBQTZHO0FBQzdHLDJEQUEyRDtBQUMzRCxFQUFFO0FBQ0YsdUdBQXVHO0FBQ3ZHLDBHQUEwRztBQUMxRyw4R0FBOEc7QUFDOUcsNEdBQTRHO0FBQzVHLCtHQUErRztBQUMvRyw2R0FBNkc7QUFDN0csK0VBQStFO0FBRS9FLE9BQU8sRUFBRSxvQkFBb0IsRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBQ2pFLE9BQU8sRUFBRSxZQUFZLEVBQUUsZ0JBQWdCLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxnQkFBZ0IsQ0FBQztBQUNsRixPQUFPLEVBQUUsMkJBQTJCLEVBQUUsTUFBTSx3QkFBd0IsQ0FBQztBQUNyRSxPQUFPLEVBQUUsaUJBQWlCLEVBQUUsTUFBTSxxQkFBcUIsQ0FBQztBQUV4RCxPQUFPLEVBQUUsa0JBQWtCLEVBQUUsTUFBTSxzQkFBc0IsQ0FBQztBQUUxRCxPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sbUJBQW1CLENBQUM7QUFFcEQsT0FBTyxFQUFFLHVCQUF1QixFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFFL0QsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0sZ0JBQWdCLENBQUM7QUFFbkQsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLG1CQUFtQixDQUFDO0FBQ2hELE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUU5QyxPQUFPLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxpQkFBaUIsQ0FB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/loop-cli.ts b/packages/loopover-miner/lib/loop-cli.ts new file mode 100644 index 0000000000..f0bfd188d9 --- /dev/null +++ b/packages/loopover-miner/lib/loop-cli.ts @@ -0,0 +1,652 @@ +// 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 -> pause-flag check (#4851, governor-state.js's persisted +// paused/reason/pausedAt) -> real-per-repo-policy-aware run-loop boundary gate (before claiming) -> real +// runAttempt -> real CI-status poll (ci-poller.js, #5394) + 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). Its per-identifier convergence history (attempts/consecutiveFailures/reenqueues) is the real, +// SQLite-persisted portfolio-queue attempt-history (portfolio-queue.js's getAttemptHistory, #5654) that the +// dequeueNext claim + markDone/markFailed calls below already maintain -- the same source a one-shot `attempt` +// invocation reads (#5654), so both share one source of truth and the counters survive a loop-daemon restart +// (crash/deploy/systemd bounce) instead of resetting with the process (#5677). + +import { checkMinerKillSwitch } from "./governor-kill-switch.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { evaluateRunLoopBoundaryGate } from "./governor-run-halt.js"; +import { openGovernorState } from "./governor-state.js"; +import type { GovernorState } from "./governor-state.js"; +import { initGovernorLedger } from "./governor-ledger.js"; +import type { GovernorLedger } from "./governor-ledger.js"; +import { initEventLedger } from "./event-ledger.js"; +import type { EventLedger } from "./event-ledger.js"; +import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import type { PortfolioQueueStore, QueueEntry } from "./portfolio-queue.js"; +import { initRunStateStore } from "./run-state.js"; +import type { RunStateStore } from "./run-state.js"; +import { runDiscover } from "./discover-cli.js"; +import { runAttempt } from "./attempt-cli.js"; +import type { AttemptCliResult } from "./attempt-cli.js"; +import { resolveAmsPolicy } from "./ams-policy.js"; +import { pollPrDisposition, classifyPrDisposition } from "./pr-disposition-poller.js"; +import type { PollPrDispositionOptions } from "./pr-disposition-poller.js"; +import { pollCheckRuns } from "./ci-poller.js"; +import type { CheckRunConclusion, PollCheckRunsOptions } from "./ci-poller.js"; +import { recordPrOutcomeSnapshot } from "./pr-outcome.js"; +import { isRejectedPr } from "./rejection-state-machine.js"; +import { buildLoopClosureSummary } from "./loop-closure.js"; +import { attemptLoopReentry } from "./loop-reentry.js"; +import { parsePrNumberFromExecResult } from "./pr-number-parse.js"; +import { resolveGitHubToken } from "./github-token-resolution.js"; +import { DEFAULT_AMS_POLICY_SPEC } from "@loopover/engine"; +import type { GovernorCapUsage } from "@loopover/engine"; + + +export type ParsedLoopArgs = + | { error: string } + | { + targets: string[]; + search: string | null; + minerLogin: string; + base: string; + live: boolean; + dryRun: boolean; + maxCycles: number | undefined; + cycleDelayMs: number; + json: boolean; + }; + +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; + ciConclusion?: CheckRunConclusion | 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 }>; + pollCheckRuns?: (repoFullName: string, prNumber: number, options?: PollCheckRunsOptions) => Promise<{ conclusion: CheckRunConclusion; checks: unknown[]; headSha: string; 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; + ciPollOptions?: PollCheckRunsOptions; +}; + +const LOOP_USAGE = + "Usage: loopover-miner loop [...] | --search --miner-login [--base ] [--live] [--dry-run] [--max-cycles ] [--cycle-delay-ms ] [--json]"; +const DEFAULT_CYCLE_DELAY_MS = 60_000; +const ISSUE_IDENTIFIER_PATTERN = /^issue:(\d+)$/; + +function parseRepoTarget(value: unknown): string | null { + 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: unknown, label: string): number { + 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: string[]): ParsedLoopArgs { + const options: { + json: boolean; + minerLogin: string | null; + base: string; + live: boolean; + dryRun: boolean; + search: string | null; + maxCycles: number | undefined; + cycleDelayMs: number; + } = { + json: false, + minerLogin: null, + base: "main", + live: false, + dryRun: false, + search: null, + maxCycles: undefined, + cycleDelayMs: DEFAULT_CYCLE_DELAY_MS, + }; + const targets: string[] = []; + + 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; + } + // #4847: see attempt-cli.js's own --dry-run comment -- distinct from --live's absence, this short-circuits + // BEFORE governor state or any other store is opened, guaranteeing zero discovery/queue/ledger writes. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--search") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; + 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, + dryRun: options.dryRun, + maxCycles: options.maxCycles, + cycleDelayMs: options.cycleDelayMs, + json: options.json, + }; +} + +function discoverArgv(parsed: Exclude): string[] { + return parsed.search !== null ? ["--search", parsed.search] : [...parsed.targets]; +} + +function parseIssueNumberFromIdentifier(identifier: unknown): number | null { + const match = typeof identifier === "string" ? identifier.match(ISSUE_IDENTIFIER_PATTERN) : null; + return match ? Number(match[1]) : null; +} + +/** + * 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. + */ +export async function runLoop(args: string[], options: RunLoopOptions = {}): Promise { + const parsed = parseLoopArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + // Narrow for nested closures (TS resets control-flow narrowing inside nested functions). + const loopArgs = parsed; + + const env = options.env ?? process.env; + const sleepFn = options.sleepFn ?? ((delayMs: number) => new Promise((resolve) => setTimeout(resolve, delayMs))); + const nowMsFn = () => options.nowMs ?? Date.now(); + const sessionStartMs = nowMsFn(); + + // #4847: reports what a real loop invocation would target and returns BEFORE governor state or any other + // store (event/governor ledger, portfolio queue, run state) is opened -- a provable zero-write path, not just + // "opened but didn't write." The loop's own discovery call enqueues newly-found candidates into the LOCAL + // portfolio queue even before any attempt happens, so a faithful dry run cannot call it either. + if (parsed.dryRun) { + const dryRunResult = { + outcome: "dry_run", + targets: parsed.targets, + search: parsed.search, + minerLogin: parsed.minerLogin, + base: parsed.base, + live: parsed.live, + maxCycles: parsed.maxCycles ?? null, + }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + const target = parsed.search !== null ? `--search ${parsed.search}` : parsed.targets.join(", "); + console.log( + `DRY RUN: would run an autonomous loop against ${target} for ${parsed.minerLogin} (base: ${parsed.base}, live: ${parsed.live}). No discovery, queue, or ledger writes were made.`, + ); + } + return 0; + } + + let governorState: GovernorState; + try { + governorState = (options.openGovernorState ?? openGovernorState)(); + } catch (error) { + return reportCliFailure( + parsed.json, + `Loop refuses to start: governor state cannot be loaded: ${describeCliError(error)}`, + 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 pollCheckRunsFn = options.pollCheckRuns ?? pollCheckRuns; + const recordPrOutcomeSnapshotFn = options.recordPrOutcomeSnapshot ?? recordPrOutcomeSnapshot; + const buildLoopClosureSummaryFn = options.buildLoopClosureSummary ?? buildLoopClosureSummary; + const attemptLoopReentryFn = options.attemptLoopReentry ?? attemptLoopReentry; + + // Resolved ONCE, at the CLI-entrypoint layer, mirroring manage-poll.js's own runManagePoll (its + // recordManagePollSnapshot callee has no env fallback of its own either -- the top-level CLI function is + // where the GitHub token gets resolved, then threaded down explicitly to every real GitHub caller). + // pollPrDisposition (unlike runDiscover, which falls back to process.env.GITHUB_TOKEN internally) has NO + // such fallback -- an unresolved githubToken here would silently poll unauthenticated. + // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the + // authenticated `loopover-mcp login` session -- cached in memory for this process's lifetime. + const githubToken = options.githubToken ?? (await resolveGitHubToken(env as NodeJS.ProcessEnv)) ?? ""; + + async function runDiscoveryOnce() { + await runDiscoverFn(discoverArgv(loopArgs), { + initPortfolioQueue: () => portfolioQueue, + githubToken, + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + nowMs: nowMsFn(), + }); + } + + let usage: GovernorCapUsage = governorState.loadCapUsage(); + const cycles: LoopCycleSummary[] = []; + let sinceSeq = eventLedger.readEvents({}).at(-1)?.seq ?? 0; + let haltReason: string | null = null; + + try { + // Checked BEFORE any work at all -- including the very first discovery call -- so an already-active kill + // switch OR an already-active pause (#4851) halts the loop without ever touching GitHub or the queue. The + // pause flag is real, persisted, operator/governor-writable state on governorState (toggled via + // `loopover-miner governor pause`/`resume`) -- unlike the kill switch, a paused run resumes simply by being + // re-invoked: every piece of per-cycle state this loop reads (portfolioQueue, runState, governorState's own + // cap usage) is already durable, so clearing the flag and restarting continues exactly where it left off. + const initialKillSwitch = checkKillSwitchFn({ env }); + const initialPauseState = governorState.loadPauseState(); + let claimed: QueueEntry | null = null; + if (initialKillSwitch.active) { + haltReason = `kill_switch_${initialKillSwitch.scope}`; + cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); + } else if (initialPauseState.paused) { + haltReason = "paused"; + 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}`; + // Release the in-flight claim so left state is defined (#5670 / mirrors run-halt's markFailed). + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + ...(claimed + ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } + : {}), + }); + break; + } + + const pauseState = governorState.loadPauseState(); + if (pauseState.paused) { + haltReason = "paused"; + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + ...(claimed + ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } + : {}), + }); + 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, claimed.apiBaseUrl); + cycles.push({ cycle: cycleIndex, outcome: "skipped_malformed_identifier", identifier: claimed.identifier }); + claimed = portfolioQueue.dequeueNext(); + continue; + } + + // Capture for the boundary-gate markFailed callback (claimed is reassigned later in the loop). + const claimedEntry = claimed; + + const amsPolicy = await resolveAmsPolicyFn(claimedEntry.repoFullName, { env }); + // Real, SQLite-persisted per-item convergence history (#5677): the dequeueNext claim above already recorded + // this attempt and the markDone/markFailed calls below record the outcome, so reading it back here shares one + // source of truth with attempt-cli.js (#5654) and survives a loop-daemon restart instead of resetting. + const convergenceInput = portfolioQueue.getAttemptHistory( + claimedEntry.repoFullName, + claimedEntry.identifier, + claimedEntry.apiBaseUrl, + ); + + const boundary = evaluateBoundaryGateFn( + { + runHalted: false, + usage, + // RunLoopOptions.resolveAmsPolicy types spec as Record (pre-existing .d.ts); + // real resolveAmsPolicy returns AmsPolicySpec — cast preserves runtime fallback behavior. + limits: (amsPolicy.spec.capLimits as typeof DEFAULT_AMS_POLICY_SPEC.capLimits | undefined) ?? DEFAULT_AMS_POLICY_SPEC.capLimits, + convergence: convergenceInput, + convergenceThresholds: + (amsPolicy.spec.convergenceThresholds as typeof DEFAULT_AMS_POLICY_SPEC.convergenceThresholds | undefined) ?? + DEFAULT_AMS_POLICY_SPEC.convergenceThresholds, + inFlightItem: { repoFullName: claimedEntry.repoFullName, identifier: claimedEntry.identifier }, + // Echoes claimed.apiBaseUrl (#5563), NOT the callback's own repoFullName/identifier alone -- two forge + // hosts can share an in-flight item with the same repo name+identifier. + markFailed: (repoFullName: string, identifier: string) => + portfolioQueue.markFailed(repoFullName, identifier, claimedEntry.apiBaseUrl), + }, + { append: (event: unknown) => governorLedger.appendGovernorEvent(event as Parameters[0]) }, + ); + + if (!boundary.canClaimNext) { + haltReason = `boundary_${boundary.verdict.reason}`; + cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason, repoFullName: claimedEntry.repoFullName, identifier: claimedEntry.identifier }); + break; + } + + const cycleStartMs = nowMsFn(); + // Local result bag: AttemptCliResult is a discriminant union; CFA after the onResult callback + // collapses typed bags to `never`, so keep this local untyped (runtime shape unchanged). + let lastResult: any = null; + const attemptArgv = [ + claimedEntry.repoFullName, + String(issueNumber), + "--miner-login", + parsed.minerLogin, + "--base", + parsed.base, + ...(parsed.live ? ["--live"] : []), + ]; + await runAttemptFn(attemptArgv, { + ...(options.attemptOptions ?? {}), + env, + onResult: (result: AttemptCliResult) => { + lastResult = result; + }, + }); + const cycleElapsedMs = nowMsFn() - cycleStartMs; + + usage = { + // Real for the agent-sdk provider (its own SDK result message reports total_cost_usd, wired through + // runMinerAttempt's real loopResult.totalCostUsd); the CLI-subprocess providers (claude-cli/codex-cli) + // report no cost signal today, so this contributes 0 for those runs -- an honest absence, not a + // fabricated number. A capLimits.budget dimension only ever meaningfully trips against agent-sdk spend. + budgetSpent: usage.budgetSpent + (lastResult?.totalCostUsd ?? 0), + 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"; + // Mid-attempt kill-switch abandon (#5670): stop the outer loop immediately instead of waiting for the + // next between-cycle probe, and treat the item like any other re-queued abandon via markFailed below. + const killSwitchAbandon = lastResult?.abandonReason === "kill_switch_engaged"; + + if (submitted || permanentBlock) { + // Both terminal -- a submitted PR is done, and a repo-wide AI-usage-policy ban never resolves on retry -- + // so neither is re-queued. markDone also clears the persisted consecutive-failure streak. + portfolioQueue.markDone(claimedEntry.repoFullName, claimedEntry.identifier, claimedEntry.apiBaseUrl); + } else { + // Any other blocked/abandoned/stale/governed outcome may resolve on a later retry, so requeue it; markFailed + // records the re-enqueue + consecutive failure the non-convergence detector reads on the next cycle. + portfolioQueue.markFailed(claimedEntry.repoFullName, claimedEntry.identifier, claimedEntry.apiBaseUrl); + } + + if (killSwitchAbandon) { + const liveKill = checkKillSwitchFn({ env }); + haltReason = liveKill.active ? `kill_switch_${liveKill.scope}` : "kill_switch_engaged"; + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + repoFullName: claimedEntry.repoFullName, + identifier: claimedEntry.identifier, + attemptOutcome, + }); + break; + } + + let reentryOutcome: "merged" | "disengaged" | "other" = "other"; + let prNumber: number | null = null; + let prDisposition: { state: "open" | "closed"; merged: boolean; closedAt: string | null; attempts: number } | null = null; + let ciConclusion: CheckRunConclusion | null = null; + if (submitted) { + prNumber = parsePrNumberFromExecResult( + lastResult?.execResult as Parameters[0], + claimedEntry.repoFullName, + ); + if (prNumber !== null) { + // Real CI-status observation (#5394): recorded BEFORE the disposition poll below, so a submitted + // PR's check-run state is captured even while it's still open, not just at its eventual merge/close. + // ci-poller.js's real GitHub check-run polling is a heuristic proxy for the gate verdict; the + // authoritative terminal merge/close outcome comes from pollPrDispositionFn below, sourced directly + // from GitHub's own PR state rather than a server-internal endpoint (#5450). + const ciStatus = await pollCheckRunsFn(claimedEntry.repoFullName, prNumber, { + githubToken, + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.ciPollOptions ?? {}), + } as PollCheckRunsOptions); + ciConclusion = ciStatus.conclusion; + eventLedger.appendEvent({ + type: "ci_status_observed", + repoFullName: claimedEntry.repoFullName, + payload: { prNumber, conclusion: ciStatus.conclusion, checkCount: ciStatus.checks.length, source: "ci-poller" }, + }); + + prDisposition = await pollPrDispositionFn(claimedEntry.repoFullName, prNumber, { + githubToken, + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.prDispositionOptions ?? {}), + } as PollPrDispositionOptions); + if (prDisposition.state === "closed") { + recordPrOutcomeSnapshotFn( + { + repoFullName: claimedEntry.repoFullName, + prNumber, + decision: prDisposition.merged ? "merged" : "closed", + closedAt: prDisposition.closedAt, + }, + { eventLedger }, + ); + // Real per-repo reputation history (#5675): a resolved terminal outcome updates the decided/unfavorable + // counts the Governor's self-reputation throttle reads on this repo's next attempt. `decided` always; + // `unfavorable` only on a closed-without-merge (rejection-state-machine.js's isRejectedPr, matching + // #5655's own-rejection classification). Forge-scoped by claimed.apiBaseUrl (#5563), like every other + // governor-state write here. + const priorReputation = governorState.loadReputationHistory(claimed.repoFullName, claimed.apiBaseUrl); + governorState.saveReputationHistory( + claimed.repoFullName, + { + decided: priorReputation.decided + 1, + unfavorable: priorReputation.unfavorable + (isRejectedPr(prDisposition) ? 1 : 0), + }, + claimed.apiBaseUrl, + ); + reentryOutcome = classifyPrDisposition(prDisposition) as "merged" | "disengaged" | "other"; + } + } + } + + 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, + ciConclusion, + reentered: reentry.decision.reenter, + reasons: reentry.decision.reasons, + }); + + if (!reentry.decision.reenter) { + haltReason = `reentry_declined:${reentry.decision.reasons.join(",")}`; + break; + } + + if (reentry.dequeued) { + // attemptLoopReentry's injectable .d.ts types dequeued.status as string; QueueEntry wants QueueStatus. + claimed = reentry.dequeued as QueueEntry; + await sleepFn(parsed.cycleDelayMs); + } else { + await sleepFn(parsed.cycleDelayMs); + await runDiscoveryOnce(); + claimed = portfolioQueue.dequeueNext(); + } + } + + if (haltReason === null && parsed.maxCycles !== undefined) { + haltReason = "max_cycles_reached"; + // The next cycle's item is primed (dequeued → 'in_progress') BEFORE the while-condition re-checks + // maxCycles -- both at the initial priming above and at each cycle's tail -- so exhausting maxCycles + // ends the run holding a claim no cycle ever processed. Release it, mirroring the kill-switch/pause + // halts (#5670): dequeueNext() only pulls 'queued' rows, so an unreleased claim is invisible to every + // future loop/attempt run until an out-of-band stale-lease sweep reclaims it. + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + } + + 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) { + return reportCliFailure(parsed.json, describeCliError(error)); + } finally { + governorState.close(); + eventLedger.close(); + governorLedger.close(); + portfolioQueue.close(); + runState.close(); + } +} diff --git a/packages/loopover-miner/lib/portfolio-queue-cli.d.ts b/packages/loopover-miner/lib/portfolio-queue-cli.d.ts index f2cf0ddb3b..dc7865070b 100644 --- a/packages/loopover-miner/lib/portfolio-queue-cli.d.ts +++ b/packages/loopover-miner/lib/portfolio-queue-cli.d.ts @@ -1,101 +1,116 @@ import type { PortfolioQueueStore, QueueEntry } from "./portfolio-queue.js"; import type { PortfolioQueueManager } from "./portfolio-queue-manager.js"; - -export type ParsedQueueListArgs = - | { - json: boolean; - repoFullName: string | null; - } - | { error: string }; - -export type ParsedQueueNextArgs = - | { json: boolean; dryRun: boolean; globalWipCap: number | undefined; perRepoWipCap: number | undefined } - | { error: string }; - -export type QueueClaimTarget = { repoFullName: string; identifier: string; apiBaseUrl: string }; - -export function selectNextEligibleTarget( - entries: Array<{ repoFullName: string; identifier: string; apiBaseUrl: string; status: string }>, - caps: { globalWipCap: number; perRepoWipCap: number } | null, -): QueueClaimTarget[]; - -export type ParsedQueueDoneArgs = - | { - repoFullName: string; - identifier: string; - dryRun: boolean; - json: boolean; - apiBaseUrl: string | undefined; - } - | { error: string }; - -export function parseQueueListArgs(args: string[]): ParsedQueueListArgs; - -export function parseQueueNextArgs(args: string[]): ParsedQueueNextArgs; - -export function parseQueueDoneArgs(args: string[]): ParsedQueueDoneArgs; - -export function parseQueueReleaseArgs(args: string[]): ParsedQueueDoneArgs; - -export function parseQueueRequeueArgs(args: string[]): ParsedQueueDoneArgs; - -export type ParsedQueueClaimBatchArgs = - | { json: boolean; dryRun: boolean; globalWipCap: number; perRepoWipCap: number } - | { error: string }; - -export function parseQueueClaimBatchArgs(args: string[]): ParsedQueueClaimBatchArgs; - -export function renderQueueTable(entries: QueueEntry[]): string; - -export function runQueueList( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore }, -): number; - -export function runQueueNext( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore }, -): number; - -export function runQueueDone( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore }, -): number; - -export function runQueueRelease( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore }, -): number; - -export function runQueueRequeue( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore }, -): number; - -export function runQueueClaimBatch( - args: string[], - options?: { initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager }, -): number; - -export const QUEUE_ITEMS: string; -export const QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS: string; - -export function renderPortfolioQueueMetrics( - queueEntries: Array<{ status: string }>, - leaseEntries: Array<{ leasedAt: string | null }>, - nowMs: number, -): string; - -export function runQueueMetrics( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore; nowMs?: number }, -): number; - -export function runQueueCli( - subcommand: string | undefined, - args: string[], - options?: { +export type ParsedQueueListArgs = { + json: boolean; + repoFullName: string | null; +} | { + error: string; +}; +export type ParsedQueueNextArgs = { + json: boolean; + dryRun: boolean; + globalWipCap: number | undefined; + perRepoWipCap: number | undefined; +} | { + error: string; +}; +export type QueueClaimTarget = { + repoFullName: string; + identifier: string; + apiBaseUrl: string; +}; +export type ParsedQueueDoneArgs = { + repoFullName: string; + identifier: string; + dryRun: boolean; + json: boolean; + apiBaseUrl: string | undefined; +} | { + error: string; +}; +export type ParsedQueueClaimBatchArgs = { + json: boolean; + dryRun: boolean; + globalWipCap: number; + perRepoWipCap: number; +} | { + error: string; +}; +export declare function parseQueueListArgs(args: string[]): ParsedQueueListArgs; +export declare function parseQueueNextArgs(args: string[]): ParsedQueueNextArgs; +/** + * Pick at most one atomically-claimable target from the store's already-priority-ordered active rows (queued + * AND in_progress interleaved, exactly `batchClaim`'s own `entries` shape). `caps` of `null` replicates the + * pre-#4850 behavior: the single highest-priority queued row, unconditionally. When caps are set, refuses to + * select anything once the global or the target row's own per-repo in-progress count has reached its cap -- + * "stops claiming once the cap is reached" (#4850), not a diversifying batch selection (that remains + * claim-batch's job via the engine's own `nextEligibleItems`). + * @param {Array<{ repoFullName: string, identifier: string, apiBaseUrl: string, status: string }>} entries + * @param {{ globalWipCap: number, perRepoWipCap: number } | null} caps + */ +export declare function selectNextEligibleTarget(entries: Array<{ + repoFullName: string; + identifier: string; + apiBaseUrl: string; + status: string; +}>, caps: { + globalWipCap: number; + perRepoWipCap: number; +} | null): QueueClaimTarget[]; +export declare function parseQueueDoneArgs(args: string[]): ParsedQueueDoneArgs; +export declare function parseQueueReleaseArgs(args: string[]): ParsedQueueDoneArgs; +export declare function parseQueueRequeueArgs(args: string[]): ParsedQueueDoneArgs; +export declare function renderQueueTable(entries: QueueEntry[]): string; +export declare function runQueueList(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; +}): number; +export declare function runQueueNext(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; +}): number; +export declare function runQueueDone(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; +}): number; +/** `release `: manually give up a CLAIMED (in_progress) item, returning it to the queue + * (the manual counterpart to the automated stuck-lease sweep). Exit 2 when there is no in-flight item to release. */ +export declare function runQueueRelease(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; +}): number; +/** `requeue `: manually put a COMPLETED (done) item back on the queue so it is picked up + * again, keeping its original FIFO position. Exit 2 when there is no done item to requeue (already queued, + * in-flight — release it instead — or absent). */ +export declare function runQueueRequeue(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; +}): number; +export declare function parseQueueClaimBatchArgs(args: string[]): ParsedQueueClaimBatchArgs; +/** Claim the next caps-aware batch via the WIP-cap-aware batch claimer (portfolio-queue-manager.js), which also + * reclaims any leases orphaned by a crashed process first (#4833 wires the previously caller-less claimer). */ +export declare function runQueueClaimBatch(args: string[], options?: { + initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager; +}): number; +export declare const QUEUE_ITEMS = "loopover_miner_portfolio_queue_items"; +export declare const QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS = "loopover_miner_portfolio_queue_oldest_in_progress_lease_age_seconds"; +/** + * Render portfolio-queue backlog health as Prometheus text-exposition gauges: current item count per status, and + * the age of the OLDEST still-in-flight lease -- the concrete "is anything stuck" signal a + * `loopover_queue_oldest_maintenance_pending_age_seconds`-style alert rule can threshold on (#5186). Pure and + * side-effect-free: the caller supplies the rows and `nowMs` (no internal clock read, matching + * store-maintenance.js's pruneLedgerByRetention convention) and prints the result. Deterministic (status series + * sorted); always emits HELP/TYPE so an empty queue is still a well-formed exposition document, and the lease-age + * gauge reads 0 (never stuck) rather than being omitted when nothing is in-flight. + * @param {Array<{ status: string }>} queueEntries - every row, any status (e.g. store.listQueue()'s output). + * @param {Array<{ leasedAt: string | null }>} leaseEntries - in-flight rows only (store.listInProgress()'s output). + * @param {number} nowMs + */ +export declare function renderPortfolioQueueMetrics(queueEntries: Array<{ + status: string; +}>, leaseEntries: Array<{ + leasedAt: string | null; +}>, nowMs: number): string; +export declare function runQueueMetrics(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; + nowMs?: number; +}): number; +export declare function runQueueCli(subcommand: string | undefined, args: string[], options?: { initPortfolioQueue?: () => PortfolioQueueStore; initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager; - }, -): number; +}): number; diff --git a/packages/loopover-miner/lib/portfolio-queue-cli.js b/packages/loopover-miner/lib/portfolio-queue-cli.js index 96a6e8e6dc..0604ffa500 100644 --- a/packages/loopover-miner/lib/portfolio-queue-cli.js +++ b/packages/loopover-miner/lib/portfolio-queue-cli.js @@ -2,104 +2,93 @@ import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { initPortfolioQueueManager } from "./portfolio-queue-manager.js"; import { runPortfolioDashboard } from "./portfolio-dashboard.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; - const QUEUE_LIST_USAGE = "Usage: loopover-miner queue list [--repo ] [--json]"; -const QUEUE_NEXT_USAGE = - "Usage: loopover-miner queue next [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; -const QUEUE_DONE_USAGE = - "Usage: loopover-miner queue done [--api-base-url ] [--dry-run] [--json]"; -const QUEUE_RELEASE_USAGE = - "Usage: loopover-miner queue release [--api-base-url ] [--dry-run] [--json]"; -const QUEUE_REQUEUE_USAGE = - "Usage: loopover-miner queue requeue [--api-base-url ] [--dry-run] [--json]"; -const QUEUE_CLAIM_BATCH_USAGE = - "Usage: loopover-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; - +const QUEUE_NEXT_USAGE = "Usage: loopover-miner queue next [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; +const QUEUE_DONE_USAGE = "Usage: loopover-miner queue done [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_RELEASE_USAGE = "Usage: loopover-miner queue release [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_REQUEUE_USAGE = "Usage: loopover-miner queue requeue [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_CLAIM_BATCH_USAGE = "Usage: loopover-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; function parseRepoArg(value, usage) { - if (!value) return { error: usage }; - const trimmed = value.trim(); - const [owner, repo, extra] = trimmed.split("/"); - if (!owner || !repo || extra !== undefined) { - return { error: "Repository must be in owner/repo form." }; - } - return { repoFullName: `${owner}/${repo}` }; + if (!value) + return { error: usage }; + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) { + return { error: "Repository must be in owner/repo form." }; + } + return { repoFullName: `${owner}/${repo}` }; } - export function parseQueueListArgs(args) { - const options = { json: false, repoFullName: null }; - const positional = []; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; - } - if (token === "--repo") { - const repoArg = args[index + 1]; - if (!repoArg || repoArg.startsWith("-")) { + const options = { json: false, repoFullName: null }; + const positional = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--repo") { + const repoArg = args[index + 1]; + if (!repoArg || repoArg.startsWith("-")) { + return { error: QUEUE_LIST_USAGE }; + } + const repo = parseRepoArg(repoArg, QUEUE_LIST_USAGE); + if ("error" in repo) + return repo; + options.repoFullName = repo.repoFullName; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + if (positional.length > 0) { return { error: QUEUE_LIST_USAGE }; - } - const repo = parseRepoArg(repoArg, QUEUE_LIST_USAGE); - if ("error" in repo) return repo; - options.repoFullName = repo.repoFullName; - index += 1; - continue; - } - if (token.startsWith("-")) { - return { error: `Unknown option: ${token}` }; - } - positional.push(token); - } - - if (positional.length > 0) { - return { error: QUEUE_LIST_USAGE }; - } - - return options; + } + return options; } - // #4850: --global-wip/--per-repo-wip are OMITTED (undefined) by default -- queue next stays uncapped, byte- // identical to its pre-#4850 behavior, unless an operator explicitly opts in. Mirrors queue claim-batch's own // flag names (portfolio-queue-manager.js's WIP-cap-aware claimer), but claim-batch's OWN default of 1/1 is not // reused here: claim-batch's whole purpose is cap enforcement, while queue next has always been a plain // highest-priority dequeue and must not silently start capping existing callers that never asked for it. export function parseQueueNextArgs(args) { - const options = { json: false, dryRun: false, globalWipCap: undefined, perRepoWipCap: undefined }; - const positional = []; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; - } - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - if (token === "--global-wip" || token === "--per-repo-wip") { - const value = Number(args[index + 1]); - if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + const options = { json: false, dryRun: false, globalWipCap: undefined, perRepoWipCap: undefined }; + const positional = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--global-wip" || token === "--per-repo-wip") { + const value = Number(args[index + 1]); + if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + return { error: QUEUE_NEXT_USAGE }; + } + if (token === "--global-wip") + options.globalWipCap = value; + else + options.perRepoWipCap = value; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + if (positional.length > 0) { return { error: QUEUE_NEXT_USAGE }; - } - if (token === "--global-wip") options.globalWipCap = value; - else options.perRepoWipCap = value; - index += 1; - continue; - } - if (token.startsWith("-")) { - return { error: `Unknown option: ${token}` }; - } - positional.push(token); - } - - if (positional.length > 0) { - return { error: QUEUE_NEXT_USAGE }; - } - return options; + } + return options; } - /** * Pick at most one atomically-claimable target from the store's already-priority-ordered active rows (queued * AND in_progress interleaved, exactly `batchClaim`'s own `entries` shape). `caps` of `null` replicates the @@ -111,392 +100,389 @@ export function parseQueueNextArgs(args) { * @param {{ globalWipCap: number, perRepoWipCap: number } | null} caps */ export function selectNextEligibleTarget(entries, caps) { - const topQueued = entries.find((entry) => entry.status === "queued"); - if (!topQueued) return []; - if (!caps) { + const topQueued = entries.find((entry) => entry.status === "queued"); + if (!topQueued) + return []; + if (!caps) { + return [{ repoFullName: topQueued.repoFullName, identifier: topQueued.identifier, apiBaseUrl: topQueued.apiBaseUrl }]; + } + const globalActiveCount = entries.filter((entry) => entry.status === "in_progress").length; + if (globalActiveCount >= caps.globalWipCap) + return []; + // Host-scope the per-repo active count (#7224): a same-named repo on a DIFFERENT forge host is a distinct backlog + // (the store keys rows by apiBaseUrl too, #5563), so an in-progress item on host A must not consume host B's + // per-repo WIP budget. Single-host is unchanged: every entry shares one apiBaseUrl, so the added match is always true. + const repoActiveCount = entries.filter((entry) => entry.status === "in_progress" && + entry.repoFullName === topQueued.repoFullName && + entry.apiBaseUrl === topQueued.apiBaseUrl).length; + if (repoActiveCount >= caps.perRepoWipCap) + return []; return [{ repoFullName: topQueued.repoFullName, identifier: topQueued.identifier, apiBaseUrl: topQueued.apiBaseUrl }]; - } - const globalActiveCount = entries.filter((entry) => entry.status === "in_progress").length; - if (globalActiveCount >= caps.globalWipCap) return []; - // Host-scope the per-repo active count (#7224): a same-named repo on a DIFFERENT forge host is a distinct backlog - // (the store keys rows by apiBaseUrl too, #5563), so an in-progress item on host A must not consume host B's - // per-repo WIP budget. Single-host is unchanged: every entry shares one apiBaseUrl, so the added match is always true. - const repoActiveCount = entries.filter( - (entry) => - entry.status === "in_progress" && - entry.repoFullName === topQueued.repoFullName && - entry.apiBaseUrl === topQueued.apiBaseUrl, - ).length; - if (repoActiveCount >= caps.perRepoWipCap) return []; - return [{ repoFullName: topQueued.repoFullName, identifier: topQueued.identifier, apiBaseUrl: topQueued.apiBaseUrl }]; } - /** Shared ` [--api-base-url ] [--json]` parse for the item-targeting subcommands * (done/release/requeue). `usage` is the command-specific message surfaced on a malformed argv. */ function parseRepoIdentifierArgs(args, usage) { - const options = { json: false, dryRun: false, apiBaseUrl: undefined }; - const positional = []; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; - } - // #4847: reports what a real mutation would do and returns before opening the portfolio queue at all. - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - // #5563: scope the target to a non-default forge host, so it doesn't collide with (or get confused for) a - // same-named repo on the default github.com host. - if (token === "--api-base-url") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) { + const options = { + json: false, + dryRun: false, + apiBaseUrl: undefined, + }; + const positional = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + // #4847: reports what a real mutation would do and returns before opening the portfolio queue at all. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + // #5563: scope the target to a non-default forge host, so it doesn't collide with (or get confused for) a + // same-named repo on the default github.com host. + if (token === "--api-base-url") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + return { error: usage }; + } + options.apiBaseUrl = value; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + if (positional.length !== 2) { return { error: usage }; - } - options.apiBaseUrl = value; - index += 1; - continue; - } - if (token.startsWith("-")) { - return { error: `Unknown option: ${token}` }; - } - positional.push(token); - } - - if (positional.length !== 2) { - return { error: usage }; - } - - const repo = parseRepoArg(positional[0], usage); - if ("error" in repo) return repo; - - const identifier = positional[1]?.trim(); - if (!identifier) { - return { error: usage }; - } - - return { - repoFullName: repo.repoFullName, - identifier, - dryRun: options.dryRun, - json: options.json, - apiBaseUrl: options.apiBaseUrl, - }; + } + const repo = parseRepoArg(positional[0], usage); + if ("error" in repo) + return repo; + const identifier = positional[1]?.trim(); + if (!identifier) { + return { error: usage }; + } + return { + repoFullName: repo.repoFullName, + identifier, + dryRun: options.dryRun, + json: options.json, + apiBaseUrl: options.apiBaseUrl, + }; } - export function parseQueueDoneArgs(args) { - return parseRepoIdentifierArgs(args, QUEUE_DONE_USAGE); + return parseRepoIdentifierArgs(args, QUEUE_DONE_USAGE); } - export function parseQueueReleaseArgs(args) { - return parseRepoIdentifierArgs(args, QUEUE_RELEASE_USAGE); + return parseRepoIdentifierArgs(args, QUEUE_RELEASE_USAGE); } - export function parseQueueRequeueArgs(args) { - return parseRepoIdentifierArgs(args, QUEUE_REQUEUE_USAGE); + return parseRepoIdentifierArgs(args, QUEUE_REQUEUE_USAGE); } - function display(value) { - if (value === null || value === undefined) return "-"; - return String(value); + if (value === null || value === undefined) + return "-"; + return String(value); } - export function renderQueueTable(entries) { - if (!Array.isArray(entries) || entries.length === 0) return "no portfolio queue entries"; - const header = [ - "repo".padEnd(24), - "identifier".padEnd(16), - // #7225: surface the host so a reader of the plain-text table can supply the `--api-base-url` a follow-up - // done/release/requeue needs to disambiguate two rows sharing a repo+identifier across forge hosts. - "host".padEnd(30), - "status".padEnd(12), - "pri".padStart(4), - "enqueued-at".padEnd(24), - ].join(" "); - const lines = entries.map((entry) => - [ - entry.repoFullName.padEnd(24), - entry.identifier.padEnd(16), - display(entry.apiBaseUrl).padEnd(30), - entry.status.padEnd(12), - display(entry.priority).padStart(4), - display(entry.enqueuedAt).padEnd(24), - ].join(" "), - ); - return [header, ...lines].join("\n"); + if (!Array.isArray(entries) || entries.length === 0) + return "no portfolio queue entries"; + const header = [ + "repo".padEnd(24), + "identifier".padEnd(16), + // #7225: surface the host so a reader of the plain-text table can supply the `--api-base-url` a follow-up + // done/release/requeue needs to disambiguate two rows sharing a repo+identifier across forge hosts. + "host".padEnd(30), + "status".padEnd(12), + "pri".padStart(4), + "enqueued-at".padEnd(24), + ].join(" "); + const lines = entries.map((entry) => [ + entry.repoFullName.padEnd(24), + entry.identifier.padEnd(16), + display(entry.apiBaseUrl).padEnd(30), + entry.status.padEnd(12), + display(entry.priority).padStart(4), + display(entry.enqueuedAt).padEnd(24), + ].join(" ")); + return [header, ...lines].join("\n"); } - function withPortfolioQueue(options, run) { - const ownsStore = options.initPortfolioQueue === undefined; - const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); - try { - return run(portfolioQueue); - } finally { - if (ownsStore) portfolioQueue.close(); - } + const ownsStore = options.initPortfolioQueue === undefined; + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + try { + return run(portfolioQueue); + } + finally { + if (ownsStore) + portfolioQueue.close(); + } } - export function runQueueList(args, options = {}) { - const parsed = parseQueueListArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - const entries = portfolioQueue.listQueue(parsed.repoFullName); - if (parsed.json) { - console.log(JSON.stringify({ entries }, null, 2)); - } else { - console.log(renderQueueTable(entries)); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } + const parsed = parseQueueListArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entries = portfolioQueue.listQueue(parsed.repoFullName); + if (parsed.json) { + console.log(JSON.stringify({ entries }, null, 2)); + } + else { + console.log(renderQueueTable(entries)); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } } - export function runQueueNext(args, options = {}) { - const parsed = parseQueueNextArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - const capsRequested = parsed.globalWipCap !== undefined || parsed.perRepoWipCap !== undefined; - if (parsed.dryRun) { - const dryRunResult = capsRequested - ? { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap } - : { outcome: "dry_run" }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else if (capsRequested) { - console.log( - `DRY RUN: would dequeue the highest-priority queued item within WIP caps (global-wip: ${parsed.globalWipCap ?? "unset"}, per-repo-wip: ${parsed.perRepoWipCap ?? "unset"}). No portfolio-queue write was made.`, - ); - } else { - console.log("DRY RUN: would dequeue the highest-priority queued item. No portfolio-queue write was made."); - } - return 0; - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - let entry; - if (capsRequested) { - // Unset dimensions stay genuinely uncapped (Infinity), not silently defaulted to 1 like claim-batch. - const caps = { - globalWipCap: parsed.globalWipCap ?? Number.POSITIVE_INFINITY, - perRepoWipCap: parsed.perRepoWipCap ?? Number.POSITIVE_INFINITY, - }; - const claimed = portfolioQueue.batchClaim((entries) => selectNextEligibleTarget(entries, caps)); - entry = claimed[0] ?? null; - } else { - entry = portfolioQueue.dequeueNext(); - } - if (parsed.json) { - console.log(JSON.stringify({ entry }, null, 2)); - } else { - console.log(entry ? entry.identifier : "none"); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } + const parsed = parseQueueNextArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + const capsRequested = parsed.globalWipCap !== undefined || parsed.perRepoWipCap !== undefined; + if (parsed.dryRun) { + const dryRunResult = capsRequested + ? { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap } + : { outcome: "dry_run" }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } + else if (capsRequested) { + console.log(`DRY RUN: would dequeue the highest-priority queued item within WIP caps (global-wip: ${parsed.globalWipCap ?? "unset"}, per-repo-wip: ${parsed.perRepoWipCap ?? "unset"}). No portfolio-queue write was made.`); + } + else { + console.log("DRY RUN: would dequeue the highest-priority queued item. No portfolio-queue write was made."); + } + return 0; + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + let entry; + if (capsRequested) { + // Unset dimensions stay genuinely uncapped (Infinity), not silently defaulted to 1 like claim-batch. + const caps = { + globalWipCap: parsed.globalWipCap ?? Number.POSITIVE_INFINITY, + perRepoWipCap: parsed.perRepoWipCap ?? Number.POSITIVE_INFINITY, + }; + const claimed = portfolioQueue.batchClaim((entries) => selectNextEligibleTarget(entries, caps)); + entry = claimed[0] ?? null; + } + else { + entry = portfolioQueue.dequeueNext(); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } + else { + console.log(entry ? entry.identifier : "none"); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } } - export function runQueueDone(args, options = {}) { - const parsed = parseQueueDoneArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - console.log(`DRY RUN: would mark ${parsed.repoFullName} ${parsed.identifier} done. No portfolio-queue write was made.`); - } - return 0; - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - const entry = portfolioQueue.markDone(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); - if (!entry) { - return reportCliFailure(parsed.json, "queue_entry_not_found"); - } - if (parsed.json) { - console.log(JSON.stringify({ entry }, null, 2)); - } else { - console.log(entry.status); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } + const parsed = parseQueueDoneArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } + else { + console.log(`DRY RUN: would mark ${parsed.repoFullName} ${parsed.identifier} done. No portfolio-queue write was made.`); + } + return 0; + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.markDone(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_found"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } + else { + console.log(entry.status); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } } - /** `release `: manually give up a CLAIMED (in_progress) item, returning it to the queue * (the manual counterpart to the automated stuck-lease sweep). Exit 2 when there is no in-flight item to release. */ export function runQueueRelease(args, options = {}) { - const parsed = parseQueueReleaseArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - console.log(`DRY RUN: would release ${parsed.repoFullName} ${parsed.identifier} back to the queue. No portfolio-queue write was made.`); - } - return 0; - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - const entry = portfolioQueue.reclaimStuckItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); - if (!entry) { - return reportCliFailure(parsed.json, "queue_entry_not_in_progress"); - } - if (parsed.json) { - console.log(JSON.stringify({ entry }, null, 2)); - } else { - console.log(entry.status); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } + const parsed = parseQueueReleaseArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } + else { + console.log(`DRY RUN: would release ${parsed.repoFullName} ${parsed.identifier} back to the queue. No portfolio-queue write was made.`); + } + return 0; + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.reclaimStuckItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_in_progress"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } + else { + console.log(entry.status); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } } - /** `requeue `: manually put a COMPLETED (done) item back on the queue so it is picked up * again, keeping its original FIFO position. Exit 2 when there is no done item to requeue (already queued, * in-flight — release it instead — or absent). */ export function runQueueRequeue(args, options = {}) { - const parsed = parseQueueRequeueArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - console.log(`DRY RUN: would requeue ${parsed.repoFullName} ${parsed.identifier}. No portfolio-queue write was made.`); - } - return 0; - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - const entry = portfolioQueue.requeueItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); - if (!entry) { - return reportCliFailure(parsed.json, "queue_entry_not_requeuable"); - } - if (parsed.json) { - console.log(JSON.stringify({ entry }, null, 2)); - } else { - console.log(entry.status); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } + const parsed = parseQueueRequeueArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } + else { + console.log(`DRY RUN: would requeue ${parsed.repoFullName} ${parsed.identifier}. No portfolio-queue write was made.`); + } + return 0; + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.requeueItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_requeuable"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } + else { + console.log(entry.status); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } } - export function parseQueueClaimBatchArgs(args) { - const options = { json: false, dryRun: false, globalWipCap: 1, perRepoWipCap: 1 }; - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; - } - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - if (token === "--global-wip" || token === "--per-repo-wip") { - const value = Number(args[index + 1]); - if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + const options = { + json: false, + dryRun: false, + globalWipCap: 1, + perRepoWipCap: 1, + }; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--global-wip" || token === "--per-repo-wip") { + const value = Number(args[index + 1]); + if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + return { error: QUEUE_CLAIM_BATCH_USAGE }; + } + if (token === "--global-wip") + options.globalWipCap = value; + else + options.perRepoWipCap = value; + index += 1; + continue; + } return { error: QUEUE_CLAIM_BATCH_USAGE }; - } - if (token === "--global-wip") options.globalWipCap = value; - else options.perRepoWipCap = value; - index += 1; - continue; - } - return { error: QUEUE_CLAIM_BATCH_USAGE }; - } - return options; + } + return options; } - /** Claim the next caps-aware batch via the WIP-cap-aware batch claimer (portfolio-queue-manager.js), which also * reclaims any leases orphaned by a crashed process first (#4833 wires the previously caller-less claimer). */ export function runQueueClaimBatch(args, options = {}) { - const parsed = parseQueueClaimBatchArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - console.log( - `DRY RUN: would claim a batch (global-wip: ${parsed.globalWipCap}, per-repo-wip: ${parsed.perRepoWipCap}). No portfolio-queue write was made.`, - ); - } - return 0; - } - - // Open the manager INSIDE the try so a store open failure returns 2 instead of crashing; the finally guards the - // close with `?.` since the initializer may have thrown before assigning. - const ownsManager = options.initPortfolioQueueManager === undefined; - let manager; - try { - manager = (options.initPortfolioQueueManager ?? initPortfolioQueueManager)({ - caps: { globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }, - }); - const claimed = manager.claimNextBatch(); - if (parsed.json) { - console.log(JSON.stringify({ claimed }, null, 2)); - } else { - console.log(claimed.length === 0 ? "none" : claimed.map((entry) => entry.identifier).join("\n")); - } - return 0; - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } finally { - if (ownsManager) manager?.close(); - } + const parsed = parseQueueClaimBatchArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } + else { + console.log(`DRY RUN: would claim a batch (global-wip: ${parsed.globalWipCap}, per-repo-wip: ${parsed.perRepoWipCap}). No portfolio-queue write was made.`); + } + return 0; + } + // Open the manager INSIDE the try so a store open failure returns 2 instead of crashing; the finally guards the + // close with `?.` since the initializer may have thrown before assigning. + const ownsManager = options.initPortfolioQueueManager === undefined; + let manager; + try { + manager = (options.initPortfolioQueueManager ?? initPortfolioQueueManager)({ + caps: { globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }, + }); + const claimed = manager.claimNextBatch(); + if (parsed.json) { + console.log(JSON.stringify({ claimed }, null, 2)); + } + else { + console.log(claimed.length === 0 ? "none" : claimed.map((entry) => entry.identifier).join("\n")); + } + return 0; + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + finally { + if (ownsManager) + manager?.close(); + } } - const QUEUE_METRICS_USAGE = "Usage: loopover-miner queue metrics"; - // Prometheus metric names for the portfolio-queue gauges (#5186). Mirrors the `loopover_miner_*` naming and // HELP/TYPE/label conventions of event-ledger-cli.js's renderEventLedgerMetrics / the engine's // renderMinerPredictionMetrics, rather than importing across the package boundary. export const QUEUE_ITEMS = "loopover_miner_portfolio_queue_items"; export const QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS = "loopover_miner_portfolio_queue_oldest_in_progress_lease_age_seconds"; - /** HELP-text escaping — backslash + newline (mirrors miner-prediction-metrics.ts's escapeHelpText). */ function escapeMetricsHelpText(help) { - return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); + return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); } - /** * Render portfolio-queue backlog health as Prometheus text-exposition gauges: current item count per status, and * the age of the OLDEST still-in-flight lease -- the concrete "is anything stuck" signal a @@ -510,64 +496,65 @@ function escapeMetricsHelpText(help) { * @param {number} nowMs */ export function renderPortfolioQueueMetrics(queueEntries, leaseEntries, nowMs) { - const countByStatus = new Map(); - for (const entry of queueEntries) { - countByStatus.set(entry.status, (countByStatus.get(entry.status) ?? 0) + 1); - } - - let oldestLeaseAgeSeconds = 0; - for (const lease of leaseEntries) { - const leasedAtMs = Date.parse(lease.leasedAt ?? ""); - if (!Number.isFinite(leasedAtMs)) continue; - const ageSeconds = Math.max(0, (nowMs - leasedAtMs) / 1000); - if (ageSeconds > oldestLeaseAgeSeconds) oldestLeaseAgeSeconds = ageSeconds; - } - - const lines = [ - `# HELP ${QUEUE_ITEMS} ${escapeMetricsHelpText("Current portfolio-queue item count, by status.")}`, - `# TYPE ${QUEUE_ITEMS} gauge`, - ]; - for (const [status, count] of [...countByStatus.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { - lines.push(`${QUEUE_ITEMS}{status="${status}"} ${count}`); - } - - lines.push( - `# HELP ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${escapeMetricsHelpText("Age in seconds of the oldest still-in-flight (in_progress) claim lease. 0 when nothing is in-flight.")}`, - ); - lines.push(`# TYPE ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} gauge`); - lines.push(`${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${oldestLeaseAgeSeconds}`); - - return `${lines.join("\n")}\n`; + const countByStatus = new Map(); + for (const entry of queueEntries) { + countByStatus.set(entry.status, (countByStatus.get(entry.status) ?? 0) + 1); + } + let oldestLeaseAgeSeconds = 0; + for (const lease of leaseEntries) { + const leasedAtMs = Date.parse(lease.leasedAt ?? ""); + if (!Number.isFinite(leasedAtMs)) + continue; + const ageSeconds = Math.max(0, (nowMs - leasedAtMs) / 1000); + if (ageSeconds > oldestLeaseAgeSeconds) + oldestLeaseAgeSeconds = ageSeconds; + } + const lines = [ + `# HELP ${QUEUE_ITEMS} ${escapeMetricsHelpText("Current portfolio-queue item count, by status.")}`, + `# TYPE ${QUEUE_ITEMS} gauge`, + ]; + for (const [status, count] of [...countByStatus.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push(`${QUEUE_ITEMS}{status="${status}"} ${count}`); + } + lines.push(`# HELP ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${escapeMetricsHelpText("Age in seconds of the oldest still-in-flight (in_progress) claim lease. 0 when nothing is in-flight.")}`); + lines.push(`# TYPE ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} gauge`); + lines.push(`${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${oldestLeaseAgeSeconds}`); + return `${lines.join("\n")}\n`; } - export function runQueueMetrics(args, options = {}) { - if (args.length > 0) { - return reportCliFailure(argsWantJson(args), QUEUE_METRICS_USAGE); - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); - // renderPortfolioQueueMetrics returns a newline-terminated document; console.log re-adds the terminator, so - // trim it to emit exactly one trailing newline (mirrors metrics-cli.js's runMetrics). - console.log( - renderPortfolioQueueMetrics(portfolioQueue.listQueue(), portfolioQueue.listInProgress(), nowMs).trimEnd(), - ); - return 0; - }); - } catch (error) { - return reportCliFailure(argsWantJson(args), describeCliError(error)); - } + if (args.length > 0) { + return reportCliFailure(argsWantJson(args), QUEUE_METRICS_USAGE); + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const nowMs = typeof options.nowMs === "number" && Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); + // renderPortfolioQueueMetrics returns a newline-terminated document; console.log re-adds the terminator, so + // trim it to emit exactly one trailing newline (mirrors metrics-cli.js's runMetrics). + console.log(renderPortfolioQueueMetrics(portfolioQueue.listQueue(), portfolioQueue.listInProgress(), nowMs).trimEnd()); + return 0; + }); + } + catch (error) { + return reportCliFailure(argsWantJson(args), describeCliError(error)); + } } - export function runQueueCli(subcommand, args, options = {}) { - if (subcommand === "list") return runQueueList(args, options); - if (subcommand === "next") return runQueueNext(args, options); - if (subcommand === "done") return runQueueDone(args, options); - if (subcommand === "release") return runQueueRelease(args, options); - if (subcommand === "requeue") return runQueueRequeue(args, options); - if (subcommand === "claim-batch") return runQueueClaimBatch(args, options); - if (subcommand === "metrics") return runQueueMetrics(args, options); - if (subcommand === "dashboard") return runPortfolioDashboard(args, options); - return reportCliFailure(argsWantJson(args), `Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); + if (subcommand === "list") + return runQueueList(args, options); + if (subcommand === "next") + return runQueueNext(args, options); + if (subcommand === "done") + return runQueueDone(args, options); + if (subcommand === "release") + return runQueueRelease(args, options); + if (subcommand === "requeue") + return runQueueRequeue(args, options); + if (subcommand === "claim-batch") + return runQueueClaimBatch(args, options); + if (subcommand === "metrics") + return runQueueMetrics(args, options); + if (subcommand === "dashboard") + return runPortfolioDashboard(args, options); + return reportCliFailure(argsWantJson(args), `Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicG9ydGZvbGlvLXF1ZXVlLWNsaS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInBvcnRmb2xpby1xdWV1ZS1jbGkudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLHVCQUF1QixFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFFL0QsT0FBTyxFQUFFLHlCQUF5QixFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFFekUsT0FBTyxFQUFFLHFCQUFxQixFQUFFLE1BQU0sMEJBQTBCLENBQUM7QUFDakUsT0FBTyxFQUFFLFlBQVksRUFBRSxnQkFBZ0IsRUFBRSxnQkFBZ0IsRUFBRSxNQUFNLGdCQUFnQixDQUFDO0FBRWxGLE1BQU0sZ0JBQWdCLEdBQUcsaUVBQWlFLENBQUM7QUFDM0YsTUFBTSxnQkFBZ0IsR0FDcEIsK0ZBQStGLENBQUM7QUFDbEcsTUFBTSxnQkFBZ0IsR0FDcEIsd0dBQXdHLENBQUM7QUFDM0csTUFBTSxtQkFBbUIsR0FDdkIsMkdBQTJHLENBQUM7QUFDOUcsTUFBTSxtQkFBbUIsR0FDdkIsMkdBQTJHLENBQUM7QUFDOUcsTUFBTSx1QkFBdUIsR0FDM0Isc0dBQXNHLENBQUM7QUFtQ3pHLFNBQVMsWUFBWSxDQUFDLEtBQXlCLEVBQUUsS0FBYTtJQUM1RCxJQUFJLENBQUMsS0FBSztRQUFFLE9BQU8sRUFBRSxLQUFLLEVBQUUsS0FBSyxFQUFFLENBQUM7SUFDcEMsTUFBTSxPQUFPLEdBQUcsS0FBSyxDQUFDLElBQUksRUFBRSxDQUFDO0lBQzdCLE1BQU0sQ0FBQyxLQUFLLEVBQUUsSUFBSSxFQUFFLEtBQUssQ0FBQyxHQUFHLE9BQU8sQ0FBQyxLQUFLLENBQUMsR0FBRyxDQUFDLENBQUM7SUFDaEQsSUFBSSxDQUFDLEtBQUssSUFBSSxDQUFDLElBQUksSUFBSSxLQUFLLEtBQUssU0FBUyxFQUFFLENBQUM7UUFDM0MsT0FBTyxFQUFFLEtBQUssRUFBRSx3Q0FBd0MsRUFBRSxDQUFDO0lBQzdELENBQUM7SUFDRCxPQUFPLEVB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/portfolio-queue-cli.ts b/packages/loopover-miner/lib/portfolio-queue-cli.ts new file mode 100644 index 0000000000..2a6149bcd6 --- /dev/null +++ b/packages/loopover-miner/lib/portfolio-queue-cli.ts @@ -0,0 +1,639 @@ +import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import type { PortfolioQueueStore, QueueEntry } from "./portfolio-queue.js"; +import { initPortfolioQueueManager } from "./portfolio-queue-manager.js"; +import type { PortfolioQueueManager } from "./portfolio-queue-manager.js"; +import { runPortfolioDashboard } from "./portfolio-dashboard.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; + +const QUEUE_LIST_USAGE = "Usage: loopover-miner queue list [--repo ] [--json]"; +const QUEUE_NEXT_USAGE = + "Usage: loopover-miner queue next [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; +const QUEUE_DONE_USAGE = + "Usage: loopover-miner queue done [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_RELEASE_USAGE = + "Usage: loopover-miner queue release [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_REQUEUE_USAGE = + "Usage: loopover-miner queue requeue [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_CLAIM_BATCH_USAGE = + "Usage: loopover-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; + +export type ParsedQueueListArgs = + | { + json: boolean; + repoFullName: string | null; + } + | { error: string }; + +export type ParsedQueueNextArgs = + | { json: boolean; dryRun: boolean; globalWipCap: number | undefined; perRepoWipCap: number | undefined } + | { error: string }; + +export type QueueClaimTarget = { repoFullName: string; identifier: string; apiBaseUrl: string }; + +export type ParsedQueueDoneArgs = + | { + repoFullName: string; + identifier: string; + dryRun: boolean; + json: boolean; + apiBaseUrl: string | undefined; + } + | { error: string }; + +export type ParsedQueueClaimBatchArgs = + | { json: boolean; dryRun: boolean; globalWipCap: number; perRepoWipCap: number } + | { error: string }; + +type PortfolioQueueCliOptions = { + initPortfolioQueue?: () => PortfolioQueueStore; + initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager; + nowMs?: number; +}; + +function parseRepoArg(value: string | undefined, usage: string): { error: string } | { repoFullName: string } { + if (!value) return { error: usage }; + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) { + return { error: "Repository must be in owner/repo form." }; + } + return { repoFullName: `${owner}/${repo}` }; +} + +export function parseQueueListArgs(args: string[]): ParsedQueueListArgs { + const options: { json: boolean; repoFullName: string | null } = { json: false, repoFullName: null }; + const positional: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--repo") { + const repoArg = args[index + 1]; + if (!repoArg || repoArg.startsWith("-")) { + return { error: QUEUE_LIST_USAGE }; + } + const repo = parseRepoArg(repoArg, QUEUE_LIST_USAGE); + if ("error" in repo) return repo; + options.repoFullName = repo.repoFullName; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + + if (positional.length > 0) { + return { error: QUEUE_LIST_USAGE }; + } + + return options; +} + +// #4850: --global-wip/--per-repo-wip are OMITTED (undefined) by default -- queue next stays uncapped, byte- +// identical to its pre-#4850 behavior, unless an operator explicitly opts in. Mirrors queue claim-batch's own +// flag names (portfolio-queue-manager.js's WIP-cap-aware claimer), but claim-batch's OWN default of 1/1 is not +// reused here: claim-batch's whole purpose is cap enforcement, while queue next has always been a plain +// highest-priority dequeue and must not silently start capping existing callers that never asked for it. +export function parseQueueNextArgs(args: string[]): ParsedQueueNextArgs { + const options: { + json: boolean; + dryRun: boolean; + globalWipCap: number | undefined; + perRepoWipCap: number | undefined; + } = { json: false, dryRun: false, globalWipCap: undefined, perRepoWipCap: undefined }; + const positional: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--global-wip" || token === "--per-repo-wip") { + const value = Number(args[index + 1]); + if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + return { error: QUEUE_NEXT_USAGE }; + } + if (token === "--global-wip") options.globalWipCap = value; + else options.perRepoWipCap = value; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + + if (positional.length > 0) { + return { error: QUEUE_NEXT_USAGE }; + } + return options; +} + +/** + * Pick at most one atomically-claimable target from the store's already-priority-ordered active rows (queued + * AND in_progress interleaved, exactly `batchClaim`'s own `entries` shape). `caps` of `null` replicates the + * pre-#4850 behavior: the single highest-priority queued row, unconditionally. When caps are set, refuses to + * select anything once the global or the target row's own per-repo in-progress count has reached its cap -- + * "stops claiming once the cap is reached" (#4850), not a diversifying batch selection (that remains + * claim-batch's job via the engine's own `nextEligibleItems`). + * @param {Array<{ repoFullName: string, identifier: string, apiBaseUrl: string, status: string }>} entries + * @param {{ globalWipCap: number, perRepoWipCap: number } | null} caps + */ +export function selectNextEligibleTarget( + entries: Array<{ repoFullName: string; identifier: string; apiBaseUrl: string; status: string }>, + caps: { globalWipCap: number; perRepoWipCap: number } | null, +): QueueClaimTarget[] { + const topQueued = entries.find((entry) => entry.status === "queued"); + if (!topQueued) return []; + if (!caps) { + return [{ repoFullName: topQueued.repoFullName, identifier: topQueued.identifier, apiBaseUrl: topQueued.apiBaseUrl }]; + } + const globalActiveCount = entries.filter((entry) => entry.status === "in_progress").length; + if (globalActiveCount >= caps.globalWipCap) return []; + // Host-scope the per-repo active count (#7224): a same-named repo on a DIFFERENT forge host is a distinct backlog + // (the store keys rows by apiBaseUrl too, #5563), so an in-progress item on host A must not consume host B's + // per-repo WIP budget. Single-host is unchanged: every entry shares one apiBaseUrl, so the added match is always true. + const repoActiveCount = entries.filter( + (entry) => + entry.status === "in_progress" && + entry.repoFullName === topQueued.repoFullName && + entry.apiBaseUrl === topQueued.apiBaseUrl, + ).length; + if (repoActiveCount >= caps.perRepoWipCap) return []; + return [{ repoFullName: topQueued.repoFullName, identifier: topQueued.identifier, apiBaseUrl: topQueued.apiBaseUrl }]; +} + +/** Shared ` [--api-base-url ] [--json]` parse for the item-targeting subcommands + * (done/release/requeue). `usage` is the command-specific message surfaced on a malformed argv. */ +function parseRepoIdentifierArgs(args: string[], usage: string): ParsedQueueDoneArgs { + const options: { json: boolean; dryRun: boolean; apiBaseUrl: string | undefined } = { + json: false, + dryRun: false, + apiBaseUrl: undefined, + }; + const positional: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + // #4847: reports what a real mutation would do and returns before opening the portfolio queue at all. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + // #5563: scope the target to a non-default forge host, so it doesn't collide with (or get confused for) a + // same-named repo on the default github.com host. + if (token === "--api-base-url") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + return { error: usage }; + } + options.apiBaseUrl = value; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + + if (positional.length !== 2) { + return { error: usage }; + } + + const repo = parseRepoArg(positional[0], usage); + if ("error" in repo) return repo; + + const identifier = positional[1]?.trim(); + if (!identifier) { + return { error: usage }; + } + + return { + repoFullName: repo.repoFullName, + identifier, + dryRun: options.dryRun, + json: options.json, + apiBaseUrl: options.apiBaseUrl, + }; +} + +export function parseQueueDoneArgs(args: string[]): ParsedQueueDoneArgs { + return parseRepoIdentifierArgs(args, QUEUE_DONE_USAGE); +} + +export function parseQueueReleaseArgs(args: string[]): ParsedQueueDoneArgs { + return parseRepoIdentifierArgs(args, QUEUE_RELEASE_USAGE); +} + +export function parseQueueRequeueArgs(args: string[]): ParsedQueueDoneArgs { + return parseRepoIdentifierArgs(args, QUEUE_REQUEUE_USAGE); +} + +function display(value: unknown): string { + if (value === null || value === undefined) return "-"; + return String(value); +} + +export function renderQueueTable(entries: QueueEntry[]): string { + if (!Array.isArray(entries) || entries.length === 0) return "no portfolio queue entries"; + const header = [ + "repo".padEnd(24), + "identifier".padEnd(16), + // #7225: surface the host so a reader of the plain-text table can supply the `--api-base-url` a follow-up + // done/release/requeue needs to disambiguate two rows sharing a repo+identifier across forge hosts. + "host".padEnd(30), + "status".padEnd(12), + "pri".padStart(4), + "enqueued-at".padEnd(24), + ].join(" "); + const lines = entries.map((entry) => + [ + entry.repoFullName.padEnd(24), + entry.identifier.padEnd(16), + display(entry.apiBaseUrl).padEnd(30), + entry.status.padEnd(12), + display(entry.priority).padStart(4), + display(entry.enqueuedAt).padEnd(24), + ].join(" "), + ); + return [header, ...lines].join("\n"); +} + +function withPortfolioQueue( + options: { initPortfolioQueue?: () => PortfolioQueueStore }, + run: (portfolioQueue: PortfolioQueueStore) => T, +): T { + const ownsStore = options.initPortfolioQueue === undefined; + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + try { + return run(portfolioQueue); + } finally { + if (ownsStore) portfolioQueue.close(); + } +} + +export function runQueueList(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore } = {}): number { + const parsed = parseQueueListArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entries = portfolioQueue.listQueue(parsed.repoFullName); + if (parsed.json) { + console.log(JSON.stringify({ entries }, null, 2)); + } else { + console.log(renderQueueTable(entries)); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +export function runQueueNext(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore } = {}): number { + const parsed = parseQueueNextArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + const capsRequested = parsed.globalWipCap !== undefined || parsed.perRepoWipCap !== undefined; + if (parsed.dryRun) { + const dryRunResult = capsRequested + ? { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap } + : { outcome: "dry_run" }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else if (capsRequested) { + console.log( + `DRY RUN: would dequeue the highest-priority queued item within WIP caps (global-wip: ${parsed.globalWipCap ?? "unset"}, per-repo-wip: ${parsed.perRepoWipCap ?? "unset"}). No portfolio-queue write was made.`, + ); + } else { + console.log("DRY RUN: would dequeue the highest-priority queued item. No portfolio-queue write was made."); + } + return 0; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + let entry; + if (capsRequested) { + // Unset dimensions stay genuinely uncapped (Infinity), not silently defaulted to 1 like claim-batch. + const caps = { + globalWipCap: parsed.globalWipCap ?? Number.POSITIVE_INFINITY, + perRepoWipCap: parsed.perRepoWipCap ?? Number.POSITIVE_INFINITY, + }; + const claimed = portfolioQueue.batchClaim((entries) => selectNextEligibleTarget(entries, caps)); + entry = claimed[0] ?? null; + } else { + entry = portfolioQueue.dequeueNext(); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry ? entry.identifier : "none"); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +export function runQueueDone(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore } = {}): number { + const parsed = parseQueueDoneArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log(`DRY RUN: would mark ${parsed.repoFullName} ${parsed.identifier} done. No portfolio-queue write was made.`); + } + return 0; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.markDone(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_found"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry.status); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +/** `release `: manually give up a CLAIMED (in_progress) item, returning it to the queue + * (the manual counterpart to the automated stuck-lease sweep). Exit 2 when there is no in-flight item to release. */ +export function runQueueRelease(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore } = {}): number { + const parsed = parseQueueReleaseArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log(`DRY RUN: would release ${parsed.repoFullName} ${parsed.identifier} back to the queue. No portfolio-queue write was made.`); + } + return 0; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.reclaimStuckItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_in_progress"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry.status); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +/** `requeue `: manually put a COMPLETED (done) item back on the queue so it is picked up + * again, keeping its original FIFO position. Exit 2 when there is no done item to requeue (already queued, + * in-flight — release it instead — or absent). */ +export function runQueueRequeue(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore } = {}): number { + const parsed = parseQueueRequeueArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log(`DRY RUN: would requeue ${parsed.repoFullName} ${parsed.identifier}. No portfolio-queue write was made.`); + } + return 0; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.requeueItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_requeuable"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry.status); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +export function parseQueueClaimBatchArgs(args: string[]): ParsedQueueClaimBatchArgs { + const options: { json: boolean; dryRun: boolean; globalWipCap: number; perRepoWipCap: number } = { + json: false, + dryRun: false, + globalWipCap: 1, + perRepoWipCap: 1, + }; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--global-wip" || token === "--per-repo-wip") { + const value = Number(args[index + 1]); + if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + return { error: QUEUE_CLAIM_BATCH_USAGE }; + } + if (token === "--global-wip") options.globalWipCap = value; + else options.perRepoWipCap = value; + index += 1; + continue; + } + return { error: QUEUE_CLAIM_BATCH_USAGE }; + } + return options; +} + +/** Claim the next caps-aware batch via the WIP-cap-aware batch claimer (portfolio-queue-manager.js), which also + * reclaims any leases orphaned by a crashed process first (#4833 wires the previously caller-less claimer). */ +export function runQueueClaimBatch(args: string[], options: { initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager } = {}): number { + const parsed = parseQueueClaimBatchArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log( + `DRY RUN: would claim a batch (global-wip: ${parsed.globalWipCap}, per-repo-wip: ${parsed.perRepoWipCap}). No portfolio-queue write was made.`, + ); + } + return 0; + } + + // Open the manager INSIDE the try so a store open failure returns 2 instead of crashing; the finally guards the + // close with `?.` since the initializer may have thrown before assigning. + const ownsManager = options.initPortfolioQueueManager === undefined; + let manager: PortfolioQueueManager | undefined; + try { + manager = (options.initPortfolioQueueManager ?? initPortfolioQueueManager)({ + caps: { globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }, + }); + const claimed = manager.claimNextBatch(); + if (parsed.json) { + console.log(JSON.stringify({ claimed }, null, 2)); + } else { + console.log(claimed.length === 0 ? "none" : claimed.map((entry) => entry.identifier).join("\n")); + } + return 0; + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } finally { + if (ownsManager) manager?.close(); + } +} + +const QUEUE_METRICS_USAGE = "Usage: loopover-miner queue metrics"; + +// Prometheus metric names for the portfolio-queue gauges (#5186). Mirrors the `loopover_miner_*` naming and +// HELP/TYPE/label conventions of event-ledger-cli.js's renderEventLedgerMetrics / the engine's +// renderMinerPredictionMetrics, rather than importing across the package boundary. +export const QUEUE_ITEMS = "loopover_miner_portfolio_queue_items"; +export const QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS = "loopover_miner_portfolio_queue_oldest_in_progress_lease_age_seconds"; + +/** HELP-text escaping — backslash + newline (mirrors miner-prediction-metrics.ts's escapeHelpText). */ +function escapeMetricsHelpText(help: string): string { + return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); +} + +/** + * Render portfolio-queue backlog health as Prometheus text-exposition gauges: current item count per status, and + * the age of the OLDEST still-in-flight lease -- the concrete "is anything stuck" signal a + * `loopover_queue_oldest_maintenance_pending_age_seconds`-style alert rule can threshold on (#5186). Pure and + * side-effect-free: the caller supplies the rows and `nowMs` (no internal clock read, matching + * store-maintenance.js's pruneLedgerByRetention convention) and prints the result. Deterministic (status series + * sorted); always emits HELP/TYPE so an empty queue is still a well-formed exposition document, and the lease-age + * gauge reads 0 (never stuck) rather than being omitted when nothing is in-flight. + * @param {Array<{ status: string }>} queueEntries - every row, any status (e.g. store.listQueue()'s output). + * @param {Array<{ leasedAt: string | null }>} leaseEntries - in-flight rows only (store.listInProgress()'s output). + * @param {number} nowMs + */ +export function renderPortfolioQueueMetrics( + queueEntries: Array<{ status: string }>, + leaseEntries: Array<{ leasedAt: string | null }>, + nowMs: number, +): string { + const countByStatus = new Map(); + for (const entry of queueEntries) { + countByStatus.set(entry.status, (countByStatus.get(entry.status) ?? 0) + 1); + } + + let oldestLeaseAgeSeconds = 0; + for (const lease of leaseEntries) { + const leasedAtMs = Date.parse(lease.leasedAt ?? ""); + if (!Number.isFinite(leasedAtMs)) continue; + const ageSeconds = Math.max(0, (nowMs - leasedAtMs) / 1000); + if (ageSeconds > oldestLeaseAgeSeconds) oldestLeaseAgeSeconds = ageSeconds; + } + + const lines = [ + `# HELP ${QUEUE_ITEMS} ${escapeMetricsHelpText("Current portfolio-queue item count, by status.")}`, + `# TYPE ${QUEUE_ITEMS} gauge`, + ]; + for (const [status, count] of [...countByStatus.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push(`${QUEUE_ITEMS}{status="${status}"} ${count}`); + } + + lines.push( + `# HELP ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${escapeMetricsHelpText("Age in seconds of the oldest still-in-flight (in_progress) claim lease. 0 when nothing is in-flight.")}`, + ); + lines.push(`# TYPE ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} gauge`); + lines.push(`${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${oldestLeaseAgeSeconds}`); + + return `${lines.join("\n")}\n`; +} + +export function runQueueMetrics(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore; nowMs?: number } = {}): number { + if (args.length > 0) { + return reportCliFailure(argsWantJson(args), QUEUE_METRICS_USAGE); + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const nowMs = typeof options.nowMs === "number" && Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); + // renderPortfolioQueueMetrics returns a newline-terminated document; console.log re-adds the terminator, so + // trim it to emit exactly one trailing newline (mirrors metrics-cli.js's runMetrics). + console.log( + renderPortfolioQueueMetrics(portfolioQueue.listQueue(), portfolioQueue.listInProgress(), nowMs).trimEnd(), + ); + return 0; + }); + } catch (error) { + return reportCliFailure(argsWantJson(args), describeCliError(error)); + } +} + +export function runQueueCli( + subcommand: string | undefined, + args: string[], + options: { + initPortfolioQueue?: () => PortfolioQueueStore; + initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager; + } = {}, +): number { + if (subcommand === "list") return runQueueList(args, options); + if (subcommand === "next") return runQueueNext(args, options); + if (subcommand === "done") return runQueueDone(args, options); + if (subcommand === "release") return runQueueRelease(args, options); + if (subcommand === "requeue") return runQueueRequeue(args, options); + if (subcommand === "claim-batch") return runQueueClaimBatch(args, options); + if (subcommand === "metrics") return runQueueMetrics(args, options); + if (subcommand === "dashboard") return runPortfolioDashboard(args, options); + return reportCliFailure(argsWantJson(args), `Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); +}