Skip to content

Commit 1977d58

Browse files
authored
fix(coding-agents): stop unsafe gitlog cleanup (#3879)
The git-log deepen path enumerated bank-wide `source:git-log` documents and deleted every returned non-canonical one as stale. The document listing endpoint's inclusive `all` tag mode can return untagged documents, so that sweep deleted unrelated documents — and because deletion cascades to facts, a routine coding-agents sync could remove unrelated memories from a shared bank. Remove the cleanup entirely: git-log sync now owns only its canonical document and never deletes other document IDs. Internal multi-tag strategy probes use `all_strict`, while `listDocumentIds()` keeps its existing public `all` default for external callers. A git-log snapshot counts as current only when the current-HEAD query contains this repository's canonical document; otherwise the sync performs an idempotent upsert. Canonical IDs remain `gitlog:<repoName>`, so same-named repositories and forks can still share one ID. This change prevents cross-document deletion; renamespacing safely needs a separate ownership/migration design. Fixes #3877.
1 parent ca38687 commit 1977d58

9 files changed

Lines changed: 195 additions & 47 deletions

File tree

hindsight-integrations/coding-agents/src/core/git.gitlog.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import type { HindsightClient } from "./hindsight";
7-
import { gitLogNewestAuthorDate, gitLogText, ingestGitLog, repoNameOf, retainCommit } from "./git";
7+
import {
8+
gitLogNewestAuthorDate,
9+
gitLogText,
10+
ingestGitLog,
11+
repoNameOf,
12+
retainCommit,
13+
syncGitLog,
14+
} from "./git";
815

916
let dir: string;
1017

@@ -132,3 +139,52 @@ describe("ingestGitLog", () => {
132139
});
133140
});
134141
});
142+
143+
describe("syncGitLog", () => {
144+
beforeEach(() => {
145+
dir = mkdtempSync(join(tmpdir(), "hs-gitlog-sync-"));
146+
initRepo(dir);
147+
});
148+
149+
it("never enumerates or deletes foreign git-log documents from a shared bank", async () => {
150+
execFileSync("git", ["-C", dir, "commit", "--allow-empty", "-m", "feat: current repo"]);
151+
const listDocumentIds = vi.fn(
152+
async (_tag: string, _tagsMatch?: "all" | "all_strict") => new Set(["gitlog:foreign-repo"])
153+
);
154+
const retain = vi.fn().mockResolvedValue(undefined);
155+
const deleteDocument = vi.fn().mockResolvedValue(undefined);
156+
const client = {
157+
listDocumentIds,
158+
retain,
159+
deleteDocument,
160+
opIds: [],
161+
} as unknown as HindsightClient;
162+
163+
const failures = await syncGitLog(client, dir, { limit: 10 });
164+
165+
expect(failures).toBe(0);
166+
expect(retain).toHaveBeenCalledTimes(1);
167+
expect(listDocumentIds).toHaveBeenCalledTimes(1);
168+
expect(listDocumentIds.mock.calls[0][0]).toMatch(/^gitlog-head:/);
169+
expect(listDocumentIds.mock.calls[0][1]).toBe("all_strict");
170+
expect(listDocumentIds).not.toHaveBeenCalledWith("source:git-log");
171+
expect(deleteDocument).not.toHaveBeenCalled();
172+
});
173+
174+
it("skips the upsert only when this repository's canonical document has the current HEAD tag", async () => {
175+
execFileSync("git", ["-C", dir, "commit", "--allow-empty", "-m", "feat: current repo"]);
176+
const listDocumentIds = vi.fn(
177+
async (_tag: string, _tagsMatch?: "all" | "all_strict") =>
178+
new Set([`gitlog:${repoNameOf(dir)}`])
179+
);
180+
const retain = vi.fn().mockResolvedValue(undefined);
181+
const client = { listDocumentIds, retain, opIds: [] } as unknown as HindsightClient;
182+
183+
const failures = await syncGitLog(client, dir, { limit: 10 });
184+
185+
expect(failures).toBe(0);
186+
expect(listDocumentIds.mock.calls[0][0]).toMatch(/^gitlog-head:/);
187+
expect(listDocumentIds.mock.calls[0][1]).toBe("all_strict");
188+
expect(retain).not.toHaveBeenCalled();
189+
});
190+
});

hindsight-integrations/coding-agents/src/core/git.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,33 @@ export async function ingestGitLog(
254254
return 1;
255255
}
256256
}
257+
258+
/**
259+
* Ensure this repository's canonical aggregated git-log document is current.
260+
*
261+
* Older deepen versions enumerated every `source:git-log` document in the bank and deleted every
262+
* non-canonical id. A bank can be shared by unrelated repositories, so that name comparison was
263+
* not an ownership check and could cascade-delete foreign memories (#3877). The canonical retain is
264+
* already an idempotent upsert; ambiguous legacy documents are deliberately left untouched.
265+
*/
266+
export async function syncGitLog(
267+
client: HindsightClient,
268+
repo: string,
269+
opts: { limit: number; log?: (m: string) => void; stampFor?: () => RetainStamp }
270+
): Promise<number> {
271+
const log = opts.log ?? (() => {});
272+
const head = gitHeadSha(repo);
273+
const canonical = `gitlog:${repoNameOf(repo)}`;
274+
const current =
275+
head !== null &&
276+
(
277+
await client
278+
.listDocumentIds(`gitlog-head:${head}`, "all_strict")
279+
.catch(() => new Set<string>())
280+
).has(canonical);
281+
if (current) {
282+
log("[gitlog] current with HEAD — skipping");
283+
return 0;
284+
}
285+
return ingestGitLog(client, repo, opts);
286+
}

hindsight-integrations/coding-agents/src/core/hindsight.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,46 @@ describe("HindsightClient.maxParallelRetains", () => {
3333
});
3434
});
3535

36+
describe("HindsightClient document-list safety", () => {
37+
it("uses strict strategy-tag matching on every page", async () => {
38+
const client = new HindsightClient({ apiUrl: "http://x", bank: "shared-bank" });
39+
const firstPage = Array.from({ length: 500 }, (_, i) => ({ id: `git:${i}` }));
40+
const fetchMock = vi.fn(async (_url: string | URL | Request) => {
41+
const offset = String(_url).includes("offset=500") ? 500 : 0;
42+
return jsonResponse(200, {
43+
items: offset === 0 ? firstPage : [{ id: "git:500" }],
44+
total: 501,
45+
});
46+
});
47+
vi.stubGlobal("fetch", fetchMock);
48+
49+
const ids = await client.listDocumentIds("source:git", "all_strict");
50+
51+
expect(ids.size).toBe(501);
52+
expect(fetchMock).toHaveBeenCalledTimes(2);
53+
expect(String(fetchMock.mock.calls[0][0])).toBe(
54+
"http://x/v1/default/banks/shared-bank/documents?tags=source%3Agit&tags_match=all_strict&limit=500&offset=0"
55+
);
56+
expect(String(fetchMock.mock.calls[1][0])).toBe(
57+
"http://x/v1/default/banks/shared-bank/documents?tags=source%3Agit&tags_match=all_strict&limit=500&offset=500"
58+
);
59+
});
60+
61+
it("preserves the inclusive all mode for existing callers that do not opt into strict matching", async () => {
62+
const client = new HindsightClient({ apiUrl: "http://x", bank: "shared-bank" });
63+
const fetchMock = vi.fn(async (_url: string | URL | Request) =>
64+
jsonResponse(200, { items: [], total: 0 })
65+
);
66+
vi.stubGlobal("fetch", fetchMock);
67+
68+
await client.listDocumentIds("custom:scope");
69+
70+
expect(String(fetchMock.mock.calls[0][0])).toContain(
71+
"tags=custom%3Ascope&tags_match=all&limit=500&offset=0"
72+
);
73+
});
74+
});
75+
3676
describe("HindsightClient.drain", () => {
3777
it("polls at most maxParallelRetains ops concurrently", async () => {
3878
const cap = 2;

hindsight-integrations/coding-agents/src/core/hindsight.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,11 +319,14 @@ export class HindsightClient {
319319
* Set. Powers the incremental git-sync's "what's already ingested?" check — since git commits are stored
320320
* with document_id `git:<sha>`, the returned Set lets a caller diff a ref's commits against memory.
321321
*/
322-
async listDocumentIds(tag: string): Promise<Set<string>> {
322+
async listDocumentIds(
323+
tag: string,
324+
tagsMatch: "all" | "all_strict" = "all"
325+
): Promise<Set<string>> {
323326
const ids = new Set<string>();
324327
const limit = 500;
325328
for (let offset = 0; ; offset += limit) {
326-
const q = `?tags=${encodeURIComponent(tag)}&tags_match=all&limit=${limit}&offset=${offset}`;
329+
const q = `?tags=${encodeURIComponent(tag)}&tags_match=${tagsMatch}&limit=${limit}&offset=${offset}`;
327330
const r = await this.req("GET", this.bankUrl(`/documents${q}`));
328331
let items: { id?: string }[] = [];
329332
let total = 0;
@@ -389,7 +392,8 @@ export class HindsightClient {
389392
}
390393
}
391394

392-
/** Delete one document (cascades its memory units/links). Used by deepen's self-cleanup. */
395+
/** Explicitly delete one document (and its cascaded memory units/links). Background sync must
396+
* never use this as a cleanup primitive: a document id alone does not prove repository ownership. */
393397
async deleteDocument(documentId: string): Promise<void> {
394398
await this.req("DELETE", this.bankUrl(`/documents/${encodeURIComponent(documentId)}`));
395399
}

hindsight-integrations/coding-agents/src/core/session-start.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { execFileSync } from "node:child_process";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
26
import { buildSessionStartContext, runSessionStartHook } from "./session-start";
37
import { resolveConfig } from "./config";
48
import { HOOK_HARNESSES } from "../harness/hook-lifecycle";
@@ -168,6 +172,35 @@ describe("buildSessionStartContext", () => {
168172
expect(out.systemMessage).toContain("is tracking the decisions");
169173
});
170174

175+
it("does not report git in sync from another repository's same-HEAD document", async () => {
176+
const repo = mkdtempSync(join(tmpdir(), "hs-session-start-shared-bank-"));
177+
try {
178+
execFileSync("git", ["-C", repo, "init", "-q"]);
179+
execFileSync("git", ["-C", repo, "config", "user.email", "test@example.com"]);
180+
execFileSync("git", ["-C", repo, "config", "user.name", "Test User"]);
181+
execFileSync("git", ["-C", repo, "commit", "--allow-empty", "-m", "initial"]);
182+
const listDocumentIds = vi.fn(async (tag: string, _match?: "all" | "all_strict") =>
183+
tag === "source:git" ? new Set(["git:existing"]) : new Set(["gitlog:foreign-repo"])
184+
);
185+
186+
const out = await buildSessionStartContext({
187+
cwd: repo,
188+
bankId: "shared-bank",
189+
cfg: resolveConfig({ codebaseSurvey: false }),
190+
client: { listDocumentIds, listPages: listPagesOk },
191+
hasGit: () => true,
192+
startSeed: vi.fn(),
193+
});
194+
195+
expect(out.systemMessage).toContain("catching up on new commits");
196+
expect(out.systemMessage).not.toContain("git in sync");
197+
expect(listDocumentIds.mock.calls[1][0]).toMatch(/^gitlog-head:/);
198+
expect(listDocumentIds.mock.calls[1][1]).toBe("all_strict");
199+
} finally {
200+
rmSync(repo, { recursive: true, force: true });
201+
}
202+
});
203+
171204
it("listDocumentIds throws (server unreachable) -> no seed, roster preamble only", async () => {
172205
const startSeed = vi.fn();
173206
const client = {

hindsight-integrations/coding-agents/src/core/session-start.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
* - empty set (cold) -> start the background seed, seededAt written, note added
2020
*/
2121
import { readFileSync } from "node:fs";
22-
import { gitHeadSha, hasGitHistory, commitsSince } from "./git";
22+
import { gitHeadSha, hasGitHistory, commitsSince, repoNameOf } from "./git";
2323
import { DEEPEN_DIFF_TARGET } from "./status";
2424
import { startBackgroundSeed } from "./seed";
2525
import { syncCompanionSkill } from "./skill-sync";
@@ -39,7 +39,7 @@ import { sessionCacheFile, sessionRootDir, writeSessionCache } from "./session-c
3939

4040
/** Minimal client shape `buildSessionStartContext` needs. */
4141
interface SeedContextClient {
42-
listDocumentIds(tag: string): Promise<Set<string>>;
42+
listDocumentIds(tag: string, tagsMatch?: "all" | "all_strict"): Promise<Set<string>>;
4343
listPages(): Promise<unknown>;
4444
knowledgePagesSupported?: boolean;
4545
// Optional: used to write the survey-baseline marker (Option A). HindsightClient has it; the
@@ -87,8 +87,8 @@ async function gitSyncNote(args: {
8787
const head = gitHeadSha(cwd);
8888
if (!head) return undefined;
8989
const gitlogCurrent = await client
90-
.listDocumentIds(`gitlog-head:${head}`)
91-
.then((s) => s.size > 0)
90+
.listDocumentIds(`gitlog-head:${head}`, "all_strict")
91+
.then((s) => s.has(`gitlog:${repoNameOf(cwd)}`))
9292
.catch(() => undefined);
9393
if (gitlogCurrent === undefined) return undefined; // server hiccup: say nothing rather than guess
9494
if (mode === "message") return gitlogCurrent ? "git in sync" : "catching up on new commits";
@@ -210,7 +210,7 @@ export async function buildSessionStartContext(args: {
210210
{
211211
let docIds: Set<string> | undefined;
212212
try {
213-
docIds = await client.listDocumentIds("source:git");
213+
docIds = await client.listDocumentIds("source:git", "all_strict");
214214
} catch {
215215
docIds = undefined; // server unreachable: transient — do nothing, try again next session
216216
}
@@ -250,7 +250,7 @@ export async function buildSessionStartContext(args: {
250250
const sha = resolveHeadSha(cwd);
251251
if (sha) {
252252
const markers = await client
253-
.listDocumentIds(SURVEY_BASELINE_TAG)
253+
.listDocumentIds(SURVEY_BASELINE_TAG, "all_strict")
254254
.catch(() => new Set<string>());
255255
const counts: number[] = [];
256256
for (const id of markers) {
@@ -262,7 +262,7 @@ export async function buildSessionStartContext(args: {
262262
// A baseline without FINDINGS means the surveyed agent died before ingesting (no
263263
// CLI on PATH, budget kill) — the marker alone must not suppress retries forever.
264264
const uploads = await client
265-
.listDocumentIds("source:upload")
265+
.listDocumentIds("source:upload", "all_strict")
266266
.catch(() => new Set<string>());
267267
const findingsAbsent =
268268
counts.length > 0 && !SURVEY_DOC_IDS.some((id) => uploads.has(id));

hindsight-integrations/coding-agents/src/core/status.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { SURVEY_DOC_IDS } from "./survey";
2121

2222
/** Minimal client shape (HindsightClient satisfies it structurally). */
2323
export interface StatusClient {
24-
listDocumentIds(tag: string): Promise<Set<string>>;
24+
listDocumentIds(tag: string, tagsMatch?: "all" | "all_strict"): Promise<Set<string>>;
2525
listPages(): Promise<unknown>;
2626
knowledgePagesSupported?: boolean;
2727
activeOperations(): Promise<number>;
@@ -61,11 +61,15 @@ export async function syncStatus(
6161
bank: string,
6262
repoDir?: string
6363
): Promise<SyncStatus> {
64-
const gitIds = await client.listDocumentIds("source:git");
65-
const chatIds = await client.listDocumentIds("source:chat").catch(() => new Set<string>());
64+
const gitIds = await client.listDocumentIds("source:git", "all_strict");
65+
const chatIds = await client
66+
.listDocumentIds("source:chat", "all_strict")
67+
.catch(() => new Set<string>());
6668
const pages = parsePageList(await client.listPages().catch(() => null));
6769
const activeOps = await client.activeOperations().catch(() => null);
68-
const uploads = await client.listDocumentIds("source:upload").catch(() => new Set<string>());
70+
const uploads = await client
71+
.listDocumentIds("source:upload", "all_strict")
72+
.catch(() => new Set<string>());
6973
const surveyDocs = SURVEY_DOC_IDS.filter((id) => uploads.has(id)).length;
7074

7175
// Survey observability: the survey-baseline:<sha> markers Chris's re-survey mechanism writes.
@@ -74,7 +78,7 @@ export async function syncStatus(
7478
let surveyCommitsBehind: number | null = null;
7579
if (repoDir) {
7680
try {
77-
const markers = await client.listDocumentIds("source:survey-baseline");
81+
const markers = await client.listDocumentIds("source:survey-baseline", "all_strict");
7882
let best: { sha: string; behind: number } | undefined;
7983
for (const id of markers) {
8084
const sha = id.replace(/^survey-baseline:/, "");

hindsight-integrations/coding-agents/src/core/sync.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export async function syncGit(
7373
const shas = (revs ? revs.split("\n") : []).filter(Boolean);
7474
if (!shas.length) return { ref, total: 0, ingested: 0, failures: 0, inSync: true };
7575

76-
const ingestedIds = await client.listDocumentIds("source:git"); // Set of `git:<sha>` already in the bank
76+
const ingestedIds = await client.listDocumentIds("source:git", "all_strict"); // Set of `git:<sha>` already in the bank
7777
const missing = shas.filter((sha) => !ingestedIds.has(`git:${sha}`));
7878
if (!missing.length) {
7979
log(`[sync] in sync — all ${shas.length} commits on ${ref} already ingested`);

0 commit comments

Comments
 (0)