Skip to content

Commit ee7b581

Browse files
committed
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.
1 parent 89adb23 commit ee7b581

2 files changed

Lines changed: 111 additions & 61 deletions

File tree

src/session/optimized-context-store.test.ts

Lines changed: 90 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@ import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
55
import type { ConversationTurn } from "@intx/types/runtime";
6-
import {
7-
createOptimizedContextStore,
8-
loadRecentTurns,
9-
resolveCheckpointAuthor,
10-
} from "./optimized-context-store.js";
6+
import { createOptimizedContextStore, loadRecentTurns } from "./optimized-context-store.js";
117
import { segmentFileName, listSegmentFiles } from "./incremental-jsonl.js";
128

139
const TURNS_FILE = "turns.jsonl";
@@ -26,11 +22,17 @@ function isolatedGitEnv(gitconfig: string): NodeJS.ProcessEnv {
2622
GIT_CONFIG_SYSTEM: "/dev/null",
2723
GIT_CONFIG_NOSYSTEM: "1",
2824
HOME: dir,
25+
XDG_CONFIG_HOME: dir,
2926
};
3027
}
3128

32-
async function headAuthor(dir: string): Promise<{ name: string; email: string }> {
33-
const proc = Bun.spawn(["git", "-C", dir, "log", "-1", "--format=%an%n%ae"], {
29+
async function headIdent(dir: string): Promise<{
30+
authorName: string;
31+
authorEmail: string;
32+
committerName: string;
33+
committerEmail: string;
34+
}> {
35+
const proc = Bun.spawn(["git", "-C", dir, "log", "-1", "--format=%an%n%ae%n%cn%n%ce"], {
3436
stdout: "pipe",
3537
stderr: "pipe",
3638
});
@@ -42,13 +44,39 @@ async function headAuthor(dir: string): Promise<{ name: string; email: string }>
4244
if (exitCode !== 0) {
4345
throw new Error(`git log failed: ${stderr.trim() || stdout.trim()}`);
4446
}
45-
const [name, email] = stdout.trimEnd().split("\n");
46-
if (name === undefined || email === undefined) {
47-
throw new Error(`unexpected git log author output: ${JSON.stringify(stdout)}`);
47+
const [authorName, authorEmail, committerName, committerEmail] = stdout.trimEnd().split("\n");
48+
if (
49+
authorName === undefined ||
50+
authorEmail === undefined ||
51+
committerName === undefined ||
52+
committerEmail === undefined
53+
) {
54+
throw new Error(`unexpected git log identity output: ${JSON.stringify(stdout)}`);
4855
}
49-
return { name, email };
56+
return { authorName, authorEmail, committerName, committerEmail };
5057
}
5158

59+
const EMPTY_CHECKPOINT_METADATA = {
60+
pendingOperations: [],
61+
tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 },
62+
};
63+
64+
async function commitEmptyCheckpoint(
65+
dir: string,
66+
opts?: Parameters<typeof createOptimizedContextStore>[1],
67+
): Promise<void> {
68+
const store = await createOptimizedContextStore(dir, opts);
69+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
70+
await store.commit({ message: "checkpoint: tool-execution" });
71+
}
72+
73+
const HARNESS_IDENT = {
74+
authorName: "interchange-harness",
75+
authorEmail: "harness@interchange.local",
76+
committerName: "interchange-harness",
77+
committerEmail: "harness@interchange.local",
78+
};
79+
5280
function turn(text: string): ConversationTurn {
5381
return { role: "user", content: [{ type: "text", text }], timestamp: 1 };
5482
}
@@ -510,51 +538,71 @@ describe("createOptimizedContextStore checkpoint", () => {
510538
expect(atHead).toHaveLength(total);
511539
}, 20_000);
512540

513-
test("records the operator identity on the cycle commit", async () => {
541+
test("records the operator identity as author and committer from global git config", async () => {
514542
const dir = tempDir();
515-
const store = await createOptimizedContextStore(dir, {
516-
author: { name: "Sawyer", email: "sawyer@dirtroad.dev" },
543+
await commitEmptyCheckpoint(dir, {
544+
env: isolatedGitEnv(`[user]\n\tname = Sawyer\n\temail = sawyer@dirtroad.dev\n`),
517545
});
518-
await store.writeMetadata({
519-
pendingOperations: [],
520-
tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 },
521-
});
522-
await store.commit({ message: "checkpoint: tool-execution" });
523546

524-
expect(await headAuthor(dir)).toEqual({
525-
name: "Sawyer",
526-
email: "sawyer@dirtroad.dev",
547+
expect(await headIdent(dir)).toEqual({
548+
authorName: "Sawyer",
549+
authorEmail: "sawyer@dirtroad.dev",
550+
committerName: "Sawyer",
551+
committerEmail: "sawyer@dirtroad.dev",
527552
});
528553
});
529-
});
530554

531-
describe("resolveCheckpointAuthor", () => {
532-
test("uses global user.name and user.email when both are set", async () => {
533-
const env = isolatedGitEnv(`[user]\n\tname = Sawyer\n\temail = sawyer@dirtroad.dev\n`);
534-
await expect(resolveCheckpointAuthor(env)).resolves.toEqual({
535-
name: "Sawyer",
536-
email: "sawyer@dirtroad.dev",
555+
test("records an injected author as both author and committer", async () => {
556+
const dir = tempDir();
557+
await commitEmptyCheckpoint(dir, {
558+
author: { name: "Sawyer", email: "sawyer@dirtroad.dev" },
559+
});
560+
561+
expect(await headIdent(dir)).toEqual({
562+
authorName: "Sawyer",
563+
authorEmail: "sawyer@dirtroad.dev",
564+
committerName: "Sawyer",
565+
committerEmail: "sawyer@dirtroad.dev",
537566
});
538567
});
539568

540569
test("falls back to the harness identity when global config is missing", async () => {
541-
const env = isolatedGitEnv("");
542-
await expect(resolveCheckpointAuthor(env)).resolves.toEqual({
543-
name: "interchange-harness",
544-
email: "harness@interchange.local",
545-
});
570+
const dir = tempDir();
571+
await commitEmptyCheckpoint(dir, { env: isolatedGitEnv("") });
572+
expect(await headIdent(dir)).toEqual(HARNESS_IDENT);
546573
});
547574

548575
test("falls back when only one of name or email is set", async () => {
549-
const nameOnly = isolatedGitEnv(`[user]\n\tname = Sawyer\n`);
550-
const emailOnly = isolatedGitEnv(`[user]\n\temail = sawyer@dirtroad.dev\n`);
551-
await expect(resolveCheckpointAuthor(nameOnly)).resolves.toEqual({
552-
name: "interchange-harness",
553-
email: "harness@interchange.local",
576+
const nameOnly = tempDir();
577+
await commitEmptyCheckpoint(nameOnly, {
578+
env: isolatedGitEnv(`[user]\n\tname = Sawyer\n`),
554579
});
555-
await expect(resolveCheckpointAuthor(emailOnly)).resolves.toEqual({
556-
name: "interchange-harness",
557-
email: "harness@interchange.local",
580+
expect(await headIdent(nameOnly)).toEqual(HARNESS_IDENT);
581+
582+
const emailOnly = tempDir();
583+
await commitEmptyCheckpoint(emailOnly, {
584+
env: isolatedGitEnv(`[user]\n\temail = sawyer@dirtroad.dev\n`),
585+
});
586+
expect(await headIdent(emailOnly)).toEqual(HARNESS_IDENT);
587+
});
588+
589+
test("falls back when global name and email are empty or whitespace", async () => {
590+
const bothEmpty = tempDir();
591+
await commitEmptyCheckpoint(bothEmpty, {
592+
env: isolatedGitEnv(`[user]\n\tname =\n\temail =\n`),
593+
});
594+
expect(await headIdent(bothEmpty)).toEqual(HARNESS_IDENT);
595+
596+
const bothWhitespace = tempDir();
597+
await commitEmptyCheckpoint(bothWhitespace, {
598+
env: isolatedGitEnv(`[user]\n\tname = \n\temail = \n`),
599+
});
600+
expect(await headIdent(bothWhitespace)).toEqual(HARNESS_IDENT);
601+
602+
const nameOnlyWhitespaceEmail = tempDir();
603+
await commitEmptyCheckpoint(nameOnlyWhitespaceEmail, {
604+
env: isolatedGitEnv(`[user]\n\tname = Sawyer\n\temail = \n`),
558605
});
606+
expect(await headIdent(nameOnlyWhitespaceEmail)).toEqual(HARNESS_IDENT);
559607
});
560608
});

src/session/optimized-context-store.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,11 @@ const TOOL_OUTPUT_DIR = "tool-output";
2929

3030
const log = getLogger([LOG_NAMESPACE_ROOT, "session", "context-store"]);
3131

32-
export type CheckpointAuthor = {
32+
export interface CheckpointAuthor {
3333
name: string;
3434
email: string;
35-
};
35+
}
3636

37-
// Cycle commits shell out to system git, so operator commit-author hooks see
38-
// this identity. Prefer their global git user when both name and email are
39-
// set; otherwise keep Interchange's harness fallback so machines without a
40-
// git identity still checkpoint.
4137
const HARNESS_AUTHOR: CheckpointAuthor = {
4238
name: "interchange-harness",
4339
email: "harness@interchange.local",
@@ -308,12 +304,17 @@ export async function loadRecentTurns(dir: string, minTurns: number): Promise<Co
308304
return turns;
309305
}
310306

311-
async function runGit(dir: string, args: string[], author?: CheckpointAuthor): Promise<string> {
307+
async function runGit(
308+
dir: string,
309+
args: string[],
310+
author?: CheckpointAuthor,
311+
env: NodeJS.ProcessEnv = process.env,
312+
): Promise<string> {
312313
const proc = Bun.spawn(["git", "-C", dir, ...args], {
313314
stdout: "pipe",
314315
stderr: "pipe",
315316
env: {
316-
...process.env,
317+
...env,
317318
...(author === undefined
318319
? {}
319320
: {
@@ -341,20 +342,19 @@ async function gitConfigGlobal(key: string, env: NodeJS.ProcessEnv): Promise<str
341342
stderr: "pipe",
342343
env,
343344
});
344-
const [exitCode, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()]);
345+
const [exitCode, stdout] = await Promise.all([
346+
proc.exited,
347+
new Response(proc.stdout).text(),
348+
new Response(proc.stderr).text(),
349+
]);
345350
if (exitCode !== 0) return null;
346351
const value = stdout.trim();
347352
return value.length > 0 ? value : null;
348353
}
349354

350-
/**
351-
* Author for Corbits cycle commits. Uses the operator's global git identity
352-
* when both `user.name` and `user.email` are set; otherwise the Interchange
353-
* harness identity.
354-
*/
355-
export async function resolveCheckpointAuthor(
356-
env: NodeJS.ProcessEnv = process.env,
357-
): Promise<CheckpointAuthor> {
355+
// Operator commit-author hooks see a real identity; machines without both
356+
// global user.name and user.email still checkpoint via the harness fallback.
357+
async function resolveCheckpointAuthor(env: NodeJS.ProcessEnv): Promise<CheckpointAuthor> {
358358
const [name, email] = await Promise.all([
359359
gitConfigGlobal("user.name", env),
360360
gitConfigGlobal("user.email", env),
@@ -438,9 +438,10 @@ async function reconcileSegmentStaging(
438438
*/
439439
export async function createOptimizedContextStore(
440440
dir: string,
441-
opts?: { author?: CheckpointAuthor },
441+
opts?: { author?: CheckpointAuthor; env?: NodeJS.ProcessEnv },
442442
): Promise<ContextStore> {
443-
const author = opts?.author ?? (await resolveCheckpointAuthor());
443+
const gitEnv = opts?.env ?? process.env;
444+
const author = opts?.author ?? (await resolveCheckpointAuthor(gitEnv));
444445
const base = await createIsogitStore(dir);
445446
const pendingBlobFilepaths = new Set<string>();
446447
const pendingSegmentPaths = new Set<string>();
@@ -599,6 +600,7 @@ export async function createOptimizedContextStore(
599600
dir,
600601
["commit", "-m", options.message, `--author=${author.name} <${author.email}>`],
601602
author,
603+
gitEnv,
602604
);
603605
pendingBlobFilepaths.clear();
604606
pendingSegmentPaths.clear();

0 commit comments

Comments
 (0)