diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 47b186ce..96500bfb 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -358,6 +358,10 @@ Session runtime state lives under the global projects tree (not in the repo): `createOptimizedContextStore` (`src/session/optimized-context-store.ts`) wraps the Interchange git store to keep per-checkpoint cost independent of session length. +Checkpoint commits go through system git and use the operator's global +`user.name` / `user.email` when both are set, so commit-author hooks see a real +identity; otherwise they fall back to Interchange's harness author +(`interchange-harness`, `harness@interchange.local`). The append-only snapshots (`turns.jsonl`, `prompt.jsonl`) are written as rolling segments (`turns-0001.jsonl`, ...) that seal at 256KB, so `git add` re-hashes only the small active segment instead of the whole growing file. Segment zero keeps the diff --git a/src/session/optimized-context-store.test.ts b/src/session/optimized-context-store.test.ts index c2b7fa63..e745f842 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -12,6 +12,71 @@ function tempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "opt-store-")); } +function isolatedGitEnv(gitconfig: string): NodeJS.ProcessEnv { + const dir = tempDir(); + const config = path.join(dir, "gitconfig"); + fs.writeFileSync(config, gitconfig); + return { + ...process.env, + GIT_CONFIG_GLOBAL: config, + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + HOME: dir, + XDG_CONFIG_HOME: dir, + }; +} + +async function headIdent(dir: string): Promise<{ + authorName: string; + authorEmail: string; + committerName: string; + committerEmail: string; +}> { + const proc = Bun.spawn(["git", "-C", dir, "log", "-1", "--format=%an%n%ae%n%cn%n%ce"], { + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(`git log failed: ${stderr.trim() || stdout.trim()}`); + } + const [authorName, authorEmail, committerName, committerEmail] = stdout.trimEnd().split("\n"); + if ( + authorName === undefined || + authorEmail === undefined || + committerName === undefined || + committerEmail === undefined + ) { + throw new Error(`unexpected git log identity output: ${JSON.stringify(stdout)}`); + } + return { authorName, authorEmail, committerName, committerEmail }; +} + +const EMPTY_CHECKPOINT_METADATA = { + pendingOperations: [], + tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }, +}; + +async function commitEmptyCheckpoint( + dir: string, + opts?: Parameters[1], +): Promise { + const store = await createOptimizedContextStore(dir, opts); + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await store.commit({ message: "checkpoint: tool-execution" }); +} + +const HARNESS_IDENT = { + authorName: "interchange-harness", + authorEmail: "harness@interchange.local", + committerName: "interchange-harness", + committerEmail: "harness@interchange.local", +}; + function turn(text: string): ConversationTurn { return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; } @@ -472,4 +537,72 @@ describe("createOptimizedContextStore checkpoint", () => { const atHead = await store.readAt(head.hash); expect(atHead).toHaveLength(total); }, 20_000); + + test("records the operator identity as author and committer from global git config", async () => { + const dir = tempDir(); + await commitEmptyCheckpoint(dir, { + env: isolatedGitEnv(`[user]\n\tname = Sawyer\n\temail = sawyer@dirtroad.dev\n`), + }); + + expect(await headIdent(dir)).toEqual({ + authorName: "Sawyer", + authorEmail: "sawyer@dirtroad.dev", + committerName: "Sawyer", + committerEmail: "sawyer@dirtroad.dev", + }); + }); + + test("records an injected author as both author and committer", async () => { + const dir = tempDir(); + await commitEmptyCheckpoint(dir, { + author: { name: "Sawyer", email: "sawyer@dirtroad.dev" }, + }); + + expect(await headIdent(dir)).toEqual({ + authorName: "Sawyer", + authorEmail: "sawyer@dirtroad.dev", + committerName: "Sawyer", + committerEmail: "sawyer@dirtroad.dev", + }); + }); + + test("falls back to the harness identity when global config is missing", async () => { + const dir = tempDir(); + await commitEmptyCheckpoint(dir, { env: isolatedGitEnv("") }); + expect(await headIdent(dir)).toEqual(HARNESS_IDENT); + }); + + test("falls back when only one of name or email is set", async () => { + const nameOnly = tempDir(); + await commitEmptyCheckpoint(nameOnly, { + env: isolatedGitEnv(`[user]\n\tname = Sawyer\n`), + }); + expect(await headIdent(nameOnly)).toEqual(HARNESS_IDENT); + + const emailOnly = tempDir(); + await commitEmptyCheckpoint(emailOnly, { + env: isolatedGitEnv(`[user]\n\temail = sawyer@dirtroad.dev\n`), + }); + expect(await headIdent(emailOnly)).toEqual(HARNESS_IDENT); + }); + + test("falls back when global name and email are empty or whitespace", async () => { + const bothEmpty = tempDir(); + await commitEmptyCheckpoint(bothEmpty, { + env: isolatedGitEnv(`[user]\n\tname =\n\temail =\n`), + }); + expect(await headIdent(bothEmpty)).toEqual(HARNESS_IDENT); + + const bothWhitespace = tempDir(); + await commitEmptyCheckpoint(bothWhitespace, { + env: isolatedGitEnv(`[user]\n\tname = \n\temail = \n`), + }); + expect(await headIdent(bothWhitespace)).toEqual(HARNESS_IDENT); + + const nameOnlyWhitespaceEmail = tempDir(); + await commitEmptyCheckpoint(nameOnlyWhitespaceEmail, { + env: isolatedGitEnv(`[user]\n\tname = Sawyer\n\temail = \n`), + }); + expect(await headIdent(nameOnlyWhitespaceEmail)).toEqual(HARNESS_IDENT); + }); }); diff --git a/src/session/optimized-context-store.ts b/src/session/optimized-context-store.ts index 12be7eaa..245a685f 100644 --- a/src/session/optimized-context-store.ts +++ b/src/session/optimized-context-store.ts @@ -29,7 +29,12 @@ const TOOL_OUTPUT_DIR = "tool-output"; const log = getLogger([LOG_NAMESPACE_ROOT, "session", "context-store"]); -const AUTHOR = { +export interface CheckpointAuthor { + name: string; + email: string; +} + +const HARNESS_AUTHOR: CheckpointAuthor = { name: "interchange-harness", email: "harness@interchange.local", }; @@ -299,16 +304,25 @@ export async function loadRecentTurns(dir: string, minTurns: number): Promise { +async function runGit( + dir: string, + args: string[], + author?: CheckpointAuthor, + env: NodeJS.ProcessEnv = process.env, +): Promise { const proc = Bun.spawn(["git", "-C", dir, ...args], { stdout: "pipe", stderr: "pipe", env: { - ...process.env, - GIT_AUTHOR_NAME: AUTHOR.name, - GIT_AUTHOR_EMAIL: AUTHOR.email, - GIT_COMMITTER_NAME: AUTHOR.name, - GIT_COMMITTER_EMAIL: AUTHOR.email, + ...env, + ...(author === undefined + ? {} + : { + GIT_AUTHOR_NAME: author.name, + GIT_AUTHOR_EMAIL: author.email, + GIT_COMMITTER_NAME: author.name, + GIT_COMMITTER_EMAIL: author.email, + }), }, }); const [exitCode, stdout, stderr] = await Promise.all([ @@ -322,6 +336,33 @@ async function runGit(dir: string, args: string[]): Promise { return stdout.trimEnd(); } +async function gitConfigGlobal(key: string, env: NodeJS.ProcessEnv): Promise { + const proc = Bun.spawn(["git", "config", "--global", "--get", key], { + stdout: "pipe", + stderr: "pipe", + env, + }); + const [exitCode, stdout] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + if (exitCode !== 0) return null; + const value = stdout.trim(); + return value.length > 0 ? value : null; +} + +// Operator commit-author hooks see a real identity; machines without both +// global user.name and user.email still checkpoint via the harness fallback. +async function resolveCheckpointAuthor(env: NodeJS.ProcessEnv): Promise { + const [name, email] = await Promise.all([ + gitConfigGlobal("user.name", env), + gitConfigGlobal("user.email", env), + ]); + if (name === null || email === null) return HARNESS_AUTHOR; + return { name, email }; +} + /** * Names of the tail turn segments (`turns-0001.jsonl`, ...) present in a commit * tree, in segment order. The base store reads the zeroth segment itself; these @@ -395,7 +436,12 @@ async function reconcileSegmentStaging( * segment files so `git add` re-hashes only the small active segment, and only * spilled tool-output blobs that are new since the last commit are staged. */ -export async function createOptimizedContextStore(dir: string): Promise { +export async function createOptimizedContextStore( + dir: string, + opts?: { author?: CheckpointAuthor; env?: NodeJS.ProcessEnv }, +): Promise { + const gitEnv = opts?.env ?? process.env; + const author = opts?.author ?? (await resolveCheckpointAuthor(gitEnv)); const base = await createIsogitStore(dir); const pendingBlobFilepaths = new Set(); const pendingSegmentPaths = new Set(); @@ -550,12 +596,12 @@ export async function createOptimizedContextStore(dir: string): Promise 0) { await runGit(dir, ["rm", "--cached", "--ignore-unmatch", "--", ...remove]); } - await runGit(dir, [ - "commit", - "-m", - options.message, - `--author=${AUTHOR.name} <${AUTHOR.email}>`, - ]); + await runGit( + dir, + ["commit", "-m", options.message, `--author=${author.name} <${author.email}>`], + author, + gitEnv, + ); pendingBlobFilepaths.clear(); pendingSegmentPaths.clear(); return describeHead(dir, options.message);