ab388b4f8c
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
180 lines
8.3 KiB
TypeScript
180 lines
8.3 KiB
TypeScript
/**
|
|
* Trace-file index tests: mtime-gated reconciliation (an unchanged tree costs zero
|
|
* directory scans), registration-time classification, gate blind spots recovered by
|
|
* the consumers' force-retry, write-time registration (import) and delete coherence.
|
|
*/
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { sessionMeta, userText } from "@prismshadow/penguin-core";
|
|
import type { SessionMetaPayload } from "@prismshadow/penguin-core";
|
|
import { makeTempRoot, makeTraceHarness, writeTraceFile } from "./helpers.js";
|
|
|
|
const P = "project-i";
|
|
const A = "agent-i";
|
|
const S1 = "session-2026-07-05-10-00-00-aabb0001";
|
|
const S2 = "session-2026-07-06-09-00-00-aabb0002";
|
|
const OLD = "session-2026-07-01-08-00-00-aabb0003";
|
|
|
|
/**
|
|
* Ages the given paths' mtimes past the reconciler's FRESH_MS window: freshly written
|
|
* dirs are deliberately never cached as clean (same-tick change hazard), so steady-state
|
|
* assertions first make the tree look quiet, exactly like a tree written minutes ago.
|
|
*/
|
|
async function backdate(...paths: string[]): Promise<void> {
|
|
// A FIXED past instant, so repeated backdates are idempotent: the blind-spot test
|
|
// resets a changed dir to exactly the mtime the gate cached, which is precisely the
|
|
// "old dir changed without moving any gated mtime" scenario.
|
|
const old = new Date("2026-01-01T00:00:00.000Z");
|
|
for (const p of paths) {
|
|
await fs.utimes(p, old, old);
|
|
}
|
|
}
|
|
|
|
function tracesRoot(root: string): string {
|
|
return path.join(root, P, "agents", A, "traces");
|
|
}
|
|
|
|
function meta(sessionId: string, over: Partial<SessionMetaPayload> = {}): SessionMetaPayload {
|
|
return {
|
|
session_id: sessionId,
|
|
model_id: "m1",
|
|
provider: "custom",
|
|
model_context_window: 1000,
|
|
system_prompt: "sp",
|
|
tools: [],
|
|
agent_state: "/tmp/a",
|
|
workspace: "/ws/one",
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe("trace-index", () => {
|
|
let root: string;
|
|
let h: ReturnType<typeof makeTraceHarness>;
|
|
|
|
beforeEach(async () => {
|
|
root = await makeTempRoot();
|
|
h = makeTraceHarness(root);
|
|
});
|
|
afterEach(async () => {
|
|
h.close();
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
});
|
|
|
|
it("registers external files on reconcile and classifies each Session once; an unchanged tree costs zero directory scans", async () => {
|
|
await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [
|
|
sessionMeta(meta(S1, { source: "subagent" })),
|
|
userText("你好,请检查代码"),
|
|
]);
|
|
await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-05"));
|
|
await h.traceIndex.reconcileAgent(P, A);
|
|
const rows = h.traceIndex.repo.listFilesByAgent(P, A);
|
|
expect(
|
|
rows.map((r) => ({ sessionId: r.sessionId, fileIndex: r.fileIndex, date: r.date })),
|
|
).toEqual([{ sessionId: S1, fileIndex: 1, date: "2026-07-05" }]);
|
|
expect(rows[0]!.sizeBytes).toBeGreaterThan(0);
|
|
// Registration-time facts: origin / workspace / first-prompt title, one head-read.
|
|
const facts = h.traceIndex.repo.getSession(S1);
|
|
expect(facts?.source).toBe("subagent");
|
|
expect(facts?.workspace).toBe("/ws/one");
|
|
expect(facts?.title).toBe("你好,请检查代码");
|
|
expect(facts?.metaRead).toBe(true);
|
|
expect(h.sources.get(S1)).toBe("subagent");
|
|
expect(h.traceIndex.counters.headReads).toBe(1);
|
|
|
|
// Steady state: reconciling an unchanged tree is pure gate stats — no readdir, no reads.
|
|
const scans = h.traceIndex.counters.dirScans;
|
|
await h.traceIndex.reconcileAgent(P, A);
|
|
await h.traceIndex.reconcileAgent(P, A);
|
|
expect(h.traceIndex.counters.dirScans).toBe(scans);
|
|
expect(h.traceIndex.counters.headReads).toBe(1);
|
|
|
|
// A new shard in the same (newest) date dir moves that dir's mtime: the gate notices.
|
|
await writeTraceFile(root, P, A, "2026-07-05", S2, 1, [
|
|
sessionMeta(meta(S2)),
|
|
userText("second session"),
|
|
]);
|
|
await h.traceIndex.reconcileAgent(P, A);
|
|
expect(h.traceIndex.repo.listFilesByAgent(P, A)).toHaveLength(2);
|
|
expect(h.traceIndex.counters.dirScans).toBe(scans + 1);
|
|
expect(h.traceIndex.repo.getSession(S2)?.metaRead).toBe(true);
|
|
});
|
|
|
|
it("a write into an OLD date dir slips past the gate; the consumers' force-retry (locateAll) recovers it", async () => {
|
|
// Two date dirs indexed; the gate then watches the root + the NEWEST dir only.
|
|
await writeTraceFile(root, P, A, "2026-07-01", OLD, 1, [userText("old day")]);
|
|
await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [userText("new day")]);
|
|
await backdate(
|
|
tracesRoot(root),
|
|
path.join(tracesRoot(root), "2026-07-01"),
|
|
path.join(tracesRoot(root), "2026-07-05"),
|
|
);
|
|
await h.traceIndex.reconcileAgent(P, A);
|
|
expect(h.traceIndex.repo.listFilesByAgent(P, A)).toHaveLength(2);
|
|
|
|
// External writer appends a SECOND shard into the old dir: neither gated mtime
|
|
// (root / newest dir) moves. Backdating the old dir again keeps its change invisible
|
|
// even to a freshness-triggered re-diff — the pure gate blind spot.
|
|
await writeTraceFile(root, P, A, "2026-07-01", OLD, 2, [userText("late arrival")]);
|
|
await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-01"));
|
|
await h.traceIndex.reconcileAgent(P, A);
|
|
expect(h.traceIndex.repo.listFilesBySession(P, A, OLD)).toHaveLength(1); // gate blind spot
|
|
|
|
// A consumer that misses must force-reconcile and retry rather than 404: the
|
|
// service's listTraceFiles goes through locateAll, which does exactly that when the
|
|
// requested index is absent... locateAll's whole-session miss is the trigger, so
|
|
// exercise it via a session the index does not know at all.
|
|
const LATE = "session-2026-07-01-09-00-00-aabb0009";
|
|
await writeTraceFile(root, P, A, "2026-07-01", LATE, 1, [userText("unseen session")]);
|
|
await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-01"));
|
|
const files = await h.service.listTraceFiles(P, A, LATE);
|
|
expect(files.map((f) => f.index)).toEqual([1]);
|
|
// The forced pass also swept in the other blind-spot shard.
|
|
expect(h.traceIndex.repo.listFilesBySession(P, A, OLD)).toHaveLength(2);
|
|
});
|
|
|
|
it("import registers synchronously (rows + facts, no re-read); Session delete removes files and rows", async () => {
|
|
const IMP = "session-2026-07-03-12-00-00-aabb0004";
|
|
const lines = [
|
|
sessionMeta(meta(IMP, { source: "schedule", workspace: "/ws/imp" })),
|
|
userText("imported prompt"),
|
|
]
|
|
.map((m) => JSON.stringify({ ...m, timestamp: "2026-07-03T12:00:00.000Z" }))
|
|
.join("\n");
|
|
const headReads = h.traceIndex.counters.headReads;
|
|
const res = await h.service.importTraceFile(P, A, lines);
|
|
expect(res).toEqual({ sessionId: IMP, index: 1, date: "2026-07-03" });
|
|
// Registered without any head-read (the records were already in memory).
|
|
expect(h.traceIndex.counters.headReads).toBe(headReads);
|
|
expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toHaveLength(1);
|
|
const facts = h.traceIndex.repo.getSession(IMP);
|
|
expect(facts?.source).toBe("schedule");
|
|
expect(facts?.workspace).toBe("/ws/imp");
|
|
expect(facts?.title).toBe("imported prompt");
|
|
|
|
await h.service.deleteSessionTraces(P, A, IMP);
|
|
expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toEqual([]);
|
|
expect(h.traceIndex.repo.getSession(IMP)).toBeNull();
|
|
// The files are gone from disk too, and a fresh reconcile does not resurrect rows.
|
|
await h.traceIndex.reconcileAgent(P, A, { force: true });
|
|
expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toEqual([]);
|
|
});
|
|
|
|
it("externally deleted files and date dirs fall out of the index on the next changed-dir pass", async () => {
|
|
await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [userText("a")]);
|
|
await writeTraceFile(root, P, A, "2026-07-05", S1, 2, [userText("b")]);
|
|
await h.traceIndex.reconcileAgent(P, A);
|
|
expect(h.traceIndex.repo.listFilesBySession(P, A, S1)).toHaveLength(2);
|
|
|
|
const dir = path.join(root, P, "agents", A, "traces", "2026-07-05");
|
|
await fs.rm(path.join(dir, `${S1}_002.jsonl`));
|
|
await h.traceIndex.reconcileAgent(P, A); // the dir's mtime moved: rescanned
|
|
expect(h.traceIndex.repo.listFilesBySession(P, A, S1).map((r) => r.fileIndex)).toEqual([1]);
|
|
|
|
await fs.rm(dir, { recursive: true });
|
|
await h.traceIndex.reconcileAgent(P, A); // the root's mtime moved: date dir purged
|
|
expect(h.traceIndex.repo.listFilesByAgent(P, A)).toEqual([]);
|
|
});
|
|
});
|