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", () => {
220220 async function createHarness(options?: {
221221 readonly hasSession?: boolean;
222222 readonly seedFilesystemCheckpoints?: boolean;
223 readonly projectWorkspaceRoot?: string;
224 readonly threadWorktreePath?: string | null;
223225 }) {
224226 const cwd = createGitRepository();
225227 tempDirs.push(cwd);
......@@ -252,7 +254,7 @@ describe("CheckpointReactor", () => {
252254 commandId: asCommandId("cmd-project-create"),
253255 projectId: asProjectId("project-1"),
254256 title: "Test Project",
255 workspaceRoot: "/tmp/test-project",
257 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,
256258 defaultModel: "gpt-5-codex",
257259 createdAt,
258260 }),
......@@ -266,7 +268,7 @@ describe("CheckpointReactor", () => {
266268 title: "Thread",
267269 model: "gpt-5-codex",
268270 branch: null,
269 worktreePath: cwd,
271 worktreePath: options?.threadWorktreePath ?? cwd,
270272 createdAt,
271273 }),
272274 );
......@@ -363,10 +365,11 @@ describe("CheckpointReactor", () => {
363365 ).toBe("v2\n");
364366 });
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 () => {
367369 const harness = await createHarness({
368370 hasSession: false,
369371 seedFilesystemCheckpoints: false,
372 threadWorktreePath: null,
370373 });
371374
372375 await Effect.runPromise(
......@@ -390,6 +393,52 @@ describe("CheckpointReactor", () => {
390393 ).toBe("v1\n");
391394 });
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
393442 it("maps runtime checkpoint.captured into thread.turn.diff.complete when missing in read model", async () => {
394443 const harness = await createHarness();
395444 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 {
4646 return normalized && normalized.length > 0 ? normalized : undefined;
4747}
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
4970function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" {
5071 switch (status) {
5172 case "failed":
......@@ -133,6 +154,7 @@ const make = Effect.gen(function* () {
133154 const readModel = yield* orchestrationEngine.getReadModel();
134155 const thread = readModel.threads.find((entry) => entry.id === threadId);
135156 const projectedSessionId = thread?.session?.providerSessionId ?? null;
157 const projectedProviderThreadId = trimToNonEmpty(thread?.session?.providerThreadId);
136158
137159 const sessions = yield* providerService.listSessions();
138160
......@@ -154,8 +176,17 @@ const make = Effect.gen(function* () {
154176 }
155177 }
156178
157 const matchedSession = sessions.find((session) => session.threadId?.trim() === threadId.trim());
158 return findSessionWithCwd(matchedSession);
179 if (projectedProviderThreadId) {
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();
159190 });
160191
161192 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (
......@@ -179,7 +210,16 @@ const make = Effect.gen(function* () {
179210 }
180211
181212 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) {
183223 yield* Effect.logWarning("checkpoint capture skipped: no active provider session cwd", {
184224 threadId: thread.id,
185225 turnId,
......@@ -197,7 +237,7 @@ const make = Effect.gen(function* () {
197237 const targetCheckpointRef = checkpointRefForThreadTurn(thread.id, nextTurnCount);
198238
199239 const fromCheckpointExists = yield* checkpointStore.hasCheckpointRef({
200 cwd: sessionRuntime.value.cwd,
240 cwd: checkpointCwd,
201241 checkpointRef: fromCheckpointRef,
202242 });
203243 if (!fromCheckpointExists) {
......@@ -209,13 +249,13 @@ const make = Effect.gen(function* () {
209249 }
210250
211251 yield* checkpointStore.captureCheckpoint({
212 cwd: sessionRuntime.value.cwd,
252 cwd: checkpointCwd,
213253 checkpointRef: targetCheckpointRef,
214254 });
215255
216256 const files = yield* checkpointStore
217257 .diffCheckpoints({
218 cwd: sessionRuntime.value.cwd,
258 cwd: checkpointCwd,
219259 fromCheckpointRef,
220260 toCheckpointRef: targetCheckpointRef,
221261 fallbackFromToHead: false,
......@@ -296,9 +336,12 @@ const make = Effect.gen(function* () {
296336 return;
297337 }
298338
299 const checkpointCwdFromThread = trimToNonEmpty(thread.worktreePath);
339 const checkpointCwdFromThreadOrProject = resolveThreadWorkspaceCwd({
340 thread,
341 projects: readModel.projects,
342 });
300343 const checkpointCwd =
301 checkpointCwdFromThread ??
344 checkpointCwdFromThreadOrProject ??
302345 Option.match(yield* resolveSessionRuntimeForThread(thread.id), {
303346 onNone: () => undefined,
304347 onSome: (runtime) => runtime.cwd,
......@@ -349,9 +392,12 @@ const make = Effect.gen(function* () {
349392 return;
350393 }
351394
352 const checkpointCwdFromThread = trimToNonEmpty(thread.worktreePath);
395 const checkpointCwdFromThreadOrProject = resolveThreadWorkspaceCwd({
396 thread,
397 projects: readModel.projects,
398 });
353399 const checkpointCwd =
354 checkpointCwdFromThread ??
400 checkpointCwdFromThreadOrProject ??
355401 Option.match(yield* resolveSessionRuntimeForThread(threadId), {
356402 onNone: () => undefined,
357403 onSome: (runtime) => runtime.cwd,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+4
......@@ -181,6 +181,10 @@ describe("ProviderCommandReactor", () => {
181181
182182 await waitFor(() => harness.startSession.mock.calls.length === 1);
183183 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
185189 const readModel = await Effect.runPromise(harness.engine.getReadModel());
186190 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 {
55 type OrchestrationEvent,
66 type ProviderKind,
77 type OrchestrationSession,
8 type ProviderSessionId,
98 type ThreadId,
109 type TurnId,
1110} from "@t3tools/contracts";
......@@ -34,6 +33,27 @@ function trimToNonEmptyOrUndefined(value: string | undefined): string | undefine
3433 return normalized && normalized.length > 0 ? normalized : undefined;
3534}
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
3757function mapProviderSessionStatusToOrchestrationStatus(
3858 status: "connecting" | "ready" | "running" | "error" | "closed",
3959): OrchestrationSession["status"] {
......@@ -115,7 +135,8 @@ const make = Effect.gen(function* () {
115135 threadId: ThreadId,
116136 createdAt: string,
117137 ) {
118 const thread = yield* resolveThread(threadId);
138 const readModel = yield* orchestrationEngine.getReadModel();
139 const thread = readModel.threads.find((entry) => entry.id === threadId);
119140 if (!thread) {
120141 return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`));
121142 }
......@@ -129,12 +150,14 @@ const make = Effect.gen(function* () {
129150 thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode"
130151 ? thread.session.providerName
131152 : undefined;
153 const effectiveCwd = resolveEffectiveThreadCwd({
154 thread,
155 projects: readModel.projects,
156 });
132157
133158 const startedSession = yield* providerService.startSession({
134159 ...(preferredProvider ? { provider: preferredProvider } : {}),
135 ...(trimToNonEmptyOrUndefined(thread.worktreePath ?? undefined)
136 ? { cwd: trimToNonEmptyOrUndefined(thread.worktreePath ?? undefined) }
137 : {}),
160 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
138161 ...(trimToNonEmptyOrUndefined(thread.model) ? { model: thread.model } : {}),
139162 });
140163