From 3b014aef78f06f336fbb17cfdfbc45873878bd2f Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Sun, 12 Jul 2026 18:52:24 +0400 Subject: [PATCH 1/4] feat(mcp): wire gittensory_feasibility_gate's claimStatus to the local claim ledger (#5157) gittensory_feasibility_gate took claimStatus as a purely caller-supplied string, never actually connected to real local claim state, even though packages/gittensory-miner/lib/claim-ledger.js already tracks it. Adds optional repoFullName/issueNumber inputs. When both are supplied and a local gittensory-miner install's claim ledger DB file exists, claimStatus is now read from that ledger (an active claim on the exact issue -> "claimed", otherwise "unclaimed") instead of trusting the caller-supplied value. The DB file's existence is checked BEFORE opening anything, so this advisory-only tool never creates the ledger as a side effect -- it stays strictly read-only, never calls recordClaim/releaseClaim/expireClaim, and never gains any ability to block, cancel, or override a claim or attempt; real claim-conflict authority remains entirely with the maintainer-only path. Falls back to today's caller-supplied-string behavior unchanged when repo/issue are omitted, the ledger file doesn't exist, or gittensory-miner isn't resolvable at all. The feasibility calculator's own decision logic is untouched -- this only changes where claimStatus is sourced from. --- packages/gittensory-mcp/bin/gittensory-mcp.js | 50 +++++- test/unit/mcp-feasibility-gate.test.ts | 145 ++++++++++++++++++ 2 files changed, 190 insertions(+), 5 deletions(-) diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index b98ebe8408..203474a7db 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -201,8 +201,46 @@ const feasibilityGateShape = { duplicateClusterRisk: z.enum(["none", "low", "medium", "high"]), issueStatus: z.enum(["ready", "needs_proof", "hold", "do_not_use", "duplicate", "invalid", "missing"]), found: z.boolean().optional(), + // Optional: when both are supplied AND a local AMS install's claim ledger is present (#5157), claimStatus is + // read from that ledger instead of trusting this caller-supplied value. Omitting either falls back to + // today's caller-supplied-string behavior unchanged. + repoFullName: z.string().min(1).optional(), + issueNumber: z.number().int().positive().optional(), }; +/** + * Read-only lookup of the caller's own claim status from a local gittensory-miner install's claim ledger + * (#5157), so `gittensory_feasibility_gate` isn't purely trusting a caller-supplied `claimStatus` string. + * Returns `null` (never throws) when there is nothing to look up: no repo/issue supplied, no local AMS + * install detected (the ledger DB file doesn't exist -- checked via `existsSync` BEFORE opening anything, so + * this never creates the ledger file/table as a side effect of an advisory-only tool), or the sibling + * `@jsonbored/gittensory-miner` package isn't resolvable at all (a standalone gittensory-mcp install with no + * miner alongside it). The caller falls back to its own supplied `claimStatus` in every `null` case. This + * tool never calls recordClaim/releaseClaim/expireClaim -- read-only, and it never gains any ability to + * block, cancel, or override a claim or attempt; real claim-conflict authority stays entirely with #4848's + * maintainer-only path. + */ +async function resolveLedgerClaimStatus(repoFullName, issueNumber) { + if (!repoFullName || !issueNumber) return null; + try { + const { resolveClaimLedgerDbPath, openClaimLedger } = await import("@jsonbored/gittensory-miner/lib/claim-ledger.js"); + const dbPath = resolveClaimLedgerDbPath(); + if (!existsSync(dbPath)) return null; + const ledger = openClaimLedger(dbPath); + try { + const activeClaims = ledger.listActiveClaims(repoFullName); + return activeClaims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; + } finally { + ledger.close(); + } + } catch { + /* v8 ignore next -- gittensory-miner genuinely unresolvable (not installed alongside gittensory-mcp); not + reproducible in this monorepo's workspace-hoisted test environment, where the sibling package always + resolves */ + return null; + } +} + const findOpportunitiesShape = { targets: z .array( @@ -521,7 +559,7 @@ const STDIO_TOOL_DESCRIPTORS = [ }, { name: "gittensory_feasibility_gate", - description: "Pure local go/raise/avoid feasibility verdict from claim status, duplicate-cluster risk, and issue quality/lifecycle status — the same discriminants the analyze-phase feasibility gate branches on. No API round-trip.", + description: "Pure local go/raise/avoid feasibility verdict from claim status, duplicate-cluster risk, and issue quality/lifecycle status — the same discriminants the analyze-phase feasibility gate branches on. When repoFullName/issueNumber are supplied and a local gittensory-miner install's claim ledger is present, claimStatus is read from that ledger instead of the caller-supplied value; otherwise falls back to the caller-supplied claimStatus unchanged. Advisory-only — never blocks, cancels, or overrides a claim or attempt; real claim-conflict resolution authority stays with the maintainer-only path. No API round-trip.", }, ]; @@ -1194,11 +1232,13 @@ server.registerTool( description: stdioToolDescription("gittensory_feasibility_gate"), inputSchema: feasibilityGateShape, }, - ({ claimStatus, duplicateClusterRisk, issueStatus, found }) => - toolResult( + async ({ claimStatus, duplicateClusterRisk, issueStatus, found, repoFullName, issueNumber }) => { + const ledgerClaimStatus = await resolveLedgerClaimStatus(repoFullName, issueNumber); + return toolResult( "Gittensory feasibility gate.", - buildFeasibilityVerdict({ claimStatus, duplicateClusterRisk, issueStatus, found }), - ), + buildFeasibilityVerdict({ claimStatus: ledgerClaimStatus ?? claimStatus, duplicateClusterRisk, issueStatus, found }), + ); + }, ); // ── Resources: decision-pack, doctor, compatibility, changelog (#292) ───────── diff --git a/test/unit/mcp-feasibility-gate.test.ts b/test/unit/mcp-feasibility-gate.test.ts index d636d8751e..aaca00f1d8 100644 --- a/test/unit/mcp-feasibility-gate.test.ts +++ b/test/unit/mcp-feasibility-gate.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { openClaimLedger } from "../../packages/gittensory-miner/lib/claim-ledger.js"; const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js"); @@ -96,3 +97,147 @@ describe("gittensory_feasibility_gate stdio tool (#4270)", () => { expect(text).not.toMatch(/hotkey|coldkey|wallet|mnemonic|payout|reward/i); }); }); + +describe("gittensory_feasibility_gate: local claim-ledger sourcing (#5157)", () => { + let ledgerRoot: string; + let ledgerDbPath: string; + let ledgerClient: Client; + let ledgerTransport: StdioClientTransport; + let ledgerConfigDir: string; + + async function connectWithLedgerDb(dbPath: string | undefined) { + ledgerConfigDir = mkdtempSync(join(tmpdir(), "gittensory-feasibility-gate-ledger-")); + const env: Record = { ...(process.env as Record), GITTENSORY_CONFIG_DIR: ledgerConfigDir }; + if (dbPath !== undefined) env.GITTENSORY_MINER_CLAIM_LEDGER_DB = dbPath; + else delete env.GITTENSORY_MINER_CLAIM_LEDGER_DB; + ledgerTransport = new StdioClientTransport({ command: "node", args: [bin, "--stdio"], env }); + ledgerClient = new Client({ name: "feasibility-gate-ledger-test", version: "0.0.1" }); + await ledgerClient.connect(ledgerTransport); + } + + beforeEach(() => { + ledgerRoot = mkdtempSync(join(tmpdir(), "gittensory-feasibility-gate-ledger-db-")); + ledgerDbPath = join(ledgerRoot, "claim-ledger.sqlite3"); + }); + + afterEach(async () => { + await ledgerClient?.close().catch(() => undefined); + if (ledgerConfigDir) rmSync(ledgerConfigDir, { recursive: true, force: true }); + if (ledgerRoot) rmSync(ledgerRoot, { recursive: true, force: true }); + }); + + it("regression: prefers ledger-backed truth (claimed) over a contradicting caller-supplied claimStatus", async () => { + const ledger = openClaimLedger(ledgerDbPath); + ledger.claimIssue("acme/widgets", 42, "in progress"); + ledger.close(); + await connectWithLedgerDb(ledgerDbPath); + + const result = await ledgerClient.callTool({ + name: "gittensory_feasibility_gate", + arguments: { + claimStatus: "unclaimed", // caller-supplied, contradicts the real ledger state + duplicateClusterRisk: "none", + issueStatus: "ready", + repoFullName: "acme/widgets", + issueNumber: 42, + }, + }); + expect(result.isError).toBeFalsy(); + // claimStatus: "claimed" triggers a "raise" verdict (claim_status_claimed) in the calculator -- the + // important assertion is that THIS ran, not the "go" path the caller's "unclaimed" lie would have produced. + const data = result.structuredContent as Record; + expect(data.verdict).toBe("raise"); + expect(data.raiseReasons).toEqual(["claim_status_claimed"]); + }); + + it("sources claimStatus: unclaimed from the ledger when no active claim matches the issue, overriding a contradicting caller-supplied value", async () => { + const ledger = openClaimLedger(ledgerDbPath); + ledger.claimIssue("acme/widgets", 99, "someone else's issue"); // a DIFFERENT issue is claimed + ledger.close(); + await connectWithLedgerDb(ledgerDbPath); + + const result = await ledgerClient.callTool({ + name: "gittensory_feasibility_gate", + arguments: { + claimStatus: "solved", // caller-supplied, would normally trigger an avoid verdict + duplicateClusterRisk: "none", + issueStatus: "ready", + repoFullName: "acme/widgets", + issueNumber: 42, // NOT the claimed issue -- ledger says unclaimed + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.verdict).toBe("go"); + }); + + it("falls back to the caller-supplied claimStatus unchanged when the ledger DB file does not exist (no local install detected)", async () => { + await connectWithLedgerDb(join(ledgerRoot, "does-not-exist.sqlite3")); + + const result = await ledgerClient.callTool({ + name: "gittensory_feasibility_gate", + arguments: { + claimStatus: "solved", + duplicateClusterRisk: "none", + issueStatus: "ready", + repoFullName: "acme/widgets", + issueNumber: 42, + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.verdict).toBe("avoid"); + expect(data.avoidReasons).toEqual(["claim_status_solved"]); + }); + + it("falls back to the caller-supplied claimStatus unchanged when repoFullName/issueNumber are omitted", async () => { + const ledger = openClaimLedger(ledgerDbPath); + ledger.claimIssue("acme/widgets", 42, "irrelevant -- no repo/issue supplied"); + ledger.close(); + await connectWithLedgerDb(ledgerDbPath); + + const result = await ledgerClient.callTool({ + name: "gittensory_feasibility_gate", + arguments: { claimStatus: "solved", duplicateClusterRisk: "none", issueStatus: "ready" }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.verdict).toBe("avoid"); + expect(data.avoidReasons).toEqual(["claim_status_solved"]); + }); + + it("invariant: never writes to the claim ledger and never gains blocking/override authority (advisory-only output shape unchanged)", async () => { + const setupLedger = openClaimLedger(ledgerDbPath); + setupLedger.claimIssue("acme/widgets", 42, "pre-existing claim"); + const before = setupLedger.listClaims(); + setupLedger.close(); + await connectWithLedgerDb(ledgerDbPath); + + const result = await ledgerClient.callTool({ + name: "gittensory_feasibility_gate", + arguments: { + claimStatus: "unclaimed", + duplicateClusterRisk: "none", + issueStatus: "ready", + repoFullName: "acme/widgets", + issueNumber: 42, + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(Object.keys(data).sort()).toEqual(["avoidReasons", "raiseReasons", "summary", "verdict"]); + + const inspectLedger = openClaimLedger(ledgerDbPath); + const after = inspectLedger.listClaims(); + inspectLedger.close(); + expect(after).toEqual(before); + }); + + it("tool description documents the ledger-sourcing behavior and advisory-only guarantee", async () => { + await connectWithLedgerDb(undefined); + const { tools } = await ledgerClient.listTools(); + const tool = tools.find((t) => t.name === "gittensory_feasibility_gate"); + expect(tool?.description).toContain("Advisory-only"); + expect(tool?.description).toContain("local gittensory-miner install's claim ledger"); + }); +}); From 5470abced01f53cc6f04619c1d4289def4d82f9e Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Sun, 12 Jul 2026 19:22:59 +0400 Subject: [PATCH 2/4] fix(mcp): report claimStatus 'unknown' on a ledger read failure instead of silently trusting the caller (#5157) resolveLedgerClaimStatus's catch block previously swallowed every error from opening/querying the ledger, not just "package not found" -- so a real local install whose ledger DB file exists but is corrupt, locked, or unreadable would silently fall back to a caller-supplied claimStatus that might contradict the (unreadable) ground truth. Narrows the fallback: module-resolution failure or a missing DB file still return null (fall back to the caller-supplied value -- correct, since there's genuinely nothing local to check). A DB file that exists but fails to open/query now returns the existing "unknown" claimStatus value instead, which the calculator already treats as a neutral signal -- honest about the read failure rather than guessing. --- packages/gittensory-mcp/bin/gittensory-mcp.js | 45 ++++++++++++------- test/unit/mcp-feasibility-gate.test.ts | 29 +++++++++++- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 203474a7db..6c1740ebab 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -201,7 +201,7 @@ const feasibilityGateShape = { duplicateClusterRisk: z.enum(["none", "low", "medium", "high"]), issueStatus: z.enum(["ready", "needs_proof", "hold", "do_not_use", "duplicate", "invalid", "missing"]), found: z.boolean().optional(), - // Optional: when both are supplied AND a local AMS install's claim ledger is present (#5157), claimStatus is + // Optional: when both are supplied AND a local gittensory-miner install's claim ledger is present (#5157), claimStatus is // read from that ledger instead of trusting this caller-supplied value. Omitting either falls back to // today's caller-supplied-string behavior unchanged. repoFullName: z.string().min(1).optional(), @@ -211,21 +211,34 @@ const feasibilityGateShape = { /** * Read-only lookup of the caller's own claim status from a local gittensory-miner install's claim ledger * (#5157), so `gittensory_feasibility_gate` isn't purely trusting a caller-supplied `claimStatus` string. - * Returns `null` (never throws) when there is nothing to look up: no repo/issue supplied, no local AMS - * install detected (the ledger DB file doesn't exist -- checked via `existsSync` BEFORE opening anything, so - * this never creates the ledger file/table as a side effect of an advisory-only tool), or the sibling - * `@jsonbored/gittensory-miner` package isn't resolvable at all (a standalone gittensory-mcp install with no - * miner alongside it). The caller falls back to its own supplied `claimStatus` in every `null` case. This - * tool never calls recordClaim/releaseClaim/expireClaim -- read-only, and it never gains any ability to - * block, cancel, or override a claim or attempt; real claim-conflict authority stays entirely with #4848's - * maintainer-only path. + * Returns `null` (fall back to the caller-supplied value unchanged) only when there is genuinely nothing to + * look up: no repo/issue supplied, no local install detected (the ledger DB file doesn't exist -- checked + * via `existsSync` BEFORE opening anything, so this never creates the ledger file/table as a side effect of + * an advisory-only tool), or the sibling `@jsonbored/gittensory-miner` package isn't resolvable at all (a + * standalone gittensory-mcp install with no miner alongside it). When the ledger DB file DOES exist (a real + * local install IS present) but reading it fails -- corrupt, locked, permission denied -- this returns + * `"unknown"` rather than silently falling back to a caller-supplied string that ground-truth data (which we + * know exists but can't currently read) might contradict; `"unknown"` is an existing, honest claimStatus + * value the calculator already understands, not a guess. This tool never calls + * recordClaim/releaseClaim/expireClaim -- read-only, and it never gains any ability to block, cancel, or + * override a claim or attempt; real claim-conflict authority stays entirely with #4848's maintainer-only + * path. */ async function resolveLedgerClaimStatus(repoFullName, issueNumber) { if (!repoFullName || !issueNumber) return null; + let claimLedgerModule; + try { + claimLedgerModule = await import("@jsonbored/gittensory-miner/lib/claim-ledger.js"); + } catch { + /* v8 ignore next -- gittensory-miner genuinely unresolvable (not installed alongside gittensory-mcp); not + reproducible in this monorepo's workspace-hoisted test environment, where the sibling package always + resolves */ + return null; + } + const { resolveClaimLedgerDbPath, openClaimLedger } = claimLedgerModule; + const dbPath = resolveClaimLedgerDbPath(); + if (!existsSync(dbPath)) return null; try { - const { resolveClaimLedgerDbPath, openClaimLedger } = await import("@jsonbored/gittensory-miner/lib/claim-ledger.js"); - const dbPath = resolveClaimLedgerDbPath(); - if (!existsSync(dbPath)) return null; const ledger = openClaimLedger(dbPath); try { const activeClaims = ledger.listActiveClaims(repoFullName); @@ -234,10 +247,10 @@ async function resolveLedgerClaimStatus(repoFullName, issueNumber) { ledger.close(); } } catch { - /* v8 ignore next -- gittensory-miner genuinely unresolvable (not installed alongside gittensory-mcp); not - reproducible in this monorepo's workspace-hoisted test environment, where the sibling package always - resolves */ - return null; + // The ledger DB file exists (a real local install IS present) but reading it failed -- corrupt, locked, + // or a permission error. Never silently trust a caller-supplied string that could contradict ground + // truth we know exists but can't currently read; "unknown" surfaces that honestly instead of guessing. + return "unknown"; } } diff --git a/test/unit/mcp-feasibility-gate.test.ts b/test/unit/mcp-feasibility-gate.test.ts index aaca00f1d8..32d3346afc 100644 --- a/test/unit/mcp-feasibility-gate.test.ts +++ b/test/unit/mcp-feasibility-gate.test.ts @@ -1,6 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -190,6 +190,33 @@ describe("gittensory_feasibility_gate: local claim-ledger sourcing (#5157)", () expect(data.avoidReasons).toEqual(["claim_status_solved"]); }); + it("regression: reports claimStatus 'unknown' (never silently trusts the caller-supplied value) when the ledger DB file exists but is unreadable/corrupt", async () => { + // A real local install IS present (the DB file exists) but it isn't a valid SQLite file -- reading it + // must fail loudly into "unknown", not silently fall back to the caller's (unverifiable) claimStatus. + // Using a caller-supplied "solved" here is the discriminating case: if the old buggy behavior (silently + // falling back to the caller-supplied value on ANY read error) were still present, this would produce an + // "avoid" verdict (claim_status_solved). "unknown" triggers neither avoid nor raise on its own, so the + // fixed behavior produces "go" instead -- these two outcomes are distinguishable, unlike using + // "unclaimed" as the caller-supplied value (which would coincidentally also yield "go" either way). + writeFileSync(ledgerDbPath, "not a sqlite database"); + await connectWithLedgerDb(ledgerDbPath); + + const result = await ledgerClient.callTool({ + name: "gittensory_feasibility_gate", + arguments: { + claimStatus: "solved", + duplicateClusterRisk: "none", + issueStatus: "ready", + repoFullName: "acme/widgets", + issueNumber: 42, + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.verdict).toBe("go"); + expect(data.avoidReasons).toEqual([]); + }); + it("falls back to the caller-supplied claimStatus unchanged when repoFullName/issueNumber are omitted", async () => { const ledger = openClaimLedger(ledgerDbPath); ledger.claimIssue("acme/widgets", 42, "irrelevant -- no repo/issue supplied"); From 301f062345c5a589f6c51b8bafd9a3348697444d Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Sun, 12 Jul 2026 19:49:03 +0400 Subject: [PATCH 3/4] fix(miner,mcp): use a driver-enforced read-only ledger open, not the writable openClaimLedger (#5157) resolveLedgerClaimStatus opened the claim ledger via openClaimLedger, which always runs CREATE TABLE IF NOT EXISTS plus a schema-version stamp on open -- a write, even against a file that merely exists but is empty/uninitialized. That contradicted this advisory-only tool's strictly-read-only guarantee: existsSync only confirms a file is there, not that its schema has already been created. Adds openClaimLedgerReadOnly to claim-ledger.js: opens the DB with node:sqlite's own `readOnly: true` mode (verified empirically that the lowercase `readonly` key is silently ignored and opens read-write anyway -- a real footgun, now called out in a comment) and touches the filesystem in no other way -- no mkdir/chmod, no CREATE TABLE, no migrations. This keeps the schema/query logic centralized in claim-ledger.js while getting a guarantee enforced by the SQLite driver itself, not just by which methods happen to get called. gittensory-mcp.js's resolveLedgerClaimStatus now uses this instead of openClaimLedger. --- packages/gittensory-mcp/bin/gittensory-mcp.js | 32 +++++++++++-------- .../gittensory-miner/lib/claim-ledger.d.ts | 8 +++++ packages/gittensory-miner/lib/claim-ledger.js | 32 +++++++++++++++++++ test/unit/mcp-feasibility-gate.test.ts | 32 +++++++++++++++++++ 4 files changed, 90 insertions(+), 14 deletions(-) diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 6c1740ebab..aeb9c7ec33 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -213,16 +213,19 @@ const feasibilityGateShape = { * (#5157), so `gittensory_feasibility_gate` isn't purely trusting a caller-supplied `claimStatus` string. * Returns `null` (fall back to the caller-supplied value unchanged) only when there is genuinely nothing to * look up: no repo/issue supplied, no local install detected (the ledger DB file doesn't exist -- checked - * via `existsSync` BEFORE opening anything, so this never creates the ledger file/table as a side effect of - * an advisory-only tool), or the sibling `@jsonbored/gittensory-miner` package isn't resolvable at all (a - * standalone gittensory-mcp install with no miner alongside it). When the ledger DB file DOES exist (a real - * local install IS present) but reading it fails -- corrupt, locked, permission denied -- this returns - * `"unknown"` rather than silently falling back to a caller-supplied string that ground-truth data (which we - * know exists but can't currently read) might contradict; `"unknown"` is an existing, honest claimStatus - * value the calculator already understands, not a guess. This tool never calls - * recordClaim/releaseClaim/expireClaim -- read-only, and it never gains any ability to block, cancel, or - * override a claim or attempt; real claim-conflict authority stays entirely with #4848's maintainer-only - * path. + * via `existsSync` BEFORE opening anything), or the sibling `@jsonbored/gittensory-miner` package isn't + * resolvable at all (a standalone gittensory-mcp install with no miner alongside it). When the ledger DB + * file DOES exist (a real local install IS present) but reading it fails -- corrupt, locked, permission + * denied -- this returns `"unknown"` rather than silently falling back to a caller-supplied string that + * ground-truth data (which we know exists but can't currently read) might contradict; `"unknown"` is an + * existing, honest claimStatus value the calculator already understands, not a guess. + * + * Uses `openClaimLedgerReadOnly` (not `openClaimLedger`), which opens the DB file in SQLite's own `readonly` + * mode -- a DRIVER-ENFORCED guarantee, not just a by-convention one. `openClaimLedger` always runs + * `CREATE TABLE IF NOT EXISTS` plus a schema-version stamp on open, which IS a write even against a file + * that merely exists but is empty/uninitialized; this tool never calls that, `recordClaim`, + * `releaseClaim`, or `expireClaim` -- it never gains any ability to block, cancel, or override a claim or + * attempt; real claim-conflict authority stays entirely with #4848's maintainer-only path. */ async function resolveLedgerClaimStatus(repoFullName, issueNumber) { if (!repoFullName || !issueNumber) return null; @@ -235,11 +238,11 @@ async function resolveLedgerClaimStatus(repoFullName, issueNumber) { resolves */ return null; } - const { resolveClaimLedgerDbPath, openClaimLedger } = claimLedgerModule; + const { resolveClaimLedgerDbPath, openClaimLedgerReadOnly } = claimLedgerModule; const dbPath = resolveClaimLedgerDbPath(); if (!existsSync(dbPath)) return null; try { - const ledger = openClaimLedger(dbPath); + const ledger = openClaimLedgerReadOnly(dbPath); try { const activeClaims = ledger.listActiveClaims(repoFullName); return activeClaims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; @@ -248,8 +251,9 @@ async function resolveLedgerClaimStatus(repoFullName, issueNumber) { } } catch { // The ledger DB file exists (a real local install IS present) but reading it failed -- corrupt, locked, - // or a permission error. Never silently trust a caller-supplied string that could contradict ground - // truth we know exists but can't currently read; "unknown" surfaces that honestly instead of guessing. + // a permission error, or not actually a claim-ledger database. Never silently trust a caller-supplied + // string that could contradict ground truth we know exists but can't currently read; "unknown" surfaces + // that honestly instead of guessing. return "unknown"; } } diff --git a/packages/gittensory-miner/lib/claim-ledger.d.ts b/packages/gittensory-miner/lib/claim-ledger.d.ts index 206eb15a18..bdead193c2 100644 --- a/packages/gittensory-miner/lib/claim-ledger.d.ts +++ b/packages/gittensory-miner/lib/claim-ledger.d.ts @@ -37,6 +37,14 @@ export function resolveClaimLedgerDbPath(env?: Record { + // A valid, but EMPTY, SQLite file (no miner_claims table) -- created directly via node:sqlite, never + // through openClaimLedger, so no schema/version-stamp write has ever happened here. If this tool + // accidentally used the writable openClaimLedger (which always runs CREATE TABLE IF NOT EXISTS + a + // schema-version stamp on open) instead of the read-only opener, this empty file would gain a + // miner_claims table as an undocumented side effect of an advisory-only lookup. + const db = new DatabaseSync(ledgerDbPath); + db.close(); + await connectWithLedgerDb(ledgerDbPath); + + const result = await ledgerClient.callTool({ + name: "gittensory_feasibility_gate", + arguments: { + claimStatus: "unclaimed", + duplicateClusterRisk: "none", + issueStatus: "ready", + repoFullName: "acme/widgets", + issueNumber: 42, + }, + }); + expect(result.isError).toBeFalsy(); + + const inspect = new DatabaseSync(ledgerDbPath, { readOnly: true }); + const tables = inspect + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name); + inspect.close(); + expect(tables).toEqual([]); + }); + it("falls back to the caller-supplied claimStatus unchanged when repoFullName/issueNumber are omitted", async () => { const ledger = openClaimLedger(ledgerDbPath); ledger.claimIssue("acme/widgets", 42, "irrelevant -- no repo/issue supplied"); From dc52111e38766603b0f32b5ee8876c31336b406b Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Sun, 12 Jul 2026 20:06:06 +0400 Subject: [PATCH 4/4] fix(miner): close the read-only DB handle on setup failure, add direct unit coverage for openClaimLedgerReadOnly (#5157) openClaimLedgerReadOnly's db.prepare() can throw when the expected table is missing (a file exists but isn't a real claim ledger) -- before this fix, that left the already-opened DatabaseSync handle unclosed, leaking a file handle every time this path was hit. Also adds direct unit tests for openClaimLedgerReadOnly in the same test process (the prior tests only exercised it indirectly through a spawned MCP subprocess, which coverage instrumentation can't see) so Codecov's patch-coverage gate can actually measure this new function: listing active claims, scoping by repo, an empty-result case, malformed-input rejection, opening a nonexistent path, opening an existing-but-empty file (the resource-leak repro), and a regression pinning the exact readOnly-vs-readonly key gotcha the read-only guarantee depends on. --- packages/gittensory-miner/lib/claim-ledger.js | 14 +++- test/unit/miner-claim-ledger.test.ts | 84 +++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/gittensory-miner/lib/claim-ledger.js b/packages/gittensory-miner/lib/claim-ledger.js index 61a7c16363..62e77d6896 100644 --- a/packages/gittensory-miner/lib/claim-ledger.js +++ b/packages/gittensory-miner/lib/claim-ledger.js @@ -192,9 +192,17 @@ export function openClaimLedgerReadOnly(dbPath) { // and opens read-write anyway, defeating the entire point of this function. Verified empirically: a write // via a `{ readonly: true }` connection succeeds with no error. const db = new DatabaseSync(resolvedPath, { readOnly: true }); - const listActiveStatement = db.prepare( - "SELECT * FROM miner_claims WHERE repo_full_name = ? AND status = 'active' ORDER BY id ASC", - ); + let listActiveStatement; + try { + listActiveStatement = db.prepare( + "SELECT * FROM miner_claims WHERE repo_full_name = ? AND status = 'active' ORDER BY id ASC", + ); + } catch (error) { + // The table doesn't exist (a file exists at this path but isn't a real claim ledger) -- close the + // connection we already opened before rethrowing, so this never leaks a file handle. + db.close(); + throw error; + } return { dbPath: resolvedPath, listActiveClaims(repoFullName) { diff --git a/test/unit/miner-claim-ledger.test.ts b/test/unit/miner-claim-ledger.test.ts index 1319b61125..583958de29 100644 --- a/test/unit/miner-claim-ledger.test.ts +++ b/test/unit/miner-claim-ledger.test.ts @@ -9,6 +9,7 @@ import { closeDefaultClaimLedger, listActiveClaims, openClaimLedger, + openClaimLedgerReadOnly, resolveClaimLedgerDbPath, } from "../../packages/gittensory-miner/lib/claim-ledger.js"; @@ -211,6 +212,89 @@ describe("gittensory-miner claim ledger (#2314)", () => { ).toThrow(); writable.close(); }); + + describe("openClaimLedgerReadOnly (#5157)", () => { + it("lists active claims matching the writable ledger's own state, scoped to the given repo", () => { + const ledger = tempLedger(); + ledger.claimIssue("acme/widgets", 42, "in progress"); + ledger.claimIssue("acme/widgets", 7); + ledger.claimIssue("other/repo", 1); + ledger.releaseClaim("acme/widgets", 7); + + const readOnly = openClaimLedgerReadOnly(ledger.dbPath); + try { + expect(readOnly.listActiveClaims("acme/widgets")).toEqual([ + { + id: expect.any(Number), + repoFullName: "acme/widgets", + issueNumber: 42, + claimedAt: expect.any(String), + status: "active", + note: "in progress", + }, + ]); + expect(readOnly.listActiveClaims("other/repo").map((c) => c.issueNumber)).toEqual([1]); + } finally { + readOnly.close(); + } + }); + + it("returns an empty array when no active claim matches the repo", () => { + const ledger = tempLedger(); + ledger.claimIssue("acme/widgets", 42); + const readOnly = openClaimLedgerReadOnly(ledger.dbPath); + try { + expect(readOnly.listActiveClaims("no/such-repo")).toEqual([]); + } finally { + readOnly.close(); + } + }); + + it("rejects a malformed repoFullName the same way the writable ledger does", () => { + const ledger = tempLedger(); + const readOnly = openClaimLedgerReadOnly(ledger.dbPath); + try { + expect(() => readOnly.listActiveClaims("no-slash")).toThrow("invalid_repo_full_name"); + } finally { + readOnly.close(); + } + }); + + it("throws when opening a path that doesn't exist (callers must existsSync-check first)", () => { + const root = tempRoot(); + expect(() => openClaimLedgerReadOnly(join(root, "does-not-exist.sqlite3"))).toThrow(); + }); + + it("regression: the underlying connection genuinely enforces read-only at the driver level (the readOnly vs. readonly key gotcha)", () => { + // Pins the exact bug this module's own code comment documents: node:sqlite silently ignores the + // lowercase `readonly` option key (opens read-write with no error), and only camelCase `readOnly` + // actually enforces it. If claim-ledger.js's implementation ever regresses back to the wrong key, + // this test starts failing because the write below would then silently succeed instead of throwing. + const ledger = tempLedger(); + ledger.claimIssue("acme/widgets", 42); + const readOnlyConnection = new DatabaseSync(ledger.dbPath, { readOnly: true }); + try { + expect(() => readOnlyConnection.exec("DELETE FROM miner_claims")).toThrow(/readonly/i); + } finally { + readOnlyConnection.close(); + } + }); + + it("never creates the schema on an existing-but-empty SQLite file (no CREATE TABLE side effect)", () => { + const root = tempRoot(); + const dbPath = join(root, "empty.sqlite3"); + const setup = new DatabaseSync(dbPath); + setup.close(); + + expect(() => openClaimLedgerReadOnly(dbPath)).toThrow(); + + const inspect = new DatabaseSync(dbPath, { readOnly: true }); + const tables = (inspect.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name: string }>) + .map((row) => row.name); + inspect.close(); + expect(tables).toEqual([]); + }); + }); }); function tempRoot() {