diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index c6f518c33..4edf3bdf1 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -1304,7 +1304,8 @@ async function probeMode(args: CliArgs): Promise { } /** - * Disambiguate colliding page slugs before staging (#2724). + * Disambiguate colliding page slugs before staging (#2724), consulting the + * ingest state so an assignment is stable across RUNS, not just within one. * * Two distinct source files can map to one transcript slug * (transcripts///-): a session resumed @@ -1314,27 +1315,57 @@ async function probeMode(args: CliArgs): Promise { * N-1 of N, and the staged-vs-collected reconciliation guard (correctly) fails * the whole batch. It repeats every run until the inputs age out of the window. * - * Fix: keep the first occurrence's slug; give each later collider a stable - * `-` suffix. Deterministic (same source path → same slug - * across runs, so incremental dedup and gbrain's session_id dedup still line - * up) and it mutates slug + page_slug together so every downstream consumer - * (writeStaged, readNewFailures mapping, state recording) computes the same key. + * Within a run: keep the first occurrence's slug; give each later collider a + * stable `-` suffix, mutating slug + page_slug together so + * every downstream consumer (writeStaged, readNewFailures mapping, state + * recording) computes the same key. + * + * Across runs (the state consult): "first occurrence" is walk-order-dependent, + * so without memory a source that got the suffixed slug in one run could take + * the bare slug in the next (its old collider aged out or was skipped as + * unchanged) — gbrain then holds the SAME transcript under two slugs. Worse, + * a NEW collider could claim a bare slug that state shows belongs to an + * unchanged (not-restaged) source, silently overwriting that page in gbrain. + * So: a slug recorded in state stays owned by its source_path — a re-ingested + * source keeps its recorded slug verbatim, and a fresh assignment never takes + * a slug owned by a DIFFERENT source. Legacy states that recorded the same + * slug for two sources (pre-#2724 overwrites) resolve first-owner-wins and + * self-heal on the next state write. */ -export function disambiguateSlugs(pages: PreparedPage[]): void { - const seen = new Set(); +export function disambiguateSlugs( + pages: PreparedPage[], + state?: { sessions: Record }, +): void { + // slug → owning source_path, from prior runs. First writer wins on legacy + // duplicate records; state key order is stable (re-read from the same file). + const ownedBy = new Map(); + for (const [src, rec] of Object.entries(state?.sessions ?? {})) { + if (rec?.page_slug && !ownedBy.has(rec.page_slug)) ownedBy.set(rec.page_slug, src); + } + const claimed = new Set(); + const available = (slug: string, src: string) => + !claimed.has(slug) && (!ownedBy.has(slug) || ownedBy.get(slug) === src); + for (const p of pages) { - if (!seen.has(p.slug)) { - seen.add(p.slug); + const recorded = state?.sessions[p.source_path]?.page_slug; + if (recorded && !claimed.has(recorded) && ownedBy.get(recorded) === p.source_path) { + claimed.add(recorded); + p.slug = recorded; + p.page_slug = recorded; continue; } - const suffix = createHash("sha256").update(p.source_path).digest("hex").slice(0, 8); - let candidate = `${p.slug}-${suffix}`; - // Guarantee uniqueness even if a prior page already took the suffixed slug - // (two colliders sharing a source_path-hash prefix is astronomically - // unlikely, but a stuck source is not the place to trust luck). - let n = 1; - while (seen.has(candidate)) candidate = `${p.slug}-${suffix}-${n++}`; - seen.add(candidate); + let candidate = p.slug; + if (!available(candidate, p.source_path)) { + const suffix = createHash("sha256").update(p.source_path).digest("hex").slice(0, 8); + candidate = `${p.slug}-${suffix}`; + // Guarantee uniqueness even if a prior page already took the suffixed + // slug (two colliders sharing a source_path-hash prefix is + // astronomically unlikely, but a stuck source is not the place to + // trust luck). + let n = 1; + while (!available(candidate, p.source_path)) candidate = `${p.slug}-${suffix}-${n++}`; + } + claimed.add(candidate); p.slug = candidate; p.page_slug = candidate; } @@ -1527,8 +1558,9 @@ function preparePages( // Colliding path-derived slugs would overwrite in the staging dir, so two // source files land as one page and the staged-vs-collected guard fails the - // whole batch every run (#2724: 887 staged → 0 ingested). Disambiguate before staging. - disambiguateSlugs(finalPrepared); + // whole batch every run (#2724: 887 staged → 0 ingested). Disambiguate + // before staging, consulting state so assignments hold across runs. + disambiguateSlugs(finalPrepared, state); // Derived from the FINAL set: partial counts must describe pages that are // actually eligible and within the limit, not the whole scanned corpus. diff --git a/test/regression-transcript-slug-collision.test.ts b/test/regression-transcript-slug-collision.test.ts index 69b472258..92266be34 100644 --- a/test/regression-transcript-slug-collision.test.ts +++ b/test/regression-transcript-slug-collision.test.ts @@ -65,6 +65,61 @@ describe("regression: disambiguateSlugs resolves colliding staged slugs", () => expect(pages.map((p) => p.slug)).toEqual(before); }); + it("state consult: a source keeps its recorded suffixed slug when its old collider is absent", () => { + // Run 1 assigned A the bare slug and B the suffix; run 2 sees only B + // (A unchanged or gone). Without the consult B would flip to bare and + // gbrain would hold the same transcript under two slugs. + const slug = "transcripts/claude-code/repo/2026-08-25-abc123def456"; + const state = { + sessions: { + "/a.jsonl": { page_slug: slug }, + "/b.jsonl": { page_slug: `${slug}-cafe0123` }, + }, + }; + const pages = [mk(slug, "/b.jsonl")]; + disambiguateSlugs(pages, state); + expect(pages[0].slug).toBe(`${slug}-cafe0123`); + expect(pages[0].page_slug).toBe(pages[0].slug); + }); + + it("state consult: a new source never takes a bare slug owned by an unchanged source", () => { + // A owns the bare slug from a prior run but is NOT restaged this run; + // new collider C must suffix, not silently overwrite A's page in gbrain. + const slug = "transcripts/claude-code/repo/2026-08-25-abc123def456"; + const state = { sessions: { "/a.jsonl": { page_slug: slug } } }; + const pages = [mk(slug, "/c.jsonl")]; + disambiguateSlugs(pages, state); + expect(pages[0].slug).not.toBe(slug); + expect(pages[0].slug.startsWith(slug + "-")).toBe(true); + }); + + it("state consult: legacy duplicate records resolve first-owner-wins and self-heal", () => { + // Pre-#2724 states could record the SAME bare slug for two sources. + // The first owner in state order keeps it; the other gets a stable + // suffix — after this run the state records distinct slugs. + const slug = "transcripts/codex/repo/2026-08-25-deadbeefcafe"; + const state = { + sessions: { + "/first.jsonl": { page_slug: slug }, + "/second.jsonl": { page_slug: slug }, + }, + }; + const pages = [mk(slug, "/first.jsonl"), mk(slug, "/second.jsonl")]; + disambiguateSlugs(pages, state); + expect(pages[0].slug).toBe(slug); + expect(pages[1].slug).not.toBe(slug); + expect(pages[1].slug.startsWith(slug + "-")).toBe(true); + }); + + it("state consult: no state (or empty sessions) behaves exactly like the stateless algorithm", () => { + const slug = "transcripts/claude-code/repo/2026-08-25-abc123def456"; + const a = [mk(slug, "/a.jsonl"), mk(slug, "/b.jsonl")]; + const b = [mk(slug, "/a.jsonl"), mk(slug, "/b.jsonl")]; + disambiguateSlugs(a); + disambiguateSlugs(b, { sessions: {} }); + expect(b.map((p) => p.slug)).toEqual(a.map((p) => p.slug)); + }); + it("call-site wiring: the prepare/stage flow actually invokes disambiguateSlugs (source pin)", () => { // The unit tests above prove the function works; nothing else proves the // flow CALLS it — a refactor could drop the invocation and every test