diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 6604edbf82a8c..7a922d79c7317 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -3,6 +3,7 @@ import { expect, it, vi } from "vite-plus/test"; import { it as effectIt } from "@effect/vitest"; import { CheckpointScopeId, + GitCommandError, MessageId, NodeId, ProviderSessionId, @@ -33,6 +34,8 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { layerRealGit } from "./ProviderTurnStartService.testkit.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -121,6 +124,7 @@ function startWithMissingWorktree( const startRootRun = vi.fn(() => Effect.void); const pruneWorktrees = vi.fn(() => Effect.void); const createWorktree = vi.fn(() => Effect.succeed({} as never)); + const listLocalBranchNames = vi.fn(() => Effect.succeed(["feature/restore"])); const layer = ProviderTurnStart.layer.pipe( Layer.provide( Layer.mergeAll( @@ -142,7 +146,11 @@ function startWithMissingWorktree( ), } as never), layerWorktreeLocation(), - Layer.mock(GitWorkflow.GitWorkflowService)({ pruneWorktrees, createWorktree }), + Layer.mock(GitWorkflow.GitWorkflowService)({ + pruneWorktrees, + createWorktree, + listLocalBranchNames, + }), Layer.mock(ProjectService.ProjectService)({ getById: () => Effect.succeedSome({ workspaceRoot: "/tmp/provider-turn-start-project" } as never), @@ -257,6 +265,8 @@ function makeLocalCommandHarness(input: { readonly text: string; readonly worktreePath?: string; readonly worktreesDir?: string; + readonly workspaceRoot?: string; + readonly gitWorkflow?: GitWorkflow.GitWorkflowService["Service"]; readonly fileSystem?: FileSystem.FileSystem; readonly previousNativeSession?: boolean; readonly previousMessages?: ReadonlyArray; @@ -664,7 +674,9 @@ function makeLocalCommandHarness(input: { ({ thread }: Parameters[0]) => Effect.succeed({ cwd: thread.worktreePath ?? "/tmp/native-account-command" } as never), ); - const createWorktree = vi.fn(() => Effect.succeed({} as never)); + const createWorktree = vi.fn(() => + Effect.succeed({} as never), + ); const getThreadRecords = vi.fn(() => Effect.succeed(projection), ); @@ -690,13 +702,18 @@ function makeLocalCommandHarness(input: { input.fileSystem === undefined ? FileSystem.layerNoop({ exists: () => Effect.succeed(true) }) : Layer.succeed(FileSystem.FileSystem, input.fileSystem), - Layer.mock(GitWorkflow.GitWorkflowService)({ - pruneWorktrees: () => Effect.void, - createWorktree, - }), + input.gitWorkflow === undefined + ? Layer.mock(GitWorkflow.GitWorkflowService)({ + pruneWorktrees: () => Effect.void, + listLocalBranchNames: () => Effect.succeed(["feature/restore"]), + createWorktree, + }) + : Layer.succeed(GitWorkflow.GitWorkflowService, input.gitWorkflow), Layer.mock(ProjectService.ProjectService)({ getById: () => - Effect.succeedSome({ workspaceRoot: "/tmp/native-account-project" } as never), + Effect.succeedSome({ + workspaceRoot: input.workspaceRoot ?? "/tmp/native-account-project", + } as never), }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThreadRecords, @@ -770,6 +787,167 @@ function makeLocalCommandHarness(input: { }; } +effectIt.effect( + "recovers the thread branch tip, falls back to the current project branch, and rejects detached HEAD", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const gitWorkflow = yield* GitWorkflow.GitWorkflowService; + const driver = yield* GitVcsDriver.GitVcsDriver; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-recreate-repo-" }); + const workspaceRoot = path.join(root, "project"); + const worktreesDir = path.join(root, "worktrees"); + const worktreePath = path.join(worktreesDir, "project", "restore"); + yield* fileSystem.makeDirectory(workspaceRoot); + const git = (cwd: string, args: ReadonlyArray) => + driver + .execute({ operation: "test.worktreeRecovery", cwd, args }) + .pipe(Effect.map((result) => result.stdout.trim())); + yield* git(workspaceRoot, ["init", "-b", "main"]); + yield* git(workspaceRoot, ["config", "user.name", "Test"]); + yield* git(workspaceRoot, ["config", "user.email", "test@example.com"]); + yield* git(workspaceRoot, ["commit", "--allow-empty", "-m", "base"]); + yield* git(workspaceRoot, ["worktree", "add", "-b", "feature/restore", worktreePath]); + yield* fileSystem.writeFileString(path.join(worktreePath, "thread.txt"), "thread work"); + yield* git(worktreePath, ["add", "."]); + yield* git(worktreePath, ["commit", "-m", "thread tip"]); + const threadTip = yield* git(worktreePath, ["rev-parse", "HEAD"]); + yield* git(workspaceRoot, ["switch", "-c", "project-current"]); + yield* fileSystem.writeFileString( + path.join(workspaceRoot, "project.txt"), + "new project code", + ); + yield* git(workspaceRoot, ["add", "."]); + yield* git(workspaceRoot, ["commit", "-m", "project tip"]); + const projectTip = yield* git(workspaceRoot, ["rev-parse", "HEAD"]); + expect(projectTip).not.toBe(threadTip); + // Tags must neither hide the thread branch nor select a different recovery tip. + yield* git(workspaceRoot, ["tag", "feature/restore", projectTip]); + yield* git(workspaceRoot, ["tag", "project-current", threadTip]); + + // Reuse the workflow across turns so recovery cannot select a cached project branch. + for (const scenario of ["retained", "deleted", "detached"] as const) { + yield* fileSystem.remove(worktreePath, { recursive: true }); + if (scenario !== "retained") { + yield* git(workspaceRoot, ["worktree", "prune"]); + yield* git(workspaceRoot, ["branch", "-D", "feature/restore"]); + } + if (scenario === "detached") { + yield* git(workspaceRoot, ["switch", "--detach"]); + expect(yield* git(workspaceRoot, ["branch", "--show-current"])).toBe(""); + } + const harness = makeLocalCommandHarness({ + text: "Continue", + workspaceRoot, + worktreePath, + worktreesDir, + fileSystem, + gitWorkflow, + failReadsAfterRunning: true, + }); + const open = harness.open.getMockImplementation()!; + harness.open.mockImplementation(() => + Effect.gen(function* () { + expect(yield* git(worktreePath, ["branch", "--show-current"])).toBe("feature/restore"); + expect(yield* git(worktreePath, ["rev-parse", "HEAD"])).toBe( + scenario === "retained" ? threadTip : projectTip, + ); + expect( + yield* fileSystem.readFileString( + path.join(worktreePath, scenario === "retained" ? "thread.txt" : "project.txt"), + ), + ).toBe(scenario === "retained" ? "thread work" : "new project code"); + return yield* open(); + }).pipe(Effect.orDie), + ); + + yield* harness.start; + + if (scenario === "detached") { + expect(harness.open).not.toHaveBeenCalled(); + expect(harness.startRootRun).not.toHaveBeenCalled(); + expect(harness.projection().runs.at(-1)?.status).toBe("failed"); + expect(harness.projection().turnItems).toMatchObject([ + { type: "error", failure: { message: expect.stringContaining("no current branch") } }, + ]); + expect(yield* fileSystem.exists(worktreePath)).toBe(false); + expect(yield* git(workspaceRoot, ["branch", "--list", "feature/restore"])).toBe(""); + } else { + expect(harness.open).toHaveBeenCalledOnce(); + expect(harness.startRootRun).toHaveBeenCalledWith( + expect.objectContaining({ + runtimePolicy: expect.objectContaining({ cwd: worktreePath }), + }), + ); + expect(harness.projection().runs.at(-1)?.status).toBe("running"); + } + } + }).pipe(Effect.scoped, Effect.provide(layerRealGit)), +); + +effectIt.effect( + "preserves recreation interrupts and retries, then fails the final attempt before provider start", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreesDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-recreate-failure-", + }); + const worktreePath = path.join(worktreesDir, "project", "restore"); + const harness = makeLocalCommandHarness({ + text: "Continue", + worktreesDir, + worktreePath, + fileSystem, + failReadsAfterRunning: true, + }); + harness.createWorktree.mockImplementationOnce(() => Effect.interrupt); + harness.createWorktree.mockImplementation(() => + Effect.fail( + new GitCommandError({ + operation: "worktree-add", + command: "git worktree add", + cwd: "/tmp/native-account-project", + detail: "fatal: private git diagnostic", + }), + ), + ); + + const interrupted = yield* Effect.exit(harness.start); + expect(interrupted._tag).toBe("Failure"); + if (interrupted._tag === "Failure") + expect(Cause.hasInterruptsOnly(interrupted.cause)).toBe(true); + expect(harness.events).toEqual([]); + expect(harness.projection().runs.at(-1)?.status).toBe("starting"); + + const retry = yield* Effect.result(harness.startWithRetry); + expect(retry).toMatchObject({ _tag: "Failure", failure: { _tag: "ProviderTurnStartError" } }); + expect(harness.events).toEqual([]); + expect(harness.projection().runs.at(-1)?.status).toBe("starting"); + + yield* harness.start; + expect(harness.createWorktree).toHaveBeenCalledTimes(3); + expect(harness.writeIfRunCurrent).toHaveBeenCalledOnce(); + expect(harness.open).not.toHaveBeenCalled(); + expect(harness.startRootRun).not.toHaveBeenCalled(); + const projection = harness.projection(); + expect(projection.runs.at(-1)).toMatchObject({ status: "failed", startedAt: null }); + expect(projection.attempts[0]).toMatchObject({ status: "failed", startedAt: null }); + expect(projection.nodes[0]).toMatchObject({ status: "failed", startedAt: null }); + expect(projection.turnItems).toMatchObject([ + { + type: "error", + failure: { + message: `Could not recreate the worktree for branch 'feature/restore' at ${worktreePath}.`, + }, + }, + ]); + expect(JSON.stringify(harness.events)).not.toContain("private git diagnostic"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + effectIt.effect("recovers an abandoned failed turn before continuing its native conversation", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts index 6e517ef73a66b..5b614b1e2aa6c 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts @@ -1,3 +1,18 @@ +import { SourceControlProviderError, SourceControlProviderKind } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as ServerConfig from "../config.ts"; +import * as GitManager from "../git/GitManager.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProjectStore from "./ProjectStore.ts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -13,3 +28,35 @@ export const layer = Layer.merge( getById: () => Effect.succeedNone, }), ); + +// Keep repository detection, branch selection and worktree creation on the real Git path. +export const layerRealGit = GitWorkflow.layer.pipe( + Layer.provide(GitManager.layer), + Layer.provide( + Layer.mergeAll( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => undefined, + resolve: ({ cwd }) => + Effect.fail( + new SourceControlProviderError({ + provider: SourceControlProviderKind.make("unknown"), + operation: "resolve", + cwd, + detail: "No source control provider in tests", + }), + ), + }), + Layer.mock(TextGeneration.TextGeneration)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + ServerSettings.layerTest(), + ), + ), + Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-recreate-" })), + Layer.provideMerge(NodeServices.layer), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 97fdb7c85ca71..9d9ae2e646cd5 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -15,7 +15,6 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -68,6 +67,28 @@ export class ProviderTurnStartError extends Schema.TaggedError()( + "WorktreeRecreateError", + { + worktreePath: Schema.String, + branch: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not recreate the worktree for branch '${this.branch}' at ${this.worktreePath}.`; + } +} + +class WorktreeRecreateNoBaseError extends Schema.TaggedError()( + "WorktreeRecreateNoBaseError", + { worktreePath: Schema.String, branch: Schema.String }, +) { + override get message(): string { + return `Could not recreate branch '${this.branch}' at ${this.worktreePath}: the project workspace has no current branch to start it from (detached HEAD?).`; + } +} + const isProviderTurnStartError = Schema.is(ProviderTurnStartError); /** @@ -131,6 +152,35 @@ export const layer: Layer.Layer< const runtimePolicy = yield* RuntimePolicy.RuntimePolicyV2; const isManagedWorktreePath = yield* makeIsManagedWorktreePath; + const recreateMissingWorktree = Effect.fn("ProviderTurnStartService.recreateMissingWorktree")( + function* (input: { + readonly cwd: string; + readonly worktreePath: string; + readonly branch: string; + }) { + const { cwd, worktreePath, branch } = input; + yield* gitWorkflow.pruneWorktrees({ cwd }); + const localBranchNames = yield* gitWorkflow.listLocalBranchNames(cwd); + if (localBranchNames.includes(branch)) { + yield* gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }); + return; + } + // Recovery can follow an external checkout, before the status cache expires. + yield* gitWorkflow.invalidateLocalStatus(cwd); + const { refName: baseRef } = yield* gitWorkflow.localStatus({ cwd }); + if (baseRef === null) { + return yield* new WorktreeRecreateNoBaseError({ worktreePath, branch }); + } + yield* gitWorkflow.createWorktree({ + cwd, + refName: `refs/heads/${baseRef}`, + newRefName: branch, + baseRefName: `refs/heads/${baseRef}`, + path: worktreePath, + }); + }, + ); + // Build draining callbacks outside startup scope so they cannot retain full thread history. const makeRunControls = (input: { readonly threadId: ThreadId; @@ -399,6 +449,39 @@ export const layer: Layer.Layer< }); }, ); + const settleStartFailure = Effect.fn("ProviderTurnStartService.settleStartFailure")( + function* (failed: { + readonly signal: string; + readonly title: string; + readonly error: Error; + readonly message?: string; + }) { + const nestedCause = "cause" in failed.error ? failed.error.cause : undefined; + yield* settleRunBeforeStart({ + signal: failed.signal, + status: "failed", + now: yield* DateTime.now, + providerInstanceId: run.providerInstanceId, + itemProviderThreadId: providerThread.id, + item: { + type: "error", + title: failed.title, + failure: makeProviderFailure({ + cause: failed.error, + message: + failed.message ?? + knownProviderFailureMessage(failed.error) ?? + (nestedCause instanceof Error + ? nestedCause.message + : typeof nestedCause === "string" + ? nestedCause + : failed.error.message), + class: "provider_error", + }), + }, + }); + }, + ); if (message.attachments.length === 0 && message.text.trimStart().startsWith("/")) { const isEmptyCompaction = message.text.trim().toLowerCase() === "/compact" && !projection.hasConversation; @@ -500,24 +583,29 @@ export const layer: Layer.Layer< worktreePath, branch, }); - yield* gitWorkflow.pruneWorktrees({ cwd: project.workspaceRoot }).pipe( - Effect.andThen( - gitWorkflow.createWorktree({ - cwd: project.workspaceRoot, - refName: branch, - path: worktreePath, - }), - ), - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (cause) => - Effect.logWarning("provider turn start failed to recreate worktree", { - threadId: projection.thread.id, - worktreePath, - cause: Cause.pretty(cause), - }), + const recreateResult = yield* Effect.result( + recreateMissingWorktree({ + cwd: project.workspaceRoot, + worktreePath, + branch, + }).pipe( + Effect.mapError((cause) => + cause._tag === "WorktreeRecreateNoBaseError" + ? cause + : new WorktreeRecreateError({ worktreePath, branch, cause }), + ), ), ); + if (recreateResult._tag === "Failure") { + if (input.willRetry === true) return yield* recreateResult.failure; + yield* settleStartFailure({ + signal: "worktree-recreate-failure", + title: "Couldn't recreate thread workspace", + error: recreateResult.failure, + message: recreateResult.failure.message, + }); + return; + } } } } @@ -553,37 +641,6 @@ export const layer: Layer.Layer< const existingSessionProjection = projection.providerSessions.find( (candidate) => candidate.id === providerSessionId, ); - const settleStartFailure = Effect.fn("ProviderTurnStartService.settleStartFailure")( - function* (failed: { - readonly signal: string; - readonly title: string; - readonly error: Error; - }) { - const nestedCause = "cause" in failed.error ? failed.error.cause : undefined; - yield* settleRunBeforeStart({ - signal: failed.signal, - status: "failed", - now: yield* DateTime.now, - providerInstanceId: run.providerInstanceId, - itemProviderThreadId: providerThread.id, - item: { - type: "error", - title: failed.title, - failure: makeProviderFailure({ - cause: failed.error, - message: - knownProviderFailureMessage(failed.error) ?? - (nestedCause instanceof Error - ? nestedCause.message - : typeof nestedCause === "string" - ? nestedCause - : failed.error.message), - class: "provider_error", - }), - }, - }); - }, - ); const failedRuns = new Map( projection.runs .filter( diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 8c15b808d61e2..e151e22782b9f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4385,7 +4385,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "branch", "--list", "--no-column", - "--format=%(refname:short)", + "--format=%(refname:lstrip=2)", ]).pipe( Effect.map((stdout) => { const branchNames: Array = [];