From be25e0f2dca77b44977fda7f9eedb23433c93949 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Thu, 16 Jul 2026 21:52:08 +0800 Subject: [PATCH] feat(miner): probe ORB live-gate-thresholds in self-review-context Closes #6487. Prefer live confidence_floor / scope caps over static .loopover.yml reconstruction when the short ORB probe succeeds; fall back unchanged on 403/404/timeout/no session. Co-authored-by: Cursor --- .../lib/github-token-resolution.d.ts | 5 + .../lib/github-token-resolution.js | 12 ++ .../lib/self-review-context.d.ts | 35 ++++- .../loopover-miner/lib/self-review-context.js | 104 +++++++++++- test/unit/miner-self-review-context.test.ts | 148 +++++++++++++++++- 5 files changed, 293 insertions(+), 11 deletions(-) diff --git a/packages/loopover-miner/lib/github-token-resolution.d.ts b/packages/loopover-miner/lib/github-token-resolution.d.ts index 2debd5f67d..6cc0fa8f16 100644 --- a/packages/loopover-miner/lib/github-token-resolution.d.ts +++ b/packages/loopover-miner/lib/github-token-resolution.d.ts @@ -12,6 +12,11 @@ export function resolveGitHubToken( options?: { fetchImpl?: GitHubTokenResolutionFetch }, ): Promise; +/** Same loopover-mcp session + API URL posture `resolveGitHubToken` uses (#6487). Null when no session. */ +export function resolveLoopoverBackendSession( + env?: NodeJS.ProcessEnv, +): { apiUrl: string; sessionToken: string } | null; + export function resetGitHubTokenResolutionForTesting(): void; export function hasGitHubTokenSource(env?: NodeJS.ProcessEnv): boolean; diff --git a/packages/loopover-miner/lib/github-token-resolution.js b/packages/loopover-miner/lib/github-token-resolution.js index 459dc1ae2b..729570a7e8 100644 --- a/packages/loopover-miner/lib/github-token-resolution.js +++ b/packages/loopover-miner/lib/github-token-resolution.js @@ -74,6 +74,18 @@ function loopoverApiUrl(env) { return DEFAULT_API_URL; } +/** + * Same loopover-mcp session + API URL posture `resolveGitHubToken` uses for backend calls (#6487). + * Returns null when there is no session token on disk (fully-standalone AMS / no `loopover-mcp login`). + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ apiUrl: string, sessionToken: string } | null} + */ +export function resolveLoopoverBackendSession(env = process.env) { + const sessionToken = loopoverSessionToken(env); + if (!sessionToken) return null; + return { apiUrl: loopoverApiUrl(env), sessionToken }; +} + async function fetchLiveGitHubTokenFromSession(sessionToken, apiUrl, fetchImpl) { try { const response = await fetchImpl(`${apiUrl}/v1/auth/github/token`, { diff --git a/packages/loopover-miner/lib/self-review-context.d.ts b/packages/loopover-miner/lib/self-review-context.d.ts index a5c5cce93b..3fdcd3bcdc 100644 --- a/packages/loopover-miner/lib/self-review-context.d.ts +++ b/packages/loopover-miner/lib/self-review-context.d.ts @@ -1,4 +1,4 @@ -import type { SelfReviewContext } from "@loopover/engine"; +import type { SelfReviewContext, FocusManifest } from "@loopover/engine"; // `bounties` is always omitted (see this file's own header comment for why), so the result is // SelfReviewContext minus that optional field rather than the full type. `issueQuality` is populated (#6057). @@ -10,8 +10,24 @@ export type SelfReviewContextResult = Omit; // stricter than any real caller needs -- same rationale as live-issue-snapshot.js's own LiveIssueSnapshotFetch. export type SelfReviewContextFetch = ( url: string, - init?: { method?: string; headers?: Record }, -) => Promise<{ ok: boolean; status: number; json: () => Promise; text: () => Promise }>; + init?: { method?: string; headers?: Record; signal?: AbortSignal }, +) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}>; + +export type LiveGateThresholdFields = { + confidence_floor: number | null; + scope_cap_files: number | null; + scope_cap_lines: number | null; +}; + +export type LoopoverBackendSessionAuth = { + apiUrl?: string; + sessionToken: string; +}; export type FetchSelfReviewContextOptions = { githubToken?: string; @@ -24,6 +40,19 @@ export type FetchSelfReviewContextOptions = { perPage?: number; maxPages?: number; requestTimeoutMs?: number; + /** Short ORB live-gate-thresholds probe budget (#6487). Default 400ms. */ + liveGateProbeTimeoutMs?: number; + /** Explicit session auth for the ORB probe; `null` forces standalone (skip probe). */ + loopoverAuth?: LoopoverBackendSessionAuth | null; + /** Env used to resolve loopover-mcp session when `loopoverAuth` is omitted. */ + env?: NodeJS.ProcessEnv; }; +export function parseLiveGateThresholdFields(payload: unknown): LiveGateThresholdFields | null; + +export function applyLiveGateThresholdsToManifest( + manifest: FocusManifest, + fields: LiveGateThresholdFields | null, +): FocusManifest; + export function fetchSelfReviewContext(repoFullName: string, options?: FetchSelfReviewContextOptions): Promise; diff --git a/packages/loopover-miner/lib/self-review-context.js b/packages/loopover-miner/lib/self-review-context.js index 563e8b6bc5..b186641e7d 100644 --- a/packages/loopover-miner/lib/self-review-context.js +++ b/packages/loopover-miner/lib/self-review-context.js @@ -4,6 +4,7 @@ import { MAX_FOCUS_MANIFEST_BYTES, parseFocusManifestContent, } from "@loopover/engine"; +import { resolveLoopoverBackendSession } from "./github-token-resolution.js"; // Real SelfReviewContext fetcher (#5145, Wave 3.5). Builds the context object the miner's self-review pass // (packages/loopover-engine/src/miner/self-review-adapter.ts) needs, at the SAME fidelity the live gate's @@ -18,6 +19,11 @@ import { // `issueQuality` is populated via buildIssueQualityReport (exported from @loopover/engine as a package-local // twin of the host engine helper — see #6057). Bounty rows and recent-merged PR history are passed as empty // arrays because this fetcher does not yet pull either source. `bounties` remains omitted for the reason above. +// +// #6487: after the static `.loopover.yml` reconstruction, optionally probe ORB's live-gate-thresholds endpoint +// (same loopover-mcp session posture as resolveGitHubToken). On success, overlay confidence_floor / +// scope_cap_files / scope_cap_lines onto the parsed manifest gate; on 403/timeout/404/no-session, keep the +// static reconstruction unchanged. Fully-standalone (ORB-absent) paths stay byte-identical. const GITHUB_API_VERSION = "2022-11-28"; const DEFAULT_API_BASE_URL = "https://api.github.com"; @@ -26,6 +32,8 @@ const DEFAULT_GITTENSOR_API_BASE = "https://api.gittensor.io"; const DEFAULT_PER_PAGE = 100; const DEFAULT_MAX_PAGES = 10; const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +/** Short ORB probe budget (#6487) — must never make discover/gate-prediction meaningfully slower when ORB is absent. */ +const DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS = 400; // Mirrors src/signals/focus-manifest-loader.ts's MANIFEST_FILE_CANDIDATES exactly -- first candidate that // resolves wins, same as the live gate's own lookup order. @@ -50,8 +58,22 @@ function githubHeaders(githubToken) { } function normalizeOptions(options = {}) { + const env = options.env ?? process.env; + // Explicit null skips the probe (tests / forced-standalone). Undefined ⇒ resolve from loopover-mcp session. + const loopoverAuth = + options.loopoverAuth === null + ? null + : options.loopoverAuth && typeof options.loopoverAuth.sessionToken === "string" && options.loopoverAuth.sessionToken + ? { + apiUrl: + typeof options.loopoverAuth.apiUrl === "string" && options.loopoverAuth.apiUrl.trim() + ? options.loopoverAuth.apiUrl.replace(/\/+$/, "") + : (resolveLoopoverBackendSession(env)?.apiUrl ?? "https://api.loopover.ai"), + sessionToken: options.loopoverAuth.sessionToken, + } + : resolveLoopoverBackendSession(env); return { - githubToken: options.githubToken ?? process.env.GITHUB_TOKEN ?? "", + githubToken: options.githubToken ?? env.GITHUB_TOKEN ?? "", apiBaseUrl: typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() ? options.apiBaseUrl.trim() : DEFAULT_API_BASE_URL, rawContentBaseUrl: typeof options.rawContentBaseUrl === "string" && options.rawContentBaseUrl.trim() ? options.rawContentBaseUrl.trim() : DEFAULT_RAW_CONTENT_BASE_URL, @@ -63,9 +85,77 @@ function normalizeOptions(options = {}) { contributorLogin: typeof options.contributorLogin === "string" ? options.contributorLogin.trim() : "", linkedIssues: Array.isArray(options.linkedIssues) ? options.linkedIssues.filter((n) => Number.isInteger(n)) : [], requestTimeoutMs: Number.isInteger(options.requestTimeoutMs) && options.requestTimeoutMs > 0 ? options.requestTimeoutMs : DEFAULT_REQUEST_TIMEOUT_MS, + liveGateProbeTimeoutMs: + Number.isInteger(options.liveGateProbeTimeoutMs) && options.liveGateProbeTimeoutMs > 0 + ? options.liveGateProbeTimeoutMs + : DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS, + loopoverAuth, }; } +/** Validate the field-limited #6486/#6487 payload; null when nothing usable is present. */ +export function parseLiveGateThresholdFields(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const confidence_floor = + typeof payload.confidence_floor === "number" && payload.confidence_floor >= 0 && payload.confidence_floor <= 1 + ? payload.confidence_floor + : null; + const scope_cap_files = typeof payload.scope_cap_files === "number" && payload.scope_cap_files > 0 ? payload.scope_cap_files : null; + const scope_cap_lines = typeof payload.scope_cap_lines === "number" && payload.scope_cap_lines > 0 ? payload.scope_cap_lines : null; + if (confidence_floor === null && scope_cap_files === null && scope_cap_lines === null) return null; + return { confidence_floor, scope_cap_files, scope_cap_lines }; +} + +/** + * Overlay live ORB thresholds onto a statically-reconstructed FocusManifest (#6487). + * - confidence_floor → raise-only readinessMinScore (mirrors applySelfTuneOverrideToSettings). + * - scope_cap_files / scope_cap_lines → prefer live sizeMaxFiles / sizeMaxLines when present. + * Other gate fields are left untouched. + */ +export function applyLiveGateThresholdsToManifest(manifest, fields) { + if (!manifest || !fields) return manifest; + const gate = { ...manifest.gate }; + if (typeof fields.confidence_floor === "number") { + const floorScore = Math.max(0, Math.min(100, Math.round(fields.confidence_floor * 100))); + if (typeof gate.readinessMinScore === "number" && floorScore > gate.readinessMinScore) { + gate.readinessMinScore = floorScore; + } + } + if (typeof fields.scope_cap_files === "number" && fields.scope_cap_files > 0) { + gate.sizeMaxFiles = fields.scope_cap_files; + } + if (typeof fields.scope_cap_lines === "number" && fields.scope_cap_lines > 0) { + gate.sizeMaxLines = fields.scope_cap_lines; + } + return { ...manifest, gate }; +} + +async function probeLiveGateThresholds(target, resolved) { + const auth = resolved.loopoverAuth; + if (!auth?.sessionToken) return null; + const url = `${auth.apiUrl}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/live-gate-thresholds`; + try { + const response = await fetchWithTimeout( + resolved.fetchImpl, + url, + { + method: "GET", + headers: { + authorization: `Bearer ${auth.sessionToken}`, + accept: "application/json", + "user-agent": "loopover-miner", + }, + }, + resolved.liveGateProbeTimeoutMs, + ); + if (!response.ok) return null; + const payload = await response.json().catch(() => null); + return parseLiveGateThresholdFields(payload); + } catch { + return null; + } +} + // A fresh AbortSignal.timeout() per call, so a stalled connection can't hang context construction forever // (#miner-github-read-timeouts) -- shared by this file's three independent fetch call sites (GitHub REST, raw // manifest content, the Gittensor contributor lookup). @@ -313,13 +403,17 @@ function computeInDuplicateCluster(collisionReport, targetIssueNumbers) { /** * Build a real SelfReviewContext from live GitHub data, at the same fidelity the live gate's own DB-backed * construction produces. See this file's header for the one field (bounties) deliberately left undefined - * and why; issueQuality is populated from the live GitHub snapshot. + * and why; issueQuality is populated from the live GitHub snapshot. Optionally overlays ORB live gate + * thresholds onto the static `.loopover.yml` reconstruction (#6487). * * @param {string} repoFullName * @param {{ * githubToken?: string, contributorLogin?: string, linkedIssues?: number[], * apiBaseUrl?: string, rawContentBaseUrl?: string, gittensorApiBase?: string, * fetchImpl?: typeof fetch, perPage?: number, maxPages?: number, requestTimeoutMs?: number, + * liveGateProbeTimeoutMs?: number, + * loopoverAuth?: { apiUrl?: string, sessionToken: string } | null, + * env?: NodeJS.ProcessEnv, * }} [options] * @returns {Promise} */ @@ -328,15 +422,17 @@ export async function fetchSelfReviewContext(repoFullName, options = {}) { if (!target) throw new Error("invalid_repo_full_name"); const resolved = normalizeOptions(options); - const [repo, issues, pullRequests, manifestContent, confirmedContributor] = await Promise.all([ + const [repo, issues, pullRequests, manifestContent, confirmedContributor, liveGateThresholds] = await Promise.all([ fetchRepositoryRecord(target, resolved), fetchOpenIssueRecords(target, resolved), fetchOpenPullRequestRecords(target, resolved), fetchManifestContent(target, resolved), fetchConfirmedContributor(resolved.contributorLogin, resolved), + probeLiveGateThresholds(target, resolved), ]); - const manifest = parseFocusManifestContent(manifestContent, "repo_file"); + const staticManifest = parseFocusManifestContent(manifestContent, "repo_file"); + const manifest = applyLiveGateThresholdsToManifest(staticManifest, liveGateThresholds); // Positional args match buildIssueQualityReport(repo, issues, pullRequests, fullName, bounties, collisions, recentMerged): // repo is the full RepositoryRecord from fetchRepositoryRecord (not a string); empty bounties/recentMerged // because this fetcher has no external bounty source and does not yet pull merge history. diff --git a/test/unit/miner-self-review-context.test.ts b/test/unit/miner-self-review-context.test.ts index 8cb0a37631..1a65178d06 100644 --- a/test/unit/miner-self-review-context.test.ts +++ b/test/unit/miner-self-review-context.test.ts @@ -4,8 +4,12 @@ vi.mock("@loopover/engine", async () => { return import("../../packages/loopover-engine/src/index"); }); -import { MAX_FOCUS_MANIFEST_BYTES } from "../../packages/loopover-engine/src/index"; -import { fetchSelfReviewContext } from "../../packages/loopover-miner/lib/self-review-context.js"; +import { MAX_FOCUS_MANIFEST_BYTES, parseFocusManifestContent } from "../../packages/loopover-engine/src/index"; +import { + applyLiveGateThresholdsToManifest, + fetchSelfReviewContext, + parseLiveGateThresholdFields, +} from "../../packages/loopover-miner/lib/self-review-context.js"; function jsonResponse(body: unknown, status = 200) { return { @@ -550,7 +554,7 @@ describe("fetchSelfReviewContext (#5145)", () => { "raw.githubusercontent.com": () => jsonResponse(null, 404), }); - await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never, requestTimeoutMs: 4000 }); + await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never, requestTimeoutMs: 4000, loopoverAuth: null }); expect(timeoutSpy.mock.calls.length).toBeGreaterThan(0); expect(timeoutSpy.mock.calls.every(([ms]) => ms === 4000)).toBe(true); @@ -567,10 +571,146 @@ describe("fetchSelfReviewContext (#5145)", () => { "raw.githubusercontent.com": () => jsonResponse(null, 404), }); - await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never, requestTimeoutMs: -3 }); + await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never, requestTimeoutMs: -3, loopoverAuth: null }); expect(timeoutSpy.mock.calls.length).toBeGreaterThan(0); expect(timeoutSpy.mock.calls.every(([ms]) => ms === 10_000)).toBe(true); timeoutSpy.mockRestore(); }); }); + +describe("live gate thresholds probe (#6487)", () => { + it("parseLiveGateThresholdFields accepts the field-limited snake_case payload and rejects empties", () => { + expect( + parseLiveGateThresholdFields({ + repoFullName: "acme/widgets", + confidence_floor: 0.91, + scope_cap_files: 8, + scope_cap_lines: 250, + }), + ).toEqual({ confidence_floor: 0.91, scope_cap_files: 8, scope_cap_lines: 250 }); + expect(parseLiveGateThresholdFields({ confidence_floor: 0.5, scope_cap_files: null, scope_cap_lines: null })).toEqual({ + confidence_floor: 0.5, + scope_cap_files: null, + scope_cap_lines: null, + }); + expect(parseLiveGateThresholdFields({ confidence_floor: null, scope_cap_files: null, scope_cap_lines: null })).toBeNull(); + expect(parseLiveGateThresholdFields({ confidence_floor: 1.5 })).toBeNull(); + expect(parseLiveGateThresholdFields(null)).toBeNull(); + }); + + it("applyLiveGateThresholdsToManifest raises readinessMinScore and prefers live scope caps", () => { + const base = parseFocusManifestContent("gate:\n readiness:\n mode: block\n minScore: 70\n size:\n mode: block\n maxFiles: 20\n maxLines: 500\n", "repo_file"); + const overlaid = applyLiveGateThresholdsToManifest(base, { + confidence_floor: 0.91, + scope_cap_files: 8, + scope_cap_lines: 250, + }); + expect(overlaid.gate.readinessMinScore).toBe(91); + expect(overlaid.gate.sizeMaxFiles).toBe(8); + expect(overlaid.gate.sizeMaxLines).toBe(250); + expect(overlaid.gate.duplicates).toBe(base.gate.duplicates); + // Raise-only: a lower live floor must not loosen the static reconstruction. + const notLoosened = applyLiveGateThresholdsToManifest(base, { + confidence_floor: 0.5, + scope_cap_files: null, + scope_cap_lines: null, + }); + expect(notLoosened.gate.readinessMinScore).toBe(70); + }); + + it("uses live ORB thresholds when the probe returns 200", async () => { + const fetchImpl = routedFetch({ + "/live-gate-thresholds": () => + jsonResponse({ + repoFullName: "acme/widgets", + confidence_floor: 0.91, + scope_cap_files: 8, + scope_cap_lines: 250, + }), + "/repos/acme/widgets/issues": () => jsonResponse([]), + "/repos/acme/widgets/pulls": () => jsonResponse([]), + "/repos/acme/widgets": () => jsonResponse(REPO_PAYLOAD), + "raw.githubusercontent.com": () => textResponse("gate:\n readiness:\n mode: block\n minScore: 70\n size:\n mode: block\n maxFiles: 20\n maxLines: 500\n"), + "api.gittensor.io/miners": () => jsonResponse([]), + }); + + const result = await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + loopoverAuth: { apiUrl: "https://orb.test", sessionToken: "mcp-test-token" }, + }); + expect(result.manifest.gate.readinessMinScore).toBe(91); + expect(result.manifest.gate.sizeMaxFiles).toBe(8); + expect(result.manifest.gate.sizeMaxLines).toBe(250); + }); + + it("falls back to static reconstruction when the probe 403s / 404s / times out", async () => { + const staticYml = "gate:\n readiness:\n mode: block\n minScore: 70\n size:\n mode: block\n maxFiles: 20\n maxLines: 500\n"; + + for (const respond of [ + () => jsonResponse({ error: "forbidden_repo" }, 403), + () => jsonResponse({ error: "live_gate_thresholds_not_found" }, 404), + () => { + throw new Error("timeout"); + }, + ]) { + const fetchImpl = routedFetch({ + "/live-gate-thresholds": respond, + "/repos/acme/widgets/issues": () => jsonResponse([]), + "/repos/acme/widgets/pulls": () => jsonResponse([]), + "/repos/acme/widgets": () => jsonResponse(REPO_PAYLOAD), + "raw.githubusercontent.com": () => textResponse(staticYml), + "api.gittensor.io/miners": () => jsonResponse([]), + }); + const result = await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + loopoverAuth: { apiUrl: "https://orb.test", sessionToken: "mcp-test-token" }, + }); + expect(result.manifest.gate.readinessMinScore).toBe(70); + expect(result.manifest.gate.sizeMaxFiles).toBe(20); + expect(result.manifest.gate.sizeMaxLines).toBe(500); + } + }); + + it("skips the probe entirely when loopoverAuth is null (fully-standalone path)", async () => { + const seen: string[] = []; + const fetchImpl = async (url: string) => { + seen.push(url); + if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD); + if (url.includes("raw.githubusercontent.com")) return textResponse("gate:\n duplicates: block\n"); + if (url.includes("api.gittensor.io/miners")) return jsonResponse([]); + return jsonResponse(null, 404); + }; + + const result = await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + loopoverAuth: null, + }); + expect(seen.some((url) => url.includes("live-gate-thresholds"))).toBe(false); + expect(result.manifest.gate.duplicates).toBe("block"); + }); + + it("uses the short probe timeout budget, not the GitHub request timeout", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + const fetchImpl = routedFetch({ + "/repos/acme/widgets/issues": () => jsonResponse([]), + "/repos/acme/widgets/pulls": () => jsonResponse([]), + "/repos/acme/widgets": () => jsonResponse(REPO_PAYLOAD), + "raw.githubusercontent.com": () => jsonResponse(null, 404), + "api.gittensor.io/miners": () => jsonResponse([]), + "/live-gate-thresholds": () => jsonResponse({ confidence_floor: 0.9, scope_cap_files: null, scope_cap_lines: null }), + }); + + await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + loopoverAuth: { apiUrl: "https://orb.test", sessionToken: "mcp-test-token" }, + requestTimeoutMs: 10_000, + liveGateProbeTimeoutMs: 350, + }); + + expect(timeoutSpy.mock.calls.some(([ms]) => ms === 350)).toBe(true); + timeoutSpy.mockRestore(); + }); +});