From a4a564bd4462681a69de88055700d6539658cda5 Mon Sep 17 00:00:00 2001 From: xfodev Date: Sun, 19 Jul 2026 12:23:35 -0700 Subject: [PATCH] chore(miner): migrate batch 2.3 leaf lib modules to TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the 8 leaf-most, lowest-fan-in packages/loopover-miner/lib modules from plain .js + hand-maintained .d.ts to real TypeScript, compiled in place by the existing tsconfig (Phase 2 batch 2.3 of the #7290 migration): cross-repo-evaluation, version, slop-assessment, discovery-throttle, claim-ledger-expiry, prompt-injection-defense, execute-local-write, portfolio-queue-expiry. Behavior is byte-identical — each .ts carries the exact control flow and comments of its .js, with types derived from the removed hand .d.ts; the compiled output stays directly-runnable Node ESM. The two expiry stores' `expireClaim`/`reclaimStuckItem` interface methods gain the optional `apiBaseUrl?` param the runtime already passes (the hand .d.ts under-declared it), matching the real ClaimLedger/PortfolioQueueStore. Every converted file is at 100% line AND branch coverage: added regression tests exercise the branches a type-only conversion newly exposes to the patch gate — the NaN/invalid-timestamp age paths in claim-ledger-expiry and portfolio-queue-expiry, and the manifest-normalizer warning/skip and error-narrowing branches in cross-repo-evaluation. No coverage waiver (no v8-ignore) was needed. Closes #7302 --- .../lib/claim-ledger-expiry.d.ts | 27 +- .../loopover-miner/lib/claim-ledger-expiry.js | 62 +- .../loopover-miner/lib/claim-ledger-expiry.ts | 52 ++ .../lib/cross-repo-evaluation.d.ts | 157 ++--- .../lib/cross-repo-evaluation.js | 585 ++++++++---------- .../lib/cross-repo-evaluation.ts | 456 ++++++++++++++ .../lib/discovery-throttle.d.ts | 21 +- .../loopover-miner/lib/discovery-throttle.js | 25 +- .../loopover-miner/lib/discovery-throttle.ts | 33 + .../lib/execute-local-write.d.ts | 21 +- .../loopover-miner/lib/execute-local-write.js | 64 +- .../loopover-miner/lib/execute-local-write.ts | 57 ++ .../lib/portfolio-queue-expiry.d.ts | 33 +- .../lib/portfolio-queue-expiry.js | 62 +- .../lib/portfolio-queue-expiry.ts | 62 ++ .../lib/prompt-injection-defense.d.ts | 16 +- .../lib/prompt-injection-defense.js | 41 +- .../lib/prompt-injection-defense.ts | 45 ++ .../loopover-miner/lib/slop-assessment.d.ts | 3 +- .../loopover-miner/lib/slop-assessment.js | 9 +- .../loopover-miner/lib/slop-assessment.ts | 17 + packages/loopover-miner/lib/version.d.ts | 7 +- packages/loopover-miner/lib/version.js | 7 +- packages/loopover-miner/lib/version.ts | 10 + test/unit/miner-claim-ledger-expiry.test.ts | 21 + test/unit/miner-cross-repo-evaluation.test.ts | 97 +++ .../unit/miner-portfolio-queue-expiry.test.ts | 12 + 27 files changed, 1404 insertions(+), 598 deletions(-) create mode 100644 packages/loopover-miner/lib/claim-ledger-expiry.ts create mode 100644 packages/loopover-miner/lib/cross-repo-evaluation.ts create mode 100644 packages/loopover-miner/lib/discovery-throttle.ts create mode 100644 packages/loopover-miner/lib/execute-local-write.ts create mode 100644 packages/loopover-miner/lib/portfolio-queue-expiry.ts create mode 100644 packages/loopover-miner/lib/prompt-injection-defense.ts create mode 100644 packages/loopover-miner/lib/slop-assessment.ts create mode 100644 packages/loopover-miner/lib/version.ts diff --git a/packages/loopover-miner/lib/claim-ledger-expiry.d.ts b/packages/loopover-miner/lib/claim-ledger-expiry.d.ts index e35a98242e..6c78c3ba71 100644 --- a/packages/loopover-miner/lib/claim-ledger-expiry.d.ts +++ b/packages/loopover-miner/lib/claim-ledger-expiry.d.ts @@ -1,20 +1,15 @@ +/** PURE — no IO, no Date, no random (#2316). */ import type { ClaimEntry } from "./claim-ledger.js"; - export declare const DEFAULT_MAX_CLAIM_AGE_MS: number; - export type ClaimLedgerExpiryStore = { - listClaims(filter?: { status?: "active" }): ClaimEntry[]; - expireClaim(repoFullName: string, issueNumber: number): ClaimEntry | null; + listClaims(filter?: { + status?: "active"; + }): ClaimEntry[]; + expireClaim(repoFullName: string, issueNumber: number, apiBaseUrl?: string): ClaimEntry | null; }; - -export function findExpiredClaims( - claims: ClaimEntry[], - nowMs: number, - maxAgeMs: number, -): ClaimEntry[]; - -export function sweepExpiredClaims( - store: ClaimLedgerExpiryStore, - nowMs: number, - maxAgeMs?: number, -): ClaimEntry[]; +/** + * Return active claims whose age is strictly greater than `maxAgeMs`. A claim whose age equals `maxAgeMs` exactly + * is still considered within the window (not expired). + */ +export declare function findExpiredClaims(claims: ClaimEntry[], nowMs: number, maxAgeMs: number): ClaimEntry[]; +export declare function sweepExpiredClaims(store: ClaimLedgerExpiryStore, nowMs: number, maxAgeMs?: number): ClaimEntry[]; diff --git a/packages/loopover-miner/lib/claim-ledger-expiry.js b/packages/loopover-miner/lib/claim-ledger-expiry.js index 7455340a20..4e4ee4c6df 100644 --- a/packages/loopover-miner/lib/claim-ledger-expiry.js +++ b/packages/loopover-miner/lib/claim-ledger-expiry.js @@ -1,41 +1,45 @@ /** PURE — no IO, no Date, no random (#2316). */ - export const DEFAULT_MAX_CLAIM_AGE_MS = 14 * 24 * 60 * 60 * 1000; - function claimAgeMs(claim, nowMs) { - const claimedAtMs = Date.parse(claim.claimedAt); - if (!Number.isFinite(claimedAtMs)) return null; - return nowMs - claimedAtMs; + const claimedAtMs = Date.parse(claim.claimedAt); + if (!Number.isFinite(claimedAtMs)) + return null; + return nowMs - claimedAtMs; } - /** * Return active claims whose age is strictly greater than `maxAgeMs`. A claim whose age equals `maxAgeMs` exactly * is still considered within the window (not expired). */ export function findExpiredClaims(claims, nowMs, maxAgeMs) { - if (!Number.isFinite(nowMs) || nowMs < 0) throw new Error("invalid_now_ms"); - if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) throw new Error("invalid_max_age_ms"); - if (!Array.isArray(claims)) throw new Error("invalid_claims"); - - const expired = []; - for (const claim of claims) { - if (claim?.status !== "active") continue; - const ageMs = claimAgeMs(claim, nowMs); - if (ageMs === null) continue; - if (ageMs > maxAgeMs) expired.push(claim); - } - return expired; + if (!Number.isFinite(nowMs) || nowMs < 0) + throw new Error("invalid_now_ms"); + if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) + throw new Error("invalid_max_age_ms"); + if (!Array.isArray(claims)) + throw new Error("invalid_claims"); + const expired = []; + for (const claim of claims) { + if (claim?.status !== "active") + continue; + const ageMs = claimAgeMs(claim, nowMs); + if (ageMs === null) + continue; + if (ageMs > maxAgeMs) + expired.push(claim); + } + return expired; } - export function sweepExpiredClaims(store, nowMs, maxAgeMs = DEFAULT_MAX_CLAIM_AGE_MS) { - const activeClaims = store.listClaims({ status: "active" }); - const expired = findExpiredClaims(activeClaims, nowMs, maxAgeMs); - const transitioned = []; - for (const claim of expired) { - // Echo the row's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an - // active claim on the same owner/repo#issue, and defaulting here would expire the wrong host's row. - const updated = store.expireClaim(claim.repoFullName, claim.issueNumber, claim.apiBaseUrl); - if (updated) transitioned.push(updated); - } - return transitioned; + const activeClaims = store.listClaims({ status: "active" }); + const expired = findExpiredClaims(activeClaims, nowMs, maxAgeMs); + const transitioned = []; + for (const claim of expired) { + // Echo the row's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an + // active claim on the same owner/repo#issue, and defaulting here would expire the wrong host's row. + const updated = store.expireClaim(claim.repoFullName, claim.issueNumber, claim.apiBaseUrl); + if (updated) + transitioned.push(updated); + } + return transitioned; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhaW0tbGVkZ2VyLWV4cGlyeS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImNsYWltLWxlZGdlci1leHBpcnkudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsZ0RBQWdEO0FBSWhELE1BQU0sQ0FBQyxNQUFNLHdCQUF3QixHQUFHLEVBQUUsR0FBRyxFQUFFLEdBQUcsRUFBRSxHQUFHLEVBQUUsR0FBRyxJQUFJLENBQUM7QUFPakUsU0FBUyxVQUFVLENBQUMsS0FBaUIsRUFBRSxLQUFhO0lBQ2xELE1BQU0sV0FBVyxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDLFNBQVMsQ0FBQyxDQUFDO0lBQ2hELElBQUksQ0FBQyxNQUFNLENBQUMsUUFBUSxDQUFDLFdBQVcsQ0FBQztRQUFFLE9BQU8sSUFBSSxDQUFDO0lBQy9DLE9BQU8sS0FBSyxHQUFHLFdBQVcsQ0FBQztBQUM3QixDQUFDO0FBRUQ7OztHQUdHO0FBQ0gsTUFBTSxVQUFVLGlCQUFpQixDQUFDLE1BQW9CLEVBQUUsS0FBYSxFQUFFLFFBQWdCO0lBQ3JGLElBQUksQ0FBQyxNQUFNLENBQUMsUUFBUSxDQUFDLEtBQUssQ0FBQyxJQUFJLEtBQUssR0FBRyxDQUFDO1FBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyxnQkFBZ0IsQ0FBQyxDQUFDO0lBQzVFLElBQUksQ0FBQyxNQUFNLENBQUMsUUFBUSxDQUFDLFFBQVEsQ0FBQyxJQUFJLFFBQVEsR0FBRyxDQUFDO1FBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyxvQkFBb0IsQ0FBQyxDQUFDO0lBQ3RGLElBQUksQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBQztRQUFFLE1BQU0sSUFBSSxLQUFLLENBQUMsZ0JBQWdCLENBQUMsQ0FBQztJQUU5RCxNQUFNLE9BQU8sR0FBaUIsRUFBRSxDQUFDO0lBQ2pDLEtBQUssTUFBTSxLQUFLLElBQUksTUFBTSxFQUFFLENBQUM7UUFDM0IsSUFBSSxLQUFLLEVBQUUsTUFBTSxLQUFLLFFBQVE7WUFBRSxTQUFTO1FBQ3pDLE1BQU0sS0FBSyxHQUFHLFVBQVUsQ0FBQyxLQUFLLEVBQUUsS0FBSyxDQUFDLENBQUM7UUFDdkMsSUFBSSxLQUFLLEtBQUssSUFBSTtZQUFFLFNBQVM7UUFDN0IsSUFBSSxLQUFLLEdBQUcsUUFBUTtZQUFFLE9BQU8sQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLENBQUM7SUFDNUMsQ0FBQztJQUNELE9BQU8sT0FBTyxDQUFDO0FBQ2pCLENBQUM7QUFFRCxNQUFNLFVBQVUsa0JBQWtCLENBQ2hDLEtBQTZCLEVBQzdCLEtBQWEsRUFDYixXQUFtQix3QkFBd0I7SUFFM0MsTUFBTSxZQUFZLEdBQUcsS0FBSyxDQUFDLFVBQVUsQ0FBQyxFQUFFLE1BQU0sRUFBRSxRQUFRLEVBQUUsQ0FBQyxDQUFDO0lBQzVELE1BQU0sT0FBTyxHQUFHLGlCQUFpQixDQUFDLFlBQVksRUFBRSxLQUFLLEVBQUUsUUFBUSxDQUFDLENBQUM7SUFDakUsTUFBTSxZQUFZLEdBQWlCLEVBQUUsQ0FBQztJQUN0QyxLQUFLLE1BQU0sS0FBSyxJQUFJLE9BQU8sRUFBRSxDQUFDO1FBQzVCLHNHQUFzRztRQUN0RyxvR0FBb0c7UUFDcEcsTUFBTSxPQUFPLEdBQUcsS0FBSyxDQUFDLFdBQVcsQ0FBQyxLQUFLLENBQUMsWUFBWSxFQUFFLEtBQUssQ0FBQyxXQUFXLEVBQUUsS0FBSyxDQUFDLFVBQVUsQ0FBQyxDQUFDO1FBQzNGLElBQUksT0FBTztZQUFFLFlBQVksQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFDLENBQUM7SUFDMUMsQ0FBQztJQUNELE9BQU8sWUFBWSxDQUFDO0FBQ3RCLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/claim-ledger-expiry.ts b/packages/loopover-miner/lib/claim-ledger-expiry.ts new file mode 100644 index 0000000000..4a2a65699f --- /dev/null +++ b/packages/loopover-miner/lib/claim-ledger-expiry.ts @@ -0,0 +1,52 @@ +/** PURE — no IO, no Date, no random (#2316). */ + +import type { ClaimEntry } from "./claim-ledger.js"; + +export const DEFAULT_MAX_CLAIM_AGE_MS = 14 * 24 * 60 * 60 * 1000; + +export type ClaimLedgerExpiryStore = { + listClaims(filter?: { status?: "active" }): ClaimEntry[]; + expireClaim(repoFullName: string, issueNumber: number, apiBaseUrl?: string): ClaimEntry | null; +}; + +function claimAgeMs(claim: ClaimEntry, nowMs: number): number | null { + const claimedAtMs = Date.parse(claim.claimedAt); + if (!Number.isFinite(claimedAtMs)) return null; + return nowMs - claimedAtMs; +} + +/** + * Return active claims whose age is strictly greater than `maxAgeMs`. A claim whose age equals `maxAgeMs` exactly + * is still considered within the window (not expired). + */ +export function findExpiredClaims(claims: ClaimEntry[], nowMs: number, maxAgeMs: number): ClaimEntry[] { + if (!Number.isFinite(nowMs) || nowMs < 0) throw new Error("invalid_now_ms"); + if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) throw new Error("invalid_max_age_ms"); + if (!Array.isArray(claims)) throw new Error("invalid_claims"); + + const expired: ClaimEntry[] = []; + for (const claim of claims) { + if (claim?.status !== "active") continue; + const ageMs = claimAgeMs(claim, nowMs); + if (ageMs === null) continue; + if (ageMs > maxAgeMs) expired.push(claim); + } + return expired; +} + +export function sweepExpiredClaims( + store: ClaimLedgerExpiryStore, + nowMs: number, + maxAgeMs: number = DEFAULT_MAX_CLAIM_AGE_MS, +): ClaimEntry[] { + const activeClaims = store.listClaims({ status: "active" }); + const expired = findExpiredClaims(activeClaims, nowMs, maxAgeMs); + const transitioned: ClaimEntry[] = []; + for (const claim of expired) { + // Echo the row's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an + // active claim on the same owner/repo#issue, and defaulting here would expire the wrong host's row. + const updated = store.expireClaim(claim.repoFullName, claim.issueNumber, claim.apiBaseUrl); + if (updated) transitioned.push(updated); + } + return transitioned; +} diff --git a/packages/loopover-miner/lib/cross-repo-evaluation.d.ts b/packages/loopover-miner/lib/cross-repo-evaluation.d.ts index 2e7121f735..af8e928558 100644 --- a/packages/loopover-miner/lib/cross-repo-evaluation.d.ts +++ b/packages/loopover-miner/lib/cross-repo-evaluation.d.ts @@ -1,90 +1,103 @@ import type { RepoStackResult } from "./stack-detection.js"; - -export const CROSS_REPO_FAILURE_CATEGORY: Readonly<{ - STACK_DETECTION: "stack_detection_gap"; - EXECUTION: "execution_gap"; - GITTENSOR_ASSUMPTION: "loopover_assumption"; - CLONE_SETUP: "clone_setup"; - OTHER: "other"; +/** Failure taxonomy surfaced in per-repo reports (#4788). */ +export declare const CROSS_REPO_FAILURE_CATEGORY: Readonly<{ + STACK_DETECTION: "stack_detection_gap"; + EXECUTION: "execution_gap"; + GITTENSOR_ASSUMPTION: "loopover_assumption"; + CLONE_SETUP: "clone_setup"; + OTHER: "other"; }>; - -export const GITTENSOR_POSITIVE_ASSUMPTION_CHECKS: ReadonlyArray<{ - id: string; - pattern: RegExp; +/** Instruction substrings that indicate a POSITIVE loopover/LoopOver CI assumption leaked into the agent prompt. + * Lines that explicitly tell the agent *not* to assume these are filtered out before scanning. */ +export declare const GITTENSOR_POSITIVE_ASSUMPTION_CHECKS: ReadonlyArray<{ + id: string; + pattern: RegExp; }>; - -export const DEFAULT_CROSS_REPO_MANIFEST_RELATIVE_PATH: string; -export const MAX_CROSS_REPO_MANIFEST_BYTES: number; -export const MAX_CROSS_REPO_MANIFEST_REPOS: number; - +export declare const DEFAULT_CROSS_REPO_MANIFEST_RELATIVE_PATH: string; +export declare const MAX_CROSS_REPO_MANIFEST_BYTES: number; +export declare const MAX_CROSS_REPO_MANIFEST_REPOS: number; export type CrossRepoEvaluationManifestRepo = { - repoFullName: string; - stackHint?: string; - requireTestCommand?: boolean; - fixturePath?: string; + repoFullName: string; + stackHint?: string; + requireTestCommand?: boolean; + fixturePath?: string; }; - export type ParsedCrossRepoEvaluationManifest = { - present: boolean; - manifest: { repos: CrossRepoEvaluationManifestRepo[] }; - warnings: string[]; + present: boolean; + manifest: { + repos: CrossRepoEvaluationManifestRepo[]; + }; + warnings: string[]; }; - export type CrossRepoEvaluationResult = { - repoFullName: string; - passed: boolean; - failureCategory: string | null; - reason: string | null; - stackDetected: boolean; - usedDefaultGoalSpec: boolean | null; - assumptionFindings: Array<{ id: string; line: string }>; - stack?: RepoStackResult; + repoFullName: string; + passed: boolean; + failureCategory: string | null; + reason: string | null; + stackDetected: boolean; + usedDefaultGoalSpec: boolean | null; + assumptionFindings: Array<{ + id: string; + line: string; + }>; + stack?: RepoStackResult; }; - export type CrossRepoEvaluationSummary = { - total: number; - passed: number; - failed: number; - majorityPassed: boolean; - withoutLoopoverConfig: number; - failuresByCategory: Record; + total: number; + passed: number; + failed: number; + majorityPassed: boolean; + withoutLoopoverConfig: number; + failuresByCategory: Record; }; - -export function normalizeCrossRepoFullName(value: unknown): string | null; - -export function parseCrossRepoEvaluationManifest( - content: string | null | undefined, -): ParsedCrossRepoEvaluationManifest; - -export function scanPositiveLoopoverAssumptions(text: string): Array<{ id: string; line: string }>; - -export function evaluateRepoReadiness( - entry: CrossRepoEvaluationManifestRepo, - options?: { +type EvaluateRepoReadinessOptions = { repoPath?: string; - resolveRepoPath?: (entry: { repoFullName: string }) => string; + resolveRepoPath?: (entry: { + repoFullName: string; + }) => string; env?: NodeJS.ProcessEnv; existsSync?: (path: string) => boolean; detectRepoStack?: (repoPath: string) => RepoStackResult; - resolveMinerGoalSpec?: (repoPath: string) => { present: boolean }; + resolveMinerGoalSpec?: (repoPath: string) => { + present: boolean; + }; buildCodingTaskSpec?: (input: Record) => { - ready: boolean; - verdict?: string; - instructions?: string; + ready: boolean; + verdict?: string; + instructions?: string; }; - }, -): CrossRepoEvaluationResult; - -export function runCrossRepoEvaluation( - parsed: ParsedCrossRepoEvaluationManifest, - options?: { +}; +/** Canonical `owner/repo` with exactly one slash and safe segments; anything else → null. */ +export declare function normalizeCrossRepoFullName(value: unknown): string | null; +/** + * Tolerant JSON manifest parser (#4788). Malformed input degrades to an empty repo list with warnings rather than + * throwing, mirroring the fleet-run-manifest / miner-goal-spec convention. + */ +export declare function parseCrossRepoEvaluationManifest(content: string | null | undefined): ParsedCrossRepoEvaluationManifest; +/** + * Scan agent instructions for positive loopover/LoopOver assumptions (#4788). Lines that already tell the agent + * *not* to assume LoopOver conventions (the negative guidance from buildValidationGuidance) are skipped. + */ +export declare function scanPositiveLoopoverAssumptions(text: string): Array<{ + id: string; + line: string; +}>; +/** + * Evaluate one benchmark repo's miner readiness without running a live coding agent (#4788). + */ +export declare function evaluateRepoReadiness(entry: CrossRepoEvaluationManifestRepo, options?: EvaluateRepoReadinessOptions): CrossRepoEvaluationResult; +/** + * Run the harness across every repo in a parsed manifest (#4788). + */ +export declare function runCrossRepoEvaluation(parsed: ParsedCrossRepoEvaluationManifest, options?: { repoFilter?: string; - } & Parameters[1], -): CrossRepoEvaluationResult[]; - -export function summarizeCrossRepoEvaluation(results: CrossRepoEvaluationResult[]): CrossRepoEvaluationSummary; - -export function formatCrossRepoEvaluationReport( - results: CrossRepoEvaluationResult[], - summary?: CrossRepoEvaluationSummary, -): string; +} & EvaluateRepoReadinessOptions): CrossRepoEvaluationResult[]; +/** + * Reduce per-repo results to pass/fail counts and whether a strict majority passed (#4788). + */ +export declare function summarizeCrossRepoEvaluation(results: CrossRepoEvaluationResult[]): CrossRepoEvaluationSummary; +/** + * Human-readable pass/fail report for one evaluation run (#4788). + */ +export declare function formatCrossRepoEvaluationReport(results: CrossRepoEvaluationResult[], summary?: CrossRepoEvaluationSummary): string; +export {}; diff --git a/packages/loopover-miner/lib/cross-repo-evaluation.js b/packages/loopover-miner/lib/cross-repo-evaluation.js index 9c8f620df2..995ae19eba 100644 --- a/packages/loopover-miner/lib/cross-repo-evaluation.js +++ b/packages/loopover-miner/lib/cross-repo-evaluation.js @@ -3,405 +3,344 @@ // evaluated through the same stack-detection + coding-task-spec path a real attempt uses (detectRepoStack, // resolveMinerGoalSpec, buildCodingTaskSpec) and failures are categorized as stack-detection gaps, execution // readiness gaps, leaked loopover assumptions in agent instructions, clone/setup problems, or other. - import { existsSync } from "node:fs"; -import { join } from "node:path"; import { buildCodingTaskSpec } from "./coding-task-spec.js"; import { resolveMinerGoalSpec } from "./miner-goal-spec.js"; import { isValidRepoSegment, resolveRepoCloneDir } from "./repo-clone.js"; import { detectRepoStack } from "./stack-detection.js"; - /** Failure taxonomy surfaced in per-repo reports (#4788). */ export const CROSS_REPO_FAILURE_CATEGORY = Object.freeze({ - STACK_DETECTION: "stack_detection_gap", - EXECUTION: "execution_gap", - GITTENSOR_ASSUMPTION: "loopover_assumption", - CLONE_SETUP: "clone_setup", - OTHER: "other", + STACK_DETECTION: "stack_detection_gap", + EXECUTION: "execution_gap", + GITTENSOR_ASSUMPTION: "loopover_assumption", + CLONE_SETUP: "clone_setup", + OTHER: "other", }); - /** Instruction substrings that indicate a POSITIVE loopover/LoopOver CI assumption leaked into the agent prompt. * Lines that explicitly tell the agent *not* to assume these are filtered out before scanning. */ export const GITTENSOR_POSITIVE_ASSUMPTION_CHECKS = Object.freeze([ - { id: "test_ci_script", pattern: /npm run test:ci/i }, - { id: "codecov_patch", pattern: /codecov\/patch/i }, - { id: "gittensor_label", pattern: /gittensor:(?:bug|feature|priority)/i }, - { id: "loopover_gate", pattern: /loopover gate/i }, + { id: "test_ci_script", pattern: /npm run test:ci/i }, + { id: "codecov_patch", pattern: /codecov\/patch/i }, + { id: "gittensor_label", pattern: /gittensor:(?:bug|feature|priority)/i }, + { id: "loopover_gate", pattern: /loopover gate/i }, ]); - export const DEFAULT_CROSS_REPO_MANIFEST_RELATIVE_PATH = "benchmarks/cross-repo/manifest.json"; export const MAX_CROSS_REPO_MANIFEST_BYTES = 65_536; export const MAX_CROSS_REPO_MANIFEST_REPOS = 100; - // True UTF-8 byte count for the size guard (#7223): JS string `.length` is UTF-16 code units, which under-counts // any multi-byte character (up to 4x for astral-plane code points), so `MAX_CROSS_REPO_MANIFEST_BYTES` -- named // and warned about in BYTES -- was actually being compared against a code-unit count. Mirrors the identical helper // in the three siblings this parser's own comment claims to follow: fleet-run-manifest.ts, miner-goal-spec.ts, // and ams-policy-spec.ts. function utf8ByteLength(value) { - let bytes = 0; - for (const char of value) { - const codePoint = char.codePointAt(0); - if (codePoint <= 0x7f) bytes += 1; - else if (codePoint <= 0x7ff) bytes += 2; - else if (codePoint <= 0xffff) bytes += 3; - else bytes += 4; - } - return bytes; + let bytes = 0; + for (const char of value) { + const codePoint = char.codePointAt(0); + if (codePoint <= 0x7f) + bytes += 1; + else if (codePoint <= 0x7ff) + bytes += 2; + else if (codePoint <= 0xffff) + bytes += 3; + else + bytes += 4; + } + return bytes; } - function cloneEmptyManifest(warnings = []) { - return { present: false, manifest: { repos: [] }, warnings }; + return { present: false, manifest: { repos: [] }, warnings }; } - /** Canonical `owner/repo` with exactly one slash and safe segments; anything else → null. */ export function normalizeCrossRepoFullName(value) { - if (typeof value !== "string") return null; - const [owner, repo, extra] = value.trim().split("/"); - if (!owner || !repo || extra !== undefined) return null; - if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return null; - return `${owner}/${repo}`; + if (typeof value !== "string") + return null; + const [owner, repo, extra] = value.trim().split("/"); + if (!owner || !repo || extra !== undefined) + return null; + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) + return null; + return `${owner}/${repo}`; } - function normalizeBoolean(value, field, fallback, warnings) { - if (value === undefined || value === null) return fallback; - if (typeof value === "boolean") return value; - warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a boolean; falling back to ${fallback}.`); - return fallback; + if (value === undefined || value === null) + return fallback; + if (typeof value === "boolean") + return value; + warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a boolean; falling back to ${fallback}.`); + return fallback; } - function normalizeOptionalString(value, field, warnings) { - if (value === undefined || value === null) return null; - if (typeof value !== "string") { - warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a string; ignoring the value.`); - return null; - } - const trimmed = value.trim(); - return trimmed || null; + if (value === undefined || value === null) + return null; + if (typeof value !== "string") { + warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a string; ignoring the value.`); + return null; + } + const trimmed = value.trim(); + return trimmed || null; } - function normalizeRepoList(value, warnings) { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) { - warnings.push(`CrossRepoEvaluationManifest field "repos" must be a list; ignoring a ${typeof value} value.`); - return []; - } - const result = []; - const seen = new Set(); - for (const [index, entry] of value.entries()) { - if (index >= MAX_CROSS_REPO_MANIFEST_REPOS) { - warnings.push( - `CrossRepoEvaluationManifest field "repos" exceeded ${MAX_CROSS_REPO_MANIFEST_REPOS} entries; extra entries ignored.`, - ); - break; - } - let repoFullName = null; - let stackHint = null; - let requireTestCommand = false; - let fixturePath = null; - if (typeof entry === "string") { - repoFullName = normalizeCrossRepoFullName(entry); - } else if (entry && typeof entry === "object" && !Array.isArray(entry)) { - const record = entry; - repoFullName = normalizeCrossRepoFullName(record.repoFullName); - stackHint = normalizeOptionalString(record.stackHint, "stackHint", warnings); - requireTestCommand = normalizeBoolean(record.requireTestCommand, "requireTestCommand", false, warnings); - fixturePath = normalizeOptionalString(record.fixturePath, "fixturePath", warnings); - } else { - warnings.push(`CrossRepoEvaluationManifest "repos" skipped a non-string, non-mapping entry.`); - continue; + if (value === undefined || value === null) + return []; + if (!Array.isArray(value)) { + warnings.push(`CrossRepoEvaluationManifest field "repos" must be a list; ignoring a ${typeof value} value.`); + return []; } - if (repoFullName === null) { - warnings.push(`CrossRepoEvaluationManifest "repos" skipped an entry with an invalid "owner/repo" name.`); - continue; + const result = []; + const seen = new Set(); + for (const [index, entry] of value.entries()) { + if (index >= MAX_CROSS_REPO_MANIFEST_REPOS) { + warnings.push(`CrossRepoEvaluationManifest field "repos" exceeded ${MAX_CROSS_REPO_MANIFEST_REPOS} entries; extra entries ignored.`); + break; + } + let repoFullName = null; + let stackHint = null; + let requireTestCommand = false; + let fixturePath = null; + if (typeof entry === "string") { + repoFullName = normalizeCrossRepoFullName(entry); + } + else if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const record = entry; + repoFullName = normalizeCrossRepoFullName(record.repoFullName); + stackHint = normalizeOptionalString(record.stackHint, "stackHint", warnings); + requireTestCommand = normalizeBoolean(record.requireTestCommand, "requireTestCommand", false, warnings); + fixturePath = normalizeOptionalString(record.fixturePath, "fixturePath", warnings); + } + else { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped a non-string, non-mapping entry.`); + continue; + } + if (repoFullName === null) { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped an entry with an invalid "owner/repo" name.`); + continue; + } + if (seen.has(repoFullName)) { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped a duplicate entry for ${repoFullName}.`); + continue; + } + seen.add(repoFullName); + const normalized = { repoFullName, requireTestCommand }; + if (stackHint) + normalized.stackHint = stackHint; + if (fixturePath) + normalized.fixturePath = fixturePath; + result.push(normalized); } - if (seen.has(repoFullName)) { - warnings.push(`CrossRepoEvaluationManifest "repos" skipped a duplicate entry for ${repoFullName}.`); - continue; - } - seen.add(repoFullName); - const normalized = { repoFullName, requireTestCommand }; - if (stackHint) normalized.stackHint = stackHint; - if (fixturePath) normalized.fixturePath = fixturePath; - result.push(normalized); - } - return result; + return result; } - /** * Tolerant JSON manifest parser (#4788). Malformed input degrades to an empty repo list with warnings rather than * throwing, mirroring the fleet-run-manifest / miner-goal-spec convention. - * - * @param {string | null | undefined} content - * @returns {{ present: boolean, manifest: { repos: Array<{ repoFullName: string, stackHint?: string, requireTestCommand?: boolean, fixturePath?: string }> }, warnings: string[] }} */ export function parseCrossRepoEvaluationManifest(content) { - if (content === undefined || content === null) return cloneEmptyManifest(); - if (typeof content !== "string") { - return cloneEmptyManifest([`CrossRepoEvaluationManifest content must be a string; got ${typeof content}.`]); - } - const trimmed = content.trim(); - if (!trimmed) return cloneEmptyManifest(); - if (utf8ByteLength(trimmed) > MAX_CROSS_REPO_MANIFEST_BYTES) { - return cloneEmptyManifest([ - `CrossRepoEvaluationManifest exceeded ${MAX_CROSS_REPO_MANIFEST_BYTES} bytes; ignoring the file.`, - ]); - } - let raw; - try { - raw = JSON.parse(trimmed); - } catch { - return cloneEmptyManifest(["CrossRepoEvaluationManifest is not valid JSON."]); - } - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return cloneEmptyManifest(["CrossRepoEvaluationManifest root must be a JSON object."]); - } - const warnings = []; - const repos = normalizeRepoList(raw.repos, warnings); - return { present: true, manifest: { repos }, warnings }; + if (content === undefined || content === null) + return cloneEmptyManifest(); + if (typeof content !== "string") { + return cloneEmptyManifest([`CrossRepoEvaluationManifest content must be a string; got ${typeof content}.`]); + } + const trimmed = content.trim(); + if (!trimmed) + return cloneEmptyManifest(); + if (utf8ByteLength(trimmed) > MAX_CROSS_REPO_MANIFEST_BYTES) { + return cloneEmptyManifest([ + `CrossRepoEvaluationManifest exceeded ${MAX_CROSS_REPO_MANIFEST_BYTES} bytes; ignoring the file.`, + ]); + } + let raw; + try { + raw = JSON.parse(trimmed); + } + catch { + return cloneEmptyManifest(["CrossRepoEvaluationManifest is not valid JSON."]); + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return cloneEmptyManifest(["CrossRepoEvaluationManifest root must be a JSON object."]); + } + const warnings = []; + const repos = normalizeRepoList(raw.repos, warnings); + return { present: true, manifest: { repos }, warnings }; } - /** * Scan agent instructions for positive loopover/LoopOver assumptions (#4788). Lines that already tell the agent * *not* to assume LoopOver conventions (the negative guidance from buildValidationGuidance) are skipped. - * - * @param {string} text - * @returns {Array<{ id: string, line: string }>} */ export function scanPositiveLoopoverAssumptions(text) { - if (typeof text !== "string") return []; - const findings = []; - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || /do not assume/i.test(trimmed)) continue; - for (const check of GITTENSOR_POSITIVE_ASSUMPTION_CHECKS) { - if (check.pattern.test(line)) findings.push({ id: check.id, line: trimmed }); + if (typeof text !== "string") + return []; + const findings = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || /do not assume/i.test(trimmed)) + continue; + for (const check of GITTENSOR_POSITIVE_ASSUMPTION_CHECKS) { + if (check.pattern.test(line)) + findings.push({ id: check.id, line: trimmed }); + } } - } - return findings; + return findings; } - function buildFailure(repoFullName, category, reason, extra = {}) { - return { - repoFullName, - passed: false, - failureCategory: category, - reason, - stackDetected: false, - usedDefaultGoalSpec: null, - assumptionFindings: [], - ...extra, - }; + return { + repoFullName, + passed: false, + failureCategory: category, + reason, + stackDetected: false, + usedDefaultGoalSpec: null, + assumptionFindings: [], + ...extra, + }; } - function buildPass(repoFullName, extra = {}) { - return { - repoFullName, - passed: true, - failureCategory: null, - reason: null, - stackDetected: true, - usedDefaultGoalSpec: true, - assumptionFindings: [], - ...extra, - }; + return { + repoFullName, + passed: true, + failureCategory: null, + reason: null, + stackDetected: true, + usedDefaultGoalSpec: true, + assumptionFindings: [], + ...extra, + }; } - function resolveEvaluationRepoPath(entry, options = {}) { - if (entry.fixturePath && typeof entry.fixturePath === "string") return entry.fixturePath; - if (typeof options.repoPath === "string" && options.repoPath.trim()) return options.repoPath.trim(); - if (typeof options.resolveRepoPath === "function") return options.resolveRepoPath(entry); - return resolveRepoCloneDir(entry.repoFullName, options.env ?? process.env); + if (entry.fixturePath && typeof entry.fixturePath === "string") + return entry.fixturePath; + if (typeof options.repoPath === "string" && options.repoPath.trim()) + return options.repoPath.trim(); + if (typeof options.resolveRepoPath === "function") + return options.resolveRepoPath(entry); + return resolveRepoCloneDir(entry.repoFullName, options.env ?? process.env); } - function defaultClaimLedger(repoFullName) { - return { listClaims: () => [] }; + return { listClaims: () => [] }; } - /** * Evaluate one benchmark repo's miner readiness without running a live coding agent (#4788). - * - * @param {{ repoFullName: string, stackHint?: string, requireTestCommand?: boolean, fixturePath?: string }} entry - * @param {{ - * repoPath?: string, - * resolveRepoPath?: (entry: { repoFullName: string }) => string, - * env?: NodeJS.ProcessEnv, - * existsSync?: (path: string) => boolean, - * detectRepoStack?: typeof detectRepoStack, - * resolveMinerGoalSpec?: typeof resolveMinerGoalSpec, - * buildCodingTaskSpec?: typeof buildCodingTaskSpec, - * }} [options] */ export function evaluateRepoReadiness(entry, options = {}) { - const repoFullName = entry?.repoFullName; - if (typeof repoFullName !== "string" || !normalizeCrossRepoFullName(repoFullName)) { - return buildFailure( - typeof repoFullName === "string" ? repoFullName : "(invalid)", - CROSS_REPO_FAILURE_CATEGORY.OTHER, - "Benchmark entry is missing a valid owner/repo name.", - ); - } - - const existsImpl = options.existsSync ?? existsSync; - const detectImpl = options.detectRepoStack ?? detectRepoStack; - const goalSpecImpl = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; - const buildSpecImpl = options.buildCodingTaskSpec ?? buildCodingTaskSpec; - const repoPath = resolveEvaluationRepoPath(entry, options); - - if (!existsImpl(repoPath)) { - return buildFailure( - repoFullName, - CROSS_REPO_FAILURE_CATEGORY.CLONE_SETUP, - `Repository path does not exist: ${repoPath}. Clone the repo or set LOOPOVER_MINER_REPO_CLONE_DIR.`, - ); - } - - const goalSpec = goalSpecImpl(repoPath); - const usedDefaultGoalSpec = goalSpec?.present !== true; - - const stack = detectImpl(repoPath); - if (stack?.detected !== true) { - return buildFailure( - repoFullName, - CROSS_REPO_FAILURE_CATEGORY.STACK_DETECTION, - stack?.reason ?? "Stack auto-detection did not recognize this repository.", - { stackDetected: false, usedDefaultGoalSpec }, - ); - } - - if (entry.requireTestCommand === true && !stack.testCommand) { - return buildFailure( - repoFullName, - CROSS_REPO_FAILURE_CATEGORY.EXECUTION, - "Stack detection succeeded but no test command was inferred while requireTestCommand is set.", - { stackDetected: true, usedDefaultGoalSpec, stack }, - ); - } - - let specResult; - try { - specResult = buildSpecImpl({ - repoFullName, - issue: { - number: 1, - title: "Cross-repo evaluation harness smoke issue", - body: "Synthetic issue used only by the cross-repo evaluation harness.", - labels: ["bug"], - }, - context: { issues: [{ number: 1 }], pullRequests: [] }, - claimLedger: defaultClaimLedger(repoFullName), - workingDirectory: repoPath, - detectRepoStack: detectImpl, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.OTHER, message, { - stackDetected: true, - usedDefaultGoalSpec, - stack, - }); - } - - if (specResult?.ready !== true) { - return buildFailure( - repoFullName, - CROSS_REPO_FAILURE_CATEGORY.EXECUTION, - `Coding task spec is not ready (verdict: ${specResult?.verdict ?? "unknown"}).`, - { stackDetected: true, usedDefaultGoalSpec, stack }, - ); - } - - const assumptionFindings = scanPositiveLoopoverAssumptions(specResult.instructions ?? ""); - if (assumptionFindings.length > 0) { - return buildFailure( - repoFullName, - CROSS_REPO_FAILURE_CATEGORY.GITTENSOR_ASSUMPTION, - `Agent instructions leak loopover-specific assumptions (${assumptionFindings.map((f) => f.id).join(", ")}).`, - { stackDetected: true, usedDefaultGoalSpec, stack, assumptionFindings }, - ); - } - - return buildPass(repoFullName, { usedDefaultGoalSpec, stack }); + const repoFullName = entry?.repoFullName; + if (typeof repoFullName !== "string" || !normalizeCrossRepoFullName(repoFullName)) { + return buildFailure(typeof repoFullName === "string" ? repoFullName : "(invalid)", CROSS_REPO_FAILURE_CATEGORY.OTHER, "Benchmark entry is missing a valid owner/repo name."); + } + const existsImpl = options.existsSync ?? existsSync; + const detectImpl = options.detectRepoStack ?? detectRepoStack; + const goalSpecImpl = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; + const buildSpecImpl = options.buildCodingTaskSpec ?? + buildCodingTaskSpec; + const repoPath = resolveEvaluationRepoPath(entry, options); + if (!existsImpl(repoPath)) { + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.CLONE_SETUP, `Repository path does not exist: ${repoPath}. Clone the repo or set LOOPOVER_MINER_REPO_CLONE_DIR.`); + } + const goalSpec = goalSpecImpl(repoPath); + const usedDefaultGoalSpec = goalSpec?.present !== true; + const stack = detectImpl(repoPath); + if (stack?.detected !== true) { + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.STACK_DETECTION, stack?.reason ?? "Stack auto-detection did not recognize this repository.", { stackDetected: false, usedDefaultGoalSpec }); + } + if (entry.requireTestCommand === true && !stack.testCommand) { + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.EXECUTION, "Stack detection succeeded but no test command was inferred while requireTestCommand is set.", { stackDetected: true, usedDefaultGoalSpec, stack }); + } + let specResult; + try { + specResult = buildSpecImpl({ + repoFullName, + issue: { + number: 1, + title: "Cross-repo evaluation harness smoke issue", + body: "Synthetic issue used only by the cross-repo evaluation harness.", + labels: ["bug"], + }, + context: { issues: [{ number: 1 }], pullRequests: [] }, + claimLedger: defaultClaimLedger(repoFullName), + workingDirectory: repoPath, + detectRepoStack: detectImpl, + }); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.OTHER, message, { + stackDetected: true, + usedDefaultGoalSpec, + stack, + }); + } + if (specResult?.ready !== true) { + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.EXECUTION, `Coding task spec is not ready (verdict: ${specResult?.verdict ?? "unknown"}).`, { stackDetected: true, usedDefaultGoalSpec, stack }); + } + const assumptionFindings = scanPositiveLoopoverAssumptions(specResult.instructions ?? ""); + if (assumptionFindings.length > 0) { + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.GITTENSOR_ASSUMPTION, `Agent instructions leak loopover-specific assumptions (${assumptionFindings.map((f) => f.id).join(", ")}).`, { stackDetected: true, usedDefaultGoalSpec, stack, assumptionFindings }); + } + return buildPass(repoFullName, { usedDefaultGoalSpec, stack }); } - /** * Run the harness across every repo in a parsed manifest (#4788). - * - * @param {ReturnType} parsed - * @param {{ repoFilter?: string } & Parameters[1]} [options] - * @returns {ReturnType[]} */ export function runCrossRepoEvaluation(parsed, options = {}) { - const repos = parsed?.manifest?.repos ?? []; - const results = []; - for (const entry of repos) { - if (options.repoFilter && entry.repoFullName !== options.repoFilter) continue; - results.push(evaluateRepoReadiness(entry, options)); - } - return results; + const repos = parsed?.manifest?.repos ?? []; + const results = []; + for (const entry of repos) { + if (options.repoFilter && entry.repoFullName !== options.repoFilter) + continue; + results.push(evaluateRepoReadiness(entry, options)); + } + return results; } - /** * Reduce per-repo results to pass/fail counts and whether a strict majority passed (#4788). - * - * @param {ReturnType[]} results */ export function summarizeCrossRepoEvaluation(results) { - const list = Array.isArray(results) ? results : []; - let passed = 0; - let failed = 0; - const failuresByCategory = {}; - for (const result of list) { - if (result?.passed === true) { - passed += 1; - continue; + const list = Array.isArray(results) ? results : []; + let passed = 0; + let failed = 0; + const failuresByCategory = {}; + for (const result of list) { + if (result?.passed === true) { + passed += 1; + continue; + } + failed += 1; + const category = result?.failureCategory ?? CROSS_REPO_FAILURE_CATEGORY.OTHER; + failuresByCategory[category] = (failuresByCategory[category] ?? 0) + 1; } - failed += 1; - const category = result?.failureCategory ?? CROSS_REPO_FAILURE_CATEGORY.OTHER; - failuresByCategory[category] = (failuresByCategory[category] ?? 0) + 1; - } - const total = passed + failed; - const majorityPassed = total > 0 ? passed > failed : false; - const withoutLoopoverConfig = list.filter((r) => r?.usedDefaultGoalSpec !== false).length; - return { - total, - passed, - failed, - majorityPassed, - withoutLoopoverConfig, - failuresByCategory, - }; + const total = passed + failed; + const majorityPassed = total > 0 ? passed > failed : false; + const withoutLoopoverConfig = list.filter((r) => r?.usedDefaultGoalSpec !== false).length; + return { + total, + passed, + failed, + majorityPassed, + withoutLoopoverConfig, + failuresByCategory, + }; } - /** * Human-readable pass/fail report for one evaluation run (#4788). - * - * @param {ReturnType[]} results - * @param {ReturnType} [summary] */ export function formatCrossRepoEvaluationReport(results, summary = summarizeCrossRepoEvaluation(results)) { - const lines = ["loopover-miner cross-repo evaluation", ""]; - for (const result of results) { - if (result.passed) { - lines.push(`PASS ${result.repoFullName}`); - continue; + const lines = ["loopover-miner cross-repo evaluation", ""]; + for (const result of results) { + if (result.passed) { + lines.push(`PASS ${result.repoFullName}`); + continue; + } + lines.push(`FAIL ${result.repoFullName} [${result.failureCategory}] ${result.reason}`); + } + lines.push("", `summary: ${summary.passed}/${summary.total} passed` + + (summary.majorityPassed ? " (majority passed)" : " (majority failed)")); + if (summary.total > 0) { + lines.push(`without loopover-specific target config: ${summary.withoutLoopoverConfig}/${summary.total}`); } - lines.push(`FAIL ${result.repoFullName} [${result.failureCategory}] ${result.reason}`); - } - lines.push( - "", - `summary: ${summary.passed}/${summary.total} passed` + - (summary.majorityPassed ? " (majority passed)" : " (majority failed)"), - ); - if (summary.total > 0) { - lines.push(`without loopover-specific target config: ${summary.withoutLoopoverConfig}/${summary.total}`); - } - const categories = Object.entries(summary.failuresByCategory).sort(([a], [b]) => a.localeCompare(b)); - if (categories.length > 0) { - lines.push("", "failures by category:"); - for (const [category, count] of categories) { - lines.push(`- ${category}: ${count}`); + const categories = Object.entries(summary.failuresByCategory).sort(([a], [b]) => a.localeCompare(b)); + if (categories.length > 0) { + lines.push("", "failures by category:"); + for (const [category, count] of categories) { + lines.push(`- ${category}: ${count}`); + } } - } - return lines.join("\n"); + return lines.join("\n"); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY3Jvc3MtcmVwby1ldmFsdWF0aW9uLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY3Jvc3MtcmVwby1ldmFsdWF0aW9uLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLGlIQUFpSDtBQUNqSCw4R0FBOEc7QUFDOUcsMkdBQTJHO0FBQzNHLDZHQUE2RztBQUM3RyxxR0FBcUc7QUFFckcsT0FBTyxFQUFFLFVBQVUsRUFBRSxNQUFNLFNBQVMsQ0FBQztBQUVyQyxPQUFPLEVBQUUsbUJBQW1CLEVBQUUsTUFBTSx1QkFBdUIsQ0FBQztBQUM1RCxPQUFPLEVBQUUsb0JBQW9CLEVBQUUsTUFBTSxzQkFBc0IsQ0FBQztBQUM1RCxPQUFPLEVBQUUsa0JBQWtCLEVBQUUsbUJBQW1CLEVBQUUsTUFBTSxpQkFBaUIsQ0FBQztBQUMxRSxPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFHdkQsNkRBQTZEO0FBQzdELE1BQU0sQ0FBQyxNQUFNLDJCQUEyQixHQU1uQyxNQUFNLENBQUMsTUFBTSxDQUFDO0lBQ2pCLGVBQWUsRUFBRSxxQkFBcUI7SUFDdEMsU0FBUyxFQUFFLGVBQWU7SUFDMUIsb0JBQW9CLEVBQUUscUJBQXFCO0lBQzNDLFdBQVcsRUFBRSxhQUFhO0lBQzFCLEtBQUssRUFBRSxPQUFPO0NBQ2YsQ0FBQyxDQUFDO0FBRUg7bUdBQ21HO0FBQ25HLE1BQU0sQ0FBQyxNQUFNLG9DQUFvQyxHQUFtRCxNQUFNLENBQUMsTUFBTSxDQUFDO0lBQ2hILEVBQUUsRUFBRSxFQUFFLGdCQUFnQixFQUFFLE9BQU8sRUFBRSxrQkFBa0IsRUFBRTtJQUNyRCxFQUFFLEVBQUUsRUFBRSxlQUFlLEVBQUUsT0FBTyxFQUFFLGlCQUFpQixFQUFFO0lBQ25ELEVBQUUsRUFBRSxFQUFFLGlCQUFpQixFQUFFLE9BQU8sRUFBRSxxQ0FBcUMsRUFBRTtJQUN6RSxFQUFFLEVBQUUsRUFBRSxlQUFlLEVBQUUsT0FBTyxFQUFFLGdCQUFnQixFQUFFO0NBQ25E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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/cross-repo-evaluation.ts b/packages/loopover-miner/lib/cross-repo-evaluation.ts new file mode 100644 index 0000000000..f579b18aef --- /dev/null +++ b/packages/loopover-miner/lib/cross-repo-evaluation.ts @@ -0,0 +1,456 @@ +// Cross-repo evaluation harness (#4788): a repeatable, offline-first readiness check that asks whether the miner +// can approach a diverse benchmark repo set without loopover-specific target-repo configuration. Each repo is +// evaluated through the same stack-detection + coding-task-spec path a real attempt uses (detectRepoStack, +// resolveMinerGoalSpec, buildCodingTaskSpec) and failures are categorized as stack-detection gaps, execution +// readiness gaps, leaked loopover assumptions in agent instructions, clone/setup problems, or other. + +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { buildCodingTaskSpec } from "./coding-task-spec.js"; +import { resolveMinerGoalSpec } from "./miner-goal-spec.js"; +import { isValidRepoSegment, resolveRepoCloneDir } from "./repo-clone.js"; +import { detectRepoStack } from "./stack-detection.js"; +import type { RepoStackResult } from "./stack-detection.js"; + +/** Failure taxonomy surfaced in per-repo reports (#4788). */ +export const CROSS_REPO_FAILURE_CATEGORY: Readonly<{ + STACK_DETECTION: "stack_detection_gap"; + EXECUTION: "execution_gap"; + GITTENSOR_ASSUMPTION: "loopover_assumption"; + CLONE_SETUP: "clone_setup"; + OTHER: "other"; +}> = Object.freeze({ + STACK_DETECTION: "stack_detection_gap", + EXECUTION: "execution_gap", + GITTENSOR_ASSUMPTION: "loopover_assumption", + CLONE_SETUP: "clone_setup", + OTHER: "other", +}); + +/** Instruction substrings that indicate a POSITIVE loopover/LoopOver CI assumption leaked into the agent prompt. + * Lines that explicitly tell the agent *not* to assume these are filtered out before scanning. */ +export const GITTENSOR_POSITIVE_ASSUMPTION_CHECKS: ReadonlyArray<{ id: string; pattern: RegExp }> = Object.freeze([ + { id: "test_ci_script", pattern: /npm run test:ci/i }, + { id: "codecov_patch", pattern: /codecov\/patch/i }, + { id: "gittensor_label", pattern: /gittensor:(?:bug|feature|priority)/i }, + { id: "loopover_gate", pattern: /loopover gate/i }, +]); + +export const DEFAULT_CROSS_REPO_MANIFEST_RELATIVE_PATH: string = "benchmarks/cross-repo/manifest.json"; +export const MAX_CROSS_REPO_MANIFEST_BYTES: number = 65_536; +export const MAX_CROSS_REPO_MANIFEST_REPOS: number = 100; + +export type CrossRepoEvaluationManifestRepo = { + repoFullName: string; + stackHint?: string; + requireTestCommand?: boolean; + fixturePath?: string; +}; + +export type ParsedCrossRepoEvaluationManifest = { + present: boolean; + manifest: { repos: CrossRepoEvaluationManifestRepo[] }; + warnings: string[]; +}; + +export type CrossRepoEvaluationResult = { + repoFullName: string; + passed: boolean; + failureCategory: string | null; + reason: string | null; + stackDetected: boolean; + usedDefaultGoalSpec: boolean | null; + assumptionFindings: Array<{ id: string; line: string }>; + stack?: RepoStackResult; +}; + +export type CrossRepoEvaluationSummary = { + total: number; + passed: number; + failed: number; + majorityPassed: boolean; + withoutLoopoverConfig: number; + failuresByCategory: Record; +}; + +type EvaluateRepoReadinessOptions = { + repoPath?: string; + resolveRepoPath?: (entry: { repoFullName: string }) => string; + env?: NodeJS.ProcessEnv; + existsSync?: (path: string) => boolean; + detectRepoStack?: (repoPath: string) => RepoStackResult; + resolveMinerGoalSpec?: (repoPath: string) => { present: boolean }; + buildCodingTaskSpec?: (input: Record) => { + ready: boolean; + verdict?: string; + instructions?: string; + }; +}; + +// True UTF-8 byte count for the size guard (#7223): JS string `.length` is UTF-16 code units, which under-counts +// any multi-byte character (up to 4x for astral-plane code points), so `MAX_CROSS_REPO_MANIFEST_BYTES` -- named +// and warned about in BYTES -- was actually being compared against a code-unit count. Mirrors the identical helper +// in the three siblings this parser's own comment claims to follow: fleet-run-manifest.ts, miner-goal-spec.ts, +// and ams-policy-spec.ts. +function utf8ByteLength(value: string): number { + let bytes = 0; + for (const char of value) { + const codePoint = char.codePointAt(0)!; + if (codePoint <= 0x7f) bytes += 1; + else if (codePoint <= 0x7ff) bytes += 2; + else if (codePoint <= 0xffff) bytes += 3; + else bytes += 4; + } + return bytes; +} + +function cloneEmptyManifest(warnings: string[] = []): ParsedCrossRepoEvaluationManifest { + return { present: false, manifest: { repos: [] }, warnings }; +} + +/** Canonical `owner/repo` with exactly one slash and safe segments; anything else → null. */ +export function normalizeCrossRepoFullName(value: unknown): string | null { + if (typeof value !== "string") return null; + const [owner, repo, extra] = value.trim().split("/"); + if (!owner || !repo || extra !== undefined) return null; + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return null; + return `${owner}/${repo}`; +} + +function normalizeBoolean(value: unknown, field: string, fallback: boolean, warnings: string[]): boolean { + if (value === undefined || value === null) return fallback; + if (typeof value === "boolean") return value; + warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a boolean; falling back to ${fallback}.`); + return fallback; +} + +function normalizeOptionalString(value: unknown, field: string, warnings: string[]): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") { + warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a string; ignoring the value.`); + return null; + } + const trimmed = value.trim(); + return trimmed || null; +} + +function normalizeRepoList(value: unknown, warnings: string[]): CrossRepoEvaluationManifestRepo[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`CrossRepoEvaluationManifest field "repos" must be a list; ignoring a ${typeof value} value.`); + return []; + } + const result: CrossRepoEvaluationManifestRepo[] = []; + const seen = new Set(); + for (const [index, entry] of value.entries()) { + if (index >= MAX_CROSS_REPO_MANIFEST_REPOS) { + warnings.push( + `CrossRepoEvaluationManifest field "repos" exceeded ${MAX_CROSS_REPO_MANIFEST_REPOS} entries; extra entries ignored.`, + ); + break; + } + let repoFullName: string | null = null; + let stackHint: string | null = null; + let requireTestCommand = false; + let fixturePath: string | null = null; + if (typeof entry === "string") { + repoFullName = normalizeCrossRepoFullName(entry); + } else if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const record = entry as Record; + repoFullName = normalizeCrossRepoFullName(record.repoFullName); + stackHint = normalizeOptionalString(record.stackHint, "stackHint", warnings); + requireTestCommand = normalizeBoolean(record.requireTestCommand, "requireTestCommand", false, warnings); + fixturePath = normalizeOptionalString(record.fixturePath, "fixturePath", warnings); + } else { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped a non-string, non-mapping entry.`); + continue; + } + if (repoFullName === null) { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped an entry with an invalid "owner/repo" name.`); + continue; + } + if (seen.has(repoFullName)) { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped a duplicate entry for ${repoFullName}.`); + continue; + } + seen.add(repoFullName); + const normalized: CrossRepoEvaluationManifestRepo = { repoFullName, requireTestCommand }; + if (stackHint) normalized.stackHint = stackHint; + if (fixturePath) normalized.fixturePath = fixturePath; + result.push(normalized); + } + return result; +} + +/** + * Tolerant JSON manifest parser (#4788). Malformed input degrades to an empty repo list with warnings rather than + * throwing, mirroring the fleet-run-manifest / miner-goal-spec convention. + */ +export function parseCrossRepoEvaluationManifest( + content: string | null | undefined, +): ParsedCrossRepoEvaluationManifest { + if (content === undefined || content === null) return cloneEmptyManifest(); + if (typeof content !== "string") { + return cloneEmptyManifest([`CrossRepoEvaluationManifest content must be a string; got ${typeof content}.`]); + } + const trimmed = content.trim(); + if (!trimmed) return cloneEmptyManifest(); + if (utf8ByteLength(trimmed) > MAX_CROSS_REPO_MANIFEST_BYTES) { + return cloneEmptyManifest([ + `CrossRepoEvaluationManifest exceeded ${MAX_CROSS_REPO_MANIFEST_BYTES} bytes; ignoring the file.`, + ]); + } + let raw: unknown; + try { + raw = JSON.parse(trimmed); + } catch { + return cloneEmptyManifest(["CrossRepoEvaluationManifest is not valid JSON."]); + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return cloneEmptyManifest(["CrossRepoEvaluationManifest root must be a JSON object."]); + } + const warnings: string[] = []; + const repos = normalizeRepoList((raw as { repos?: unknown }).repos, warnings); + return { present: true, manifest: { repos }, warnings }; +} + +/** + * Scan agent instructions for positive loopover/LoopOver assumptions (#4788). Lines that already tell the agent + * *not* to assume LoopOver conventions (the negative guidance from buildValidationGuidance) are skipped. + */ +export function scanPositiveLoopoverAssumptions(text: string): Array<{ id: string; line: string }> { + if (typeof text !== "string") return []; + const findings: Array<{ id: string; line: string }> = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || /do not assume/i.test(trimmed)) continue; + for (const check of GITTENSOR_POSITIVE_ASSUMPTION_CHECKS) { + if (check.pattern.test(line)) findings.push({ id: check.id, line: trimmed }); + } + } + return findings; +} + +function buildFailure( + repoFullName: string, + category: string, + reason: string, + extra: Partial = {}, +): CrossRepoEvaluationResult { + return { + repoFullName, + passed: false, + failureCategory: category, + reason, + stackDetected: false, + usedDefaultGoalSpec: null, + assumptionFindings: [], + ...extra, + }; +} + +function buildPass(repoFullName: string, extra: Partial = {}): CrossRepoEvaluationResult { + return { + repoFullName, + passed: true, + failureCategory: null, + reason: null, + stackDetected: true, + usedDefaultGoalSpec: true, + assumptionFindings: [], + ...extra, + }; +} + +function resolveEvaluationRepoPath( + entry: CrossRepoEvaluationManifestRepo, + options: EvaluateRepoReadinessOptions = {}, +): string { + if (entry.fixturePath && typeof entry.fixturePath === "string") return entry.fixturePath; + if (typeof options.repoPath === "string" && options.repoPath.trim()) return options.repoPath.trim(); + if (typeof options.resolveRepoPath === "function") return options.resolveRepoPath(entry); + return resolveRepoCloneDir(entry.repoFullName, options.env ?? process.env); +} + +function defaultClaimLedger(repoFullName: string): { listClaims: () => never[] } { + return { listClaims: () => [] }; +} + +/** + * Evaluate one benchmark repo's miner readiness without running a live coding agent (#4788). + */ +export function evaluateRepoReadiness( + entry: CrossRepoEvaluationManifestRepo, + options: EvaluateRepoReadinessOptions = {}, +): CrossRepoEvaluationResult { + const repoFullName = entry?.repoFullName; + if (typeof repoFullName !== "string" || !normalizeCrossRepoFullName(repoFullName)) { + return buildFailure( + typeof repoFullName === "string" ? repoFullName : "(invalid)", + CROSS_REPO_FAILURE_CATEGORY.OTHER, + "Benchmark entry is missing a valid owner/repo name.", + ); + } + + const existsImpl = options.existsSync ?? existsSync; + const detectImpl = options.detectRepoStack ?? detectRepoStack; + const goalSpecImpl = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; + const buildSpecImpl: NonNullable = + options.buildCodingTaskSpec ?? + (buildCodingTaskSpec as unknown as NonNullable); + const repoPath = resolveEvaluationRepoPath(entry, options); + + if (!existsImpl(repoPath)) { + return buildFailure( + repoFullName, + CROSS_REPO_FAILURE_CATEGORY.CLONE_SETUP, + `Repository path does not exist: ${repoPath}. Clone the repo or set LOOPOVER_MINER_REPO_CLONE_DIR.`, + ); + } + + const goalSpec = goalSpecImpl(repoPath); + const usedDefaultGoalSpec = goalSpec?.present !== true; + + const stack = detectImpl(repoPath); + if (stack?.detected !== true) { + return buildFailure( + repoFullName, + CROSS_REPO_FAILURE_CATEGORY.STACK_DETECTION, + stack?.reason ?? "Stack auto-detection did not recognize this repository.", + { stackDetected: false, usedDefaultGoalSpec }, + ); + } + + if (entry.requireTestCommand === true && !stack.testCommand) { + return buildFailure( + repoFullName, + CROSS_REPO_FAILURE_CATEGORY.EXECUTION, + "Stack detection succeeded but no test command was inferred while requireTestCommand is set.", + { stackDetected: true, usedDefaultGoalSpec, stack }, + ); + } + + let specResult; + try { + specResult = buildSpecImpl({ + repoFullName, + issue: { + number: 1, + title: "Cross-repo evaluation harness smoke issue", + body: "Synthetic issue used only by the cross-repo evaluation harness.", + labels: ["bug"], + }, + context: { issues: [{ number: 1 }], pullRequests: [] }, + claimLedger: defaultClaimLedger(repoFullName), + workingDirectory: repoPath, + detectRepoStack: detectImpl, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.OTHER, message, { + stackDetected: true, + usedDefaultGoalSpec, + stack, + }); + } + + if (specResult?.ready !== true) { + return buildFailure( + repoFullName, + CROSS_REPO_FAILURE_CATEGORY.EXECUTION, + `Coding task spec is not ready (verdict: ${specResult?.verdict ?? "unknown"}).`, + { stackDetected: true, usedDefaultGoalSpec, stack }, + ); + } + + const assumptionFindings = scanPositiveLoopoverAssumptions(specResult.instructions ?? ""); + if (assumptionFindings.length > 0) { + return buildFailure( + repoFullName, + CROSS_REPO_FAILURE_CATEGORY.GITTENSOR_ASSUMPTION, + `Agent instructions leak loopover-specific assumptions (${assumptionFindings.map((f) => f.id).join(", ")}).`, + { stackDetected: true, usedDefaultGoalSpec, stack, assumptionFindings }, + ); + } + + return buildPass(repoFullName, { usedDefaultGoalSpec, stack }); +} + +/** + * Run the harness across every repo in a parsed manifest (#4788). + */ +export function runCrossRepoEvaluation( + parsed: ParsedCrossRepoEvaluationManifest, + options: { repoFilter?: string } & EvaluateRepoReadinessOptions = {}, +): CrossRepoEvaluationResult[] { + const repos = parsed?.manifest?.repos ?? []; + const results: CrossRepoEvaluationResult[] = []; + for (const entry of repos) { + if (options.repoFilter && entry.repoFullName !== options.repoFilter) continue; + results.push(evaluateRepoReadiness(entry, options)); + } + return results; +} + +/** + * Reduce per-repo results to pass/fail counts and whether a strict majority passed (#4788). + */ +export function summarizeCrossRepoEvaluation(results: CrossRepoEvaluationResult[]): CrossRepoEvaluationSummary { + const list = Array.isArray(results) ? results : []; + let passed = 0; + let failed = 0; + const failuresByCategory: Record = {}; + for (const result of list) { + if (result?.passed === true) { + passed += 1; + continue; + } + failed += 1; + const category = result?.failureCategory ?? CROSS_REPO_FAILURE_CATEGORY.OTHER; + failuresByCategory[category] = (failuresByCategory[category] ?? 0) + 1; + } + const total = passed + failed; + const majorityPassed = total > 0 ? passed > failed : false; + const withoutLoopoverConfig = list.filter((r) => r?.usedDefaultGoalSpec !== false).length; + return { + total, + passed, + failed, + majorityPassed, + withoutLoopoverConfig, + failuresByCategory, + }; +} + +/** + * Human-readable pass/fail report for one evaluation run (#4788). + */ +export function formatCrossRepoEvaluationReport( + results: CrossRepoEvaluationResult[], + summary: CrossRepoEvaluationSummary = summarizeCrossRepoEvaluation(results), +): string { + const lines = ["loopover-miner cross-repo evaluation", ""]; + for (const result of results) { + if (result.passed) { + lines.push(`PASS ${result.repoFullName}`); + continue; + } + lines.push(`FAIL ${result.repoFullName} [${result.failureCategory}] ${result.reason}`); + } + lines.push( + "", + `summary: ${summary.passed}/${summary.total} passed` + + (summary.majorityPassed ? " (majority passed)" : " (majority failed)"), + ); + if (summary.total > 0) { + lines.push(`without loopover-specific target config: ${summary.withoutLoopoverConfig}/${summary.total}`); + } + const categories = Object.entries(summary.failuresByCategory).sort(([a], [b]) => a.localeCompare(b)); + if (categories.length > 0) { + lines.push("", "failures by category:"); + for (const [category, count] of categories) { + lines.push(`- ${category}: ${count}`); + } + } + return lines.join("\n"); +} diff --git a/packages/loopover-miner/lib/discovery-throttle.d.ts b/packages/loopover-miner/lib/discovery-throttle.d.ts index 60f40ebb1b..842b45dedd 100644 --- a/packages/loopover-miner/lib/discovery-throttle.d.ts +++ b/packages/loopover-miner/lib/discovery-throttle.d.ts @@ -1,8 +1,13 @@ -export const DEFAULT_RATE_LIMIT_LOW_WATER_MARK: number; -export const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK: number; -export function resolveThrottledConcurrency( - baseConcurrency: number, - rateLimitRemaining: number | null, - lowWaterMark: number, - highWaterMark: number, -): number; +/** At or below this remaining budget, serialize discovery to a single in-flight request. */ +export declare const DEFAULT_RATE_LIMIT_LOW_WATER_MARK = 50; +/** At or above this remaining budget, run at the full configured concurrency. */ +export declare const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK = 250; +/** + * Resolve the concurrency the fanout may run at for the currently-recorded rate-limit budget. Returns an integer + * in `[1, baseConcurrency]`: + * - an unknown budget (`null`/non-finite — nothing recorded yet) runs at full `baseConcurrency`; + * - at or below `lowWaterMark` it clamps to a single in-flight request; + * - at or above `highWaterMark` it runs at full `baseConcurrency`; + * - in between it scales linearly with the remaining fraction of the low→high band. + */ +export declare function resolveThrottledConcurrency(baseConcurrency: number, rateLimitRemaining: number | null, lowWaterMark: number, highWaterMark: number): number; diff --git a/packages/loopover-miner/lib/discovery-throttle.js b/packages/loopover-miner/lib/discovery-throttle.js index 819a445559..05be67166e 100644 --- a/packages/loopover-miner/lib/discovery-throttle.js +++ b/packages/loopover-miner/lib/discovery-throttle.js @@ -3,12 +3,10 @@ // into a 403. This pure helper maps the recorded remaining budget to an allowed in-flight concurrency so the // fanout tapers off as the budget approaches zero. It only decides *how many* requests may run; it never changes // which docs are fetched or how a policy verdict is derived from them. - /** At or below this remaining budget, serialize discovery to a single in-flight request. */ export const DEFAULT_RATE_LIMIT_LOW_WATER_MARK = 50; /** At or above this remaining budget, run at the full configured concurrency. */ export const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK = 250; - /** * Resolve the concurrency the fanout may run at for the currently-recorded rate-limit budget. Returns an integer * in `[1, baseConcurrency]`: @@ -16,18 +14,17 @@ export const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK = 250; * - at or below `lowWaterMark` it clamps to a single in-flight request; * - at or above `highWaterMark` it runs at full `baseConcurrency`; * - in between it scales linearly with the remaining fraction of the low→high band. - * @param {number} baseConcurrency - * @param {number|null} rateLimitRemaining - * @param {number} lowWaterMark - * @param {number} highWaterMark - * @returns {number} */ export function resolveThrottledConcurrency(baseConcurrency, rateLimitRemaining, lowWaterMark, highWaterMark) { - if (!Number.isFinite(rateLimitRemaining)) return baseConcurrency; - if (rateLimitRemaining <= lowWaterMark) return 1; - if (rateLimitRemaining >= highWaterMark) return baseConcurrency; - // remaining is strictly inside the (low, high) band, so the fraction is in (0, 1) and the ceil lands in - // [1, baseConcurrency] without any further clamping. - const fraction = (rateLimitRemaining - lowWaterMark) / (highWaterMark - lowWaterMark); - return Math.ceil(fraction * baseConcurrency); + if (!Number.isFinite(rateLimitRemaining)) + return baseConcurrency; + if (rateLimitRemaining <= lowWaterMark) + return 1; + if (rateLimitRemaining >= highWaterMark) + return baseConcurrency; + // remaining is strictly inside the (low, high) band, so the fraction is in (0, 1) and the ceil lands in + // [1, baseConcurrency] without any further clamping. + const fraction = (rateLimitRemaining - lowWaterMark) / (highWaterMark - lowWaterMark); + return Math.ceil(fraction * baseConcurrency); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGlzY292ZXJ5LXRocm90dGxlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiZGlzY292ZXJ5LXRocm90dGxlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLCtHQUErRztBQUMvRyw4R0FBOEc7QUFDOUcsNkdBQTZHO0FBQzdHLGlIQUFpSDtBQUNqSCx1RUFBdUU7QUFFdkUsNEZBQTRGO0FBQzVGLE1BQU0sQ0FBQyxNQUFNLGlDQUFpQyxHQUFHLEVBQUUsQ0FBQztBQUNwRCxpRkFBaUY7QUFDakYsTUFBTSxDQUFDLE1BQU0sa0NBQWtDLEdBQUcsR0FBRyxDQUFDO0FBRXREOzs7Ozs7O0dBT0c7QUFDSCxNQUFNLFVBQVUsMkJBQTJCLENBQ3pDLGVBQXVCLEVBQ3ZCLGtCQUFpQyxFQUNqQyxZQUFvQixFQUNwQixhQUFxQjtJQUVyQixJQUFJLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQyxrQkFBa0IsQ0FBQztRQUFFLE9BQU8sZUFBZSxDQUFDO0lBQ2pFLElBQUksa0JBQW1CLElBQUksWUFBWTtRQUFFLE9BQU8sQ0FBQyxDQUFDO0lBQ2xELElBQUksa0JBQW1CLElBQUksYUFBYTtRQUFFLE9BQU8sZUFBZSxDQUFDO0lBQ2pFLHdHQUF3RztJQUN4RyxxREFBcUQ7SUFDckQsTUFBTSxRQUFRLEdBQUcsQ0FBQyxrQkFBbUIsR0FBRyxZQUFZLENBQUMsR0FBRyxDQUFDLGFBQWEsR0FBRyxZQUFZLENBQUMsQ0FBQztJQUN2RixPQUFPLElBQUksQ0FBQyxJQUFJLENBQUMsUUFBUSxHQUFHLGVBQWUsQ0FBQyxDQUFDO0FBQy9DLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/discovery-throttle.ts b/packages/loopover-miner/lib/discovery-throttle.ts new file mode 100644 index 0000000000..b015dab9f9 --- /dev/null +++ b/packages/loopover-miner/lib/discovery-throttle.ts @@ -0,0 +1,33 @@ +// Dynamic discovery back-off (#4844): the fanout already records GitHub's `x-ratelimit-remaining`, but nothing +// slowed its own concurrent fetching in response — a `discover` run could sprint at full concurrency straight +// into a 403. This pure helper maps the recorded remaining budget to an allowed in-flight concurrency so the +// fanout tapers off as the budget approaches zero. It only decides *how many* requests may run; it never changes +// which docs are fetched or how a policy verdict is derived from them. + +/** At or below this remaining budget, serialize discovery to a single in-flight request. */ +export const DEFAULT_RATE_LIMIT_LOW_WATER_MARK = 50; +/** At or above this remaining budget, run at the full configured concurrency. */ +export const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK = 250; + +/** + * Resolve the concurrency the fanout may run at for the currently-recorded rate-limit budget. Returns an integer + * in `[1, baseConcurrency]`: + * - an unknown budget (`null`/non-finite — nothing recorded yet) runs at full `baseConcurrency`; + * - at or below `lowWaterMark` it clamps to a single in-flight request; + * - at or above `highWaterMark` it runs at full `baseConcurrency`; + * - in between it scales linearly with the remaining fraction of the low→high band. + */ +export function resolveThrottledConcurrency( + baseConcurrency: number, + rateLimitRemaining: number | null, + lowWaterMark: number, + highWaterMark: number, +): number { + if (!Number.isFinite(rateLimitRemaining)) return baseConcurrency; + if (rateLimitRemaining! <= lowWaterMark) return 1; + if (rateLimitRemaining! >= highWaterMark) return baseConcurrency; + // remaining is strictly inside the (low, high) band, so the fraction is in (0, 1) and the ceil lands in + // [1, baseConcurrency] without any further clamping. + const fraction = (rateLimitRemaining! - lowWaterMark) / (highWaterMark - lowWaterMark); + return Math.ceil(fraction * baseConcurrency); +} diff --git a/packages/loopover-miner/lib/execute-local-write.d.ts b/packages/loopover-miner/lib/execute-local-write.d.ts index d168c8e362..092044c53e 100644 --- a/packages/loopover-miner/lib/execute-local-write.d.ts +++ b/packages/loopover-miner/lib/execute-local-write.d.ts @@ -1,14 +1,13 @@ import type { LocalWriteActionSpec } from "@loopover/engine"; - export type ExecuteLocalWriteResult = { - action: string; - stdout: string; - stderr: string; - code: number | null; - timedOut: boolean; + action: string; + stdout: string; + stderr: string; + code: number | null; + timedOut: boolean; }; - -export function executeLocalWrite( - spec: LocalWriteActionSpec, - options?: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }, -): Promise; +export declare function executeLocalWrite(spec: LocalWriteActionSpec, options?: { + cwd?: string; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; +}): Promise; diff --git a/packages/loopover-miner/lib/execute-local-write.js b/packages/loopover-miner/lib/execute-local-write.js index 628ff60755..3d9f66de68 100644 --- a/packages/loopover-miner/lib/execute-local-write.js +++ b/packages/loopover-miner/lib/execute-local-write.js @@ -7,44 +7,36 @@ // the given working directory. Per local-write-tools.ts's own boundary comment, this always runs with // whatever `gh`/`git` credentials are already configured in that environment -- loopover never performs // the write itself. - import { spawn } from "node:child_process"; - const DEFAULT_TIMEOUT_MS = 120_000; - -/** - * @param {import("@loopover/engine").LocalWriteActionSpec} spec - * @param {{ cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs?: number }} [options] - * @returns {Promise<{ action: string, stdout: string, stderr: string, code: number | null, timedOut: boolean }>} - */ export function executeLocalWrite(spec, options = {}) { - const cwd = options.cwd ?? process.cwd(); - const env = options.env ?? process.env; - const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS; - - return new Promise((resolve) => { - const child = spawn("sh", ["-c", spec.command], { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - const timer = setTimeout(() => { - child.kill("SIGKILL"); - resolve({ action: spec.action, stdout, stderr, code: null, timedOut: true }); - }, timeoutMs); - child.stdout?.on("data", (chunk) => { - stdout += chunk.toString("utf8"); + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS; + return new Promise((resolve) => { + const child = spawn("sh", ["-c", spec.command], { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve({ action: spec.action, stdout, stderr, code: null, timedOut: true }); + }, timeoutMs); + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (err) => { + // A spawn-level error (e.g. no `sh` on PATH) fires before the child ever produces output -- mirrors + // createRealCliSubprocessSpawn's own identical handling. + clearTimeout(timer); + resolve({ action: spec.action, stdout, stderr: err.message, code: null, timedOut: false }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ action: spec.action, stdout, stderr, code, timedOut: false }); + }); }); - child.stderr?.on("data", (chunk) => { - stderr += chunk.toString("utf8"); - }); - child.on("error", (err) => { - // A spawn-level error (e.g. no `sh` on PATH) fires before the child ever produces output -- mirrors - // createRealCliSubprocessSpawn's own identical handling. - clearTimeout(timer); - resolve({ action: spec.action, stdout, stderr: err.message, code: null, timedOut: false }); - }); - child.on("close", (code) => { - clearTimeout(timer); - resolve({ action: spec.action, stdout, stderr, code, timedOut: false }); - }); - }); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZXhlY3V0ZS1sb2NhbC13cml0ZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImV4ZWN1dGUtbG9jYWwtd3JpdGUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsa0dBQWtHO0FBQ2xHLHFHQUFxRztBQUNyRyx3R0FBd0c7QUFDeEcsb0dBQW9HO0FBQ3BHLGdHQUFnRztBQUNoRywyR0FBMkc7QUFDM0csc0dBQXNHO0FBQ3RHLHdHQUF3RztBQUN4RyxvQkFBb0I7QUFFcEIsT0FBTyxFQUFFLEtBQUssRUFBRSxNQUFNLG9CQUFvQixDQUFDO0FBRzNDLE1BQU0sa0JBQWtCLEdBQUcsT0FBTyxDQUFDO0FBVW5DLE1BQU0sVUFBVSxpQkFBaUIsQ0FDL0IsSUFBMEIsRUFDMUIsVUFBeUUsRUFBRTtJQUUzRSxNQUFNLEdBQUcsR0FBRyxPQUFPLENBQUMsR0FBRyxJQUFJLE9BQU8sQ0FBQyxHQUFHLEVBQUUsQ0FBQztJQUN6QyxNQUFNLEdBQUcsR0FBRyxPQUFPLENBQUMsR0FBRyxJQUFJLE9BQU8sQ0FBQyxHQUFHLENBQUM7SUFDdkMsTUFBTSxTQUFTLEdBQUcsTUFBTSxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsU0FBUyxDQUFDLENBQUMsQ0FBQyxDQUFFLE9BQU8sQ0FBQyxTQUFvQixDQUFDLENBQUMsQ0FBQyxrQkFBa0IsQ0FBQztJQUUxRyxPQUFPLElBQUksT0FBTyxDQUEwQixDQUFDLE9BQU8sRUFBRSxFQUFFO1FBQ3RELE1BQU0sS0FBSyxHQUFHLEtBQUssQ0FBQyxJQUFJLEVBQUUsQ0FBQyxJQUFJLEVBQUUsSUFBSSxDQUFDLE9BQU8sQ0FBQyxFQUFFLEVBQUUsR0FBRyxFQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUUsQ0FBQyxRQUFRLEVBQUUsTUFBTSxFQUFFLE1BQU0sQ0FBQyxFQUFFLENBQUMsQ0FBQztRQUNqRyxJQUFJLE1BQU0sR0FBRyxFQUFFLENBQUM7UUFDaEIsSUFBSSxNQUFNLEdBQUcsRUFBRSxDQUFDO1FBQ2hCLE1BQU0sS0FBSyxHQUFHLFVBQVUsQ0FBQyxHQUFHLEVBQUU7WUFDNUIsS0FBSyxDQUFDLElBQUksQ0FBQyxTQUFTLENBQUMsQ0FBQztZQUN0QixPQUFPLENBQUMsRUFBRSxNQUFNLEVBQUUsSUFBSSxDQUFDLE1BQU0sRUFBRSxNQUFNLEVBQUUsTUFBTSxFQUFFLElBQUksRUFBRSxJQUFJLEVBQUUsUUFBUSxFQUFFLElBQUksRUFBRSxDQUFDLENBQUM7UUFDL0UsQ0FBQyxFQUFFLFNBQVMsQ0FBQyxDQUFDO1FBQ2QsS0FBSyxDQUFDLE1BQU0sRUFBRSxFQUFFLENBQUMsTUFBTSxFQUFFLENBQUMsS0FBYSxFQUFFLEVBQUU7WUFDekMsTUFBTSxJQUFJLEtBQUssQ0FBQyxRQUFRLENBQUMsTUFBTSxDQUFDLENBQUM7UUFDbkMsQ0FBQyxDQUFDLENBQUM7UUFDSCxLQUFLLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQyxNQUFNLEVBQUUsQ0FBQyxLQUFhLEVBQUUsRUFBRTtZQUN6QyxNQUFNLElBQUksS0FBSyxDQUFDLFFBQVEsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUNuQyxDQUFDLENBQUMsQ0FBQztRQUNILEtBQUssQ0FBQyxFQUFFLENBQUMsT0FBTyxFQUFFLENBQUMsR0FBVSxFQUFFLEVBQUU7WUFDL0Isb0dBQW9HO1lBQ3BHLHlEQUF5RDtZQUN6RCxZQUFZLENBQUMsS0FBSyxDQUFDLENBQUM7WUFDcEIsT0FBTyxDQUFDLEVBQUUsTUFBTSxFQUFFLElBQUksQ0FBQyxNQUFNLEVBQUUsTUFBTSxFQUFFLE1BQU0sRUFBRSxHQUFHLENBQUMsT0FBTyxFQUFFLElBQUksRUFBRSxJQUFJLEVBQUUsUUFBUSxFQUFFLEtBQUssRUFBRSxDQUFDLENBQUM7UUFDN0YsQ0FBQyxDQUFDLENBQUM7UUFDSCxLQUFLLENBQUMsRUFBRSxDQUFDLE9BQU8sRUFBRSxDQUFDLElBQW1CLEVBQUUsRUFBRTtZQUN4QyxZQUFZLENBQUMsS0FBSyxDQUFDLENBQUM7WUFDcEIsT0FBTyxDQUFDLEVBQUUsTUFBTSxFQUFFLElBQUksQ0FBQyxNQUFNLEVBQUUsTUFBTSxFQUFFLE1BQU0sRUFBRSxJQUFJLEVBQUUsUUFBUSxFQUFFLEtBQUssRUFBRSxDQUFDLENBQUM7UUFDMUUsQ0FBQyxDQUFDLENBQUM7SUFDTCxDQUFDLENBQUMsQ0FBQztBQUNMLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/execute-local-write.ts b/packages/loopover-miner/lib/execute-local-write.ts new file mode 100644 index 0000000000..3808c774b7 --- /dev/null +++ b/packages/loopover-miner/lib/execute-local-write.ts @@ -0,0 +1,57 @@ +// Real executeLocalWrite implementation (#5132, Wave 3.5). Mirrors coding-agent-construction.js's +// createRealCliSubprocessSpawn pattern (real child_process, resolve-not-reject on error/timeout so a +// killed/errored process's partial output -- e.g. an auth failure line on stderr -- is never lost to an +// unhandled rejection) but for LocalWriteActionSpec.command: a single shell-safe string (built with +// packages/loopover-engine/src/miner/local-write-tools.ts's own single-quote escaping), not the +// cmd/args-array CliSubprocessSpawnFn contract the coding-agent driver itself uses. Runs it via `sh -c` in +// the given working directory. Per local-write-tools.ts's own boundary comment, this always runs with +// whatever `gh`/`git` credentials are already configured in that environment -- loopover never performs +// the write itself. + +import { spawn } from "node:child_process"; +import type { LocalWriteActionSpec } from "@loopover/engine"; + +const DEFAULT_TIMEOUT_MS = 120_000; + +export type ExecuteLocalWriteResult = { + action: string; + stdout: string; + stderr: string; + code: number | null; + timedOut: boolean; +}; + +export function executeLocalWrite( + spec: LocalWriteActionSpec, + options: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + const timeoutMs = Number.isFinite(options.timeoutMs) ? (options.timeoutMs as number) : DEFAULT_TIMEOUT_MS; + + return new Promise((resolve) => { + const child = spawn("sh", ["-c", spec.command], { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve({ action: spec.action, stdout, stderr, code: null, timedOut: true }); + }, timeoutMs); + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (err: Error) => { + // A spawn-level error (e.g. no `sh` on PATH) fires before the child ever produces output -- mirrors + // createRealCliSubprocessSpawn's own identical handling. + clearTimeout(timer); + resolve({ action: spec.action, stdout, stderr: err.message, code: null, timedOut: false }); + }); + child.on("close", (code: number | null) => { + clearTimeout(timer); + resolve({ action: spec.action, stdout, stderr, code, timedOut: false }); + }); + }); +} diff --git a/packages/loopover-miner/lib/portfolio-queue-expiry.d.ts b/packages/loopover-miner/lib/portfolio-queue-expiry.d.ts index f52ae5ed8c..517f51a59d 100644 --- a/packages/loopover-miner/lib/portfolio-queue-expiry.d.ts +++ b/packages/loopover-miner/lib/portfolio-queue-expiry.d.ts @@ -1,20 +1,21 @@ +/** PURE — no IO, no Date, no random (#4827). Mirror of claim-ledger-expiry.js for the portfolio-queue store: a + * crashed/killed process leaves its item stuck 'in_progress' forever, so sweep leases older than a bound back to + * 'queued'. */ import type { QueueEntry, QueueLeaseEntry } from "./portfolio-queue.js"; - export declare const DEFAULT_MAX_LEASE_MS: number; - export type PortfolioQueueExpiryStore = { - listInProgress(): QueueLeaseEntry[]; - reclaimStuckItem(repoFullName: string, identifier: string): QueueEntry | null; + listInProgress(): QueueLeaseEntry[]; + reclaimStuckItem(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null; }; - -export function findStuckItems( - items: QueueLeaseEntry[], - nowMs: number, - maxLeaseMs: number, -): QueueLeaseEntry[]; - -export function sweepStuckItems( - store: PortfolioQueueExpiryStore, - nowMs: number, - maxLeaseMs?: number, -): QueueEntry[]; +/** + * Return in-flight items whose lease age is strictly greater than `maxLeaseMs`. An item whose age equals + * `maxLeaseMs` exactly is still within the window (not stuck). Items that are not 'in_progress', or whose + * `leasedAt` is missing/unparseable, are never returned. + */ +export declare function findStuckItems(items: QueueLeaseEntry[], nowMs: number, maxLeaseMs: number): QueueLeaseEntry[]; +/** + * Reclaim every stuck in-flight item back to 'queued', returning the reclaimed entries. `store.listInProgress()` + * supplies the lease-annotated rows and `store.reclaimStuckItem()` performs the atomic per-item flip — the same + * store/sweep split sweepExpiredClaims uses. + */ +export declare function sweepStuckItems(store: PortfolioQueueExpiryStore, nowMs: number, maxLeaseMs?: number): QueueEntry[]; diff --git a/packages/loopover-miner/lib/portfolio-queue-expiry.js b/packages/loopover-miner/lib/portfolio-queue-expiry.js index d88387897b..7a679aea93 100644 --- a/packages/loopover-miner/lib/portfolio-queue-expiry.js +++ b/packages/loopover-miner/lib/portfolio-queue-expiry.js @@ -1,51 +1,55 @@ /** PURE — no IO, no Date, no random (#4827). Mirror of claim-ledger-expiry.js for the portfolio-queue store: a * crashed/killed process leaves its item stuck 'in_progress' forever, so sweep leases older than a bound back to * 'queued'. */ - // A generous default: a real attempt rarely holds a single portfolio item for long, so 30 minutes without the row // leaving 'in_progress' strongly implies the owning process died rather than that it is still working. export const DEFAULT_MAX_LEASE_MS = 30 * 60 * 1000; - function leaseAgeMs(item, nowMs) { - const leasedAtMs = Date.parse(item.leasedAt); - if (!Number.isFinite(leasedAtMs)) return null; - return nowMs - leasedAtMs; + const leasedAtMs = Date.parse(item.leasedAt); + if (!Number.isFinite(leasedAtMs)) + return null; + return nowMs - leasedAtMs; } - /** * Return in-flight items whose lease age is strictly greater than `maxLeaseMs`. An item whose age equals * `maxLeaseMs` exactly is still within the window (not stuck). Items that are not 'in_progress', or whose * `leasedAt` is missing/unparseable, are never returned. */ export function findStuckItems(items, nowMs, maxLeaseMs) { - if (!Number.isFinite(nowMs) || nowMs < 0) throw new Error("invalid_now_ms"); - if (!Number.isFinite(maxLeaseMs) || maxLeaseMs < 0) throw new Error("invalid_max_lease_ms"); - if (!Array.isArray(items)) throw new Error("invalid_items"); - - const stuck = []; - for (const item of items) { - if (item?.status !== "in_progress") continue; - const ageMs = leaseAgeMs(item, nowMs); - if (ageMs === null) continue; - if (ageMs > maxLeaseMs) stuck.push(item); - } - return stuck; + if (!Number.isFinite(nowMs) || nowMs < 0) + throw new Error("invalid_now_ms"); + if (!Number.isFinite(maxLeaseMs) || maxLeaseMs < 0) + throw new Error("invalid_max_lease_ms"); + if (!Array.isArray(items)) + throw new Error("invalid_items"); + const stuck = []; + for (const item of items) { + if (item?.status !== "in_progress") + continue; + const ageMs = leaseAgeMs(item, nowMs); + if (ageMs === null) + continue; + if (ageMs > maxLeaseMs) + stuck.push(item); + } + return stuck; } - /** * Reclaim every stuck in-flight item back to 'queued', returning the reclaimed entries. `store.listInProgress()` * supplies the lease-annotated rows and `store.reclaimStuckItem()` performs the atomic per-item flip — the same * store/sweep split sweepExpiredClaims uses. */ export function sweepStuckItems(store, nowMs, maxLeaseMs = DEFAULT_MAX_LEASE_MS) { - const inProgress = store.listInProgress(); - const stuck = findStuckItems(inProgress, nowMs, maxLeaseMs); - const reclaimed = []; - for (const item of stuck) { - // Echo the item's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an - // in-flight item with the same owner/repo+identifier, and defaulting here would reclaim the wrong host's row. - const updated = store.reclaimStuckItem(item.repoFullName, item.identifier, item.apiBaseUrl); - if (updated) reclaimed.push(updated); - } - return reclaimed; + const inProgress = store.listInProgress(); + const stuck = findStuckItems(inProgress, nowMs, maxLeaseMs); + const reclaimed = []; + for (const item of stuck) { + // Echo the item's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an + // in-flight item with the same owner/repo+identifier, and defaulting here would reclaim the wrong host's row. + const updated = store.reclaimStuckItem(item.repoFullName, item.identifier, item.apiBaseUrl); + if (updated) + reclaimed.push(updated); + } + return reclaimed; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicG9ydGZvbGlvLXF1ZXVlLWV4cGlyeS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInBvcnRmb2xpby1xdWV1ZS1leHBpcnkudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7O2dCQUVnQjtBQUloQixrSEFBa0g7QUFDbEgsdUdBQXVHO0FBQ3ZHLE1BQU0sQ0FBQyxNQUFNLG9CQUFvQixHQUFHLEVBQUUsR0FBRyxFQUFFLEdBQUcsSUFBSSxDQUFDO0FBT25ELFNBQVMsVUFBVSxDQUFDLElBQXFCLEVBQUUsS0FBYTtJQUN0RCxNQUFNLFVBQVUsR0FBRyxJQUFJLENBQUMsS0FBSyxDQUFDLElBQUksQ0FBQyxRQUFrQixDQUFDLENBQUM7SUFDdkQsSUFBSSxDQUFDLE1BQU0sQ0FBQyxRQUFRLENBQUMsVUFBVSxDQUFDO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDOUMsT0FBTyxLQUFLLEdBQUcsVUFBVSxDQUFDO0FBQzVCLENBQUM7QUFFRDs7OztHQUlHO0FBQ0gsTUFBTSxVQUFVLGNBQWMsQ0FBQyxLQUF3QixFQUFFLEtBQWEsRUFBRSxVQUFrQjtJQUN4RixJQUFJLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQyxLQUFLLENBQUMsSUFBSSxLQUFLLEdBQUcsQ0FBQztRQUFFLE1BQU0sSUFBSSxLQUFLLENBQUMsZ0JBQWdCLENBQUMsQ0FBQztJQUM1RSxJQUFJLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQyxVQUFVLENBQUMsSUFBSSxVQUFVLEdBQUcsQ0FBQztRQUFFLE1BQU0sSUFBSSxLQUFLLENBQUMsc0JBQXNCLENBQUMsQ0FBQztJQUM1RixJQUFJLENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FBQyxLQUFLLENBQUM7UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLGVBQWUsQ0FBQyxDQUFDO0lBRTVELE1BQU0sS0FBSyxHQUFzQixFQUFFLENBQUM7SUFDcEMsS0FBSyxNQUFNLElBQUksSUFBSSxLQUFLLEVBQUUsQ0FBQztRQUN6QixJQUFJLElBQUksRUFBRSxNQUFNLEtBQUssYUFBYTtZQUFFLFNBQVM7UUFDN0MsTUFBTSxLQUFLLEdBQUcsVUFBVSxDQUFDLElBQUksRUFBRSxLQUFLLENBQUMsQ0FBQztRQUN0QyxJQUFJLEtBQUssS0FBSyxJQUFJO1lBQUUsU0FBUztRQUM3QixJQUFJLEtBQUssR0FBRyxVQUFVO1lBQUUsS0FBSyxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQztJQUMzQyxDQUFDO0lBQ0QsT0FBTyxLQUFLLENBQUM7QUFDZixDQUFDO0FBRUQ7Ozs7R0FJRztBQUNILE1BQU0sVUFBVSxlQUFlLENBQzdCLEtBQWdDLEVBQ2hDLEtBQWEsRUFDYixhQUFxQixvQkFBb0I7SUFFekMsTUFBTSxVQUFVLEdBQUcsS0FBSyxDQUFDLGNBQWMsRUFBRSxDQUFDO0lBQzFDLE1BQU0sS0FBSyxHQUFHLGNBQWMsQ0FBQyxVQUFVLEVBQUUsS0FBSyxFQUFFLFVBQVUsQ0FBQyxDQUFDO0lBQzVELE1BQU0sU0FBUyxHQUFpQixFQUFFLENBQUM7SUFDbkMsS0FBSyxNQUFNLElBQUksSUFBSSxLQUFLLEVBQUUsQ0FBQztRQUN6Qix1R0FBdUc7UUFDdkcsOEdBQThHO1FBQzlHLE1BQU0sT0FBTyxHQUFHLEtBQUssQ0FBQyxnQkFBZ0IsQ0FBQyxJQUFJLENBQUMsWUFBWSxFQUFFLElBQUksQ0FBQyxVQUFVLEVBQUUsSUFBSSxDQUFDLFVBQVUsQ0FBQyxDQUFDO1FBQzVGLElBQUksT0FBTztZQUFFLFNBQVMsQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFDLENBQUM7SUFDdkMsQ0FBQztJQUNELE9BQU8sU0FBUyxDQUFDO0FBQ25CLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/portfolio-queue-expiry.ts b/packages/loopover-miner/lib/portfolio-queue-expiry.ts new file mode 100644 index 0000000000..1b23da008e --- /dev/null +++ b/packages/loopover-miner/lib/portfolio-queue-expiry.ts @@ -0,0 +1,62 @@ +/** PURE — no IO, no Date, no random (#4827). Mirror of claim-ledger-expiry.js for the portfolio-queue store: a + * crashed/killed process leaves its item stuck 'in_progress' forever, so sweep leases older than a bound back to + * 'queued'. */ + +import type { QueueEntry, QueueLeaseEntry } from "./portfolio-queue.js"; + +// A generous default: a real attempt rarely holds a single portfolio item for long, so 30 minutes without the row +// leaving 'in_progress' strongly implies the owning process died rather than that it is still working. +export const DEFAULT_MAX_LEASE_MS = 30 * 60 * 1000; + +export type PortfolioQueueExpiryStore = { + listInProgress(): QueueLeaseEntry[]; + reclaimStuckItem(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null; +}; + +function leaseAgeMs(item: QueueLeaseEntry, nowMs: number): number | null { + const leasedAtMs = Date.parse(item.leasedAt as string); + if (!Number.isFinite(leasedAtMs)) return null; + return nowMs - leasedAtMs; +} + +/** + * Return in-flight items whose lease age is strictly greater than `maxLeaseMs`. An item whose age equals + * `maxLeaseMs` exactly is still within the window (not stuck). Items that are not 'in_progress', or whose + * `leasedAt` is missing/unparseable, are never returned. + */ +export function findStuckItems(items: QueueLeaseEntry[], nowMs: number, maxLeaseMs: number): QueueLeaseEntry[] { + if (!Number.isFinite(nowMs) || nowMs < 0) throw new Error("invalid_now_ms"); + if (!Number.isFinite(maxLeaseMs) || maxLeaseMs < 0) throw new Error("invalid_max_lease_ms"); + if (!Array.isArray(items)) throw new Error("invalid_items"); + + const stuck: QueueLeaseEntry[] = []; + for (const item of items) { + if (item?.status !== "in_progress") continue; + const ageMs = leaseAgeMs(item, nowMs); + if (ageMs === null) continue; + if (ageMs > maxLeaseMs) stuck.push(item); + } + return stuck; +} + +/** + * Reclaim every stuck in-flight item back to 'queued', returning the reclaimed entries. `store.listInProgress()` + * supplies the lease-annotated rows and `store.reclaimStuckItem()` performs the atomic per-item flip — the same + * store/sweep split sweepExpiredClaims uses. + */ +export function sweepStuckItems( + store: PortfolioQueueExpiryStore, + nowMs: number, + maxLeaseMs: number = DEFAULT_MAX_LEASE_MS, +): QueueEntry[] { + const inProgress = store.listInProgress(); + const stuck = findStuckItems(inProgress, nowMs, maxLeaseMs); + const reclaimed: QueueEntry[] = []; + for (const item of stuck) { + // Echo the item's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an + // in-flight item with the same owner/repo+identifier, and defaulting here would reclaim the wrong host's row. + const updated = store.reclaimStuckItem(item.repoFullName, item.identifier, item.apiBaseUrl); + if (updated) reclaimed.push(updated); + } + return reclaimed; +} diff --git a/packages/loopover-miner/lib/prompt-injection-defense.d.ts b/packages/loopover-miner/lib/prompt-injection-defense.d.ts index 55d6af8706..f26df5b8aa 100644 --- a/packages/loopover-miner/lib/prompt-injection-defense.d.ts +++ b/packages/loopover-miner/lib/prompt-injection-defense.d.ts @@ -1,5 +1,11 @@ -export const PROMPT_INJECTION_RE: RegExp; - -export function hasPromptInjection(text: string | null | undefined): boolean; - -export function neutralizePromptInjection(text: string | null | undefined): { text: string; injected: boolean }; +export declare const PROMPT_INJECTION_RE: RegExp; +/** True when the text contains an agent-manipulation / prompt-injection pattern. */ +export declare function hasPromptInjection(text: string | null | undefined): boolean; +/** + * Replace injection-like spans with a defanged marker so the literal manipulation never reaches the + * coding agent verbatim. Returns the neutralized text + whether anything was flagged. + */ +export declare function neutralizePromptInjection(text: string | null | undefined): { + text: string; + injected: boolean; +}; diff --git a/packages/loopover-miner/lib/prompt-injection-defense.js b/packages/loopover-miner/lib/prompt-injection-defense.js index 0498277240..8302365199 100644 --- a/packages/loopover-miner/lib/prompt-injection-defense.js +++ b/packages/loopover-miner/lib/prompt-injection-defense.js @@ -11,38 +11,33 @@ // duplicated here rather than shared, matching how src/review/prompt-injection.ts itself documents being // a self-contained port of its own upstream (reviewbot's src/core/prompt-injection.ts). Keep the two // regex sources in sync by hand if either evolves. - const INJECTION_SOURCE = [ - "\\b(?:ignore|disregard|forget)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|the|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?|rules?|rubric|policy|guidelines?|directions?)\\b", - "\\b(?:override|bypass)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?)\\b|\\b(?:override|bypass)\\s+the\\s+(?:rules?|rubric|policy|guidelines?|directions?)\\b[^.]{0,40}\\b(?:approve|merge|accept|whitelist|allow|pass|scor(?:e|ing))\\b", - "\\byou are now\\s+(?:an?\\s+(?:\\w+\\s+)?(?:ai|assistant|language model|reviewer|maintainer|admin|moderator|bot|developer|owner|system)|(?:unrestricted|uncensored|unfiltered|unbound|jailbroken))\\b", - "\\b(?:this is|here is|below is)\\s+the\\s+(?:system|developer)\\s+prompt\\b|\\b(?:system|developer)\\s+prompt\\s*:", - "\\b(?:approve|merge|accept|whitelist|allow|pass)\\s+this\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b|\\b(?:please|kindly|just)\\s+(?:approve|merge|accept|whitelist|allow|pass)\\s+the\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b", - "\\bas an?\\s+(?:ai|assistant|language model)\\b[^.]{0,30}\\b(?:you must\\s+(?:ignore|approve|obey|disregard|comply)|ignore\\s+(?:previous|prior|all|the|any)|approve\\s+(?:this|the))\\b", - "\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\byour\\s+(?:system prompt|rubric|instructions?)\\b|\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\bthe\\s+(?:system|developer)\\s+prompt\\b[^.]{0,40}\\byou\\s+(?:were\\s+)?(?:given|sent|provided|received)\\b", - "\\b(?:pretend|roleplay)\\b[^.]{0,24}\\byou\\s+are\\b", + "\\b(?:ignore|disregard|forget)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|the|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?|rules?|rubric|policy|guidelines?|directions?)\\b", + "\\b(?:override|bypass)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?)\\b|\\b(?:override|bypass)\\s+the\\s+(?:rules?|rubric|policy|guidelines?|directions?)\\b[^.]{0,40}\\b(?:approve|merge|accept|whitelist|allow|pass|scor(?:e|ing))\\b", + "\\byou are now\\s+(?:an?\\s+(?:\\w+\\s+)?(?:ai|assistant|language model|reviewer|maintainer|admin|moderator|bot|developer|owner|system)|(?:unrestricted|uncensored|unfiltered|unbound|jailbroken))\\b", + "\\b(?:this is|here is|below is)\\s+the\\s+(?:system|developer)\\s+prompt\\b|\\b(?:system|developer)\\s+prompt\\s*:", + "\\b(?:approve|merge|accept|whitelist|allow|pass)\\s+this\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b|\\b(?:please|kindly|just)\\s+(?:approve|merge|accept|whitelist|allow|pass)\\s+the\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b", + "\\bas an?\\s+(?:ai|assistant|language model)\\b[^.]{0,30}\\b(?:you must\\s+(?:ignore|approve|obey|disregard|comply)|ignore\\s+(?:previous|prior|all|the|any)|approve\\s+(?:this|the))\\b", + "\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\byour\\s+(?:system prompt|rubric|instructions?)\\b|\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\bthe\\s+(?:system|developer)\\s+prompt\\b[^.]{0,40}\\byou\\s+(?:were\\s+)?(?:given|sent|provided|received)\\b", + "\\b(?:pretend|roleplay)\\b[^.]{0,24}\\byou\\s+are\\b", ].join("|"); - export const PROMPT_INJECTION_RE = new RegExp(INJECTION_SOURCE, "i"); - /** True when the text contains an agent-manipulation / prompt-injection pattern. */ export function hasPromptInjection(text) { - return !!text && PROMPT_INJECTION_RE.test(text); + return !!text && PROMPT_INJECTION_RE.test(text); } - /** * Replace injection-like spans with a defanged marker so the literal manipulation never reaches the * coding agent verbatim. Returns the neutralized text + whether anything was flagged. - * - * @param {string | null | undefined} text - * @returns {{ text: string, injected: boolean }} */ export function neutralizePromptInjection(text) { - if (!text) return { text: text ?? "", injected: false }; - let injected = false; - const cleaned = text.replace(new RegExp(INJECTION_SOURCE, "gi"), () => { - injected = true; - return "[external-instruction-redacted]"; - }); - return { text: cleaned, injected }; + if (!text) + return { text: text ?? "", injected: false }; + let injected = false; + const cleaned = text.replace(new RegExp(INJECTION_SOURCE, "gi"), () => { + injected = true; + return "[external-instruction-redacted]"; + }); + return { text: cleaned, injected }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicHJvbXB0LWluamVjdGlvbi1kZWZlbnNlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsicHJvbXB0LWluamVjdGlvbi1kZWZlbnNlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLHNHQUFzRztBQUN0Ryx1R0FBdUc7QUFDdkcscUdBQXFHO0FBQ3JHLHNHQUFzRztBQUN0RyxrRUFBa0U7QUFDbEUsRUFBRTtBQUNGLHVHQUF1RztBQUN2RyxtR0FBbUc7QUFDbkcsdUdBQXVHO0FBQ3ZHLG9HQUFvRztBQUNwRyx5R0FBeUc7QUFDekcscUdBQXFHO0FBQ3JHLG1EQUFtRDtBQUVuRCxNQUFNLGdCQUFnQixHQUFHO0lBQ3ZCLHNMQUFzTDtJQUN0TCw2UkFBNlI7SUFDN1IsdU1BQXVNO0lBQ3ZNLG9IQUFvSDtJQUNwSCx3UkFBd1I7SUFDeFIsMExBQTBMO0lBQzFMLDRRQUE0UTtJQUM1USxzREFBc0Q7Q0FDdkQsQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUM7QUFFWixNQUFNLENBQUMsTUFBTSxtQkFBbUIsR0FBRyxJQUFJLE1BQU0sQ0FBQyxnQkFBZ0IsRUFBRSxHQUFHLENBQUMsQ0FBQztBQUVyRSxvRkFBb0Y7QUFDcEYsTUFBTSxVQUFVLGtCQUFrQixDQUFDLElBQStCO0lBQ2hFLE9BQU8sQ0FBQyxDQUFDLElBQUksSUFBSSxtQkFBbUIsQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLENBQUM7QUFDbEQsQ0FBQztBQUVEOzs7R0FHRztBQUNILE1BQU0sVUFBVSx5QkFBeUIsQ0FBQyxJQUErQjtJQUN2RSxJQUFJLENBQUMsSUFBSTtRQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUUsSUFBSSxJQUFJLEVBQUUsRUFBRSxRQUFRLEVBQUUsS0FBSyxFQUFFLENBQUM7SUFDeEQsSUFBSSxRQUFRLEdBQUcsS0FBSyxDQUFDO0lBQ3JCLE1BQU0sT0FBTyxHQUFHLElBQUksQ0FBQyxPQUFPLENBQUMsSUFBSSxNQUFNLENBQUMsZ0JBQWdCLEVBQUUsSUFBSSxDQUFDLEVBQUUsR0FBRyxFQUFFO1FBQ3BFLFFBQVEsR0FBRyxJQUFJLENBQUM7UUFDaEIsT0FBTyxpQ0FBaUMsQ0FBQztJQUMzQyxDQUFDLENBQUMsQ0FBQztJQUNILE9BQU8sRUFBRSxJQUFJLEVBQUUsT0FBTyxFQUFFLFFBQVEsRUFBRSxDQUFDO0FBQ3JDLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/prompt-injection-defense.ts b/packages/loopover-miner/lib/prompt-injection-defense.ts new file mode 100644 index 0000000000..0256e36fd1 --- /dev/null +++ b/packages/loopover-miner/lib/prompt-injection-defense.ts @@ -0,0 +1,45 @@ +// Detect + defang prompt-injection / agent-manipulation text in UNTRUSTED third-party repo content (a +// customer's own issue title/body) before it reaches the coding agent's own instructions (#4795). Such +// content is DATA, never instructions -- but a coding agent operating with real write authority on a +// customer's repository can still be steered by it, so we both flag it (a strong negative signal) and +// redact the literal manipulation so it can't be obeyed verbatim. +// +// SELF-CONTAINED NATIVE PORT: byte-faithful to src/review/prompt-injection.ts's proven regex (the same +// reviewer-manipulation shape, now defending the coding agent's own instructions instead of the AI +// reviewer's prompt). No cross-package import -- packages/loopover-miner never depends on root src/ (a +// separate Cloudflare Worker deployable, see package.json's own dependency list), so the pattern is +// duplicated here rather than shared, matching how src/review/prompt-injection.ts itself documents being +// a self-contained port of its own upstream (reviewbot's src/core/prompt-injection.ts). Keep the two +// regex sources in sync by hand if either evolves. + +const INJECTION_SOURCE = [ + "\\b(?:ignore|disregard|forget)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|the|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?|rules?|rubric|policy|guidelines?|directions?)\\b", + "\\b(?:override|bypass)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?)\\b|\\b(?:override|bypass)\\s+the\\s+(?:rules?|rubric|policy|guidelines?|directions?)\\b[^.]{0,40}\\b(?:approve|merge|accept|whitelist|allow|pass|scor(?:e|ing))\\b", + "\\byou are now\\s+(?:an?\\s+(?:\\w+\\s+)?(?:ai|assistant|language model|reviewer|maintainer|admin|moderator|bot|developer|owner|system)|(?:unrestricted|uncensored|unfiltered|unbound|jailbroken))\\b", + "\\b(?:this is|here is|below is)\\s+the\\s+(?:system|developer)\\s+prompt\\b|\\b(?:system|developer)\\s+prompt\\s*:", + "\\b(?:approve|merge|accept|whitelist|allow|pass)\\s+this\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b|\\b(?:please|kindly|just)\\s+(?:approve|merge|accept|whitelist|allow|pass)\\s+the\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b", + "\\bas an?\\s+(?:ai|assistant|language model)\\b[^.]{0,30}\\b(?:you must\\s+(?:ignore|approve|obey|disregard|comply)|ignore\\s+(?:previous|prior|all|the|any)|approve\\s+(?:this|the))\\b", + "\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\byour\\s+(?:system prompt|rubric|instructions?)\\b|\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\bthe\\s+(?:system|developer)\\s+prompt\\b[^.]{0,40}\\byou\\s+(?:were\\s+)?(?:given|sent|provided|received)\\b", + "\\b(?:pretend|roleplay)\\b[^.]{0,24}\\byou\\s+are\\b", +].join("|"); + +export const PROMPT_INJECTION_RE = new RegExp(INJECTION_SOURCE, "i"); + +/** True when the text contains an agent-manipulation / prompt-injection pattern. */ +export function hasPromptInjection(text: string | null | undefined): boolean { + return !!text && PROMPT_INJECTION_RE.test(text); +} + +/** + * Replace injection-like spans with a defanged marker so the literal manipulation never reaches the + * coding agent verbatim. Returns the neutralized text + whether anything was flagged. + */ +export function neutralizePromptInjection(text: string | null | undefined): { text: string; injected: boolean } { + if (!text) return { text: text ?? "", injected: false }; + let injected = false; + const cleaned = text.replace(new RegExp(INJECTION_SOURCE, "gi"), () => { + injected = true; + return "[external-instruction-redacted]"; + }); + return { text: cleaned, injected }; +} diff --git a/packages/loopover-miner/lib/slop-assessment.d.ts b/packages/loopover-miner/lib/slop-assessment.d.ts index 833e490e67..fd4f17bdf3 100644 --- a/packages/loopover-miner/lib/slop-assessment.d.ts +++ b/packages/loopover-miner/lib/slop-assessment.d.ts @@ -1,3 +1,2 @@ import type { SlopAssessment, SlopAssessmentInput } from "@loopover/engine"; - -export function runSlopAssessment(input: SlopAssessmentInput): SlopAssessment; +export declare function runSlopAssessment(input: SlopAssessmentInput): SlopAssessment; diff --git a/packages/loopover-miner/lib/slop-assessment.js b/packages/loopover-miner/lib/slop-assessment.js index 98ec8d132d..fa18443839 100644 --- a/packages/loopover-miner/lib/slop-assessment.js +++ b/packages/loopover-miner/lib/slop-assessment.js @@ -1,5 +1,4 @@ import { buildSlopAssessment } from "@loopover/engine"; - // Production runSlopAssessment binding (#5133, Wave 3.5 follow-up to #2334). `attempt-runner.js`'s // `deps.runSlopAssessment` (via #2333's iterate-loop -> self-review-adapter's `SelfReviewAdapterDeps`) had // no production implementation anywhere in this package -- only the test double in @@ -10,11 +9,7 @@ import { buildSlopAssessment } from "@loopover/engine"; // a real binding could be a direct pass-through with no mapping logic once the deterministic scorer itself // became portable -- which #5133 did (`src/signals/slop.ts`'s PR-side scorer is now extracted to // `packages/loopover-engine/src/signals/slop.ts`, byte-parity-verified against the live gate's own copy). - -/** - * @param {import("@loopover/engine").SlopAssessmentInput} input - * @returns {import("@loopover/engine").SlopAssessment} - */ export function runSlopAssessment(input) { - return buildSlopAssessment(input); + return buildSlopAssessment(input); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2xvcC1hc3Nlc3NtZW50LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsic2xvcC1hc3Nlc3NtZW50LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBR3ZELG1HQUFtRztBQUNuRywyR0FBMkc7QUFDM0csbUZBQW1GO0FBQ25GLGtIQUFrSDtBQUNsSCw4R0FBOEc7QUFDOUcsc0dBQXNHO0FBQ3RHLCtHQUErRztBQUMvRywyR0FBMkc7QUFDM0csaUdBQWlHO0FBQ2pHLDBHQUEwRztBQUUxRyxNQUFNLFVBQVUsaUJBQWlCLENBQUMsS0FBMEI7SUFDMUQsT0FBTyxtQkFBbUIsQ0FBQyxLQUFLLENBQUMsQ0FBQztBQUNwQyxDQUFDIn0= \ No newline at end of file diff --git a/packages/loopover-miner/lib/slop-assessment.ts b/packages/loopover-miner/lib/slop-assessment.ts new file mode 100644 index 0000000000..0736e9d6b2 --- /dev/null +++ b/packages/loopover-miner/lib/slop-assessment.ts @@ -0,0 +1,17 @@ +import { buildSlopAssessment } from "@loopover/engine"; +import type { SlopAssessment, SlopAssessmentInput } from "@loopover/engine"; + +// Production runSlopAssessment binding (#5133, Wave 3.5 follow-up to #2334). `attempt-runner.js`'s +// `deps.runSlopAssessment` (via #2333's iterate-loop -> self-review-adapter's `SelfReviewAdapterDeps`) had +// no production implementation anywhere in this package -- only the test double in +// `test/unit/miner-attempt-runner.test.ts` existed. `packages/loopover-engine/src/miner/self-review-adapter.ts`'s +// own header comment already anticipated this exact binding: `SelfReviewSlopInput`/`SelfReviewSlopAssessment` +// are a deliberate, hand-kept STRUCTURAL MIRROR of `buildSlopAssessment`'s own `SlopAssessmentInput`/ +// `SlopAssessment` (down to reusing the SAME canonical `AdvisoryFinding` type for `findings`), specifically so +// a real binding could be a direct pass-through with no mapping logic once the deterministic scorer itself +// became portable -- which #5133 did (`src/signals/slop.ts`'s PR-side scorer is now extracted to +// `packages/loopover-engine/src/signals/slop.ts`, byte-parity-verified against the live gate's own copy). + +export function runSlopAssessment(input: SlopAssessmentInput): SlopAssessment { + return buildSlopAssessment(input); +} diff --git a/packages/loopover-miner/lib/version.d.ts b/packages/loopover-miner/lib/version.d.ts index 1b7404ce8e..1be9f1defc 100644 --- a/packages/loopover-miner/lib/version.d.ts +++ b/packages/loopover-miner/lib/version.d.ts @@ -1,3 +1,4 @@ -export const MINER_PACKAGE_VERSION: string; - -export function resolveMinerVersion(env?: Record): string; +/** Package.json semver at import time — the laptop npm-install default. */ +export declare const MINER_PACKAGE_VERSION: string; +/** Resolved miner release id: `LOOPOVER_MINER_VERSION` wins when set (fleet Docker image builds). */ +export declare function resolveMinerVersion(env?: Record): string; diff --git a/packages/loopover-miner/lib/version.js b/packages/loopover-miner/lib/version.js index 866e1f6b41..c3ff373653 100644 --- a/packages/loopover-miner/lib/version.js +++ b/packages/loopover-miner/lib/version.js @@ -1,10 +1,9 @@ import ownPackageJson from "../package.json" with { type: "json" }; - /** Package.json semver at import time — the laptop npm-install default. */ export const MINER_PACKAGE_VERSION = ownPackageJson.version; - /** Resolved miner release id: `LOOPOVER_MINER_VERSION` wins when set (fleet Docker image builds). */ export function resolveMinerVersion(env = process.env) { - const override = typeof env.LOOPOVER_MINER_VERSION === "string" ? env.LOOPOVER_MINER_VERSION.trim() : ""; - return override || MINER_PACKAGE_VERSION; + const override = typeof env.LOOPOVER_MINER_VERSION === "string" ? env.LOOPOVER_MINER_VERSION.trim() : ""; + return override || MINER_PACKAGE_VERSION; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidmVyc2lvbi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInZlcnNpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxjQUFjLE1BQU0saUJBQWlCLENBQUMsT0FBTyxJQUFJLEVBQUUsTUFBTSxFQUFFLENBQUM7QUFFbkUsMkVBQTJFO0FBQzNFLE1BQU0sQ0FBQyxNQUFNLHFCQUFxQixHQUFXLGNBQWMsQ0FBQyxPQUFPLENBQUM7QUFFcEUscUdBQXFHO0FBQ3JHLE1BQU0sVUFBVSxtQkFBbUIsQ0FBQyxNQUEwQyxPQUFPLENBQUMsR0FBRztJQUN2RixNQUFNLFFBQVEsR0FBRyxPQUFPLEdBQUcsQ0FBQyxzQkFBc0IsS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLEdBQUcsQ0FBQyxzQkFBc0IsQ0FBQyxJQUFJLEVBQUUsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDO0lBQ3pHLE9BQU8sUUFBUSxJQUFJLHFCQUFxQixDQUFDO0FBQzNDLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/version.ts b/packages/loopover-miner/lib/version.ts new file mode 100644 index 0000000000..b32adac6ee --- /dev/null +++ b/packages/loopover-miner/lib/version.ts @@ -0,0 +1,10 @@ +import ownPackageJson from "../package.json" with { type: "json" }; + +/** Package.json semver at import time — the laptop npm-install default. */ +export const MINER_PACKAGE_VERSION: string = ownPackageJson.version; + +/** Resolved miner release id: `LOOPOVER_MINER_VERSION` wins when set (fleet Docker image builds). */ +export function resolveMinerVersion(env: Record = process.env): string { + const override = typeof env.LOOPOVER_MINER_VERSION === "string" ? env.LOOPOVER_MINER_VERSION.trim() : ""; + return override || MINER_PACKAGE_VERSION; +} diff --git a/test/unit/miner-claim-ledger-expiry.test.ts b/test/unit/miner-claim-ledger-expiry.test.ts index ff7d47d236..e2e280cf5d 100644 --- a/test/unit/miner-claim-ledger-expiry.test.ts +++ b/test/unit/miner-claim-ledger-expiry.test.ts @@ -75,6 +75,27 @@ describe("loopover-miner claim ledger expiry (#2316)", () => { expect(findExpiredClaims([fresh, stale, released], nowMs, maxAgeMs)).toEqual([stale]); }); + it("findExpiredClaims skips an active claim whose claimedAt is unparseable (NaN age path)", () => { + const nowMs = Date.parse("2026-07-03T00:00:00.000Z"); + const maxAgeMs = 1 * 24 * 60 * 60 * 1000; + const bogus = claim({ issueNumber: 7, claimedAt: "not-a-date" }); // Date.parse -> NaN -> claimAgeMs null + const stale = claim({ issueNumber: 8, claimedAt: "2026-06-01T00:00:00.000Z" }); + expect(findExpiredClaims([bogus, stale], nowMs, maxAgeMs)).toEqual([stale]); + }); + + it("sweepExpiredClaims defaults maxAgeMs and skips rows whose store.expireClaim reports no transition (null)", () => { + // A pure-object store (not the real ledger): the row is ~26 years old, so it is selected as expired under the + // DEFAULT_MAX_CLAIM_AGE_MS default (maxAgeMs omitted), but expireClaim returns null (already gone), so the + // `if (updated)` guard skips it and nothing is transitioned. + const expiredRow = claim({ issueNumber: 42, claimedAt: "2000-01-01T00:00:00.000Z" }); + const store = { + listClaims: () => [expiredRow], + expireClaim: () => null, + }; + const nowMs = Date.parse("2026-07-03T00:00:00.000Z"); + expect(sweepExpiredClaims(store, nowMs)).toEqual([]); + }); + it("findExpiredClaims treats age === maxAgeMs as still active (boundary)", () => { const nowMs = Date.parse("2026-07-10T00:00:00.000Z"); const maxAgeMs = 7 * 24 * 60 * 60 * 1000; diff --git a/test/unit/miner-cross-repo-evaluation.test.ts b/test/unit/miner-cross-repo-evaluation.test.ts index f2046cc2c5..1f1de6a21d 100644 --- a/test/unit/miner-cross-repo-evaluation.test.ts +++ b/test/unit/miner-cross-repo-evaluation.test.ts @@ -144,6 +144,28 @@ describe("cross-repo evaluation harness (#4788)", () => { expect(parsed.manifest.repos).toEqual([]); expect(parsed.warnings[0]).toContain("must be a list"); }); + + it("treats an object with a missing or null repos field as an empty repo list", () => { + const missing = parseCrossRepoEvaluationManifest(JSON.stringify({})); + expect(missing.present).toBe(true); + expect(missing.manifest.repos).toEqual([]); + expect(missing.warnings).toEqual([]); + + const nulled = parseCrossRepoEvaluationManifest(JSON.stringify({ repos: null })); + expect(nulled.present).toBe(true); + expect(nulled.manifest.repos).toEqual([]); + expect(nulled.warnings).toEqual([]); + }); + + it("drops a whitespace-only stackHint / fixturePath to undefined without a warning", () => { + const parsed = parseCrossRepoEvaluationManifest( + JSON.stringify({ repos: [{ repoFullName: "acme/blank", stackHint: " ", fixturePath: " " }] }), + ); + expect(parsed.manifest.repos).toEqual([{ repoFullName: "acme/blank", requireTestCommand: false }]); + expect(parsed.manifest.repos[0]?.stackHint).toBeUndefined(); + expect(parsed.manifest.repos[0]?.fixturePath).toBeUndefined(); + expect(parsed.warnings).toEqual([]); + }); }); describe("scanPositiveLoopoverAssumptions", () => { @@ -305,6 +327,48 @@ describe("cross-repo evaluation harness (#4788)", () => { const result = evaluateRepoReadiness({ repoFullName: "not-a-repo", requireTestCommand: false }); expect(result.failureCategory).toBe(CROSS_REPO_FAILURE_CATEGORY.OTHER); }); + + it("reports a non-string repoFullName as the placeholder '(invalid)'", () => { + const result = evaluateRepoReadiness({ repoFullName: 123 } as never); + expect(result.failureCategory).toBe(CROSS_REPO_FAILURE_CATEGORY.OTHER); + expect(result.repoFullName).toBe("(invalid)"); + }); + + it("fails other with String(error) when buildCodingTaskSpec throws a non-Error value", () => { + const repoPath = tempRepo({ "package.json": pkg({ scripts: { test: "node --test" } }) }); + const result = evaluateRepoReadiness( + { repoFullName: "acme/throws-string", requireTestCommand: false }, + { + repoPath, + existsSync: () => true, + buildCodingTaskSpec: () => { + throw "kaboom-string"; + }, + }, + ); + expect(result.failureCategory).toBe(CROSS_REPO_FAILURE_CATEGORY.OTHER); + expect(result.reason).toBe("kaboom-string"); + }); + + it("falls back to a 'unknown' verdict when an unready spec omits its verdict", () => { + const repoPath = tempRepo({ "package.json": pkg({ scripts: { test: "node --test" } }) }); + const result = evaluateRepoReadiness( + { repoFullName: "acme/no-verdict", requireTestCommand: false }, + { repoPath, existsSync: () => true, buildCodingTaskSpec: () => ({ ready: false }) }, + ); + expect(result.failureCategory).toBe(CROSS_REPO_FAILURE_CATEGORY.EXECUTION); + expect(result.reason).toContain("unknown"); + }); + + it("treats a ready spec with no instructions as leak-free (empty-string scan fallback)", () => { + const repoPath = tempRepo({ "package.json": pkg({ scripts: { test: "node --test" } }) }); + const result = evaluateRepoReadiness( + { repoFullName: "acme/no-instructions", requireTestCommand: false }, + { repoPath, existsSync: () => true, buildCodingTaskSpec: () => ({ ready: true }) }, + ); + expect(result.passed).toBe(true); + expect(result.assumptionFindings).toEqual([]); + }); }); describe("runCrossRepoEvaluation + summarizeCrossRepoEvaluation", () => { @@ -381,6 +445,39 @@ describe("cross-repo evaluation harness (#4788)", () => { expect(summary.majorityPassed).toBe(true); expect(summary.failuresByCategory.other).toBe(1); }); + + it("runCrossRepoEvaluation treats a parsed manifest without a repos list as no repos", () => { + expect(runCrossRepoEvaluation({} as never)).toEqual([]); + expect(runCrossRepoEvaluation(undefined as never)).toEqual([]); + }); + + it("summarizeCrossRepoEvaluation treats a non-array input as an empty run", () => { + const summary = summarizeCrossRepoEvaluation(null as never); + expect(summary.total).toBe(0); + expect(summary.majorityPassed).toBe(false); + }); + + it("formatCrossRepoEvaluationReport defaults its summary and omits the totals line for an empty run", () => { + // Called with a single argument: the summary defaults to summarizeCrossRepoEvaluation([]) (total 0), so the + // "without loopover-specific target config" line and the failures-by-category block are both omitted. + const report = formatCrossRepoEvaluationReport([]); + expect(report).toBe( + ["loopover-miner cross-repo evaluation", "", "", "summary: 0/0 passed (majority failed)"].join("\n"), + ); + }); + + it("formatCrossRepoEvaluationReport sorts multiple failure categories alphabetically", () => { + const results = [ + { repoFullName: "acme/a", passed: false, failureCategory: CROSS_REPO_FAILURE_CATEGORY.STACK_DETECTION, reason: "x" }, + { repoFullName: "acme/b", passed: false, failureCategory: CROSS_REPO_FAILURE_CATEGORY.CLONE_SETUP, reason: "y" }, + ] as never; + const report = formatCrossRepoEvaluationReport(results); + // clone_setup sorts before stack_detection_gap (the sort comparator runs only with >= 2 categories). + const cloneIdx = report.indexOf("- clone_setup: 1"); + const stackIdx = report.indexOf("- stack_detection_gap: 1"); + expect(cloneIdx).toBeGreaterThan(-1); + expect(stackIdx).toBeGreaterThan(cloneIdx); + }); }); describe("committed benchmark manifest + CLI", () => { diff --git a/test/unit/miner-portfolio-queue-expiry.test.ts b/test/unit/miner-portfolio-queue-expiry.test.ts index 782edbe626..2a5a3ca87c 100644 --- a/test/unit/miner-portfolio-queue-expiry.test.ts +++ b/test/unit/miner-portfolio-queue-expiry.test.ts @@ -173,6 +173,18 @@ describe("sweepStuckItems (#4827)", () => { expect(stillInProgress).toEqual([expect.objectContaining({ apiBaseUrl: "https://api.github.com" })]); }); + it("skips items whose store.reclaimStuckItem reports no transition (null)", () => { + // A pure-object store (not the real store): the item's lease is ancient, so findStuckItems selects it, but + // reclaimStuckItem returns null (already left in_progress), so the `if (updated)` guard skips it. + const stuck = leaseItem({ leasedAt: "2000-01-01T00:00:00.000Z" }); + const store = { + listInProgress: () => [stuck], + reclaimStuckItem: () => null, + }; + const nowMs = Date.parse("2026-07-12T12:00:00.000Z"); + expect(sweepStuckItems(store, nowMs, 30 * 60 * 1000)).toEqual([]); + }); + it("defaults the bound to DEFAULT_MAX_LEASE_MS and reclaims nothing when all leases are fresh", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-12T10:00:00.000Z"));