| author | |
| committer | |
| log | 76519303b9a488a0b9f4fb4c4b1968d6f9bacba8 |
| tree | 1c7558bf5cd6e5b4fa0522c16bb91f8514eba9b0 |
| parent | 448c48cd109bee06abecffddb3fc7130a50e3666 |
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 | }); |
| 365 | 367 | ||
| 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 | }); |
| 371 | 374 | ||
| 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 | }); |
| 392 | 395 | ||
| 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 | } |
| 48 | 48 | ||
| 49 | function 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 | |||
| 49 | function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" { | 70 | function 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); | ||
| 136 | 158 | ||
| 137 | const sessions = yield* providerService.listSessions(); | 159 | const sessions = yield* providerService.listSessions(); |
| 138 | 160 | ||
| ... | @@ -154,8 +176,17 @@ const make = Effect.gen(function* () { | ... | @@ -154,8 +176,17 @@ const make = Effect.gen(function* () { |
| 154 | } | 176 | } |
| 155 | } | 177 | } |
| 156 | 178 | ||
| 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 | }); |
| 160 | 191 | ||
| 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 | } |
| 180 | 211 | ||
| 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); |
| 198 | 238 | ||
| 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 | } |
| 210 | 250 | ||
| 211 | yield* checkpointStore.captureCheckpoint({ | 251 | yield* checkpointStore.captureCheckpoint({ |
| 212 | cwd: sessionRuntime.value.cwd, | 252 | cwd: checkpointCwd, |
| 213 | checkpointRef: targetCheckpointRef, | 253 | checkpointRef: targetCheckpointRef, |
| 214 | }); | 254 | }); |
| 215 | 255 | ||
| 216 | const files = yield* checkpointStore | 256 | 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 | } |
| 298 | 338 | ||
| 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 | } |
| 351 | 394 | ||
| 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", () => { |
| 181 | 181 | ||
| 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 | }); | ||
| 184 | 188 | ||
| 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 | } |
| 36 | 35 | ||
| 36 | function 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 | |||
| 37 | function mapProviderSessionStatusToOrchestrationStatus( | 57 | function 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.providerName | 151 | ? thread.session.providerName |
| 131 | : undefined; | 152 | : undefined; |
| 153 | const effectiveCwd = resolveEffectiveThreadCwd({ | ||
| 154 | thread, | ||
| 155 | projects: readModel.projects, | ||
| 156 | }); | ||
| 132 | 157 | ||
| 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 | }); |
| 140 | 163 |