authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 16:45:00-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 16:45:00-07:00
log0dd5c64bcfac7974d81bac76740ecd83540077e8
tree5928af5f93a8cacd90fa9793ee38e89576c5844e
parentf33fdc992488e36ccb70cbb71d55e630b5184cbd
signature Signed by PGP key B5690EEEBB952194

fix(server): detect nested Git workspaces for checkpoints (#9842)


5 files changed, 129 insertions(+), 17 deletions(-)

apps/server/src/git/Utils.ts deleted-7
......@@ -1,7 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4
5export function isGitRepository(cwd: string): boolean {
6 return NodeFS.existsSync(NodePath.join(cwd, ".git"));
7}
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+77
......@@ -590,6 +590,83 @@ describe("CheckpointReactor", () => {
590590 }),
591591 );
592592
593 effectIt.effect("captures and reverts checkpoints from a nested Git workspace", () =>
594 Effect.gen(function* () {
595 const repositoryRoot = createGitRepository();
596 tempDirs.push(repositoryRoot);
597 const workspaceRoot = NodePath.join(repositoryRoot, "apps", "server");
598 NodeFS.mkdirSync(workspaceRoot, { recursive: true });
599 const filePath = NodePath.join(workspaceRoot, "index.ts");
600 NodeFS.writeFileSync(filePath, "export const value = 1;\n");
601 runGit(repositoryRoot, ["add", "."]);
602 runGit(repositoryRoot, ["commit", "-m", "Add nested workspace"]);
603 const harness = yield* Effect.promise(() =>
604 createHarness({
605 seedFilesystemCheckpoints: false,
606 projectWorkspaceRoot: workspaceRoot,
607 threadWorktreePath: workspaceRoot,
608 providerSessionCwd: workspaceRoot,
609 }),
610 );
611 const threadId = ThreadId.make("thread-1");
612 const turnId = asTurnId("turn-nested");
613 const createdAt = "2026-01-01T00:00:00.000Z";
614 harness.provider.emit({
615 type: "turn.started",
616 eventId: EventId.make("evt-nested-start"),
617 provider: ProviderDriverKind.make("codex"),
618 createdAt,
619 threadId,
620 turnId,
621 });
622 yield* Effect.promise(harness.drain);
623 expect(gitRefExists(repositoryRoot, checkpointRefForThreadTurn(threadId, 0))).toBe(true);
624 expect(yield* harness.nextReceipt).toMatchObject({
625 type: "checkpoint.baseline.captured",
626 });
627
628 NodeFS.writeFileSync(filePath, "export const value = 2;\n");
629 harness.provider.emit({
630 type: "turn.completed",
631 eventId: EventId.make("evt-nested-complete"),
632 provider: ProviderDriverKind.make("codex"),
633 createdAt,
634 threadId,
635 turnId,
636 payload: { state: "completed" },
637 });
638 yield* Effect.promise(harness.drain);
639 const thread = (yield* Effect.promise(harness.readModel)).threads.find(
640 (entry) => entry.id === threadId,
641 );
642 expect(thread?.checkpoints[0]).toMatchObject({
643 status: "ready",
644 files: [{ path: "apps/server/index.ts", additions: 1, deletions: 1 }],
645 });
646 expect(yield* harness.nextReceipt).toMatchObject({
647 type: "checkpoint.diff.finalized",
648 turnId,
649 });
650 expect(yield* harness.nextReceipt).toMatchObject({ type: "turn.processing.quiesced" });
651
652 yield* harness.engine.dispatch({
653 type: "thread.checkpoint.revert",
654 commandId: CommandId.make("cmd-nested-revert"),
655 threadId,
656 turnCount: 0,
657 createdAt,
658 });
659 yield* Effect.promise(harness.drain);
660 expect(NodeFS.readFileSync(filePath, "utf8")).toBe("export const value = 1;\n");
661 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ threadId, numTurns: 1 });
662 expect(gitRefExists(repositoryRoot, checkpointRefForThreadTurn(threadId, 1))).toBe(false);
663 const reverted = (yield* Effect.promise(harness.readModel)).threads.find(
664 (entry) => entry.id === threadId,
665 );
666 expect(reverted?.checkpoints).toEqual([]);
667 }),
668 );
669
593670 it("refreshes local git status state on turn completion using the session cwd", async () => {
594671 const gitStatusRefreshCalls: string[] = [];
595672 const harness = await createHarness({
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-6
......@@ -35,7 +35,6 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"
3535import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
3636import type { CheckpointStoreError } from "../../checkpointing/Errors.ts";
3737import type { OrchestrationDispatchError } from "../Errors.ts";
38import { isGitRepository } from "../../git/Utils.ts";
3938import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
4039import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
4140import * as PullRequestService from "../../pullRequest/PullRequestService.ts";
......@@ -181,8 +180,6 @@ const make = Effect.gen(function* () {
181180 return project ? [project] : [];
182181 });
183182
184 const isGitWorkspace = (cwd: string) => isGitRepository(cwd);
185
186183 // Resolves the workspace CWD for checkpoint operations, preferring the
187184 // active provider session CWD and falling back to the thread/project config.
188185 // Returns undefined when no CWD can be determined or the workspace is not
......@@ -192,7 +189,7 @@ const make = Effect.gen(function* () {
192189 readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null };
193190 readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>;
194191 readonly preferSessionRuntime: boolean;
195 }): Effect.fn.Return<string | undefined> {
192 }): Effect.fn.Return<string | undefined, CheckpointStoreError> {
196193 const fromSession = yield* resolveSessionRuntimeForThread(input.threadId);
197194 const fromThread = resolveThreadWorkspaceCwd({
198195 thread: input.thread,
......@@ -213,7 +210,7 @@ const make = Effect.gen(function* () {
213210 if (!cwd) {
214211 return undefined;
215212 }
216 if (!isGitWorkspace(cwd)) {
213 if (!(yield* checkpointStore.isGitRepository(cwd))) {
217214 return undefined;
218215 }
219216 return cwd;
......@@ -751,7 +748,7 @@ const make = Effect.gen(function* () {
751748 }).pipe(Effect.catch(() => Effect.void));
752749 return;
753750 }
754 if (!isGitWorkspace(sessionRuntime.value.cwd)) {
751 if (!(yield* checkpointStore.isGitRepository(sessionRuntime.value.cwd))) {
755752 yield* appendRevertFailureActivity({
756753 threadId: event.payload.threadId,
757754 turnCount: event.payload.turnCount,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+41-2
......@@ -2,6 +2,7 @@
22import * as NodeFS from "node:fs";
33import * as NodeOS from "node:os";
44import * as NodePath from "node:path";
5import * as NodeChildProcess from "node:child_process";
56
67import {
78 OrchestrationReadModel,
......@@ -44,6 +45,9 @@ import {
4445 type ProviderServiceShape,
4546} from "../../provider/Services/ProviderService.ts";
4647import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
48import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
49import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
50import * as VcsProcess from "../../vcs/VcsProcess.ts";
4751import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4852import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
4953import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
......@@ -257,9 +261,15 @@ describe("ProviderRuntimeIngestion", () => {
257261 async function createHarness(options?: {
258262 serverSettings?: Partial<ServerSettings>;
259263 threadTitle?: string;
264 workspaceSubdirectory?: string;
260265 }) {
261 const workspaceRoot = makeTempDir("t3-provider-project-");
262 NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git"));
266 const repositoryRoot = makeTempDir("t3-provider-project-");
267 NodeChildProcess.execFileSync("git", ["init", "--initial-branch=main"], {
268 cwd: repositoryRoot,
269 stdio: "ignore",
270 });
271 const workspaceRoot = NodePath.join(repositoryRoot, options?.workspaceSubdirectory ?? "");
272 NodeFS.mkdirSync(workspaceRoot, { recursive: true });
263273 const provider = createProviderServiceHarness();
264274 const sqlCounter = makeSqlStatementCounter();
265275 const orchestrationLayer = OrchestrationEngineLive.pipe(
......@@ -284,6 +294,8 @@ describe("ProviderRuntimeIngestion", () => {
284294 Layer.provideMerge(SqlitePersistenceMemory),
285295 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
286296 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
297 Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))),
298 Layer.provideMerge(VcsProcess.layer),
287299 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
288300 Layer.provideMerge(NodeServices.layer),
289301 Layer.provideMerge(Layer.succeed(Tracer.Tracer, sqlCounter.tracer)),
......@@ -3177,6 +3189,33 @@ describe("ProviderRuntimeIngestion", () => {
31773189 });
31783190 });
31793191
3192 effectIt.effect("tracks provider diff updates from a nested Git workspace", () =>
3193 Effect.gen(function* () {
3194 const harness = yield* Effect.promise(() =>
3195 createHarness({ workspaceSubdirectory: "apps/server" }),
3196 );
3197 yield* Effect.promise(() =>
3198 harness.emitAndDrain([
3199 {
3200 type: "turn.diff.updated",
3201 eventId: asEventId("evt-nested-diff"),
3202 provider: ProviderDriverKind.make("codex"),
3203 createdAt: "2026-01-01T00:00:00.000Z",
3204 threadId: asThreadId("thread-1"),
3205 turnId: asTurnId("nested-turn"),
3206 payload: {
3207 unifiedDiff: "diff --git a/apps/server/file.ts b/apps/server/file.ts\n+new\n",
3208 },
3209 },
3210 ]),
3211 );
3212 const snapshot = yield* Effect.promise(harness.readModel);
3213 expect(snapshot.threads[0]?.checkpoints).toEqual([
3214 expect.objectContaining({ turnId: "nested-turn", status: "missing" }),
3215 ]);
3216 }),
3217 );
3218
31803219 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {
31813220 const harness = await createHarness();
31823221 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+8-2
......@@ -36,7 +36,7 @@ import { formatTokens } from "@t3tools/shared/usageFormat";
3636import { ProviderService } from "../../provider/Services/ProviderService.ts";
3737import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
3838import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
39import { isGitRepository } from "../../git/Utils.ts";
39import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
4040import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
4141import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
4242import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
......@@ -956,6 +956,7 @@ const make = Effect.gen(function* () {
956956 const providerService = yield* ProviderService;
957957 const projectionTurnRepository = yield* ProjectionTurnRepository;
958958 const serverSettingsService = yield* ServerSettingsService;
959 const checkpointStore = yield* CheckpointStore.CheckpointStore;
959960 const providerCommandId = (event: ProviderRuntimeEvent, tag: string) =>
960961 crypto.randomUUIDv4.pipe(
961962 Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)),
......@@ -2013,7 +2014,12 @@ const make = Effect.gen(function* () {
20132014 : undefined;
20142015 const workspaceCwd =
20152016 checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot ?? undefined;
2016 if (turnId && checkpointContext && workspaceCwd && isGitRepository(workspaceCwd)) {
2017 if (
2018 turnId &&
2019 checkpointContext &&
2020 workspaceCwd &&
2021 (yield* checkpointStore.isGitRepository(workspaceCwd))
2022 ) {
20172023 // Skip if a checkpoint already exists for this turn. A real
20182024 // (non-placeholder) capture from CheckpointReactor should not
20192025 // be clobbered, and dispatching a duplicate placeholder for the