fix(stats): dedupe forked-session entries to stop double-counting

SessionManager.fork() and createBranchedSession() deep-copy a parent's
entries into the child JSONL — same entry_id, timestamp, model,
responseId, token counts, cost. Stats keyed uniqueness on
(session_file, entry_id), so both files contributed to request, token,
and cost totals.

insertMessageStats and insertUserMessageStats now skip rows whose
(entry_id, timestamp) already exists under a different session_file
(first-write-wins). A one-shot migration on initDb, gated by the
fork_dedupe_v1 meta sentinel, collapses pre-existing duplicate rows
in messages and user_messages by keeping the lowest-id row per
(entry_id, timestamp) group.

Fixes #3370
This commit is contained in:
roboomp
2026-06-24 14:14:25 +00:00
parent c053afa087
commit b5769a4e10
3 changed files with 378 additions and 7 deletions
+295
View File
@@ -0,0 +1,295 @@
import { Database } from "bun:sqlite";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { syncAllSessions } from "@oh-my-pi/omp-stats/aggregator";
import { closeDb, getOverallStats, getRecentRequests, initDb, insertMessageStats } from "@oh-my-pi/omp-stats/db";
import type { MessageStats } from "@oh-my-pi/omp-stats/types";
import { getAgentDir, getSessionsDir, getStatsDbPath, setAgentDir, TempDir } from "@oh-my-pi/pi-utils";
const XDG_KEYS = ["XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"] as const;
const originalConfigDir = process.env.PI_CONFIG_DIR;
const originalAgentDir = getAgentDir();
const originalXdg: Record<string, string | undefined> = {};
let tempDir: TempDir | null = null;
beforeEach(() => {
tempDir = TempDir.createSync("@pi-stats-fork-dedup-");
for (const key of XDG_KEYS) {
originalXdg[key] = process.env[key];
delete process.env[key];
}
const configDir = path.relative(os.homedir(), tempDir.join("config"));
process.env.PI_CONFIG_DIR = configDir;
setAgentDir(path.join(os.homedir(), configDir, "agent"));
});
afterEach(() => {
closeDb();
if (originalConfigDir === undefined) {
delete process.env.PI_CONFIG_DIR;
} else {
process.env.PI_CONFIG_DIR = originalConfigDir;
}
for (const key of XDG_KEYS) {
const prior = originalXdg[key];
if (prior === undefined) delete process.env[key];
else process.env[key] = prior;
}
setAgentDir(originalAgentDir);
tempDir?.removeSync();
tempDir = null;
});
interface AssistantOptions {
entryId: string;
parentId?: string | null;
timestamp: string;
}
function buildUserEntry(entryId: string, timestamp: string, content: string) {
return {
type: "message",
id: entryId,
parentId: null,
timestamp,
message: { role: "user", content },
};
}
function buildAssistantEntry(opts: AssistantOptions) {
return {
type: "message",
id: opts.entryId,
parentId: opts.parentId ?? null,
timestamp: opts.timestamp,
message: {
role: "assistant",
content: [{ type: "text", text: "ok" }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.4",
responseId: `resp-${opts.entryId}`,
usage: {
input: 100,
output: 50,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 150,
cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
},
stopReason: "stop",
timestamp: Date.parse(opts.timestamp),
duration: 10,
ttft: 5,
},
};
}
async function writeSessionFile(
folderSlug: string,
fileName: string,
header: { id: string; cwd: string; parentSession?: string },
entries: unknown[],
): Promise<string> {
const sessionDir = path.join(getSessionsDir(), folderSlug);
await fs.mkdir(sessionDir, { recursive: true });
const sessionFile = path.join(sessionDir, fileName);
const headerEntry = {
type: "session",
version: 3,
id: header.id,
timestamp: new Date().toISOString(),
cwd: header.cwd,
...(header.parentSession ? { parentSession: header.parentSession } : {}),
};
const lines = [headerEntry, ...entries].map(entry => JSON.stringify(entry)).join("\n");
await Bun.write(sessionFile, `${lines}\n`);
return sessionFile;
}
function makeStat(sessionFile: string, entryId: string, timestamp: number, premium = 0): MessageStats {
return {
sessionFile,
entryId,
folder: "/tmp/fork-dedup",
model: "gpt-5.4",
provider: "openai",
api: "openai-responses",
timestamp,
duration: 10,
ttft: 5,
stopReason: "stop",
errorMessage: null,
usage: {
input: 100,
output: 50,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 150,
premiumRequests: premium,
cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
},
agentType: "main",
};
}
describe("stats sync deduplicates forked-session entries", () => {
it("counts each provider request once even when a fork copied the entries", async () => {
const ts = new Date("2026-06-24T10:00:00.000Z").toISOString();
const userEntry = buildUserEntry("user01ab", ts, "hello");
const assistantEntry = buildAssistantEntry({ entryId: "asst01ab", parentId: "user01ab", timestamp: ts });
const parentFile = await writeSessionFile(
"--tmp--fork-dedup",
"01_parent.jsonl",
{ id: "parent00", cwd: "/tmp/project" },
[userEntry, assistantEntry],
);
// `SessionManager.createBranchedSession` / `forkFrom` deep-copy the
// parent's entries into the child file with `parentSession` in the
// header. Earlier stats sync keyed uniqueness on (session_file,
// entry_id), so both files contributed the same provider request to
// every aggregate.
await writeSessionFile(
"--tmp--fork-dedup",
"02_fork.jsonl",
{ id: "fork0000", cwd: "/tmp/project", parentSession: parentFile },
[userEntry, assistantEntry],
);
await syncAllSessions({ workers: 1 });
const assistantRequests = getRecentRequests(10).filter(r => r.entryId === "asst01ab");
expect(assistantRequests).toHaveLength(1);
expect(assistantRequests[0].sessionFile).toBe(parentFile);
const overall = getOverallStats();
expect(overall.totalRequests).toBe(1);
expect(overall.totalInputTokens).toBe(100);
expect(overall.totalOutputTokens).toBe(50);
expect(overall.totalCost).toBeCloseTo(0.003, 8);
});
it("admits new entries appended in the forked session", async () => {
const ts = new Date("2026-06-24T10:00:00.000Z").toISOString();
const userEntry = buildUserEntry("user01ab", ts, "hello");
const assistantEntry = buildAssistantEntry({ entryId: "asst01ab", parentId: "user01ab", timestamp: ts });
const parentFile = await writeSessionFile(
"--tmp--fork-dedup",
"01_parent.jsonl",
{ id: "parent00", cwd: "/tmp/project" },
[userEntry, assistantEntry],
);
// After fork, the child file appends a fresh user+assistant pair.
// Inherited entries must dedupe; new ones must still count.
const newTs = new Date("2026-06-24T10:05:00.000Z").toISOString();
const newUserEntry = buildUserEntry("user02cd", newTs, "follow-up");
const newAssistantEntry = buildAssistantEntry({
entryId: "asst02cd",
parentId: "user02cd",
timestamp: newTs,
});
await writeSessionFile(
"--tmp--fork-dedup",
"02_fork.jsonl",
{ id: "fork0000", cwd: "/tmp/project", parentSession: parentFile },
[userEntry, assistantEntry, newUserEntry, newAssistantEntry],
);
await syncAllSessions({ workers: 1 });
const overall = getOverallStats();
expect(overall.totalRequests).toBe(2);
expect(overall.totalInputTokens).toBe(200);
expect(overall.totalOutputTokens).toBe(100);
expect(overall.totalCost).toBeCloseTo(0.006, 8);
});
it("collapses pre-existing duplicate rows on init (one-shot migration)", async () => {
await initDb();
closeDb();
// Clear the fork-dedupe sentinel so the migration runs against the
// hand-inserted dupes, then plant two messages and two user_messages
// rows that share (entry_id, timestamp) under different session_files
// — exactly the shape `SessionManager.fork()` leaves behind.
const database = new Database(getStatsDbPath());
database.prepare("DELETE FROM meta WHERE key = ?").run("fork_dedupe_v1");
const ts = Date.now();
const insertMessage = database.prepare(`
INSERT INTO messages (
session_file, entry_id, folder, model, provider, api, timestamp,
duration, ttft, stop_reason, error_message,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, agent_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const sharedArgs = [
"/tmp/fork-dedup/project",
"gpt-5.4",
"openai",
"openai-responses",
ts,
10,
5,
"stop",
null,
100,
50,
0,
0,
150,
0,
0.001,
0.002,
0,
0,
0.003,
"main",
];
insertMessage.run("/tmp/parent.jsonl", "asst01ab", ...sharedArgs);
insertMessage.run("/tmp/fork.jsonl", "asst01ab", ...sharedArgs);
const insertUser = database.prepare(`
INSERT INTO user_messages (
session_file, entry_id, folder, timestamp, model, provider,
chars, words, yelling, profanity, anguish,
negation, repetition, blame
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const sharedUser = ["/tmp/fork-dedup/project", ts, "gpt-5.4", "openai", 5, 1, 0, 0, 0, 0, 0, 0];
insertUser.run("/tmp/parent.jsonl", "user01ab", ...sharedUser);
insertUser.run("/tmp/fork.jsonl", "user01ab", ...sharedUser);
database.close();
await initDb();
const messageRows = getRecentRequests(10).filter(r => r.entryId === "asst01ab");
expect(messageRows).toHaveLength(1);
expect(messageRows[0].sessionFile).toBe("/tmp/parent.jsonl");
const overall = getOverallStats();
expect(overall.totalRequests).toBe(1);
expect(overall.totalCost).toBeCloseTo(0.003, 8);
// The migration is idempotent. Re-running init must not delete the
// surviving row.
closeDb();
await initDb();
expect(getOverallStats().totalRequests).toBe(1);
});
it("still upserts premium_requests for re-syncs of the same session file", async () => {
await initDb();
const stat = makeStat("/tmp/session-a.jsonl", "asst01ab", Date.now(), 0);
insertMessageStats([stat]);
const upgraded = { ...stat, usage: { ...stat.usage, premiumRequests: 1 } };
insertMessageStats([upgraded]);
const requests = getRecentRequests(10).filter(r => r.entryId === "asst01ab");
expect(requests).toHaveLength(1);
expect(requests[0].usage.premiumRequests).toBe(1);
});
});