| author | |
| committer | |
| log | 01fc7d228dd6794ffb35b57752d0218559de5e89 |
| tree | 35e3cbec18e8a7ea3f51e19c8fc01184d7962907 |
| parent | 17822fab708a90e82ac961e3b4895932667ce419 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 124 insertions(+), 0 deletions(-)
apps/server/src/git/GitWorkflowService.ts+7| ... | @@ -84,6 +84,9 @@ export class GitWorkflowService extends Context.Service< | ... | @@ -84,6 +84,9 @@ export class GitWorkflowService extends Context.Service< |
| 84 | readonly removeWorktree: ( | 84 | readonly removeWorktree: ( |
| 85 | input: VcsRemoveWorktreeInput, | 85 | input: VcsRemoveWorktreeInput, |
| 86 | ) => Effect.Effect<void, GitCommandError>; | 86 | ) => Effect.Effect<void, GitCommandError>; |
| 87 | readonly pruneWorktrees: (input: { | ||
| 88 | readonly cwd: string; | ||
| 89 | }) => Effect.Effect<void, GitCommandError>; | ||
| 87 | readonly createRef: ( | 90 | readonly createRef: ( |
| 88 | input: VcsCreateRefInput, | 91 | input: VcsCreateRefInput, |
| 89 | ) => Effect.Effect<VcsCreateRefResult, GitCommandError>; | 92 | ) => Effect.Effect<VcsCreateRefResult, GitCommandError>; |
| ... | @@ -319,6 +322,10 @@ export const make = Effect.gen(function* () { | ... | @@ -319,6 +322,10 @@ export const make = Effect.gen(function* () { |
| 319 | ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( | 322 | ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( |
| 320 | Effect.andThen(git.removeWorktree(input)), | 323 | Effect.andThen(git.removeWorktree(input)), |
| 321 | ), | 324 | ), |
| 325 | pruneWorktrees: (input) => | ||
| 326 | ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( | ||
| 327 | Effect.andThen(git.pruneWorktrees(input)), | ||
| 328 | ), | ||
| 322 | createRef: (input) => | 329 | createRef: (input) => |
| 323 | ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( | 330 | ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( |
| 324 | Effect.andThen(git.createRef(input)), | 331 | Effect.andThen(git.createRef(input)), |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+53| ... | @@ -270,6 +270,11 @@ describe("ProviderCommandReactor", () => { | ... | @@ -270,6 +270,11 @@ describe("ProviderCommandReactor", () => { |
| 270 | : "renamed-branch", | 270 | : "renamed-branch", |
| 271 | }), | 271 | }), |
| 272 | ); | 272 | ); |
| 273 | const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); | ||
| 274 | const createWorktree = vi.fn( | ||
| 275 | (input: { readonly refName: string; readonly path: string | null }) => | ||
| 276 | Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }), | ||
| 277 | ); | ||
| 273 | const refreshStatus = vi.fn((_: string) => | 278 | const refreshStatus = vi.fn((_: string) => |
| 274 | Effect.succeed({ | 279 | Effect.succeed({ |
| 275 | isRepo: true, | 280 | isRepo: true, |
| ... | @@ -402,6 +407,8 @@ describe("ProviderCommandReactor", () => { | ... | @@ -402,6 +407,8 @@ describe("ProviderCommandReactor", () => { |
| 402 | Layer.provideMerge( | 407 | Layer.provideMerge( |
| 403 | Layer.mock(GitWorkflowService.GitWorkflowService)({ | 408 | Layer.mock(GitWorkflowService.GitWorkflowService)({ |
| 404 | renameBranch, | 409 | renameBranch, |
| 410 | pruneWorktrees, | ||
| 411 | createWorktree, | ||
| 405 | } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>), | 412 | } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>), |
| 406 | ), | 413 | ), |
| 407 | Layer.provideMerge( | 414 | Layer.provideMerge( |
| ... | @@ -506,6 +513,8 @@ describe("ProviderCommandReactor", () => { | ... | @@ -506,6 +513,8 @@ describe("ProviderCommandReactor", () => { |
| 506 | respondToUserInput, | 513 | respondToUserInput, |
| 507 | stopSession, | 514 | stopSession, |
| 508 | renameBranch, | 515 | renameBranch, |
| 516 | pruneWorktrees, | ||
| 517 | createWorktree, | ||
| 509 | refreshStatus, | 518 | refreshStatus, |
| 510 | generateBranchName, | 519 | generateBranchName, |
| 511 | generateThreadTitle, | 520 | generateThreadTitle, |
| ... | @@ -1517,6 +1526,50 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1517,6 +1526,50 @@ describe("ProviderCommandReactor", () => { |
| 1517 | expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); | 1526 | expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); |
| 1518 | }); | 1527 | }); |
| 1519 | 1528 | ||
| 1529 | it("recreates a missing worktree from the thread branch before starting a turn", async () => { | ||
| 1530 | const harness = await createHarness(); | ||
| 1531 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 1532 | const worktreePath = NodePath.join(harness.stateDir, "missing-worktree"); | ||
| 1533 | |||
| 1534 | await harness.runEffect( | ||
| 1535 | harness.engine.dispatch({ | ||
| 1536 | type: "thread.meta.update", | ||
| 1537 | commandId: CommandId.make("cmd-thread-missing-worktree"), | ||
| 1538 | threadId: ThreadId.make("thread-1"), | ||
| 1539 | branch: "feature/restore", | ||
| 1540 | worktreePath, | ||
| 1541 | }), | ||
| 1542 | ); | ||
| 1543 | |||
| 1544 | await harness.runEffect( | ||
| 1545 | harness.engine.dispatch({ | ||
| 1546 | type: "thread.turn.start", | ||
| 1547 | commandId: CommandId.make("cmd-turn-start-missing-worktree"), | ||
| 1548 | threadId: ThreadId.make("thread-1"), | ||
| 1549 | message: { | ||
| 1550 | messageId: asMessageId("user-message-missing-worktree"), | ||
| 1551 | role: "user", | ||
| 1552 | text: "continue", | ||
| 1553 | attachments: [], | ||
| 1554 | }, | ||
| 1555 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 1556 | runtimeMode: "approval-required", | ||
| 1557 | createdAt: now, | ||
| 1558 | }), | ||
| 1559 | ); | ||
| 1560 | |||
| 1561 | await waitFor(() => harness.startSession.mock.calls.length === 1); | ||
| 1562 | expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" }); | ||
| 1563 | expect(harness.createWorktree).toHaveBeenCalledWith({ | ||
| 1564 | cwd: "/tmp/provider-project", | ||
| 1565 | refName: "feature/restore", | ||
| 1566 | path: worktreePath, | ||
| 1567 | }); | ||
| 1568 | expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( | ||
| 1569 | harness.startSession.mock.invocationCallOrder[0]!, | ||
| 1570 | ); | ||
| 1571 | }); | ||
| 1572 | |||
| 1520 | it("forwards codex model options through session start and turn send", async () => { | 1573 | it("forwards codex model options through session start and turn send", async () => { |
| 1521 | const harness = await createHarness(); | 1574 | const harness = await createHarness(); |
| 1522 | const now = "2026-01-01T00:00:00.000Z"; | 1575 | const now = "2026-01-01T00:00:00.000Z"; |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+50| ... | @@ -19,6 +19,7 @@ import * as Crypto from "effect/Crypto"; | ... | @@ -19,6 +19,7 @@ import * as Crypto from "effect/Crypto"; |
| 19 | import * as Duration from "effect/Duration"; | 19 | import * as Duration from "effect/Duration"; |
| 20 | import * as Effect from "effect/Effect"; | 20 | import * as Effect from "effect/Effect"; |
| 21 | import * as Equal from "effect/Equal"; | 21 | import * as Equal from "effect/Equal"; |
| 22 | import * as FileSystem from "effect/FileSystem"; | ||
| 22 | import * as Layer from "effect/Layer"; | 23 | import * as Layer from "effect/Layer"; |
| 23 | import * as Option from "effect/Option"; | 24 | import * as Option from "effect/Option"; |
| 24 | import * as Schema from "effect/Schema"; | 25 | import * as Schema from "effect/Schema"; |
| ... | @@ -305,6 +306,7 @@ const make = Effect.gen(function* () { | ... | @@ -305,6 +306,7 @@ const make = Effect.gen(function* () { |
| 305 | const providerService = yield* ProviderService; | 306 | const providerService = yield* ProviderService; |
| 306 | const providerRegistry = yield* ProviderRegistry; | 307 | const providerRegistry = yield* ProviderRegistry; |
| 307 | const gitWorkflow = yield* GitWorkflowService; | 308 | const gitWorkflow = yield* GitWorkflowService; |
| 309 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 308 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; | 310 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 309 | const textGeneration = yield* TextGeneration; | 311 | const textGeneration = yield* TextGeneration; |
| 310 | const serverSettingsService = yield* ServerSettingsService; | 312 | const serverSettingsService = yield* ServerSettingsService; |
| ... | @@ -428,6 +430,52 @@ const make = Effect.gen(function* () { | ... | @@ -428,6 +430,52 @@ const make = Effect.gen(function* () { |
| 428 | .pipe(Effect.map(Option.getOrUndefined)); | 430 | .pipe(Effect.map(Option.getOrUndefined)); |
| 429 | }); | 431 | }); |
| 430 | 432 | ||
| 433 | /** | ||
| 434 | * Recreates a thread's worktree from its branch when the directory has | ||
| 435 | * disappeared. Provider sessions resume into the persisted cwd, so a missing | ||
| 436 | * worktree makes every later turn fail as a bogus "session not found". | ||
| 437 | * Best-effort: on failure the turn proceeds and reports the real error. | ||
| 438 | */ | ||
| 439 | const ensureThreadWorktree = Effect.fnUntraced(function* (thread: { | ||
| 440 | readonly id: ThreadId; | ||
| 441 | readonly projectId: ProjectId; | ||
| 442 | readonly branch: string | null; | ||
| 443 | readonly worktreePath: string | null; | ||
| 444 | }) { | ||
| 445 | const { worktreePath, branch } = thread; | ||
| 446 | if (!worktreePath || !branch) { | ||
| 447 | return; | ||
| 448 | } | ||
| 449 | const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true)); | ||
| 450 | if (exists) { | ||
| 451 | return; | ||
| 452 | } | ||
| 453 | const project = yield* resolveProject(thread.projectId); | ||
| 454 | if (!project) { | ||
| 455 | return; | ||
| 456 | } | ||
| 457 | const cwd = project.workspaceRoot; | ||
| 458 | yield* Effect.logWarning("provider command reactor recreating missing worktree", { | ||
| 459 | threadId: thread.id, | ||
| 460 | worktreePath, | ||
| 461 | branch, | ||
| 462 | }); | ||
| 463 | // A directory deleted without `git worktree remove` leaves an admin entry | ||
| 464 | // that makes `git worktree add` refuse the path; prune clears it. | ||
| 465 | yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( | ||
| 466 | Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), | ||
| 467 | Effect.catchCause((cause) => | ||
| 468 | Cause.hasInterruptsOnly(cause) | ||
| 469 | ? Effect.failCause(cause) | ||
| 470 | : Effect.logWarning("provider command reactor failed to recreate worktree", { | ||
| 471 | threadId: thread.id, | ||
| 472 | worktreePath, | ||
| 473 | cause: Cause.pretty(cause), | ||
| 474 | }), | ||
| 475 | ), | ||
| 476 | ); | ||
| 477 | }); | ||
| 478 | |||
| 431 | const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { | 479 | const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { |
| 432 | return yield* projectionSnapshotQuery | 480 | return yield* projectionSnapshotQuery |
| 433 | .getThreadDetailById(threadId) | 481 | .getThreadDetailById(threadId) |
| ... | @@ -1083,6 +1131,8 @@ const make = Effect.gen(function* () { | ... | @@ -1083,6 +1131,8 @@ const make = Effect.gen(function* () { |
| 1083 | return; | 1131 | return; |
| 1084 | } | 1132 | } |
| 1085 | 1133 | ||
| 1134 | yield* ensureThreadWorktree(thread); | ||
| 1135 | |||
| 1086 | const isFirstUserMessageTurn = | 1136 | const isFirstUserMessageTurn = |
| 1087 | thread.messages.filter((entry) => entry.role === "user").length === 1; | 1137 | thread.messages.filter((entry) => entry.role === "user").length === 1; |
| 1088 | if (isFirstUserMessageTurn) { | 1138 | if (isFirstUserMessageTurn) { |
apps/server/src/vcs/GitVcsDriver.ts+4| ... | @@ -310,6 +310,10 @@ export class GitVcsDriver extends Context.Service< | ... | @@ -310,6 +310,10 @@ export class GitVcsDriver extends Context.Service< |
| 310 | readonly removeWorktree: ( | 310 | readonly removeWorktree: ( |
| 311 | input: VcsRemoveWorktreeInput, | 311 | input: VcsRemoveWorktreeInput, |
| 312 | ) => Effect.Effect<void, GitCommandError>; | 312 | ) => Effect.Effect<void, GitCommandError>; |
| 313 | /** Drops worktree admin entries whose directory is already gone (`git worktree prune`). */ | ||
| 314 | readonly pruneWorktrees: (input: { | ||
| 315 | readonly cwd: string; | ||
| 316 | }) => Effect.Effect<void, GitCommandError>; | ||
| 313 | readonly renameBranch: ( | 317 | readonly renameBranch: ( |
| 314 | input: GitRenameBranchInput, | 318 | input: GitRenameBranchInput, |
| 315 | ) => Effect.Effect<GitRenameBranchResult, GitCommandError>; | 319 | ) => Effect.Effect<GitRenameBranchResult, GitCommandError>; |
apps/server/src/vcs/GitVcsDriverCore.ts+10| ... | @@ -3017,6 +3017,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -3017,6 +3017,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3017 | }); | 3017 | }); |
| 3018 | }); | 3018 | }); |
| 3019 | 3019 | ||
| 3020 | const pruneWorktrees: GitVcsDriver.GitVcsDriver["Service"]["pruneWorktrees"] = Effect.fn( | ||
| 3021 | "pruneWorktrees", | ||
| 3022 | )(function* (input) { | ||
| 3023 | yield* executeGit("GitVcsDriver.pruneWorktrees", input.cwd, ["worktree", "prune"], { | ||
| 3024 | timeoutMs: 15_000, | ||
| 3025 | fallbackErrorDetail: "git worktree prune failed", | ||
| 3026 | }); | ||
| 3027 | }); | ||
| 3028 | |||
| 3020 | const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn( | 3029 | const renameBranch: GitVcsDriver.GitVcsDriver["Service"]["renameBranch"] = Effect.fn( |
| 3021 | "renameBranch", | 3030 | "renameBranch", |
| 3022 | )(function* (input) { | 3031 | )(function* (input) { |
| ... | @@ -3222,6 +3231,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -3222,6 +3231,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3222 | withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), | 3231 | withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), |
| 3223 | setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), | 3232 | setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), |
| 3224 | removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), | 3233 | removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), |
| 3234 | pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)), | ||
| 3225 | renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), | 3235 | renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), |
| 3226 | createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), | 3236 | createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), |
| 3227 | switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), | 3237 | switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), |