From 89adb232b00595f2dc8d5ffefb084fcc1aa5b933 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 29 Aug 2026 17:35:23 -0700 Subject: [PATCH 1/2] Use the operator git identity for session checkpoints Cycle commits shell out to system git with a synthetic harness author, so operator commit-author hooks reject every tool cycle. Using global user.name and user.email when both are set makes those hooks see a real identity. --- docs/IMPLEMENTATION.md | 4 + src/session/optimized-context-store.test.ts | 87 ++++++++++++++++++++- src/session/optimized-context-store.ts | 70 ++++++++++++++--- 3 files changed, 147 insertions(+), 14 deletions(-) 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..89441a74 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -3,7 +3,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { ConversationTurn } from "@intx/types/runtime"; -import { createOptimizedContextStore, loadRecentTurns } from "./optimized-context-store.js"; +import { + createOptimizedContextStore, + loadRecentTurns, + resolveCheckpointAuthor, +} from "./optimized-context-store.js"; import { segmentFileName, listSegmentFiles } from "./incremental-jsonl.js"; const TURNS_FILE = "turns.jsonl"; @@ -12,6 +16,39 @@ 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, + }; +} + +async function headAuthor(dir: string): Promise<{ name: string; email: string }> { + const proc = Bun.spawn(["git", "-C", dir, "log", "-1", "--format=%an%n%ae"], { + 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 [name, email] = stdout.trimEnd().split("\n"); + if (name === undefined || email === undefined) { + throw new Error(`unexpected git log author output: ${JSON.stringify(stdout)}`); + } + return { name, email }; +} + function turn(text: string): ConversationTurn { return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; } @@ -472,4 +509,52 @@ describe("createOptimizedContextStore checkpoint", () => { const atHead = await store.readAt(head.hash); expect(atHead).toHaveLength(total); }, 20_000); + + test("records the operator identity on the cycle commit", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir, { + author: { name: "Sawyer", email: "sawyer@dirtroad.dev" }, + }); + await store.writeMetadata({ + pendingOperations: [], + tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }, + }); + await store.commit({ message: "checkpoint: tool-execution" }); + + expect(await headAuthor(dir)).toEqual({ + name: "Sawyer", + email: "sawyer@dirtroad.dev", + }); + }); +}); + +describe("resolveCheckpointAuthor", () => { + test("uses global user.name and user.email when both are set", async () => { + const env = isolatedGitEnv(`[user]\n\tname = Sawyer\n\temail = sawyer@dirtroad.dev\n`); + await expect(resolveCheckpointAuthor(env)).resolves.toEqual({ + name: "Sawyer", + email: "sawyer@dirtroad.dev", + }); + }); + + test("falls back to the harness identity when global config is missing", async () => { + const env = isolatedGitEnv(""); + await expect(resolveCheckpointAuthor(env)).resolves.toEqual({ + name: "interchange-harness", + email: "harness@interchange.local", + }); + }); + + test("falls back when only one of name or email is set", async () => { + const nameOnly = isolatedGitEnv(`[user]\n\tname = Sawyer\n`); + const emailOnly = isolatedGitEnv(`[user]\n\temail = sawyer@dirtroad.dev\n`); + await expect(resolveCheckpointAuthor(nameOnly)).resolves.toEqual({ + name: "interchange-harness", + email: "harness@interchange.local", + }); + await expect(resolveCheckpointAuthor(emailOnly)).resolves.toEqual({ + name: "interchange-harness", + email: "harness@interchange.local", + }); + }); }); diff --git a/src/session/optimized-context-store.ts b/src/session/optimized-context-store.ts index 12be7eaa..f2122ddf 100644 --- a/src/session/optimized-context-store.ts +++ b/src/session/optimized-context-store.ts @@ -29,7 +29,16 @@ const TOOL_OUTPUT_DIR = "tool-output"; const log = getLogger([LOG_NAMESPACE_ROOT, "session", "context-store"]); -const AUTHOR = { +export type CheckpointAuthor = { + name: string; + email: string; +}; + +// Cycle commits shell out to system git, so operator commit-author hooks see +// this identity. Prefer their global git user when both name and email are +// set; otherwise keep Interchange's harness fallback so machines without a +// git identity still checkpoint. +const HARNESS_AUTHOR: CheckpointAuthor = { name: "interchange-harness", email: "harness@interchange.local", }; @@ -299,16 +308,20 @@ export async function loadRecentTurns(dir: string, minTurns: number): Promise { +async function runGit(dir: string, args: string[], author?: CheckpointAuthor): 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, + ...(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 +335,34 @@ 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()]); + if (exitCode !== 0) return null; + const value = stdout.trim(); + return value.length > 0 ? value : null; +} + +/** + * Author for Corbits cycle commits. Uses the operator's global git identity + * when both `user.name` and `user.email` are set; otherwise the Interchange + * harness identity. + */ +export async function resolveCheckpointAuthor( + env: NodeJS.ProcessEnv = process.env, +): 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,11 @@ 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 }, +): Promise { + const author = opts?.author ?? (await resolveCheckpointAuthor()); const base = await createIsogitStore(dir); const pendingBlobFilepaths = new Set(); const pendingSegmentPaths = new Set(); @@ -550,12 +595,11 @@ 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, + ); pendingBlobFilepaths.clear(); pendingSegmentPaths.clear(); return describeHead(dir, options.message); From ee7b58106fc112e8487346a41b5a907889989eb8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 29 Aug 2026 22:39:29 -0700 Subject: [PATCH 2/2] Pin session checkpoint identity through the store Keep author resolution file-private and drain git-config stderr so piped git children cannot stall. Tests drive the default path through the store with an isolated GIT_CONFIG_GLOBAL and pin both author and committer. --- src/session/optimized-context-store.test.ts | 132 +++++++++++++------- src/session/optimized-context-store.ts | 40 +++--- 2 files changed, 111 insertions(+), 61 deletions(-) diff --git a/src/session/optimized-context-store.test.ts b/src/session/optimized-context-store.test.ts index 89441a74..e745f842 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -3,11 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { ConversationTurn } from "@intx/types/runtime"; -import { - createOptimizedContextStore, - loadRecentTurns, - resolveCheckpointAuthor, -} from "./optimized-context-store.js"; +import { createOptimizedContextStore, loadRecentTurns } from "./optimized-context-store.js"; import { segmentFileName, listSegmentFiles } from "./incremental-jsonl.js"; const TURNS_FILE = "turns.jsonl"; @@ -26,11 +22,17 @@ function isolatedGitEnv(gitconfig: string): NodeJS.ProcessEnv { GIT_CONFIG_SYSTEM: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", HOME: dir, + XDG_CONFIG_HOME: dir, }; } -async function headAuthor(dir: string): Promise<{ name: string; email: string }> { - const proc = Bun.spawn(["git", "-C", dir, "log", "-1", "--format=%an%n%ae"], { +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", }); @@ -42,13 +44,39 @@ async function headAuthor(dir: string): Promise<{ name: string; email: string }> if (exitCode !== 0) { throw new Error(`git log failed: ${stderr.trim() || stdout.trim()}`); } - const [name, email] = stdout.trimEnd().split("\n"); - if (name === undefined || email === undefined) { - throw new Error(`unexpected git log author output: ${JSON.stringify(stdout)}`); + 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 { name, email }; + 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 }; } @@ -510,51 +538,71 @@ describe("createOptimizedContextStore checkpoint", () => { expect(atHead).toHaveLength(total); }, 20_000); - test("records the operator identity on the cycle commit", async () => { + test("records the operator identity as author and committer from global git config", async () => { const dir = tempDir(); - const store = await createOptimizedContextStore(dir, { - author: { name: "Sawyer", email: "sawyer@dirtroad.dev" }, + await commitEmptyCheckpoint(dir, { + env: isolatedGitEnv(`[user]\n\tname = Sawyer\n\temail = sawyer@dirtroad.dev\n`), }); - await store.writeMetadata({ - pendingOperations: [], - tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }, - }); - await store.commit({ message: "checkpoint: tool-execution" }); - expect(await headAuthor(dir)).toEqual({ - name: "Sawyer", - email: "sawyer@dirtroad.dev", + expect(await headIdent(dir)).toEqual({ + authorName: "Sawyer", + authorEmail: "sawyer@dirtroad.dev", + committerName: "Sawyer", + committerEmail: "sawyer@dirtroad.dev", }); }); -}); -describe("resolveCheckpointAuthor", () => { - test("uses global user.name and user.email when both are set", async () => { - const env = isolatedGitEnv(`[user]\n\tname = Sawyer\n\temail = sawyer@dirtroad.dev\n`); - await expect(resolveCheckpointAuthor(env)).resolves.toEqual({ - name: "Sawyer", - email: "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 env = isolatedGitEnv(""); - await expect(resolveCheckpointAuthor(env)).resolves.toEqual({ - name: "interchange-harness", - email: "harness@interchange.local", - }); + 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 = isolatedGitEnv(`[user]\n\tname = Sawyer\n`); - const emailOnly = isolatedGitEnv(`[user]\n\temail = sawyer@dirtroad.dev\n`); - await expect(resolveCheckpointAuthor(nameOnly)).resolves.toEqual({ - name: "interchange-harness", - email: "harness@interchange.local", + const nameOnly = tempDir(); + await commitEmptyCheckpoint(nameOnly, { + env: isolatedGitEnv(`[user]\n\tname = Sawyer\n`), }); - await expect(resolveCheckpointAuthor(emailOnly)).resolves.toEqual({ - name: "interchange-harness", - email: "harness@interchange.local", + 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 f2122ddf..245a685f 100644 --- a/src/session/optimized-context-store.ts +++ b/src/session/optimized-context-store.ts @@ -29,15 +29,11 @@ const TOOL_OUTPUT_DIR = "tool-output"; const log = getLogger([LOG_NAMESPACE_ROOT, "session", "context-store"]); -export type CheckpointAuthor = { +export interface CheckpointAuthor { name: string; email: string; -}; +} -// Cycle commits shell out to system git, so operator commit-author hooks see -// this identity. Prefer their global git user when both name and email are -// set; otherwise keep Interchange's harness fallback so machines without a -// git identity still checkpoint. const HARNESS_AUTHOR: CheckpointAuthor = { name: "interchange-harness", email: "harness@interchange.local", @@ -308,12 +304,17 @@ 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, + ...env, ...(author === undefined ? {} : { @@ -341,20 +342,19 @@ async function gitConfigGlobal(key: string, env: NodeJS.ProcessEnv): Promise 0 ? value : null; } -/** - * Author for Corbits cycle commits. Uses the operator's global git identity - * when both `user.name` and `user.email` are set; otherwise the Interchange - * harness identity. - */ -export async function resolveCheckpointAuthor( - env: NodeJS.ProcessEnv = process.env, -): Promise { +// 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), @@ -438,9 +438,10 @@ async function reconcileSegmentStaging( */ export async function createOptimizedContextStore( dir: string, - opts?: { author?: CheckpointAuthor }, + opts?: { author?: CheckpointAuthor; env?: NodeJS.ProcessEnv }, ): Promise { - const author = opts?.author ?? (await resolveCheckpointAuthor()); + 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(); @@ -599,6 +600,7 @@ export async function createOptimizedContextStore( dir, ["commit", "-m", options.message, `--author=${author.name} <${author.email}>`], author, + gitEnv, ); pendingBlobFilepaths.clear(); pendingSegmentPaths.clear();