mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
505fb01cde
Two regressions surfaced after v1.0.108 hit users:
(A) Session counters lost after /ctx-upgrade (statusline shows "0 calls /
\$0.00 this session" even though the session was active and pre-upgrade
work happened). Promise "preserved across compact, restart & upgrade"
was broken on the SESSION dimension.
Root: commit b392c2f (May 2 concurrency refactor) dropped
persistToolCallCounter as collateral damage; the read-on-startup
counterpart was never written. DB schema (tool_calls table) and
methods (incrementToolCall, getToolCallTotals, getToolCallByTool)
stayed intact in src/session/db.ts but no consumer wired them.
Fix:
- New module src/session/persist-tool-calls.ts exporting
persistToolCallCounter() (write-back) + restoreSessionStats()
(read-on-startup) + RestoredSessionStats interface.
- server.ts trackResponse() now setImmediates a write-back after
every MCP tool response.
- server.ts main() hydrates sessionStats.calls,
sessionStats.bytesReturned, and sessionStats.sessionStart from
SessionDB BEFORE the heartbeat setInterval registers — counters
survive process restart, --continue, /ctx-upgrade.
- Both helpers are pure, never throw, no-op cleanly when DB missing.
- Centralized getSessionDbPath() helper at src/server.ts so write-back,
restore, and any future SessionDB caller hash to the same file
under worktree isolation.
Tests: 9 in tests/session/server-tool-call-wiring.test.ts (4 write-back
+ 5 restore + edge cases like multi-session "latest wins" + sessionStart
hydration).
(B) ctx_stats output cosmetic gaps: Auto-memory section had no bars,
Persistent memory bars were silently empty when the project's local
SessionDB had no events even though the section header promises
lifetime data.
Fix in src/session/analytics.ts:
- renderAutoMemory now renders a 20-char dataBar(count, maxCount)
next to each prefix row (project / memory / feedback / user /
reference) with width proportional to the largest count.
- LifetimeStats interface gains categoryCounts: Record<string, number>.
- getLifetimeStats aggregates "SELECT category, COUNT(*) GROUP BY
category" across every per-project SessionDB during its existing
iteration loop (try/catch tolerates older schemas without the
column).
- renderProjectMemory prefers opts.lifetime?.categoryCounts (lifetime
scope) over pm.by_category (project scope) for the bar block —
matches the lifetime framing of the section header. Falls back to
project-local data when lifetime aggregate is empty (legacy callers).
Tests: 1 in lifetime-stats.test.ts (categoryCounts aggregated across
multiple SessionDBs) + 2 in stats-output-format.test.ts (auto-memory
proportional bars + persistent memory bars from lifetime when project
DB empty).
Verification:
- Full vitest: 2320 pass / 9 fail (8 OpenCode + 1 cli.mcp.json — all
pre-existing, verified by git stash baseline run).
- npx tsc --noEmit: clean.
- 0 new failures introduced by either fix.
175 lines
6.3 KiB
TypeScript
175 lines
6.3 KiB
TypeScript
/**
|
|
* server-tool-call-wiring — Bug regression for /ctx-upgrade reset
|
|
*
|
|
* Originally fixed in 4742160 (added persistToolCallCounter), then
|
|
* dropped as collateral by the b392c2f concurrency refactor. Same-session
|
|
* statusline now flips to `0 calls / $0.00` after `/ctx-upgrade` because
|
|
* the counter only lives in process memory and the read-side restore was
|
|
* never wired in.
|
|
*
|
|
* Cycle 1 (write-back): the server-side helper must persist (toolName, bytes)
|
|
* into the SessionDB tool_calls table so a fresh process can recover them.
|
|
*
|
|
* Cycle 2 (restore-on-startup): on boot, the server must hydrate
|
|
* sessionStats.calls / bytesReturned / sessionStart from the latest session
|
|
* row so the statusline picks up where the prior PID left off.
|
|
*/
|
|
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
import { afterAll, describe, expect, test } from "vitest";
|
|
import { SessionDB } from "../../src/session/db.js";
|
|
import {
|
|
persistToolCallCounter,
|
|
restoreSessionStats,
|
|
} from "../../src/session/persist-tool-calls.js";
|
|
|
|
const tmpDirs: string[] = [];
|
|
|
|
afterAll(() => {
|
|
for (const dir of tmpDirs) {
|
|
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
function freshDbPath(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "tool-call-wiring-"));
|
|
tmpDirs.push(dir);
|
|
return join(dir, "session.db");
|
|
}
|
|
|
|
function seedSession(dbPath: string, sessionId: string): void {
|
|
const db = new SessionDB({ dbPath });
|
|
try {
|
|
// ensureSession writes into session_meta with started_at = now()
|
|
db.ensureSession(sessionId, "/fake/project");
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
describe("Cycle 1 — persistToolCallCounter (write-back)", () => {
|
|
test("no-op when DB file does not exist (best-effort, never throws)", () => {
|
|
const missing = join(tmpdir(), `does-not-exist-${Date.now()}.db`);
|
|
expect(() => persistToolCallCounter(missing, "ctx_search", 1024)).not.toThrow();
|
|
expect(existsSync(missing)).toBe(false);
|
|
});
|
|
|
|
test("no-op when DB exists but no session_meta row yet", () => {
|
|
const dbPath = freshDbPath();
|
|
// Create the schema only — no session row.
|
|
new SessionDB({ dbPath }).close();
|
|
expect(() => persistToolCallCounter(dbPath, "ctx_search", 100)).not.toThrow();
|
|
// Counter should remain empty since there is no session to attach to.
|
|
const db = new SessionDB({ dbPath });
|
|
try {
|
|
const stats = db.getToolCallStats("any-id");
|
|
expect(stats.totalCalls).toBe(0);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test("writes (toolName, bytes) under the latest session id", () => {
|
|
const dbPath = freshDbPath();
|
|
seedSession(dbPath, "sess-write-1");
|
|
|
|
persistToolCallCounter(dbPath, "ctx_search", 1024);
|
|
persistToolCallCounter(dbPath, "ctx_search", 2048);
|
|
persistToolCallCounter(dbPath, "ctx_fetch_and_index", 4096);
|
|
|
|
const db = new SessionDB({ dbPath });
|
|
try {
|
|
const stats = db.getToolCallStats("sess-write-1");
|
|
expect(stats.totalCalls).toBe(3);
|
|
expect(stats.totalBytesReturned).toBe(1024 + 2048 + 4096);
|
|
expect(stats.byTool.ctx_search?.calls).toBe(2);
|
|
expect(stats.byTool.ctx_search?.bytesReturned).toBe(3072);
|
|
expect(stats.byTool.ctx_fetch_and_index?.calls).toBe(1);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test("survives across SessionDB instance close — process-restart proxy", () => {
|
|
const dbPath = freshDbPath();
|
|
seedSession(dbPath, "sess-write-2");
|
|
|
|
// First "process": writes 2 calls.
|
|
persistToolCallCounter(dbPath, "ctx_execute", 500);
|
|
persistToolCallCounter(dbPath, "ctx_execute", 700);
|
|
|
|
// Second "process": new SessionDB instance, same dbPath.
|
|
const db = new SessionDB({ dbPath });
|
|
try {
|
|
const stats = db.getToolCallStats("sess-write-2");
|
|
expect(stats.totalCalls).toBe(2);
|
|
expect(stats.totalBytesReturned).toBe(1200);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Cycle 2 — restoreSessionStats (read-side)", () => {
|
|
test("returns null when DB file is missing", () => {
|
|
const missing = join(tmpdir(), `does-not-exist-${Date.now()}.db`);
|
|
expect(restoreSessionStats(missing)).toBeNull();
|
|
});
|
|
|
|
test("returns null when DB exists but no sessions recorded", () => {
|
|
const dbPath = freshDbPath();
|
|
new SessionDB({ dbPath }).close();
|
|
expect(restoreSessionStats(dbPath)).toBeNull();
|
|
});
|
|
|
|
test("hydrates calls + bytesReturned from latest session", () => {
|
|
const dbPath = freshDbPath();
|
|
seedSession(dbPath, "sess-restore-1");
|
|
|
|
// Pre-seed prior tool-call rows (simulates work done by previous PID).
|
|
persistToolCallCounter(dbPath, "ctx_search", 1500);
|
|
persistToolCallCounter(dbPath, "ctx_search", 2500);
|
|
persistToolCallCounter(dbPath, "ctx_execute", 800);
|
|
|
|
const restored = restoreSessionStats(dbPath);
|
|
expect(restored).not.toBeNull();
|
|
expect(restored!.calls.ctx_search).toBe(2);
|
|
expect(restored!.calls.ctx_execute).toBe(1);
|
|
expect(restored!.bytesReturned.ctx_search).toBe(4000);
|
|
expect(restored!.bytesReturned.ctx_execute).toBe(800);
|
|
});
|
|
|
|
test("hydrates sessionStart from session_meta.started_at", () => {
|
|
const dbPath = freshDbPath();
|
|
seedSession(dbPath, "sess-restore-2");
|
|
persistToolCallCounter(dbPath, "ctx_search", 1);
|
|
|
|
const restored = restoreSessionStats(dbPath);
|
|
expect(restored).not.toBeNull();
|
|
// sessionStart must be a finite epoch-ms in the past (or "now"-ish).
|
|
expect(typeof restored!.sessionStart).toBe("number");
|
|
expect(restored!.sessionStart).toBeGreaterThan(0);
|
|
expect(restored!.sessionStart).toBeLessThanOrEqual(Date.now() + 1000);
|
|
});
|
|
|
|
test("returns latest session only when multiple exist", () => {
|
|
const dbPath = freshDbPath();
|
|
seedSession(dbPath, "sess-old");
|
|
// Newer session — `started_at DESC LIMIT 1` should win.
|
|
// SQLite datetime() resolves to 1-second granularity; pause briefly so the
|
|
// newer row gets a strictly later timestamp.
|
|
const wait = Date.now() + 1100;
|
|
while (Date.now() < wait) { /* spin */ }
|
|
seedSession(dbPath, "sess-new");
|
|
|
|
persistToolCallCounter(dbPath, "ctx_search", 100);
|
|
|
|
const restored = restoreSessionStats(dbPath);
|
|
expect(restored).not.toBeNull();
|
|
// Latest session only has one ctx_search row.
|
|
expect(restored!.calls.ctx_search).toBe(1);
|
|
});
|
|
});
|