authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 08:05:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 08:05:37-07:00
logc163d502dd32b993c03d8c20a5fae55b159bd8dc
tree7aaea18d35524268a41de29d9e490ab0cf18e8b5
parent4ee2a9d046a0986451c703c9221bb32ba53540bb
signature Signed by PGP key B5690EEEBB952194

perf(server): avoid full patches for checkpoint summaries (#9694)


14 files changed, 376 insertions(+), 137 deletions(-)

apps/server/package.json-1
......@@ -28,7 +28,6 @@
2828 "@effect/sql-sqlite-bun": "catalog:",
2929 "@ff-labs/fff-node": "0.9.4",
3030 "@opencode-ai/sdk": "^1.3.15",
31 "@pierre/diffs": "catalog:",
3231 "effect": "catalog:",
3332 "msgpackr-extract": "3.0.4",
3433 "node-pty": "^1.1.0",
apps/server/src/checkpointing/CheckpointStore.test.ts+178
......@@ -4,6 +4,7 @@ import * as NodePath from "node:path";
44import * as NodeServices from "@effect/platform-node/NodeServices";
55import { it } from "@effect/vitest";
66import { ThreadId, type VcsError } from "@t3tools/contracts";
7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
78import * as Effect from "effect/Effect";
89import * as FileSystem from "effect/FileSystem";
910import * as Layer from "effect/Layer";
......@@ -12,6 +13,7 @@ import * as Scope from "effect/Scope";
1213import { describe, expect } from "vite-plus/test";
1314
1415import { checkpointRefForThreadTurn } from "./Utils.ts";
16import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts";
1517import * as CheckpointStore from "./CheckpointStore.ts";
1618import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1719import * as VcsProcess from "../vcs/VcsProcess.ts";
......@@ -250,6 +252,182 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => {
250252 expect(whitespaceIgnoredDiff).toContain("+ <div>");
251253 expect(whitespaceIgnoredDiff).not.toContain("- <h1>Title</h1>");
252254 expect(whitespaceIgnoredDiff).not.toContain("+ <h1>Title</h1>");
255
256 for (const ignoreWhitespace of [false, true]) {
257 const numstat = yield* checkpointStore.diffCheckpoints({
258 cwd: tmp,
259 fromCheckpointRef,
260 toCheckpointRef,
261 ignoreWhitespace,
262 format: "numstat",
263 });
264 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
265 {
266 path: "Component.tsx",
267 additions: ignoreWhitespace ? 4 : 6,
268 deletions: ignoreWhitespace ? 0 : 2,
269 },
270 ]);
271 }
272 }),
273 );
274 });
275
276 describe("checkpoint file summaries", () => {
277 it.effect("counts changes whose full patch exceeds the output limit", () =>
278 Effect.gen(function* () {
279 const tmp = yield* makeTmpDir();
280 yield* initRepoWithCommit(tmp);
281 const checkpointStore = yield* CheckpointStore.CheckpointStore;
282 const threadId = ThreadId.make("large-checkpoint-summary");
283 const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0);
284 const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
285 const filePath = NodePath.join(tmp, "README.md");
286 const lineCount = 20_000;
287 yield* writeTextFile(filePath, `${"before".repeat(50)}\n`.repeat(lineCount));
288 yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: fromCheckpointRef });
289 yield* writeTextFile(filePath, `${"after".repeat(60)}\n`.repeat(lineCount));
290 yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef });
291
292 const numstat = yield* checkpointStore.diffCheckpoints({
293 cwd: tmp,
294 fromCheckpointRef,
295 toCheckpointRef,
296 ignoreWhitespace: false,
297 format: "numstat",
298 });
299
300 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
301 { path: "README.md", additions: lineCount, deletions: lineCount },
302 ]);
303 expect(numstat.length).toBeLessThan(100);
304 }),
305 );
306
307 it.effect("preserves file paths and turn ranges without changing the user index", () =>
308 Effect.gen(function* () {
309 const tmp = yield* makeTmpDir();
310 yield* initRepoWithCommit(tmp);
311 yield* git(tmp, ["config", "diff.renames", "copies"]);
312 const fileSystem = yield* FileSystem.FileSystem;
313 const checkpointStore = yield* CheckpointStore.CheckpointStore;
314 const threadId = ThreadId.make("checkpoint-summary-paths");
315 const baseline = checkpointRefForThreadTurn(threadId, 0);
316 const firstTurn = checkpointRefForThreadTurn(threadId, 1);
317 const secondTurn = checkpointRefForThreadTurn(threadId, 2);
318 const copiedText = Array.from({ length: 20 }, (_, index) => `copy line ${index}\n`).join(
319 "",
320 );
321 const platform = yield* HostProcessPlatform;
322 const renamedPath = platform === "win32" ? "renamed café.txt" : "renamed\tcafé\nname.txt";
323 const addedPath = platform === "win32" ? "new café.txt" : "new\tfile\n名.txt";
324 for (const [path, contents] of Object.entries({
325 "copy-source.txt": copiedText,
326 "deleted.txt": "delete me\n",
327 "rename-old.txt": "before\nkeep one\nkeep two\nkeep three\n",
328 "binary.bin": "\0before",
329 })) {
330 yield* writeTextFile(NodePath.join(tmp, path), contents);
331 }
332 yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: baseline });
333
334 yield* fileSystem.rename(
335 NodePath.join(tmp, "rename-old.txt"),
336 NodePath.join(tmp, renamedPath),
337 );
338 yield* fileSystem.remove(NodePath.join(tmp, "deleted.txt"));
339 for (const [path, contents] of Object.entries({
340 "copy-source.txt": `${copiedText}one more\n`,
341 "copied.txt": copiedText,
342 [renamedPath]: "after\nkeep one\nkeep two\nkeep three\n",
343 "binary.bin": "\0after",
344 "empty.txt": "",
345 [addedPath]: "first\nsecond\n",
346 })) {
347 yield* writeTextFile(NodePath.join(tmp, path), contents);
348 }
349 yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: firstTurn });
350 const userIndex = yield* fileSystem.readFile(NodePath.join(tmp, ".git/index"));
351 const input = {
352 cwd: tmp,
353 fromCheckpointRef: baseline,
354 toCheckpointRef: firstTurn,
355 ignoreWhitespace: false,
356 format: "numstat" as const,
357 };
358 const firstSummary = parseTurnDiffFilesFromNumstat(
359 yield* checkpointStore.diffCheckpoints(input),
360 );
361 const expectedFiles = [
362 { path: "binary.bin", additions: 0, deletions: 0 },
363 { path: "copied.txt", additions: 0, deletions: 0 },
364 { path: "copy-source.txt", additions: 1, deletions: 0 },
365 { path: "deleted.txt", additions: 0, deletions: 1 },
366 { path: "empty.txt", additions: 0, deletions: 0 },
367 { path: addedPath, additions: 2, deletions: 0 },
368 { path: renamedPath, additions: 1, deletions: 1 },
369 ].toSorted((left, right) => left.path.localeCompare(right.path));
370 expect(firstSummary).toEqual(expectedFiles);
371
372 yield* fileSystem.remove(NodePath.join(tmp, "empty.txt"));
373 yield* writeTextFile(NodePath.join(tmp, "copy-source.txt"), "replacement\n");
374 yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: secondTurn });
375 const secondSummary = parseTurnDiffFilesFromNumstat(
376 yield* checkpointStore.diffCheckpoints({
377 ...input,
378 fromCheckpointRef: firstTurn,
379 toCheckpointRef: secondTurn,
380 }),
381 );
382 expect(secondSummary).toEqual([
383 { path: "copy-source.txt", additions: 1, deletions: 21 },
384 { path: "empty.txt", additions: 0, deletions: 0 },
385 ]);
386
387 const inclusiveSummary = parseTurnDiffFilesFromNumstat(
388 yield* checkpointStore.diffCheckpoints({ ...input, toCheckpointRef: secondTurn }),
389 );
390 expect(inclusiveSummary).toEqual(
391 expectedFiles
392 .filter((file) => file.path !== "empty.txt")
393 .map((file) =>
394 file.path === "copy-source.txt" ? { ...file, additions: 1, deletions: 20 } : file,
395 ),
396 );
397 expect(
398 yield* checkpointStore.diffCheckpoints({ ...input, toCheckpointRef: baseline }),
399 ).toBe("");
400 expect(yield* fileSystem.readFile(NodePath.join(tmp, ".git/index"))).toEqual(userIndex);
401 }),
402 );
403
404 it.effect("uses HEAD for a missing baseline only when requested", () =>
405 Effect.gen(function* () {
406 const tmp = yield* makeTmpDir();
407 yield* initRepoWithCommit(tmp);
408 const checkpointStore = yield* CheckpointStore.CheckpointStore;
409 const threadId = ThreadId.make("checkpoint-summary-fallback");
410 const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0);
411 const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
412 yield* writeTextFile(NodePath.join(tmp, "README.md"), "changed\n");
413 yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef });
414 const input = {
415 cwd: tmp,
416 fromCheckpointRef,
417 toCheckpointRef,
418 ignoreWhitespace: false,
419 format: "numstat" as const,
420 };
421
422 const error = yield* Effect.flip(checkpointStore.diffCheckpoints(input));
423 expect(error._tag).toBe("VcsProcessExitError");
424 const numstat = yield* checkpointStore.diffCheckpoints({
425 ...input,
426 fallbackFromToHead: true,
427 });
428 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
429 { path: "README.md", additions: 1, deletions: 1 },
430 ]);
253431 }),
254432 );
255433 });
apps/server/src/checkpointing/CheckpointStore.ts+3-1
......@@ -39,6 +39,7 @@ export interface DiffCheckpointsInput {
3939 readonly toCheckpointRef: CheckpointRef;
4040 readonly fallbackFromToHead?: boolean;
4141 readonly ignoreWhitespace: boolean;
42 readonly format?: "patch" | "numstat";
4243}
4344
4445export interface DeleteCheckpointRefsInput {
......@@ -77,8 +78,9 @@ export class CheckpointStore extends Context.Service<
7778 ) => Effect.Effect<boolean, CheckpointStoreError>;
7879
7980 /**
80 * Compute a patch diff between two checkpoint refs.
81 * Compute a diff between two checkpoint refs. Defaults to a full patch.
8182 *
83 * Numstat output has NUL-delimited paths for file summaries.
8284 * Can optionally treat a missing "from" ref as `HEAD`.
8385 */
8486 readonly diffCheckpoints: (
apps/server/src/checkpointing/Diffs.test.ts+34-48
......@@ -1,68 +1,54 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { parseTurnDiffFilesFromUnifiedDiff } from "./Diffs.ts";
3import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts";
44
5describe("parseTurnDiffFilesFromUnifiedDiff", () => {
6 it("returns empty list for empty diff", () => {
7 expect(parseTurnDiffFilesFromUnifiedDiff("")).toEqual([]);
5describe("parseTurnDiffFilesFromNumstat", () => {
6 it("returns an empty list when no files changed", () => {
7 expect(parseTurnDiffFilesFromNumstat("")).toEqual([]);
88 });
99
10 it("parses per-file additions and deletions", () => {
11 const diff = [
12 "diff --git a/a.txt b/a.txt",
13 "index 1111111..2222222 100644",
14 "--- a/a.txt",
15 "+++ b/a.txt",
16 "@@ -1,2 +1,3 @@",
17 " one",
18 "-two",
19 "+two updated",
20 "+three",
21 "diff --git a/src/b.ts b/src/b.ts",
22 "index 3333333..4444444 100644",
23 "--- a/src/b.ts",
24 "+++ b/src/b.ts",
25 "@@ -3,2 +3,0 @@",
26 "-old",
27 "-stale",
28 "",
29 ].join("\n");
30
31 expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([
10 it("sorts files and preserves addition and deletion counts", () => {
11 const numstat = ["0\t2\tsrc/b.ts", "2\t1\ta.txt", ""].join("\0");
12 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
3213 { path: "a.txt", additions: 2, deletions: 1 },
3314 { path: "src/b.ts", additions: 0, deletions: 2 },
3415 ]);
3516 });
3617
37 it("parses rename-only diffs with zero line changes", () => {
38 const diff = [
39 "diff --git a/src/old.ts b/src/new.ts",
40 "similarity index 100%",
41 "rename from src/old.ts",
42 "rename to src/new.ts",
18 it("uses destination paths for renames and copies", () => {
19 const numstat = [
20 "0\t0\t",
21 "src/old.ts",
22 "src/new.ts",
23 "2\t1\t",
24 "src/source.ts",
25 "src/copied.ts",
26 "1\t0\tother.ts",
4327 "",
44 ].join("\n");
28 ].join("\0");
4529
46 expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([
30 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
31 { path: "other.ts", additions: 1, deletions: 0 },
32 { path: "src/copied.ts", additions: 2, deletions: 1 },
4733 { path: "src/new.ts", additions: 0, deletions: 0 },
4834 ]);
4935 });
5036
51 it("normalizes CRLF input before parsing", () => {
52 const diff = [
53 "diff --git a/a.txt b/a.txt",
54 "index 1111111..2222222 100644",
55 "--- a/a.txt",
56 "+++ b/a.txt",
57 "@@ -1 +1,2 @@",
58 "-one",
59 "+one updated",
60 "+two",
61 "",
62 ].join("\r\n");
37 it("keeps binary files and empty files with zero line changes", () => {
38 const numstat = ["-\t-\timage.png", "0\t0\tempty.txt", ""].join("\0");
39 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
40 { path: "empty.txt", additions: 0, deletions: 0 },
41 { path: "image.png", additions: 0, deletions: 0 },
42 ]);
43 });
6344
64 expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([
65 { path: "a.txt", additions: 2, deletions: 1 },
45 it("preserves Unicode, tabs, line endings, and spaces in paths", () => {
46 const path = " café\tline\r\nname.txt ";
47 const numstat = `3\t2\t\0old\tname\n.txt\0${path}\0`;
48
49 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([{ path, additions: 3, deletions: 2 }]);
50 expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([
51 { path, additions: 1, deletions: 0 },
6652 ]);
6753 });
6854});
apps/server/src/checkpointing/Diffs.ts+23-17
......@@ -1,27 +1,33 @@
1import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2
31export interface TurnDiffFileSummary {
42 readonly path: string;
53 readonly additions: number;
64 readonly deletions: number;
75}
86
9export function parseTurnDiffFilesFromUnifiedDiff(
10 diff: string,
11): ReadonlyArray<TurnDiffFileSummary> {
12 const normalized = diff.replace(/\r\n/g, "\n").trim();
13 if (normalized.length === 0) {
14 return [];
15 }
7/** Reads Git's NUL-delimited numstat output without decoding display paths. */
8export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<TurnDiffFileSummary> {
9 const records = numstat.split("\0");
10 const files: TurnDiffFileSummary[] = [];
11
12 for (let index = 0; index < records.length; index += 1) {
13 const record = records[index]!;
14 const counts = /^(\d+|-)\t(\d+|-)\t/.exec(record);
15 if (!counts) continue;
1616
17 const parsedPatches = parsePatchFiles(normalized);
18 const files = parsedPatches.flatMap((patch) =>
19 patch.files.map((file) => ({
20 path: file.name,
21 additions: file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0),
22 deletions: file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0),
23 })),
24 );
17 let path = record.slice(counts[0].length);
18 if (path.length === 0) {
19 // Renames and copies use two more records: the source and destination.
20 path = records[index + 2] ?? "";
21 index += 2;
22 }
23 if (path.length === 0) continue;
24
25 files.push({
26 path,
27 additions: counts[1] === "-" ? 0 : Number(counts[1]),
28 deletions: counts[2] === "-" ? 0 : Number(counts[2]),
29 });
30 }
2531
2632 return files.toSorted((left, right) => left.path.localeCompare(right.path));
2733}
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+101-61
......@@ -27,6 +27,7 @@ import * as Exit from "effect/Exit";
2727import * as Layer from "effect/Layer";
2828import * as ManagedRuntime from "effect/ManagedRuntime";
2929import * as PubSub from "effect/PubSub";
30import * as Queue from "effect/Queue";
3031import * as Scope from "effect/Scope";
3132import * as Stream from "effect/Stream";
3233import { it as effectIt } from "@effect/vitest";
......@@ -43,7 +44,8 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
4344import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
4445import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
4546import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
46import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";
47import { RuntimeReceiptBusTest } from "./RuntimeReceiptBus.ts";
48import * as RuntimeReceiptBus from "../Services/RuntimeReceiptBus.ts";
4749import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
4850import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
4951import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
......@@ -262,7 +264,8 @@ describe("CheckpointReactor", () => {
262264 | OrchestrationEngineService
263265 | CheckpointReactor
264266 | CheckpointStore.CheckpointStore
265 | ProjectionSnapshotQuery,
267 | ProjectionSnapshotQuery
268 | RuntimeReceiptBus.RuntimeReceiptBus,
266269 unknown
267270 > | null = null;
268271 let scope: Scope.Closeable | null = null;
......@@ -356,7 +359,7 @@ describe("CheckpointReactor", () => {
356359 const layer = CheckpointReactorLive.pipe(
357360 Layer.provideMerge(orchestrationLayer),
358361 Layer.provideMerge(projectionSnapshotLayer),
359 Layer.provideMerge(RuntimeReceiptBusLive),
362 Layer.provideMerge(RuntimeReceiptBusTest),
360363 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
361364 Layer.provideMerge(Layer.mock(PullRequestService)({ refreshAfterTurn })),
362365 Layer.provideMerge(vcsStatusBroadcasterLayer),
......@@ -380,8 +383,21 @@ describe("CheckpointReactor", () => {
380383 const checkpointStore = await runtime.runPromise(
381384 Effect.service(CheckpointStore.CheckpointStore),
382385 );
383 scope = await Effect.runPromise(Scope.make("sequential"));
384 await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));
386 const receiptBus = await runtime.runPromise(
387 Effect.service(RuntimeReceiptBus.RuntimeReceiptBus),
388 );
389 const testScope = await Effect.runPromise(Scope.make("sequential"));
390 scope = testScope;
391 const receipts = await Effect.runPromise(
392 Effect.gen(function* () {
393 const receipts = yield* Queue.unbounded<RuntimeReceiptBus.OrchestrationRuntimeReceipt>();
394 yield* Stream.runForEach(receiptBus.streamEventsForTest, (receipt) =>
395 Queue.offer(receipts, receipt),
396 ).pipe(Effect.forkIn(testScope, { startImmediately: true }));
397 yield* reactor.start().pipe(Scope.provide(testScope));
398 return receipts;
399 }),
400 );
385401 const drain = () => Effect.runPromise(reactor.drain);
386402
387403 const createdAt = "2026-01-01T00:00:00.000Z";
......@@ -470,16 +486,19 @@ describe("CheckpointReactor", () => {
470486 provider,
471487 cwd,
472488 drain,
489 nextReceipt: Queue.take(receipts),
473490 pullRequestRefreshes,
474491 };
475492 }
476493
477 it("captures pre-turn baseline on turn.started and post-turn checkpoint on turn.completed", async () => {
478 const harness = await createHarness({ seedFilesystemCheckpoints: false });
479 const createdAt = "2026-01-01T00:00:00.000Z";
494 effectIt.effect("captures baseline and large turn summaries before completion receipts", () =>
495 Effect.gen(function* () {
496 const harness = yield* Effect.promise(() =>
497 createHarness({ seedFilesystemCheckpoints: false }),
498 );
499 const createdAt = "2026-01-01T00:00:00.000Z";
480500
481 await Effect.runPromise(
482 harness.engine.dispatch({
501 yield* harness.engine.dispatch({
483502 type: "thread.session.set",
484503 commandId: CommandId.make("cmd-session-set-capture"),
485504 threadId: ThreadId.make("thread-1"),
......@@ -493,62 +512,83 @@ describe("CheckpointReactor", () => {
493512 updatedAt: createdAt,
494513 },
495514 createdAt,
496 }),
497 );
515 });
498516
499 harness.provider.emit({
500 type: "turn.started",
501 eventId: EventId.make("evt-turn-started-1"),
502 provider: ProviderDriverKind.make("codex"),
517 harness.provider.emit({
518 type: "turn.started",
519 eventId: EventId.make("evt-turn-started-1"),
520 provider: ProviderDriverKind.make("codex"),
503521
504 createdAt: "2026-01-01T00:00:00.000Z",
505 threadId: ThreadId.make("thread-1"),
506 turnId: asTurnId("turn-1"),
507 });
508 await waitForGitRefExists(
509 harness.cwd,
510 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
511 );
522 createdAt: "2026-01-01T00:00:00.000Z",
523 threadId: ThreadId.make("thread-1"),
524 turnId: asTurnId("turn-1"),
525 });
526 expect(yield* harness.nextReceipt).toMatchObject({
527 type: "checkpoint.baseline.captured",
528 checkpointTurnCount: 0,
529 });
512530
513 NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8");
514 harness.provider.emit({
515 type: "turn.completed",
516 eventId: EventId.make("evt-turn-completed-1"),
517 provider: ProviderDriverKind.make("codex"),
531 NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8");
532 const largeFileLineCount = 25_000;
533 NodeFS.writeFileSync(
534 NodePath.join(harness.cwd, "large.txt"),
535 `${"payload".repeat(64)}\n`.repeat(largeFileLineCount),
536 "utf8",
537 );
538 harness.provider.emit({
539 type: "turn.completed",
540 eventId: EventId.make("evt-turn-completed-1"),
541 provider: ProviderDriverKind.make("codex"),
518542
519 createdAt: "2026-01-01T00:00:00.000Z",
520 threadId: ThreadId.make("thread-1"),
521 turnId: asTurnId("turn-1"),
522 payload: { state: "completed" },
523 });
543 createdAt: "2026-01-01T00:00:00.000Z",
544 threadId: ThreadId.make("thread-1"),
545 turnId: asTurnId("turn-1"),
546 payload: { state: "completed" },
547 });
524548
525 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
526 const thread = await waitForThread(
527 harness.readModel,
528 (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1,
529 );
530 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
531 expect(
532 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)),
533 ).toBe(true);
534 expect(
535 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)),
536 ).toBe(true);
537 expect(
538 gitShowFileAtRef(
539 harness.cwd,
540 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
541 "README.md",
542 ),
543 ).toBe("v1\n");
544 expect(
545 gitShowFileAtRef(
546 harness.cwd,
547 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
548 "README.md",
549 ),
550 ).toBe("v2\n");
551 });
549 expect(yield* harness.nextReceipt).toMatchObject({
550 type: "checkpoint.diff.finalized",
551 turnId: "turn-1",
552 checkpointTurnCount: 1,
553 });
554 const thread = (yield* Effect.promise(harness.readModel)).threads.find(
555 (entry) => entry.id === "thread-1",
556 );
557 expect(thread?.checkpoints[0]).toMatchObject({
558 checkpointTurnCount: 1,
559 files: [
560 { path: "large.txt", kind: "modified", additions: largeFileLineCount, deletions: 0 },
561 { path: "README.md", kind: "modified", additions: 1, deletions: 1 },
562 ],
563 });
564 expect(yield* harness.nextReceipt).toMatchObject({
565 type: "turn.processing.quiesced",
566 turnId: "turn-1",
567 checkpointTurnCount: 1,
568 });
569 yield* Effect.promise(harness.drain);
570 expect(
571 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)),
572 ).toBe(true);
573 expect(
574 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)),
575 ).toBe(true);
576 expect(
577 gitShowFileAtRef(
578 harness.cwd,
579 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
580 "README.md",
581 ),
582 ).toBe("v1\n");
583 expect(
584 gitShowFileAtRef(
585 harness.cwd,
586 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
587 "README.md",
588 ),
589 ).toBe("v2\n");
590 }),
591 );
552592
553593 it("refreshes local git status state on turn completion using the session cwd", async () => {
554594 const gitStatusRefreshCalls: string[] = [];
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-2
......@@ -21,7 +21,7 @@ import * as Stream from "effect/Stream";
2121import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
2222import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
2323
24import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
24import { parseTurnDiffFilesFromNumstat } from "../../checkpointing/Diffs.ts";
2525import {
2626 checkpointRefForThreadTurn,
2727 resolveThreadWorkspaceCwd,
......@@ -270,10 +270,11 @@ const make = Effect.gen(function* () {
270270 toCheckpointRef: targetCheckpointRef,
271271 fallbackFromToHead: false,
272272 ignoreWhitespace: false,
273 format: "numstat",
273274 })
274275 .pipe(
275276 Effect.map((diff) =>
276 parseTurnDiffFilesFromUnifiedDiff(diff).map((file) => ({
277 parseTurnDiffFilesFromNumstat(diff).map((file) => ({
277278 path: file.path,
278279 kind: "modified" as const,
279280 additions: file.additions,
apps/server/src/vcs/GitVcsDriver.test.ts+4
......@@ -68,6 +68,7 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({
6868it.effect("GitVcsDriver forwards execute env to the VCS process", () => {
6969 let observedEnv: NodeJS.ProcessEnv | undefined;
7070 let observedAppendTruncationMarker: boolean | undefined;
71 let observedOutputMode: VcsProcess.VcsProcessInput["outputMode"];
7172
7273 return Effect.gen(function* () {
7374 const driver = yield* GitVcsDriver.makeVcsDriverShape();
......@@ -80,12 +81,14 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => {
8081 GIT_INDEX_FILE: "/tmp/t3-index",
8182 },
8283 appendTruncationMarker: true,
84 outputMode: "error",
8385 });
8486
8587 assert.deepStrictEqual(observedEnv, {
8688 GIT_INDEX_FILE: "/tmp/t3-index",
8789 });
8890 assert.strictEqual(observedAppendTruncationMarker, true);
91 assert.strictEqual(observedOutputMode, "error");
8992 }).pipe(
9093 Effect.provide(
9194 Layer.mergeAll(
......@@ -95,6 +98,7 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => {
9598 Effect.sync(() => {
9699 observedEnv = input.env;
97100 observedAppendTruncationMarker = input.appendTruncationMarker;
101 observedOutputMode = input.outputMode;
98102 return {
99103 exitCode: ChildProcessSpawner.ExitCode(0),
100104 stdout: "",
apps/server/src/vcs/GitVcsDriver.ts+6-1
......@@ -431,6 +431,7 @@ const gitCommand = (
431431 readonly allowNonZeroExit?: boolean;
432432 readonly timeoutMs?: number;
433433 readonly maxOutputBytes?: number;
434 readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"];
434435 readonly appendTruncationMarker?: boolean;
435436 },
436437) =>
......@@ -447,6 +448,7 @@ const gitCommand = (
447448 : {}),
448449 ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
449450 ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
451 ...(options?.outputMode !== undefined ? { outputMode: options.outputMode } : {}),
450452 ...(options?.appendTruncationMarker !== undefined
451453 ? { appendTruncationMarker: options.appendTruncationMarker }
452454 : {}),
......@@ -485,6 +487,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
485487 ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}),
486488 ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
487489 ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),
490 ...(input.outputMode !== undefined ? { outputMode: input.outputMode } : {}),
488491 ...(input.appendTruncationMarker !== undefined
489492 ? { appendTruncationMarker: input.appendTruncationMarker }
490493 : {}),
......@@ -838,6 +841,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
838841 "checkpoint.from_ref": input.fromCheckpointRef,
839842 "checkpoint.to_ref": input.toCheckpointRef,
840843 "checkpoint.ignore_whitespace": input.ignoreWhitespace,
844 "checkpoint.format": input.format ?? "patch",
841845 "checkpoint.fallback_from_to_head": input.fallbackFromToHead,
842846 });
843847
......@@ -869,7 +873,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
869873 cwd: input.cwd,
870874 args: [
871875 "diff",
872 "--patch",
876 ...(input.format === "numstat" ? ["--numstat", "-z"] : ["--patch"]),
873877 "--no-color",
874878 "--no-ext-diff",
875879 "--no-textconv",
......@@ -880,6 +884,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
880884 ],
881885 allowNonZeroExit: true,
882886 maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
887 outputMode: input.format === "numstat" ? "error" : "truncate",
883888 });
884889
885890 if (result.exitCode !== 0) {
apps/server/src/vcs/VcsDriver.ts+1
......@@ -31,6 +31,7 @@ export interface VcsDiffCheckpointsInput {
3131 readonly toCheckpointRef: CheckpointRef;
3232 readonly fallbackFromToHead?: boolean;
3333 readonly ignoreWhitespace: boolean;
34 readonly format?: "patch" | "numstat";
3435}
3536
3637export interface VcsDeleteCheckpointRefsInput {
apps/server/src/vcs/VcsProcess.test.ts+20-1
......@@ -1,5 +1,6 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
2import { assert, describe, expect, it } from "@effect/vitest";
3import { HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";
34import * as Duration from "effect/Duration";
45import * as Deferred from "effect/Deferred";
56import * as Effect from "effect/Effect";
......@@ -359,6 +360,24 @@ describe("VcsProcess.run", () => {
359360 }).pipe(provideLive),
360361 );
361362
363 it.effect("fails with measured byte counts when output must not be truncated", () =>
364 Effect.gen(function* () {
365 const error = yield* run({
366 operation: "test.output-limit",
367 command: "node",
368 args: ["-e", "process.stdout.write('x'.repeat(2048))"],
369 cwd: yield* HostProcessWorkingDirectory,
370 maxOutputBytes: 128,
371 outputMode: "error",
372 }).pipe(Effect.flip);
373
374 assert(error._tag === "VcsProcessOutputLimitError");
375 expect(error.stream).toBe("stdout");
376 expect(error.maxBytes).toBe(128);
377 expect(error.observedBytes).toBeGreaterThan(error.maxBytes);
378 }).pipe(provideLive),
379 );
380
362381 it.effect("fails with VcsProcessTimeoutError on timeout", () =>
363382 Effect.gen(function* () {
364383 const errorFiber = yield* run({
apps/server/src/vcs/VcsProcess.ts+2-1
......@@ -29,6 +29,7 @@ export interface VcsProcessInput {
2929 readonly allowNonZeroExit?: boolean;
3030 readonly timeoutMs?: number;
3131 readonly maxOutputBytes?: number;
32 readonly outputMode?: ProcessRunner.ProcessRunInput["outputMode"];
3233 readonly appendTruncationMarker?: boolean;
3334}
3435
......@@ -125,7 +126,7 @@ export const make = Effect.gen(function* () {
125126 ...(input.env !== undefined ? { env: input.env } : {}),
126127 timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
127128 maxOutputBytes: input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
128 outputMode: "truncate",
129 outputMode: input.outputMode ?? "truncate",
129130 truncatedMarker: input.appendTruncationMarker ? OUTPUT_TRUNCATED_MARKER : "",
130131 timeoutBehavior: "error",
131132 })
docs/internals/glossary.md+1-1
......@@ -147,7 +147,7 @@ The starting checkpoint for diffing a thread timeline. This flow is surfaced thr
147147
148148#### Checkpoint diff
149149
150The patch difference between two checkpoints. Query logic lives in [CheckpointDiffQuery.ts][20], diff parsing lives in [Diffs.ts][23], and finalization is coordinated by [CheckpointReactor.ts][6].
150The difference between two checkpoints. [CheckpointDiffQuery.ts][20] reads full patches on demand. [CheckpointReactor.ts][6] uses NUL-delimited Git numstat output for automatic file summaries, parsed by [Diffs.ts][23].
151151
152152#### Turn diff
153153
pnpm-lock.yaml-3
......@@ -496,9 +496,6 @@ importers:
496496 '@opencode-ai/sdk':
497497 specifier: ^1.3.15
498498 version: 1.15.13
499 '@pierre/diffs':
500 specifier: 'catalog:'
501 version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
502499 effect:
503500 specifier: 4.0.0-beta.103
504501 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)