History

fix(server): resume checkpointing after git init (#10078)

Yash Singh committed 2 months ago

Commit c843c191 parent ab0933a2 files · +182 −77Browse files

Unified · Split

2 files
  1. apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +142 −42
  2. apps/server/src/orchestration/Layers/CheckpointReactor.ts +40 −35
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+142−42
@@ -292,6 +292,7 @@ describe("CheckpointReactor", () => { · 291 unmodified lines
292292 async function createHarness(options?: {
293293 readonly hasSession?: boolean;
294294 readonly seedFilesystemCheckpoints?: boolean;
295+ readonly initializeGit?: boolean;
295296 readonly projectWorkspaceRoot?: string;
296297 readonly threadWorktreePath?: string | null;
297298 readonly threadBranch?: string | null;
@@ -303,6 +304,9 @@ describe("CheckpointReactor", () => { · 5 unmodified lines
303304 readonly pullRequestRefreshCalls?: Array<string>;
304305 }) {
305306 const cwd = createGitRepository();
307+ if (options?.initializeGit === false) {
308+ NodeFS.rmSync(NodePath.join(cwd, ".git"), { recursive: true });
309+ }
306310 tempDirs.push(cwd);
307311 const provider = createProviderServiceHarness(
308312 cwd,
@@ -1158,52 +1162,148 @@ describe("CheckpointReactor", () => { · 849 unmodified lines
11581162 ).toBe(true);
11591163 });
11601164
1161− it("appends capture failure activity when turn diff summary cannot be derived", async () => {
1162− const harness = await createHarness({ seedFilesystemCheckpoints: false });
1163− const createdAt = "2026-01-01T00:00:00.000Z";
1164−
1165− await Effect.runPromise(
1166− harness.engine.dispatch({
1167− type: "thread.session.set",
1168− commandId: CommandId.make("cmd-session-set-missing-baseline-diff"),
1165+ effectIt.effect("captures a checkpoint without a summary when the baseline is missing", () =>
1166+ Effect.gen(function* () {
1167+ const harness = yield* Effect.promise(() =>
1168+ createHarness({ seedFilesystemCheckpoints: false }),
1169+ );
1170+ harness.provider.emit({
1171+ type: "turn.completed",
1172+ eventId: EventId.make("evt-turn-completed-missing-baseline"),
1173+ provider: ProviderDriverKind.make("codex"),
1174+ createdAt: "2026-01-01T00:00:00.000Z",
11691175 threadId: ThreadId.make("thread-1"),
1170− session: {
1171− threadId: ThreadId.make("thread-1"),
1172− status: "ready",
1173− providerName: "codex",
1174− runtimeMode: "approval-required",
1175− activeTurnId: null,
1176− lastError: null,
1177− updatedAt: createdAt,
1178− },
1179− createdAt,
1180− }),
1181− );
1182−
1183− harness.provider.emit({
1184− type: "turn.completed",
1185− eventId: EventId.make("evt-turn-completed-missing-baseline"),
1186− provider: ProviderDriverKind.make("codex"),
1176+ turnId: asTurnId("turn-missing-baseline"),
1177+ payload: { state: "completed" },
1178+ });
1179+ expect(yield* harness.nextReceipt).toMatchObject({
1180+ type: "checkpoint.diff.finalized",
1181+ checkpointTurnCount: 1,
1182+ });
1183+ yield* Effect.promise(harness.drain);
1184+ const thread = (yield* Effect.promise(harness.readModel)).threads[0];
1185+ expect(thread?.checkpoints[0]).toMatchObject({
1186+ status: "ready",
1187+ checkpointTurnCount: 1,
1188+ files: [],
1189+ });
1190+ expect(
1191+ gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)),
1192+ ).toBe(true);
1193+ expect(
1194+ thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"),
1195+ ).toBe(false);
1196+ }),
1197+ );
11871198
1188− createdAt: "2026-01-01T00:00:00.000Z",
1189− threadId: ThreadId.make("thread-1"),
1190− turnId: asTurnId("turn-missing-baseline"),
1191− payload: { state: "completed" },
1192− });
1199+ effectIt.effect.each([
1200+ { timing: "between turns", commit: false },
1201+ { timing: "between turns", commit: true },
1202+ { timing: "during a turn", commit: false },
1203+ { timing: "during a turn", commit: true },
1204+ ])("resumes checkpointing after git init $timing (commit: $commit)", ({ timing, commit }) =>
1205+ Effect.gen(function* () {
1206+ const harness = yield* Effect.promise(() =>
1207+ createHarness({ initializeGit: false, seedFilesystemCheckpoints: false }),
1208+ );
1209+ const threadId = ThreadId.make("thread-1");
1210+ const createdAt = "2026-01-01T00:00:00.000Z";
1211+ const emit = (type: "turn.started" | "turn.completed", turn: number) =>
1212+ harness.provider.emit({
1213+ type,
1214+ eventId: EventId.make(`${type}-${turn}`),
1215+ provider: ProviderDriverKind.make("codex"),
1216+ createdAt,
1217+ threadId,
1218+ turnId: asTurnId(`turn-${turn}`),
1219+ ...(type === "turn.completed" ? { payload: { state: "completed" } } : {}),
1220+ });
1221+ emit("turn.started", 1);
1222+ yield* Effect.promise(harness.drain);
1223+ NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "before git\n");
1224+ emit("turn.completed", 1);
1225+ yield* Effect.promise(harness.drain);
1226+ expect((yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints).toEqual([]);
11931227
1194− await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
1195− const thread = await waitForThread(
1196− harness.readModel,
1197− (entry) =>
1198− entry.checkpoints.length === 1 &&
1199− entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"),
1200− );
1228+ if (timing === "during a turn") {
1229+ emit("turn.started", 2);
1230+ yield* Effect.promise(harness.drain);
1231+ }
1232+ runGit(harness.cwd, ["init", "--initial-branch=main"]);
1233+ if (commit) {
1234+ runGit(harness.cwd, ["add", "."]);
1235+ runGit(harness.cwd, [
1236+ "-c",
1237+ "user.name=Test",
1238+ "-c",
1239+ "user.email=test@example.com",
1240+ "commit",
1241+ "-m",
1242+ "Initial",
1243+ ]);
1244+ }
1245+ if (timing === "between turns") {
1246+ // Exercise the domain entry point as well as the provider turn-start event.
1247+ yield* harness.engine.dispatch({
1248+ type: "thread.turn.start",
1249+ commandId: CommandId.make("cmd-after-git-init"),
1250+ threadId,
1251+ message: {
1252+ messageId: MessageId.make("message-after-git-init"),
1253+ role: "user",
1254+ text: "continue",
1255+ attachments: [],
1256+ },
1257+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1258+ runtimeMode: "approval-required",
1259+ createdAt,
1260+ });
1261+ expect(yield* harness.nextReceipt).toMatchObject({
1262+ type: "checkpoint.baseline.captured",
1263+ checkpointTurnCount: 0,
1264+ });
1265+ emit("turn.started", 2);
1266+ yield* Effect.promise(harness.drain);
1267+ }
1268+ NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "after git\n");
1269+ emit("turn.completed", 2);
1270+ expect(yield* harness.nextReceipt).toMatchObject({
1271+ type: "checkpoint.diff.finalized",
1272+ checkpointTurnCount: 1,
1273+ });
1274+ expect(yield* harness.nextReceipt).toMatchObject({ type: "turn.processing.quiesced" });
1275+ yield* Effect.promise(harness.drain);
1276+ const firstCheckpoint = (yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints[0];
1277+ expect(firstCheckpoint?.files).toEqual(
1278+ timing === "between turns"
1279+ ? [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }]
1280+ : [],
1281+ );
1282+ expect(
1283+ gitShowFileAtRef(harness.cwd, checkpointRefForThreadTurn(threadId, 1), "README.md"),
1284+ ).toBe("after git\n");
1285+ expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 0))).toBe(
1286+ timing === "between turns",
1287+ );
12011288
1202− expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
1203− expect(
1204− thread.activities.some((activity) => activity.kind === "checkpoint.capture.failed"),
1205− ).toBe(true);
1206− });
1289+ emit("turn.started", 3);
1290+ yield* Effect.promise(harness.drain);
1291+ NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "next turn\n");
1292+ emit("turn.completed", 3);
1293+ expect(yield* harness.nextReceipt).toMatchObject({
1294+ type: "checkpoint.diff.finalized",
1295+ checkpointTurnCount: 2,
1296+ });
1297+ yield* Effect.promise(harness.drain);
1298+ const thread = (yield* Effect.promise(harness.readModel)).threads[0];
1299+ expect(thread?.checkpoints[1]?.files).toEqual([
1300+ { path: "README.md", kind: "modified", additions: 1, deletions: 1 },
1301+ ]);
1302+ expect(
1303+ thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"),
1304+ ).toBe(false);
1305+ }),
1306+ );
12071307
12081308 it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => {
12091309 const harness = await createHarness({
apps/server/src/orchestration/Layers/CheckpointReactor.ts+40−35
@@ -258,41 +258,46 @@ const make = Effect.gen(function* () { · 257 unmodified lines
258258 // reflects files created or deleted during this turn.
259259 yield* workspaceEntries.refresh(input.cwd);
260260
261− const files = yield* checkpointStore
262− .diffCheckpoints({
263− cwd: input.cwd,
264− fromCheckpointRef,
265− toCheckpointRef: targetCheckpointRef,
266− fallbackFromToHead: false,
267− ignoreWhitespace: false,
268− format: "numstat",
269− })
270− .pipe(
271− Effect.map((diff) =>
272− parseTurnDiffFilesFromNumstat(diff).map((file) => ({
273− path: file.path,
274− kind: "modified" as const,
275− additions: file.additions,
276− deletions: file.deletions,
277− })),
278− ),
279− Effect.tapError((error) =>
280− appendCaptureFailureActivity({
281− threadId: input.threadId,
282− turnId: input.turnId,
283− detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`,
284− createdAt: input.createdAt,
285− }),
286− ),
287− Effect.catch((error) =>
288− Effect.logWarning("failed to derive checkpoint file summary", {
289− threadId: input.threadId,
290− turnId: input.turnId,
291− turnCount: input.turnCount,
292− detail: error.message,
293− }).pipe(Effect.as([])),
294− ),
295− );
261+ // Git may have been initialized during this turn, leaving no pre-turn
262+ // snapshot. Keep the completion checkpoint for future turns, but do not
263+ // invent a baseline or attempt a diff against a ref that does not exist.
264+ const files = yield* (
265+ fromCheckpointExists
266+ ? checkpointStore.diffCheckpoints({
267+ cwd: input.cwd,
268+ fromCheckpointRef,
269+ toCheckpointRef: targetCheckpointRef,
270+ fallbackFromToHead: false,
271+ ignoreWhitespace: false,
272+ format: "numstat",
273+ })
274+ : Effect.succeed("")
275+ ).pipe(
276+ Effect.map((diff) =>
277+ parseTurnDiffFilesFromNumstat(diff).map((file) => ({
278+ path: file.path,
279+ kind: "modified" as const,
280+ additions: file.additions,
281+ deletions: file.deletions,
282+ })),
283+ ),
284+ Effect.tapError((error) =>
285+ appendCaptureFailureActivity({
286+ threadId: input.threadId,
287+ turnId: input.turnId,
288+ detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`,
289+ createdAt: input.createdAt,
290+ }),
291+ ),
292+ Effect.catch((error) =>
293+ Effect.logWarning("failed to derive checkpoint file summary", {
294+ threadId: input.threadId,
295+ turnId: input.turnId,
296+ turnCount: input.turnCount,
297+ detail: error.message,
298+ }).pipe(Effect.as([])),
299+ ),
300+ );
296301
297302 const assistantMessageId =
298303 input.assistantMessageId ??