diff --git a/packages/loopover-miner/lib/event-ledger.ts b/packages/loopover-miner/lib/event-ledger.ts index b85fc4f29..361af3fc7 100644 --- a/packages/loopover-miner/lib/event-ledger.ts +++ b/packages/loopover-miner/lib/event-ledger.ts @@ -46,6 +46,7 @@ export type EventLedger = { dbPath: string; appendEvent(event: AppendEventInput): LedgerEntry; readEvents(filter?: ReadEventsFilter): LedgerEntry[]; + latestSeq(): number; purgeByRepo(repoFullName: string): number; close(): void; }; @@ -178,6 +179,7 @@ export function initEventLedger(dbPath: string = resolveEventLedgerDbPath()): Ev pruneLedgerByRetention(db, EVENT_LEDGER_RETENTION_SPEC, resolveLedgerRetentionPolicy(), Date.now()); const nextSeqStatement = db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM miner_event_ledger"); + const latestSeqStatement = db.prepare("SELECT COALESCE(MAX(seq), 0) AS latestSeq FROM miner_event_ledger"); const appendStatement = db.prepare(` INSERT INTO miner_event_ledger (seq, event_type, repo_full_name, payload_json, created_at) VALUES (?, ?, ?, ?, ?) @@ -233,6 +235,12 @@ export function initEventLedger(dbPath: string = resolveEventLedgerDbPath()): Ev } return rows.map((row) => rowToEntry(asEventDbRow(row))); }, + // The cursor-priming read (#10008): a single indexed MAX(seq) lookup, so callers that only need "where did + // the ledger leave off" (runLoop's startup cursor) never materialize or JSON.parse a single row. + latestSeq() { + const { latestSeq } = latestSeqStatement.get() as unknown as { latestSeq: number }; + return latestSeq; + }, // Explicit, operator-invoked right-to-be-forgotten purge (#5564) — never runs automatically. See the // IMMUTABILITY INVARIANT note above: this is a deliberate, separate exception, not a normal ledger write. // Requires a real repoFullName (unlike the optional filter above): a purge must never silently no-op on a @@ -261,6 +269,10 @@ export function readEvents(filter?: ReadEventsFilter): LedgerEntry[] { return getDefaultEventLedger().readEvents(filter); } +export function latestSeq(): number { + return getDefaultEventLedger().latestSeq(); +} + export function closeDefaultEventLedger(): void { if (!defaultEventLedger) return; defaultEventLedger.close(); diff --git a/packages/loopover-miner/lib/loop-cli.ts b/packages/loopover-miner/lib/loop-cli.ts index 5d21d3ebb..01a7c7fa5 100644 --- a/packages/loopover-miner/lib/loop-cli.ts +++ b/packages/loopover-miner/lib/loop-cli.ts @@ -339,7 +339,7 @@ export async function runLoop(args: string[], options: RunLoopOptions = {}): Pro let usage: GovernorCapUsage = governorState.loadCapUsage(); const cycles: LoopCycleSummary[] = []; - let sinceSeq = eventLedger.readEvents({}).at(-1)?.seq ?? 0; + let sinceSeq = eventLedger.latestSeq(); let haltReason: string | null = null; let amsPolicyWithWarnings: { source: string; warnings: string[] } | null = null; diff --git a/test/unit/miner-event-ledger.test.ts b/test/unit/miner-event-ledger.test.ts index 601f8d4cf..fa70045ec 100644 --- a/test/unit/miner-event-ledger.test.ts +++ b/test/unit/miner-event-ledger.test.ts @@ -7,6 +7,7 @@ import { appendEvent, closeDefaultEventLedger, initEventLedger, + latestSeq, readEvents, resolveEventLedgerDbPath, } from "../../packages/loopover-miner/lib/event-ledger"; @@ -79,6 +80,16 @@ describe("loopover-miner event ledger (#2290)", () => { expect(new Set(seqs).size).toBe(50); // all unique }); + it("(#10008) latestSeq() reports the current MAX(seq) without reading any row", () => { + const ledger = tempLedger(); + expect(ledger.latestSeq()).toBe(0); + ledger.appendEvent({ type: "discovered_issue", payload: { i: 1 } }); + ledger.appendEvent({ type: "discovered_issue", payload: { i: 2 } }); + const third = ledger.appendEvent({ type: "discovered_issue", payload: { i: 3 } }); + expect(ledger.latestSeq()).toBe(3); + expect(ledger.latestSeq()).toBe(third.seq); + }); + it("filters by repoFullName", () => { const ledger = tempLedger(); ledger.appendEvent({ type: "discovered_issue", repoFullName: "o/a", payload: {} }); @@ -199,6 +210,7 @@ describe("loopover-miner event ledger (#2290)", () => { try { const entry = appendEvent({ type: "discovered_issue", repoFullName: "acme/widgets", payload: { issueNumber: 1 } }); expect(readEvents()).toEqual([entry]); + expect(latestSeq()).toBe(entry.seq); // First close releases the real singleton; the second hits the already-closed no-op early return. closeDefaultEventLedger(); closeDefaultEventLedger(); diff --git a/test/unit/miner-loop-cli.test.ts b/test/unit/miner-loop-cli.test.ts index 41c3d237f..d30af5c30 100644 --- a/test/unit/miner-loop-cli.test.ts +++ b/test/unit/miner-loop-cli.test.ts @@ -757,6 +757,129 @@ describe("runLoop (#5135)", () => { expect(printed.cycles[0]).toMatchObject({ outcome: "attempted", prNumber: null, ciConclusion: null }); }); + it("REGRESSION (#10008): runLoop primes its ledger cursor from latestSeq() without reading every event", async () => { + const { governorLedger, portfolioQueue, runState, governorState } = tempStores(); + // A spy double standing in for the injected EventLedger seam (RunLoopOptions.initEventLedger) -- latestSeq is + // the only method the initial-halt priming path may call; readEvents materializing/parsing every row is + // exactly the regression this test pins. + const eventLedger = { + dbPath: "", + appendEvent: vi.fn(), + readEvents: vi.fn(() => []), + latestSeq: vi.fn(() => 42), + purgeByRepo: vi.fn(() => 0), + close: vi.fn(), + }; + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const runDiscoverSpy = vi.fn(); + const runAttemptSpy = vi.fn(); + + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + ...readyLoopOptions({ checkMinerKillSwitch: () => ({ scope: "global" as const, active: true }) }), + }); + + expect(exitCode).toBe(0); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.haltReason).toBe("kill_switch_global"); + expect(eventLedger.latestSeq).toHaveBeenCalledTimes(1); + expect(eventLedger.readEvents).not.toHaveBeenCalled(); + }); + + it("(#10008) primes sinceSeq from a non-empty ledger's latestSeq() before the first buildLoopClosureSummary call", async () => { + const { governorLedger, portfolioQueue, runState, governorState } = tempStores(); + const eventLedger = { + dbPath: "", + appendEvent: vi.fn(), + readEvents: vi.fn(() => []), + latestSeq: vi.fn(() => 7), + purgeByRepo: vi.fn(() => 0), + close: vi.fn(), + }; + vi.spyOn(console, "log").mockImplementation(() => undefined); + const item = { repoFullName: "acme/widgets", identifier: "issue:7" }; + const runDiscoverSpy = primeOnceDiscover(portfolioQueue, item); + const runAttemptSpy = vi.fn(async (_args: string[], options?: Record) => { + (options?.onResult as ((result: unknown) => void) | undefined)?.({ + outcome: "attempt_submitted", + repoFullName: "acme/widgets", + issueNumber: 7, + minerLogin: "alice", + base: "main", + mode: "dry_run", + attemptId: "loop-attempt-sinceseq-primed", + submissionMode: "observe", + totalTurnsUsed: 1, + totalCostUsd: 0, + iterationsUsed: 1, + execResult: { action: "open_pr", stdout: "no url printed here\n", stderr: "", code: 0, timedOut: false }, + }); + return 0; + }); + const buildLoopClosureSummarySpy = vi.fn((_sources: unknown, _options?: unknown) => ({ sinceSeq: 7, lastSeq: 7 })); + + await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "1", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + buildLoopClosureSummary: buildLoopClosureSummarySpy, + ...readyLoopOptions(), + }); + + expect(buildLoopClosureSummarySpy).toHaveBeenCalledTimes(1); + expect(buildLoopClosureSummarySpy.mock.calls[0]?.[1]).toMatchObject({ sinceSeq: 7 }); + }); + + it("(#10008) primes sinceSeq at 0 from an empty ledger's latestSeq() before the first buildLoopClosureSummary call", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const item = { repoFullName: "acme/widgets", identifier: "issue:9" }; + const runDiscoverSpy = primeOnceDiscover(portfolioQueue, item); + const runAttemptSpy = vi.fn(async (_args: string[], options?: Record) => { + (options?.onResult as ((result: unknown) => void) | undefined)?.({ + outcome: "attempt_submitted", + repoFullName: "acme/widgets", + issueNumber: 9, + minerLogin: "alice", + base: "main", + mode: "dry_run", + attemptId: "loop-attempt-sinceseq-empty", + submissionMode: "observe", + totalTurnsUsed: 1, + totalCostUsd: 0, + iterationsUsed: 1, + execResult: { action: "open_pr", stdout: "no url printed here\n", stderr: "", code: 0, timedOut: false }, + }); + return 0; + }); + const buildLoopClosureSummarySpy = vi.fn((_sources: unknown, _options?: unknown) => ({ sinceSeq: 0, lastSeq: 0 })); + + await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "1", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + buildLoopClosureSummary: buildLoopClosureSummarySpy, + ...readyLoopOptions(), + }); + + expect(buildLoopClosureSummarySpy).toHaveBeenCalledTimes(1); + expect(buildLoopClosureSummarySpy.mock.calls[0]?.[1]).toMatchObject({ sinceSeq: 0 }); + }); + it("REGRESSION: a repeatedly-blocked (non-permanent) outcome requeues the item and eventually halts on real non-convergence, not forever", async () => { const { eventLedger, governorLedger, portfolioQueue, runState, governorState, paths } = tempStores(); vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/test/unit/miner-mcp-contract.test.ts b/test/unit/miner-mcp-contract.test.ts index 54bf52b3e..b1c17ff02 100644 --- a/test/unit/miner-mcp-contract.test.ts +++ b/test/unit/miner-mcp-contract.test.ts @@ -113,6 +113,7 @@ const benignEventLedger: MinerMcpServerOptions["initEventLedger"] = () => ({ dbPath: "", appendEvent: readThrows, readEvents: () => [], + latestSeq: readThrows, purgeByRepo: readThrows, close() {}, }); @@ -197,6 +198,7 @@ const READ_ONLY_TOOLS: ToolContract[] = [ createdAt: "2026-01-01T00:00:00.000Z", }, ], + latestSeq: readThrows, purgeByRepo: readThrows, close() {}, }), @@ -207,6 +209,7 @@ const READ_ONLY_TOOLS: ToolContract[] = [ dbPath: "", appendEvent: readThrows, readEvents: readThrows, + latestSeq: readThrows, purgeByRepo: readThrows, close() {}, }), @@ -252,15 +255,15 @@ const READ_ONLY_TOOLS: ToolContract[] = [ args: {}, valid: { initPredictionLedger: () => ({ readPredictions: () => [], close() {} }), - initEventLedger: () => ({ dbPath: "", appendEvent: readThrows, readEvents: () => [], purgeByRepo: readThrows, close() {} }), + initEventLedger: () => ({ dbPath: "", appendEvent: readThrows, readEvents: () => [], latestSeq: readThrows, purgeByRepo: readThrows, close() {} }), }, missing: { initPredictionLedger: openerThrows, - initEventLedger: () => ({ dbPath: "", appendEvent: readThrows, readEvents: () => [], purgeByRepo: readThrows, close() {} }), + initEventLedger: () => ({ dbPath: "", appendEvent: readThrows, readEvents: () => [], latestSeq: readThrows, purgeByRepo: readThrows, close() {} }), }, corrupt: { initPredictionLedger: () => ({ readPredictions: readThrows, close() {} }), - initEventLedger: () => ({ dbPath: "", appendEvent: readThrows, readEvents: () => [], purgeByRepo: readThrows, close() {} }), + initEventLedger: () => ({ dbPath: "", appendEvent: readThrows, readEvents: () => [], latestSeq: readThrows, purgeByRepo: readThrows, close() {} }), }, excluded: [], },