Skip to content

Commit b1bc36f

Browse files
fix(cli): the codex harvest reads the checkout, so a session reports the branch it is on (langwatch#7415)
* fix(cli): the codex harvest reads the checkout, so a session reports the branch it is on A codex session could not attribute to a pull request. The harvest built its session-context record from the rollout's session_meta alone, and codex writes git identity there only when the session STARTED inside a repository, then never revises it. Both halves of that hurt. Every agent on our box starts codex a directory above its checkouts, so session_meta carries `git: null` and the session reported no repository for its whole life, however many pull requests it reviewed. A session that does start inside a checkout is no better off over time: it reports the branch it opened with, so a reviewer moving from one pull request to the next keeps naming the first. The harvest runs on the machine that ran the turn, moments after it, so it now reads the working directory itself through the same helper the claude hook uses, and keeps the transcript's values as the fallback for a rollout harvested away from its checkout. Verified on the agents box: a codex turn in a checkout on a pull request's branch reported github.com/langwatch/langwatch@feat/home-pill-setup-menu, and the pull-request usage API attributed 38,473 tokens to that pull request. Before this the same session reported nothing at all. Claude-Session: https://claude.ai/code/session_01BUKKUiZbSBHmrLK9JJj4Ba * test(cli): one contextAttr helper, taking named parameters The new module-scope helper took positional parameters, against the repo convention for functions with several arguments, and it collided with a describe-scoped helper of the same name that already took named ones. The inner one shadowed it, so the file carried two helpers with one name and two different signatures. There is now one helper, at module scope, taking named parameters. It keeps the inner one's guard on the logs POST being present, which reports a missing session-context record instead of throwing a TypeError that hides which expectation failed. Claude-Session: https://claude.ai/code/session_01BUKKUiZbSBHmrLK9JJj4Ba
1 parent b682b88 commit b1bc36f

3 files changed

Lines changed: 214 additions & 30 deletions

File tree

sdks/typescript/src/cli/utils/governance/__tests__/codex-turn-harvest.unit.test.ts

Lines changed: 119 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,37 @@ const spansOf = (bodies: any[]) =>
110110
const attrOf = (span: any, key: string) =>
111111
span.attributes.find((a: any) => a.key === key)?.value?.stringValue;
112112

113+
/** One attribute of the session-context record, from the logs POST. */
114+
const contextAttr = ({
115+
bodies,
116+
urls,
117+
key,
118+
}: {
119+
bodies: any[];
120+
urls: string[];
121+
key: string;
122+
}) => {
123+
const at = urls.indexOf("https://e/v1/logs");
124+
// Without this the missing POST reads as `bodies[-1]` and the chain
125+
// throws a TypeError, which hides which expectation actually failed.
126+
expect(at, "no session-context record was posted").toBeGreaterThan(-1);
127+
return attrOf(bodies[at].resourceLogs[0].scopeLogs[0].logRecords[0], key);
128+
};
129+
130+
/**
131+
* A working directory git answers for: the checkout the session is sitting in,
132+
* on the branch it is on right now, which is what the harvest reads instead of
133+
* trusting the transcript's record of the session's first minute.
134+
*/
135+
const checkoutOn =
136+
(branch: string) =>
137+
({ args }: { args: string[]; cwd: string }): string | null => {
138+
if (args[0] === "remote") return "https://github.com/acme/acme-app.git";
139+
if (args[0] === "branch") return branch;
140+
// Not a linked worktree, so readWorktreeName finds nothing to name.
141+
return null;
142+
};
143+
113144
describe("harvestCodexThread", () => {
114145
describe("given a session that ran without the langwatch wrapper", () => {
115146
describe("when its completed turn is harvested", () => {
@@ -353,6 +384,94 @@ describe("harvestCodexThread", () => {
353384
expect(urls).toContain("https://e/v1/traces");
354385
});
355386

387+
/** @scenario "The reported branch follows the checkout, not the session's first minute" */
388+
it("reports the branch the checkout is on now", async () => {
389+
writeRollout(THREAD, [
390+
sessionMeta(GIT),
391+
taskStarted(TRACE),
392+
userMessage("hi"),
393+
agentFinal("hello"),
394+
]);
395+
const { bodies, urls, impl } = recordingFetch();
396+
397+
await harvestCodexThread({
398+
threadId: THREAD,
399+
nowMs: 1785654950000,
400+
endpoint: "https://e/v1/traces",
401+
logsEndpoint: "https://e/v1/logs",
402+
token: "sk-lw-test",
403+
sessionsRoot: root,
404+
stateDir,
405+
fetchImpl: impl,
406+
runGit: checkoutOn("review/pr-7412"),
407+
});
408+
409+
expect(contextAttr({ bodies, urls, key: "vcs.ref.head.name" })).toBe(
410+
"review/pr-7412",
411+
);
412+
expect(contextAttr({ bodies, urls, key: "vcs.repository.name" })).toBe(
413+
"acme-app",
414+
);
415+
});
416+
417+
/** @scenario "A session whose transcript records no repository still reports one" */
418+
it("reports the working directory's repository when the transcript has none", async () => {
419+
writeRollout(THREAD, [
420+
sessionMeta(),
421+
taskStarted(TRACE),
422+
userMessage("hi"),
423+
agentFinal("hello"),
424+
]);
425+
const { bodies, urls, impl } = recordingFetch();
426+
427+
await harvestCodexThread({
428+
threadId: THREAD,
429+
nowMs: 1785654950000,
430+
endpoint: "https://e/v1/traces",
431+
logsEndpoint: "https://e/v1/logs",
432+
token: "sk-lw-test",
433+
sessionsRoot: root,
434+
stateDir,
435+
fetchImpl: impl,
436+
runGit: checkoutOn("review/pr-7412"),
437+
});
438+
439+
expect(contextAttr({ bodies, urls, key: "vcs.repository.owner" })).toBe("acme");
440+
expect(contextAttr({ bodies, urls, key: "vcs.ref.head.name" })).toBe(
441+
"review/pr-7412",
442+
);
443+
});
444+
445+
/** @scenario "A transcript harvested away from its checkout keeps what codex recorded" */
446+
it("falls back to the transcript when git cannot read the directory", async () => {
447+
writeRollout(THREAD, [
448+
sessionMeta(GIT),
449+
taskStarted(TRACE),
450+
userMessage("hi"),
451+
agentFinal("hello"),
452+
]);
453+
const { bodies, urls, impl } = recordingFetch();
454+
455+
await harvestCodexThread({
456+
threadId: THREAD,
457+
nowMs: 1785654950000,
458+
endpoint: "https://e/v1/traces",
459+
logsEndpoint: "https://e/v1/logs",
460+
token: "sk-lw-test",
461+
sessionsRoot: root,
462+
stateDir,
463+
fetchImpl: impl,
464+
runGit: () => null,
465+
});
466+
467+
expect(contextAttr({ bodies, urls, key: "vcs.ref.head.name" })).toBe(
468+
"feat/pricing",
469+
);
470+
expect(contextAttr({ bodies, urls, key: "vcs.repository.name" })).toBe(
471+
"acme-app",
472+
);
473+
});
474+
356475
/** @scenario "A notify that fires after every turn posts the repository once" */
357476
it("does not re-post an unchanged context on the next turn", async () => {
358477
writeRollout(THREAD, [
@@ -444,24 +563,6 @@ describe("harvestCodexThread", () => {
444563
fetchImpl: impl,
445564
});
446565

447-
const contextAttr = ({
448-
bodies,
449-
urls,
450-
key,
451-
}: {
452-
bodies: any[];
453-
urls: string[];
454-
key: string;
455-
}) => {
456-
const at = urls.indexOf("https://e/v1/logs");
457-
// Without this the missing POST reads as `bodies[-1]` and the chain
458-
// throws a TypeError, which hides which expectation actually failed.
459-
expect(at, "no session-context record was posted").toBeGreaterThan(-1);
460-
return bodies[at].resourceLogs[0].scopeLogs[0].logRecords[0].attributes.find(
461-
(a: any) => a.key === key,
462-
)?.value?.stringValue;
463-
};
464-
465566
describe("when the session is harvested", () => {
466567
/** @scenario "The harvest names the session by the first thing the user asked" */
467568
it("posts the prompt's first line as the session title", async () => {

sdks/typescript/src/cli/utils/governance/codex-rollout-otlp.ts

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { createHash } from "node:crypto";
99
import { readFile, readdir, stat } from "node:fs/promises";
1010
import { homedir } from "node:os";
1111
import { join } from "node:path";
12+
import {
13+
type GitRunner,
14+
readSessionContext,
15+
runGitCommand,
16+
} from "@/cli/commands/ingestion/git-context";
1217
import { LANGWATCH_SDK_VERSION } from "@/internal/constants";
1318
import { GovernanceCliError } from "./cli-api";
1419
import {
@@ -216,6 +221,7 @@ export async function harvestCodexThread(args: {
216221
sessionsRoot?: string;
217222
stateDir?: string;
218223
fetchImpl?: typeof fetch;
224+
runGit?: GitRunner;
219225
}): Promise<number> {
220226
const root = args.sessionsRoot ?? defaultCodexSessionsRoot();
221227
const file = await findRolloutForThread(args.threadId, root);
@@ -236,6 +242,7 @@ export async function harvestCodexThread(args: {
236242
threadName: meta?.sessionId ? threadNames.get(meta.sessionId) : null,
237243
stateDir: args.stateDir,
238244
fetchImpl: args.fetchImpl,
245+
runGit: args.runGit,
239246
});
240247
const recent = turns.slice(-RECENT_TURN_WINDOW);
241248
if (recent.length === 0) return 0;
@@ -371,6 +378,44 @@ async function postCodexTurns(args: {
371378
* the grammar cannot read, or a refused POST emits nothing and reports false,
372379
* because the content spans riding beside this are worth posting either way.
373380
*/
381+
/**
382+
* Which repository and branch a codex session is working in.
383+
*
384+
* The rollout's own `session_meta` is the weaker of the two sources and is
385+
* consulted second. Codex fills it only when the session STARTED inside a
386+
* repository, and never revises it: a reviewer that checks out one pull
387+
* request's branch after another still reports the branch it opened with, and a
388+
* session started a directory above the checkout reports nothing for its whole
389+
* life, however much repository work it does. Codex has no equivalent of a
390+
* native worktree switch, so that first directory is the session for good.
391+
*
392+
* The harvest runs on the machine that ran the turn, moments after it, so the
393+
* working directory can be read directly and answers for the turn being
394+
* harvested rather than for the session's first minute. The rollout's values
395+
* stay as the fallback, which is what a transcript harvested on another machine
396+
* (or after the checkout is gone) still has.
397+
*/
398+
function codexSessionContext({
399+
meta,
400+
runGit,
401+
}: {
402+
meta: CodexRolloutMeta;
403+
runGit: GitRunner;
404+
}): SessionContext | null {
405+
const live = meta.cwd
406+
? readSessionContext({ directory: meta.cwd, runGit })
407+
: null;
408+
if (live) return live;
409+
const repository = meta.gitRepositoryUrl
410+
? parseGitRemoteUrl(meta.gitRepositoryUrl)
411+
: null;
412+
if (!repository) return null;
413+
return {
414+
repository,
415+
...(meta.gitBranch ? { branch: meta.gitBranch } : {}),
416+
};
417+
}
418+
374419
export async function postCodexSessionContext(args: {
375420
meta: CodexRolloutMeta | null;
376421
nowMs: number;
@@ -380,28 +425,34 @@ export async function postCodexSessionContext(args: {
380425
threadName?: string | null;
381426
stateDir?: string;
382427
fetchImpl?: typeof fetch;
428+
runGit?: GitRunner;
383429
}): Promise<boolean> {
384-
const { meta, nowMs, logsEndpoint, token, threadName, stateDir, fetchImpl } =
385-
args;
430+
const {
431+
meta,
432+
nowMs,
433+
logsEndpoint,
434+
token,
435+
threadName,
436+
stateDir,
437+
fetchImpl,
438+
runGit,
439+
} = args;
386440
if (!logsEndpoint) return false;
387441
if (!meta?.sessionId) return false;
388-
const repository = meta.gitRepositoryUrl
389-
? parseGitRemoteUrl(meta.gitRepositoryUrl)
390-
: null;
391442
const title = meta.firstUserMessage
392443
? sessionTitleFromPrompt(meta.firstUserMessage)
393444
: null;
394445
const name = normalizeSessionName(threadName);
446+
const context = codexSessionContext({
447+
meta,
448+
runGit: runGit ?? runGitCommand,
449+
});
395450
// A codex session appears in the sessions screen only through this
396451
// record, so a session outside any repository still posts one as long
397452
// as there is a name to carry. With no identity and no name there is
398453
// nothing to say.
399-
if (!repository && !title && !name) return false;
400-
const context: SessionContext = {
401-
...(repository ? { repository } : {}),
402-
...(meta.gitBranch ? { branch: meta.gitBranch } : {}),
403-
};
404-
const fingerprint = sessionContextFingerprint(context, { title, name });
454+
if (!context && !title && !name) return false;
455+
const fingerprint = sessionContextFingerprint(context ?? {}, { title, name });
405456
const stateFile = stateFilePath({
406457
stateDir: stateDir ?? defaultStateDir(),
407458
agent: "codex",
@@ -411,7 +462,7 @@ export async function postCodexSessionContext(args: {
411462
const payload = buildSessionContextLogPayload({
412463
sessionId: meta.sessionId,
413464
agent: "codex",
414-
context,
465+
context: context ?? {},
415466
timeUnixNano: `${nowMs}000000`,
416467
scopeVersion: LANGWATCH_SDK_VERSION,
417468
// Codex withholds prompt text from its own events, so the transcript's
@@ -484,6 +535,7 @@ async function postCodexSessionContexts(args: {
484535
threadNames?: Map<string, string>;
485536
stateDir?: string;
486537
fetchImpl?: typeof fetch;
538+
runGit?: GitRunner;
487539
}): Promise<void> {
488540
const { metas, threadNames, ...post } = args;
489541
if (metas.length === 0) return;
@@ -537,6 +589,7 @@ export async function harvestAndEmitCodexIO(args: {
537589
sessionsRoot?: string;
538590
stateDir?: string;
539591
fetchImpl?: typeof fetch;
592+
runGit?: GitRunner;
540593
}): Promise<number> {
541594
const {
542595
sinceMs,
@@ -547,6 +600,7 @@ export async function harvestAndEmitCodexIO(args: {
547600
sessionsRoot,
548601
stateDir,
549602
fetchImpl,
603+
runGit,
550604
} = args;
551605
const root = sessionsRoot ?? defaultCodexSessionsRoot();
552606
const { turns, metas } = await readRollouts({
@@ -561,6 +615,7 @@ export async function harvestAndEmitCodexIO(args: {
561615
threadNames: await readCodexThreadNames(codexSessionIndexPath(root)),
562616
stateDir,
563617
fetchImpl,
618+
runGit,
564619
});
565620
if (turns.length === 0) return 0;
566621
await postCodexTurns({ turns, nowMs, endpoint, token, fetchImpl });
@@ -585,6 +640,7 @@ export function createCodexIOStreamer(args: {
585640
sessionsRoot?: string;
586641
stateDir?: string;
587642
fetchImpl?: typeof fetch;
643+
runGit?: GitRunner;
588644
}): { harvest: (nowMs: number) => Promise<number> } {
589645
const root = args.sessionsRoot ?? defaultCodexSessionsRoot();
590646
const emitted = new Set<string>();
@@ -606,6 +662,7 @@ export function createCodexIOStreamer(args: {
606662
threadNames: await readCodexThreadNames(codexSessionIndexPath(root)),
607663
stateDir: args.stateDir,
608664
fetchImpl: args.fetchImpl,
665+
runGit: args.runGit,
609666
});
610667
const fresh = turns.filter((t) => t.traceId && !emitted.has(t.traceId));
611668
if (fresh.length === 0) return 0;

specs/ai-governance/cli-wrappers/codex-rollout-io.feature

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,39 @@ Feature: Codex Path B recovers the full request body from the rollout transcript
138138
branch (which is what links it to its pull request) with no hooks.json
139139
entry and no per-hook trust grant.
140140

141+
What codex recorded is the weaker source and is read second. It is written
142+
only when the session started inside a repository, and it is never revised,
143+
so a reviewer that checks out one pull request's branch after another would
144+
report the branch it opened with for the rest of its life. The harvest runs
145+
on the machine that ran the turn, moments after it, so it reads the working
146+
directory itself and answers for the turn it is harvesting.
147+
141148
@unit
142149
Scenario: The harvest reports the repository the session worked on
143150
Given a rollout whose session_meta names a remote and a branch
144151
When the completed turn is harvested
145152
Then one session-context record posts beside the conversation
146153
And the codex session gains its repository and branch
147154

155+
@unit
156+
Scenario: The reported branch follows the checkout, not the session's first minute
157+
Given a session that has moved its checkout to another branch since it started
158+
When the completed turn is harvested
159+
Then the record names the branch the checkout is on now
160+
161+
@unit
162+
Scenario: A session whose transcript records no repository still reports one
163+
Given a rollout whose session_meta records no git identity
164+
And a working directory that is a checkout
165+
When the completed turn is harvested
166+
Then the record names the repository of that working directory
167+
168+
@unit
169+
Scenario: A transcript harvested away from its checkout keeps what codex recorded
170+
Given a rollout whose working directory cannot be read as a repository
171+
When the completed turn is harvested
172+
Then the record falls back to the remote and branch in the session_meta
173+
148174
@unit
149175
Scenario: A notify that fires after every turn posts the repository once
150176
Given a session whose context was already posted

0 commit comments

Comments
 (0)