Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/loopover-miner/lib/event-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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 (?, ?, ?, ?, ?)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion packages/loopover-miner/lib/loop-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
12 changes: 12 additions & 0 deletions test/unit/miner-event-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
appendEvent,
closeDefaultEventLedger,
initEventLedger,
latestSeq,
readEvents,
resolveEventLedgerDbPath,
} from "../../packages/loopover-miner/lib/event-ledger";
Expand Down Expand Up @@ -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: {} });
Expand Down Expand Up @@ -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();
Expand Down
123 changes: 123 additions & 0 deletions test/unit/miner-loop-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => {
(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<string, unknown>) => {
(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);
Expand Down
9 changes: 6 additions & 3 deletions test/unit/miner-mcp-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const benignEventLedger: MinerMcpServerOptions["initEventLedger"] = () => ({
dbPath: "",
appendEvent: readThrows,
readEvents: () => [],
latestSeq: readThrows,
purgeByRepo: readThrows,
close() {},
});
Expand Down Expand Up @@ -197,6 +198,7 @@ const READ_ONLY_TOOLS: ToolContract[] = [
createdAt: "2026-01-01T00:00:00.000Z",
},
],
latestSeq: readThrows,
purgeByRepo: readThrows,
close() {},
}),
Expand All @@ -207,6 +209,7 @@ const READ_ONLY_TOOLS: ToolContract[] = [
dbPath: "",
appendEvent: readThrows,
readEvents: readThrows,
latestSeq: readThrows,
purgeByRepo: readThrows,
close() {},
}),
Expand Down Expand Up @@ -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: [],
},
Expand Down