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", () => {...@@ -590,6 +590,83 @@ describe("CheckpointReactor", () => {
590 }),590 }),
591 );591 );
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
593 it("refreshes local git status state on turn completion using the session cwd", async () => {670 it("refreshes local git status state on turn completion using the session cwd", async () => {
594 const gitStatusRefreshCalls: string[] = [];671 const gitStatusRefreshCalls: string[] = [];
595 const harness = await createHarness({672 const harness = await createHarness({
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-6
...@@ -35,7 +35,6 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"...@@ -35,7 +35,6 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"
35import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";35import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
36import type { CheckpointStoreError } from "../../checkpointing/Errors.ts";36import type { CheckpointStoreError } from "../../checkpointing/Errors.ts";
37import type { OrchestrationDispatchError } from "../Errors.ts";37import type { OrchestrationDispatchError } from "../Errors.ts";
38import { isGitRepository } from "../../git/Utils.ts";
39import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";38import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
40import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";39import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
41import * as PullRequestService from "../../pullRequest/PullRequestService.ts";40import * as PullRequestService from "../../pullRequest/PullRequestService.ts";
...@@ -181,8 +180,6 @@ const make = Effect.gen(function* () {...@@ -181,8 +180,6 @@ const make = Effect.gen(function* () {
181 return project ? [project] : [];180 return project ? [project] : [];
182 });181 });
183182
184 const isGitWorkspace = (cwd: string) => isGitRepository(cwd);
185
186 // Resolves the workspace CWD for checkpoint operations, preferring the183 // Resolves the workspace CWD for checkpoint operations, preferring the
187 // active provider session CWD and falling back to the thread/project config.184 // active provider session CWD and falling back to the thread/project config.
188 // Returns undefined when no CWD can be determined or the workspace is not185 // Returns undefined when no CWD can be determined or the workspace is not
...@@ -192,7 +189,7 @@ const make = Effect.gen(function* () {...@@ -192,7 +189,7 @@ const make = Effect.gen(function* () {
192 readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null };189 readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null };
193 readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>;190 readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>;
194 readonly preferSessionRuntime: boolean;191 readonly preferSessionRuntime: boolean;
195 }): Effect.fn.Return<string | undefined> {192 }): Effect.fn.Return<string | undefined, CheckpointStoreError> {
196 const fromSession = yield* resolveSessionRuntimeForThread(input.threadId);193 const fromSession = yield* resolveSessionRuntimeForThread(input.threadId);
197 const fromThread = resolveThreadWorkspaceCwd({194 const fromThread = resolveThreadWorkspaceCwd({
198 thread: input.thread,195 thread: input.thread,
...@@ -213,7 +210,7 @@ const make = Effect.gen(function* () {...@@ -213,7 +210,7 @@ const make = Effect.gen(function* () {
213 if (!cwd) {210 if (!cwd) {
214 return undefined;211 return undefined;
215 }212 }
216 if (!isGitWorkspace(cwd)) {213 if (!(yield* checkpointStore.isGitRepository(cwd))) {
217 return undefined;214 return undefined;
218 }215 }
219 return cwd;216 return cwd;
...@@ -751,7 +748,7 @@ const make = Effect.gen(function* () {...@@ -751,7 +748,7 @@ const make = Effect.gen(function* () {
751 }).pipe(Effect.catch(() => Effect.void));748 }).pipe(Effect.catch(() => Effect.void));
752 return;749 return;
753 }750 }
754 if (!isGitWorkspace(sessionRuntime.value.cwd)) {751 if (!(yield* checkpointStore.isGitRepository(sessionRuntime.value.cwd))) {
755 yield* appendRevertFailureActivity({752 yield* appendRevertFailureActivity({
756 threadId: event.payload.threadId,753 threadId: event.payload.threadId,
757 turnCount: event.payload.turnCount,754 turnCount: event.payload.turnCount,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+41-2
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
2import * as NodeFS from "node:fs";2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";4import * as NodePath from "node:path";
5import * as NodeChildProcess from "node:child_process";
56
6import {7import {
7 OrchestrationReadModel,8 OrchestrationReadModel,
...@@ -44,6 +45,9 @@ import {...@@ -44,6 +45,9 @@ import {
44 type ProviderServiceShape,45 type ProviderServiceShape,
45} from "../../provider/Services/ProviderService.ts";46} from "../../provider/Services/ProviderService.ts";
46import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";47import * 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";
47import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";51import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
48import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";52import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
49import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";53import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
...@@ -257,9 +261,15 @@ describe("ProviderRuntimeIngestion", () => {...@@ -257,9 +261,15 @@ describe("ProviderRuntimeIngestion", () => {
257 async function createHarness(options?: {261 async function createHarness(options?: {
258 serverSettings?: Partial<ServerSettings>;262 serverSettings?: Partial<ServerSettings>;
259 threadTitle?: string;263 threadTitle?: string;
264 workspaceSubdirectory?: string;
260 }) {265 }) {
261 const workspaceRoot = makeTempDir("t3-provider-project-");266 const repositoryRoot = makeTempDir("t3-provider-project-");
262 NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git"));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 });
263 const provider = createProviderServiceHarness();273 const provider = createProviderServiceHarness();
264 const sqlCounter = makeSqlStatementCounter();274 const sqlCounter = makeSqlStatementCounter();
265 const orchestrationLayer = OrchestrationEngineLive.pipe(275 const orchestrationLayer = OrchestrationEngineLive.pipe(
...@@ -284,6 +294,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -284,6 +294,8 @@ describe("ProviderRuntimeIngestion", () => {
284 Layer.provideMerge(SqlitePersistenceMemory),294 Layer.provideMerge(SqlitePersistenceMemory),
285 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),295 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
286 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),296 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
297 Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))),
298 Layer.provideMerge(VcsProcess.layer),
287 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),299 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
288 Layer.provideMerge(NodeServices.layer),300 Layer.provideMerge(NodeServices.layer),
289 Layer.provideMerge(Layer.succeed(Tracer.Tracer, sqlCounter.tracer)),301 Layer.provideMerge(Layer.succeed(Tracer.Tracer, sqlCounter.tracer)),
...@@ -3177,6 +3189,33 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3177,6 +3189,33 @@ describe("ProviderRuntimeIngestion", () => {
3177 });3189 });
3178 });3190 });
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
3180 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {3219 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {
3181 const harness = await createHarness();3220 const harness = await createHarness();
3182 const now = "2026-01-01T00:00:00.000Z";3221 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";...@@ -36,7 +36,7 @@ import { formatTokens } from "@t3tools/shared/usageFormat";
36import { ProviderService } from "../../provider/Services/ProviderService.ts";36import { ProviderService } from "../../provider/Services/ProviderService.ts";
37import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";37import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
38import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";38import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
39import { isGitRepository } from "../../git/Utils.ts";39import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
40import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";40import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
41import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";41import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
42import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";42import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
...@@ -956,6 +956,7 @@ const make = Effect.gen(function* () {...@@ -956,6 +956,7 @@ const make = Effect.gen(function* () {
956 const providerService = yield* ProviderService;956 const providerService = yield* ProviderService;
957 const projectionTurnRepository = yield* ProjectionTurnRepository;957 const projectionTurnRepository = yield* ProjectionTurnRepository;
958 const serverSettingsService = yield* ServerSettingsService;958 const serverSettingsService = yield* ServerSettingsService;
959 const checkpointStore = yield* CheckpointStore.CheckpointStore;
959 const providerCommandId = (event: ProviderRuntimeEvent, tag: string) =>960 const providerCommandId = (event: ProviderRuntimeEvent, tag: string) =>
960 crypto.randomUUIDv4.pipe(961 crypto.randomUUIDv4.pipe(
961 Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)),962 Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)),
...@@ -2013,7 +2014,12 @@ const make = Effect.gen(function* () {...@@ -2013,7 +2014,12 @@ const make = Effect.gen(function* () {
2013 : undefined;2014 : undefined;
2014 const workspaceCwd =2015 const workspaceCwd =
2015 checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot ?? undefined;2016 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 ) {
2017 // Skip if a checkpoint already exists for this turn. A real2023 // Skip if a checkpoint already exists for this turn. A real
2018 // (non-placeholder) capture from CheckpointReactor should not2024 // (non-placeholder) capture from CheckpointReactor should not
2019 // be clobbered, and dispatching a duplicate placeholder for the2025 // be clobbered, and dispatching a duplicate placeholder for the