authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 20:15:40-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log76519303b9a488a0b9f4fb4c4b1968d6f9bacba8
tree1c7558bf5cd6e5b4fa0522c16bb91f8514eba9b0
parent448c48cd109bee06abecffddb3fc7130a50e3666

fix checkpoint path


4 files changed, 140 insertions(+), 18 deletions(-)

apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+52-3
...@@ -220,6 +220,8 @@ describe("CheckpointReactor", () => {...@@ -220,6 +220,8 @@ describe("CheckpointReactor", () => {
220 async function createHarness(options?: {220 async function createHarness(options?: {
221 readonly hasSession?: boolean;221 readonly hasSession?: boolean;
222 readonly seedFilesystemCheckpoints?: boolean;222 readonly seedFilesystemCheckpoints?: boolean;
223 readonly projectWorkspaceRoot?: string;
224 readonly threadWorktreePath?: string | null;
223 }) {225 }) {
224 const cwd = createGitRepository();226 const cwd = createGitRepository();
225 tempDirs.push(cwd);227 tempDirs.push(cwd);
...@@ -252,7 +254,7 @@ describe("CheckpointReactor", () => {...@@ -252,7 +254,7 @@ describe("CheckpointReactor", () => {
252 commandId: asCommandId("cmd-project-create"),254 commandId: asCommandId("cmd-project-create"),
253 projectId: asProjectId("project-1"),255 projectId: asProjectId("project-1"),
254 title: "Test Project",256 title: "Test Project",
255 workspaceRoot: "/tmp/test-project",257 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,
256 defaultModel: "gpt-5-codex",258 defaultModel: "gpt-5-codex",
257 createdAt,259 createdAt,
258 }),260 }),
...@@ -266,7 +268,7 @@ describe("CheckpointReactor", () => {...@@ -266,7 +268,7 @@ describe("CheckpointReactor", () => {
266 title: "Thread",268 title: "Thread",
267 model: "gpt-5-codex",269 model: "gpt-5-codex",
268 branch: null,270 branch: null,
269 worktreePath: cwd,271 worktreePath: options?.threadWorktreePath ?? cwd,
270 createdAt,272 createdAt,
271 }),273 }),
272 );274 );
...@@ -363,10 +365,11 @@ describe("CheckpointReactor", () => {...@@ -363,10 +365,11 @@ describe("CheckpointReactor", () => {
363 ).toBe("v2\n");365 ).toBe("v2\n");
364 });366 });
365367
366 it("captures pre-turn baseline from thread.turn.start domain flow before runtime turn events", async () => {368 it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => {
367 const harness = await createHarness({369 const harness = await createHarness({
368 hasSession: false,370 hasSession: false,
369 seedFilesystemCheckpoints: false,371 seedFilesystemCheckpoints: false,
372 threadWorktreePath: null,
370 });373 });
371374
372 await Effect.runPromise(375 await Effect.runPromise(
...@@ -390,6 +393,52 @@ describe("CheckpointReactor", () => {...@@ -390,6 +393,52 @@ describe("CheckpointReactor", () => {
390 ).toBe("v1\n");393 ).toBe("v1\n");
391 });394 });
392395
396 it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => {
397 const harness = await createHarness({
398 hasSession: false,
399 seedFilesystemCheckpoints: false,
400 threadWorktreePath: null,
401 });
402 const createdAt = new Date().toISOString();
403
404 await Effect.runPromise(
405 harness.engine.dispatch({
406 type: "thread.session.set",
407 commandId: asCommandId("cmd-session-set-missing-provider-cwd"),
408 threadId: asThreadId("thread-1"),
409 session: {
410 threadId: asThreadId("thread-1"),
411 status: "running",
412 providerName: "codex",
413 providerSessionId: asSessionId("sess-missing"),
414 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-missing"),
415 activeTurnId: asTurnId("turn-missing-cwd"),
416 lastError: null,
417 updatedAt: createdAt,
418 },
419 createdAt,
420 }),
421 );
422
423 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
424 harness.provider.emit({
425 type: "turn.completed",
426 eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"),
427 provider: "codex",
428 sessionId: asSessionId("sess-missing"),
429 createdAt: new Date().toISOString(),
430 threadId: ProviderThreadId.makeUnsafe("provider-thread-missing"),
431 turnId: asTurnId("turn-missing-cwd"),
432 status: "completed",
433 });
434
435 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
436 expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1))).toBe(true);
437 expect(
438 gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1), "README.md"),
439 ).toBe("v2\n");
440 });
441
393 it("maps runtime checkpoint.captured into thread.turn.diff.complete when missing in read model", async () => {442 it("maps runtime checkpoint.captured into thread.turn.diff.complete when missing in read model", async () => {
394 const harness = await createHarness();443 const harness = await createHarness();
395 const createdAt = new Date().toISOString();444 const createdAt = new Date().toISOString();
apps/server/src/orchestration/Layers/CheckpointReactor.ts+56-10
...@@ -46,6 +46,27 @@ function trimToNonEmpty(value: string | null | undefined): string | undefined {...@@ -46,6 +46,27 @@ function trimToNonEmpty(value: string | null | undefined): string | undefined {
46 return normalized && normalized.length > 0 ? normalized : undefined;46 return normalized && normalized.length > 0 ? normalized : undefined;
47}47}
4848
49function resolveThreadWorkspaceCwd(input: {
50 readonly thread: {
51 readonly projectId: string;
52 readonly worktreePath: string | null;
53 };
54 readonly projects: ReadonlyArray<{
55 readonly id: string;
56 readonly workspaceRoot: string;
57 }>;
58}): string | undefined {
59 const worktreeCwd = trimToNonEmpty(input.thread.worktreePath);
60 if (worktreeCwd) {
61 return worktreeCwd;
62 }
63
64 const workspaceRoot = input.projects.find(
65 (project) => project.id === input.thread.projectId,
66 )?.workspaceRoot;
67 return trimToNonEmpty(workspaceRoot);
68}
69
49function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" {70function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" {
50 switch (status) {71 switch (status) {
51 case "failed":72 case "failed":
...@@ -133,6 +154,7 @@ const make = Effect.gen(function* () {...@@ -133,6 +154,7 @@ const make = Effect.gen(function* () {
133 const readModel = yield* orchestrationEngine.getReadModel();154 const readModel = yield* orchestrationEngine.getReadModel();
134 const thread = readModel.threads.find((entry) => entry.id === threadId);155 const thread = readModel.threads.find((entry) => entry.id === threadId);
135 const projectedSessionId = thread?.session?.providerSessionId ?? null;156 const projectedSessionId = thread?.session?.providerSessionId ?? null;
157 const projectedProviderThreadId = trimToNonEmpty(thread?.session?.providerThreadId);
136158
137 const sessions = yield* providerService.listSessions();159 const sessions = yield* providerService.listSessions();
138160
...@@ -154,8 +176,17 @@ const make = Effect.gen(function* () {...@@ -154,8 +176,17 @@ const make = Effect.gen(function* () {
154 }176 }
155 }177 }
156178
157 const matchedSession = sessions.find((session) => session.threadId?.trim() === threadId.trim());179 if (projectedProviderThreadId) {
158 return findSessionWithCwd(matchedSession);180 const matchedSession = sessions.find(
181 (session) => session.threadId?.trim() === projectedProviderThreadId,
182 );
183 const fromProviderThread = findSessionWithCwd(matchedSession);
184 if (Option.isSome(fromProviderThread)) {
185 return fromProviderThread;
186 }
187 }
188
189 return Option.none();
159 });190 });
160191
161 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (192 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (
...@@ -179,7 +210,16 @@ const make = Effect.gen(function* () {...@@ -179,7 +210,16 @@ const make = Effect.gen(function* () {
179 }210 }
180211
181 const sessionRuntime = yield* resolveSessionRuntimeForThread(thread.id);212 const sessionRuntime = yield* resolveSessionRuntimeForThread(thread.id);
182 if (Option.isNone(sessionRuntime)) {213 const checkpointCwd =
214 Option.match(sessionRuntime, {
215 onNone: () => undefined,
216 onSome: (runtime) => runtime.cwd,
217 }) ??
218 resolveThreadWorkspaceCwd({
219 thread,
220 projects: readModel.projects,
221 });
222 if (!checkpointCwd) {
183 yield* Effect.logWarning("checkpoint capture skipped: no active provider session cwd", {223 yield* Effect.logWarning("checkpoint capture skipped: no active provider session cwd", {
184 threadId: thread.id,224 threadId: thread.id,
185 turnId,225 turnId,
...@@ -197,7 +237,7 @@ const make = Effect.gen(function* () {...@@ -197,7 +237,7 @@ const make = Effect.gen(function* () {
197 const targetCheckpointRef = checkpointRefForThreadTurn(thread.id, nextTurnCount);237 const targetCheckpointRef = checkpointRefForThreadTurn(thread.id, nextTurnCount);
198238
199 const fromCheckpointExists = yield* checkpointStore.hasCheckpointRef({239 const fromCheckpointExists = yield* checkpointStore.hasCheckpointRef({
200 cwd: sessionRuntime.value.cwd,240 cwd: checkpointCwd,
201 checkpointRef: fromCheckpointRef,241 checkpointRef: fromCheckpointRef,
202 });242 });
203 if (!fromCheckpointExists) {243 if (!fromCheckpointExists) {
...@@ -209,13 +249,13 @@ const make = Effect.gen(function* () {...@@ -209,13 +249,13 @@ const make = Effect.gen(function* () {
209 }249 }
210250
211 yield* checkpointStore.captureCheckpoint({251 yield* checkpointStore.captureCheckpoint({
212 cwd: sessionRuntime.value.cwd,252 cwd: checkpointCwd,
213 checkpointRef: targetCheckpointRef,253 checkpointRef: targetCheckpointRef,
214 });254 });
215255
216 const files = yield* checkpointStore256 const files = yield* checkpointStore
217 .diffCheckpoints({257 .diffCheckpoints({
218 cwd: sessionRuntime.value.cwd,258 cwd: checkpointCwd,
219 fromCheckpointRef,259 fromCheckpointRef,
220 toCheckpointRef: targetCheckpointRef,260 toCheckpointRef: targetCheckpointRef,
221 fallbackFromToHead: false,261 fallbackFromToHead: false,
...@@ -296,9 +336,12 @@ const make = Effect.gen(function* () {...@@ -296,9 +336,12 @@ const make = Effect.gen(function* () {
296 return;336 return;
297 }337 }
298338
299 const checkpointCwdFromThread = trimToNonEmpty(thread.worktreePath);339 const checkpointCwdFromThreadOrProject = resolveThreadWorkspaceCwd({
340 thread,
341 projects: readModel.projects,
342 });
300 const checkpointCwd =343 const checkpointCwd =
301 checkpointCwdFromThread ??344 checkpointCwdFromThreadOrProject ??
302 Option.match(yield* resolveSessionRuntimeForThread(thread.id), {345 Option.match(yield* resolveSessionRuntimeForThread(thread.id), {
303 onNone: () => undefined,346 onNone: () => undefined,
304 onSome: (runtime) => runtime.cwd,347 onSome: (runtime) => runtime.cwd,
...@@ -349,9 +392,12 @@ const make = Effect.gen(function* () {...@@ -349,9 +392,12 @@ const make = Effect.gen(function* () {
349 return;392 return;
350 }393 }
351394
352 const checkpointCwdFromThread = trimToNonEmpty(thread.worktreePath);395 const checkpointCwdFromThreadOrProject = resolveThreadWorkspaceCwd({
396 thread,
397 projects: readModel.projects,
398 });
353 const checkpointCwd =399 const checkpointCwd =
354 checkpointCwdFromThread ??400 checkpointCwdFromThreadOrProject ??
355 Option.match(yield* resolveSessionRuntimeForThread(threadId), {401 Option.match(yield* resolveSessionRuntimeForThread(threadId), {
356 onNone: () => undefined,402 onNone: () => undefined,
357 onSome: (runtime) => runtime.cwd,403 onSome: (runtime) => runtime.cwd,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+4
...@@ -181,6 +181,10 @@ describe("ProviderCommandReactor", () => {...@@ -181,6 +181,10 @@ describe("ProviderCommandReactor", () => {
181181
182 await waitFor(() => harness.startSession.mock.calls.length === 1);182 await waitFor(() => harness.startSession.mock.calls.length === 1);
183 await waitFor(() => harness.sendTurn.mock.calls.length === 1);183 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
184 expect(harness.startSession.mock.calls[0]?.[0]).toMatchObject({
185 cwd: "/tmp/provider-project",
186 model: "gpt-5-codex",
187 });
184188
185 const readModel = await Effect.runPromise(harness.engine.getReadModel());189 const readModel = await Effect.runPromise(harness.engine.getReadModel());
186 const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1"));190 const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1"));
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+28-5
...@@ -5,7 +5,6 @@ import {...@@ -5,7 +5,6 @@ import {
5 type OrchestrationEvent,5 type OrchestrationEvent,
6 type ProviderKind,6 type ProviderKind,
7 type OrchestrationSession,7 type OrchestrationSession,
8 type ProviderSessionId,
9 type ThreadId,8 type ThreadId,
10 type TurnId,9 type TurnId,
11} from "@t3tools/contracts";10} from "@t3tools/contracts";
...@@ -34,6 +33,27 @@ function trimToNonEmptyOrUndefined(value: string | undefined): string | undefine...@@ -34,6 +33,27 @@ function trimToNonEmptyOrUndefined(value: string | undefined): string | undefine
34 return normalized && normalized.length > 0 ? normalized : undefined;33 return normalized && normalized.length > 0 ? normalized : undefined;
35}34}
3635
36function resolveEffectiveThreadCwd(input: {
37 readonly thread: {
38 readonly projectId: string;
39 readonly worktreePath: string | null;
40 };
41 readonly projects: ReadonlyArray<{
42 readonly id: string;
43 readonly workspaceRoot: string;
44 }>;
45}): string | undefined {
46 const worktreeCwd = trimToNonEmptyOrUndefined(input.thread.worktreePath ?? undefined);
47 if (worktreeCwd) {
48 return worktreeCwd;
49 }
50
51 const workspaceRoot = input.projects.find(
52 (project) => project.id === input.thread.projectId,
53 )?.workspaceRoot;
54 return trimToNonEmptyOrUndefined(workspaceRoot);
55}
56
37function mapProviderSessionStatusToOrchestrationStatus(57function mapProviderSessionStatusToOrchestrationStatus(
38 status: "connecting" | "ready" | "running" | "error" | "closed",58 status: "connecting" | "ready" | "running" | "error" | "closed",
39): OrchestrationSession["status"] {59): OrchestrationSession["status"] {
...@@ -115,7 +135,8 @@ const make = Effect.gen(function* () {...@@ -115,7 +135,8 @@ const make = Effect.gen(function* () {
115 threadId: ThreadId,135 threadId: ThreadId,
116 createdAt: string,136 createdAt: string,
117 ) {137 ) {
118 const thread = yield* resolveThread(threadId);138 const readModel = yield* orchestrationEngine.getReadModel();
139 const thread = readModel.threads.find((entry) => entry.id === threadId);
119 if (!thread) {140 if (!thread) {
120 return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`));141 return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`));
121 }142 }
...@@ -129,12 +150,14 @@ const make = Effect.gen(function* () {...@@ -129,12 +150,14 @@ const make = Effect.gen(function* () {
129 thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode"150 thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode"
130 ? thread.session.providerName151 ? thread.session.providerName
131 : undefined;152 : undefined;
153 const effectiveCwd = resolveEffectiveThreadCwd({
154 thread,
155 projects: readModel.projects,
156 });
132157
133 const startedSession = yield* providerService.startSession({158 const startedSession = yield* providerService.startSession({
134 ...(preferredProvider ? { provider: preferredProvider } : {}),159 ...(preferredProvider ? { provider: preferredProvider } : {}),
135 ...(trimToNonEmptyOrUndefined(thread.worktreePath ?? undefined)160 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
136 ? { cwd: trimToNonEmptyOrUndefined(thread.worktreePath ?? undefined) }
137 : {}),
138 ...(trimToNonEmptyOrUndefined(thread.model) ? { model: thread.model } : {}),161 ...(trimToNonEmptyOrUndefined(thread.model) ? { model: thread.model } : {}),
139 });162 });
140163