diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 0f2632cb..3a671591 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -67,6 +67,7 @@ import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; import { GitVcsDriver, type GitRemoteStatusOptions, + type GitWorktreeBaseRef, type GitWorktreeEntry, } from "../vcs/GitVcsDriver.ts"; import { VcsDriverRegistry, type VcsDriverHandle } from "../vcs/VcsDriverRegistry.ts"; @@ -144,6 +145,11 @@ export interface GitWorkflowServiceShape { readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; + /** See GitVcsDriverShape.resolveFreshWorktreeBase. */ + readonly resolveFreshWorktreeBase: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect; readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; readonly createRef: ( input: VcsCreateRefInput, @@ -520,6 +526,10 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(git.createWorktree(input)), ), + resolveFreshWorktreeBase: (input) => + ensureGitCommand("GitWorkflowService.resolveFreshWorktreeBase", input.cwd).pipe( + Effect.andThen(git.resolveFreshWorktreeBase(input)), + ), removeWorktree: (input) => ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c7d8e690..8125997e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -60,7 +60,10 @@ import { ProviderCommandReactorLive, resolveForkTurnBoundary, } from "./ProviderCommandReactor.ts"; -import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -169,6 +172,9 @@ describe("ProviderCommandReactor", () => { /** Mirror the provider lifecycle's projected `starting` state while a * replacement session is being bound. */ readonly projectStartingDuringRestart?: boolean; + /** State left behind by a previous server process, seeded before the + * reactor starts. */ + readonly beforeStart?: (engine: OrchestrationEngineShape) => Effect.Effect; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -542,6 +548,9 @@ describe("ProviderCommandReactor", () => { const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); scope = await Effect.runPromise(Scope.make("sequential")); + if (input?.beforeStart) { + await Effect.runPromise(input.beforeStart(engine).pipe(Effect.orDie)); + } await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(reactor.drain); @@ -1139,6 +1148,79 @@ describe("ProviderCommandReactor", () => { }); }); + // A server restart mid-turn (crash, update, dev reload) leaves the session + // row saying "running" with nothing behind it; without this the thread shows + // "Preparing turn" forever and the only way out is deleting it. + it("settles sessions left in flight by the previous server process on start", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const staleThreadId = ThreadId.make("thread-stale-restart"); + const staleTurnId = asTurnId("turn-stale-restart"); + const harness = await createHarness({ + beforeStart: (engine) => + Effect.gen(function* () { + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-project-create-stale"), + projectId: asProjectId("project-stale"), + title: "Stale Project", + workspaceRoot: PROJECT_ROOT, + defaultModelSelection: null, + createdAt: now, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-stale"), + threadId: staleThreadId, + projectId: asProjectId("project-stale"), + title: "Stale Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-stale"), + threadId: staleThreadId, + session: { + threadId: staleThreadId, + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: staleTurnId, + pendingBackgroundTaskCount: 0, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + }), + }); + + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === staleThreadId); + return ( + thread?.session?.status === "interrupted" && + thread.activities.some((entry) => entry.kind === "provider.session.restart-interrupted") + ); + }); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === staleThreadId); + expect(thread?.session).toMatchObject({ status: "interrupted", activeTurnId: null }); + expect(thread?.latestTurn).toMatchObject({ turnId: staleTurnId, state: "interrupted" }); + expect( + thread?.activities.find((entry) => entry.kind === "provider.session.restart-interrupted"), + ).toMatchObject({ turnId: staleTurnId }); + // The thread the harness created after start is untouched. + const freshThread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(freshThread?.session ?? null).toBeNull(); + }); + it("settles a stale running session when the provider reports no active turn to steer", async () => { const harness = await createHarness(); const turnId = asTurnId("turn-stale"); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 22d4f5c3..39952766 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -425,6 +425,7 @@ const make = Effect.gen(function* () { | "provider.approval.respond.failed" | "provider.user-input.respond.failed" | "provider.session.stop.failed" + | "provider.session.restart-interrupted" | "provider.goal.failed"; readonly summary: string; readonly detail: string; @@ -2470,7 +2471,61 @@ const make = Effect.gen(function* () { } }); + // Provider processes do not outlive the server, so a session still recorded + // as starting or running when the server comes up belongs to the previous + // process and no command can reach it. Settle each one as interrupted, with + // a note in the thread, instead of leaving the thread on "Preparing turn" + // until the user gives up and deletes it. + const restartInterruptedStatuses: ReadonlySet = new Set([ + "starting", + "running", + ]); + const settleSessionsFromPreviousProcess = Effect.fn("settleSessionsFromPreviousProcess")( + function* () { + const snapshot = yield* projectionSnapshotQuery.getShellSnapshot(); + for (const thread of snapshot.threads) { + const session = thread.session; + if (!session || !restartInterruptedStatuses.has(session.status)) { + continue; + } + const createdAt = yield* nowIso; + yield* setThreadSession({ + threadId: thread.id, + session: { + ...session, + status: "interrupted", + activeTurnId: null, + pendingBackgroundTaskCount: 0, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + yield* appendProviderFailureActivity({ + threadId: thread.id, + kind: "provider.session.restart-interrupted", + summary: "Turn interrupted by a server restart", + detail: + "Threadlines restarted while this turn was in progress, so its agent session is gone. Send a message to continue.", + turnId: session.activeTurnId, + createdAt, + }); + } + }, + ); + const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { + yield* settleSessionsFromPreviousProcess().pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "provider command reactor failed to settle sessions left by the previous process", + { + cause: Cause.pretty(cause), + }, + ), + ), + ); + const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { if ( event.type === "thread.runtime-mode-set" || diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 15d34a92..d0436685 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3189,6 +3189,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.succeed({ worktree: { path: "/tmp/wt", refName: "feature/demo" }, }), + resolveFreshWorktreeBase: (input) => + Effect.succeed({ refName: input.branch, isRemote: false }), workingTreeDiff: () => Effect.succeed({ diff: "" }), discardChanges: (input) => Effect.succeed({ discardedPaths: [...input.filePaths] }), stageChanges: (input) => Effect.succeed({ stagedPaths: [...input.filePaths] }), @@ -4462,6 +4464,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { layers: { gitVcsDriver: { createWorktree, + resolveFreshWorktreeBase: (input) => + Effect.succeed({ refName: input.branch, isRemote: false }), }, vcsStatusBroadcaster: { refreshStatus, @@ -4582,6 +4586,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { layers: { gitVcsDriver: { createWorktree, + resolveFreshWorktreeBase: (input) => + Effect.succeed({ refName: input.branch, isRemote: false }), }, orchestrationEngine: { dispatch: (command) => @@ -4683,6 +4689,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { layers: { gitVcsDriver: { createWorktree, + resolveFreshWorktreeBase: (input) => + Effect.succeed({ refName: input.branch, isRemote: false }), }, orchestrationEngine: { dispatch: (command) => { @@ -4786,6 +4794,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { layers: { gitVcsDriver: { createWorktree, + resolveFreshWorktreeBase: (input) => + Effect.succeed({ refName: input.branch, isRemote: false }), }, orchestrationEngine: { dispatch: (command) => diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index 0ddfe66f..687494f5 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -122,6 +122,19 @@ export const layer = Layer.effect( const nodePty = yield* Effect.promise(() => import("node-pty")); + // node-pty's Windows kill path forks a helper to list the shell's console + // processes and takes the first IPC message back as the answer. Under + // `node --watch` the server carries WATCH_REPORT_DEPENDENCIES, which the + // forked helper inherits; its module loader then reports its own requires + // over that same channel, node-pty reads `consoleProcessList` off the + // wrong message, and the resulting unhandled rejection takes the server + // down. The fork is deferred until the pty reports ready, so the variable + // has to stay unset rather than be hidden around the kill call. The server + // loads its module graph eagerly, so watch mode loses nothing here. + if (globalThis.process.platform === "win32") { + delete globalThis.process.env.WATCH_REPORT_DEPENDENCIES; + } + const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( ensureNodePtySpawnHelperExecutable().pipe( Effect.provideService(FileSystem.FileSystem, fs), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index c4b9eabc..65b7f719 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -110,6 +110,12 @@ export interface GitRemoteStatusDetails { aheadOfDefaultCount: number; } +/** Start point for a new worktree: a local branch or a remote-tracking ref. */ +export interface GitWorktreeBaseRef { + readonly refName: string; + readonly isRemote: boolean; +} + export interface GitRemoteStatusOptions { readonly forceRefresh?: boolean; } @@ -304,6 +310,17 @@ export interface GitVcsDriverShape { readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; + /** + * Where a worktree cut "from `branch`" should start. Fetches the branch's + * upstream (best effort, bounded) and answers with the upstream ref when it + * is strictly ahead of the local branch, so a thread started from `main` + * begins at the latest `main` rather than at a stale local copy. The local + * branch wins whenever it has commits of its own or has no upstream. + */ + readonly resolveFreshWorktreeBase: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect; readonly fetchPullRequestBranch: ( input: GitFetchPullRequestBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7e237e37..72de1564 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import { createServer } from "node:http"; import { setTimeout as sleepRealTime } from "node:timers/promises"; @@ -898,12 +899,78 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(created.worktree.refName, "feature/worktree"); assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); - yield* driver.removeWorktree({ cwd, path: worktreePath }); + // Installed dependencies nest deeper than the Windows MAX_PATH limit; + // removal must still take the whole folder with it. const fileSystem = yield* FileSystem.FileSystem; + const deepPath = pathService.join( + worktreePath, + "node_modules", + ...Array.from({ length: 12 }, () => "a".repeat(40)), + ); + yield* fileSystem.makeDirectory(deepPath, { recursive: true }); + yield* fileSystem.writeFileString(pathService.join(deepPath, "index.js"), ""); + + yield* driver.removeWorktree({ cwd, path: worktreePath, force: true }); assert.equal(yield* fileSystem.exists(worktreePath), false); }), ); + // "From main" means the latest main. When the upstream has moved on, the + // new branch starts there; when the local branch has its own commits, the + // user's local state wins. Either way the new branch tracks nothing. + it.effect("starts a new worktree from the upstream when the local base is behind", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + + // Someone else lands a commit on the base branch. + const peer = yield* makeTmpDir("git-vcs-driver-peer-"); + yield* git(peer, ["clone", "--branch", initialBranch, remote, "."]); + yield* git(peer, ["config", "user.email", "test@test.com"]); + yield* git(peer, ["config", "user.name", "Test"]); + yield* writeTextFile(peer, "upstream.txt", "upstream\n"); + yield* git(peer, ["add", "."]); + yield* git(peer, ["commit", "-m", "upstream commit"]); + yield* git(peer, ["push", "origin", initialBranch]); + + const behind = yield* driver.resolveFreshWorktreeBase({ cwd, branch: initialBranch }); + assert.deepStrictEqual(behind, { refName: `origin/${initialBranch}`, isRemote: true }); + + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "fresh"); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: behind.refName, + newRefName: "threadlines/0123abcd", + }); + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "upstream.txt")), + true, + ); + const upstreamOfNewBranch = yield* git(worktreePath, [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ]).pipe(Effect.orElseSucceed(() => "none")); + assert.equal(upstreamOfNewBranch, "none"); + + // A local commit on the base makes it diverge: the local branch wins. + yield* writeTextFile(cwd, "local.txt", "local\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "local commit"]); + const diverged = yield* driver.resolveFreshWorktreeBase({ cwd, branch: initialBranch }); + assert.deepStrictEqual(diverged, { refName: initialBranch, isRemote: false }); + }), + ); + // A worktree whose folder was deleted out-of-band leaves its registration // behind, and `git worktree add` refuses to reuse the path. Recreating at // the same path is the recovery flow for a thread whose checkout was @@ -1971,3 +2038,38 @@ it.live("waits for another process to release the index lock", () => assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "locked.txt"); }).pipe(Effect.provide(TestLayer)), ); + +// Thread deletion kills the worktree's terminals and removes the worktree at +// the same time. On Windows a shell whose working directory is the worktree +// pins the folder, so git unregisters the worktree but cannot delete it. +// Removal has to outlast the holder instead of failing. Real clock, because +// the retry delays between attempts must actually elapse. +it.live("removes a worktree that a process briefly holds as its working directory", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "held"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/held", + }); + + // A process that sits in the worktree for a moment, then exits on its own. + const holder = spawn(process.execPath, ["-e", "setTimeout(() => {}, 1500)"], { + cwd: worktreePath, + stdio: "ignore", + }); + yield* Effect.addFinalizer(() => Effect.sync(() => holder.kill())); + yield* Effect.promise(() => sleepRealTime(200)); + + yield* driver.removeWorktree({ cwd, path: worktreePath, force: true }); + assert.equal(yield* fileSystem.exists(worktreePath), false); + const worktrees = yield* driver.listWorktrees({ cwd }); + assert.isUndefined(worktrees.find((worktree) => worktree.branch === "feature/held")); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), +); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 59e76a01..58481b2b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -85,6 +85,16 @@ const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const GIT_FETCH_NO_WRITE_FETCH_HEAD = "--no-write-fetch-head"; +/** Upper bound on the pre-worktree fetch; past it the local branch is used as-is. */ +const WORKTREE_BASE_FETCH_TIMEOUT = Duration.seconds(15); +/** + * How long removeWorktree keeps trying to delete a folder git already + * unregistered but could not delete because a process still sat in it. + * Terminal shells exit within a second or two of thread cleanup; five seconds + * leaves headroom without hanging the caller. + */ +const LEFTOVER_WORKTREE_REMOVE_ATTEMPTS = 20; +const LEFTOVER_WORKTREE_REMOVE_DELAY = Duration.millis(250); const BACKGROUND_GIT_FETCH_ENV = Object.freeze({ GCM_INTERACTIVE: "Never", GIT_TERMINAL_PROMPT: "0", @@ -4344,8 +4354,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + // `--no-track`: a thread's branch may start from a remote-tracking ref + // (see resolveFreshWorktreeBase) and must not inherit it as upstream, or a + // later push would target the base branch itself. const args = input.newRefName - ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] + ? ["worktree", "add", "--no-track", "-b", input.newRefName, worktreePath, input.refName] : ["worktree", "add", "--no-guess", worktreePath, input.refName]; yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { @@ -4429,6 +4442,80 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); + const resolveFreshWorktreeBase: GitVcsDriver.GitVcsDriverShape["resolveFreshWorktreeBase"] = + Effect.fn("resolveFreshWorktreeBase")(function* (input) { + const local = { refName: input.branch, isRemote: false } as const; + const upstreamRef = (yield* runGitStdout( + "GitVcsDriver.resolveFreshWorktreeBase.upstream", + input.cwd, + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${input.branch}@{upstream}`], + true, + )).trim(); + if (upstreamRef.length === 0 || upstreamRef.includes("@{upstream}")) { + return local; + } + const remoteNames = yield* runGitStdout( + "GitVcsDriver.resolveFreshWorktreeBase.remotes", + input.cwd, + ["remote"], + ).pipe( + Effect.map(parseRemoteNames), + Effect.catch(() => Effect.succeed>([])), + ); + const remoteName = remoteNames.find((name) => upstreamRef.startsWith(`${name}/`)); + if (!remoteName) { + return local; + } + const remoteBranch = upstreamRef.slice(remoteName.length + 1); + + // Best effort: an offline machine or a slow remote must not hold up the + // thread, and a failed fetch simply leaves the tracking ref as it was. + yield* withGitMutationPermitForCwd( + input.cwd, + executeGit( + "GitVcsDriver.resolveFreshWorktreeBase.fetch", + input.cwd, + [ + "fetch", + GIT_FETCH_NO_WRITE_FETCH_HEAD, + "--quiet", + "--no-tags", + remoteName, + remoteBranchFetchRefspec(remoteName, remoteBranch), + ], + { + env: BACKGROUND_GIT_FETCH_ENV, + timeoutMs: Duration.toMillis(WORKTREE_BASE_FETCH_TIMEOUT), + }, + ), + ).pipe(Effect.ignore); + + const [localSha, upstreamSha] = yield* Effect.all([ + runGitStdout( + "GitVcsDriver.resolveFreshWorktreeBase.localSha", + input.cwd, + ["rev-parse", "--verify", "--quiet", `refs/heads/${input.branch}`], + true, + ), + runGitStdout( + "GitVcsDriver.resolveFreshWorktreeBase.upstreamSha", + input.cwd, + ["rev-parse", "--verify", "--quiet", `refs/remotes/${upstreamRef}`], + true, + ), + ]); + if (upstreamSha.trim().length === 0 || localSha.trim() === upstreamSha.trim()) { + return local; + } + const localIsBehind = yield* executeGit( + "GitVcsDriver.resolveFreshWorktreeBase.isAncestor", + input.cwd, + ["merge-base", "--is-ancestor", input.branch, upstreamRef], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + return localIsBehind ? { refName: upstreamRef, isRemote: true } : local; + }); + const fetchPullRequestBranch: GitVcsDriver.GitVcsDriverShape["fetchPullRequestBranch"] = Effect.fn("fetchPullRequestBranch")(function* (input) { const remoteName = yield* resolvePrimaryRemoteName(input.cwd); @@ -4504,15 +4591,54 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const removeWorktree: GitVcsDriver.GitVcsDriverShape["removeWorktree"] = Effect.fn( "removeWorktree", )(function* (input) { - const args = ["worktree", "remove"]; + // A worktree that has had its dependencies installed holds paths longer + // than the Windows MAX_PATH limit. Without core.longpaths git deletes what + // it can, drops the registration, and exits with "Filename too long", + // leaving a dead folder that the app can no longer remove. The flag is a + // no-op on other platforms. + const args = ["-c", "core.longpaths=true", "worktree", "remove"]; if (input.force) { args.push("--force"); } args.push(input.path); + + // git unregisters the worktree before it deletes the files, so a folder + // that something still holds open comes back as "failed to delete ... + // Permission denied" with the registration already gone. Thread deletion + // hits exactly that on Windows: the worktree's terminal shell is killed a + // beat after removal starts, and until it exits its working directory + // pins the folder. Finish the deletion ourselves once the holder lets go, + // but only when git really did unregister the worktree. + const finishInterruptedRemoval = (cause: GitCommandError) => + Effect.gen(function* () { + if (yield* isWorktreeRegistered(input.cwd, input.path)) { + return yield* Effect.fail(cause); + } + for (let attempt = 0; attempt < LEFTOVER_WORKTREE_REMOVE_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + yield* Effect.sleep(LEFTOVER_WORKTREE_REMOVE_DELAY); + } + yield* fileSystem + .remove(input.path, { recursive: true, force: true }) + .pipe(Effect.catch(() => Effect.void)); + const stillThere = yield* fileSystem + .exists(input.path) + .pipe(Effect.catch(() => Effect.succeed(false))); + if (!stillThere) { + return; + } + } + return yield* Effect.fail(cause); + }); + yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { timeoutMs: 15_000, fallbackErrorMessage: "git worktree remove failed", }).pipe( + Effect.catchIf( + (error) => /failed to delete/iu.test(error.detail ?? ""), + finishInterruptedRemoval, + ), Effect.mapError((error) => createGitCommandError( "GitVcsDriver.removeWorktree", @@ -4525,6 +4651,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); + /** Whether `git worktree list` still knows the checkout at `worktreePath`. */ + const isWorktreeRegistered = Effect.fn("isWorktreeRegistered")(function* ( + cwd: string, + worktreePath: string, + ) { + const normalize = (value: string) => { + const forwardSlashes = value.replace(/\\/g, "/").replace(/\/+$/, ""); + return globalThis.process.platform === "win32" + ? forwardSlashes.toLowerCase() + : forwardSlashes; + }; + const result = yield* executeGit( + "GitVcsDriver.isWorktreeRegistered", + cwd, + ["worktree", "list", "--porcelain"], + { timeoutMs: 5_000, allowNonZeroExit: true }, + ).pipe(Effect.catch(() => Effect.succeed(null))); + if (result === null || result.exitCode !== 0) { + return true; + } + const target = normalize(worktreePath); + return result.stdout + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .some((line) => normalize(line.slice("worktree ".length)) === target); + }); + const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( function* (input) { if (input.oldBranch === input.newBranch) { @@ -4888,6 +5041,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* stageChanges, unstageChanges, createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), + resolveFreshWorktreeBase, fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ca4cffe2..10a33eae 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -652,9 +652,15 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => } if (bootstrap?.prepareWorktree) { + // "From main" means the latest main: start from the upstream + // when the local branch has fallen behind it. + const base = yield* gitWorkflow.resolveFreshWorktreeBase({ + cwd: bootstrap.prepareWorktree.projectCwd, + branch: bootstrap.prepareWorktree.baseBranch, + }); const worktree = yield* gitWorkflow.createWorktree({ cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, + refName: base.refName, newRefName: bootstrap.prepareWorktree.branch, path: null, }); diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 7c12dc7d..f6cf26db 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -9,6 +9,7 @@ import { resolveActiveWorktreePath, resolveBranchSelectionTarget, resolveCurrentWorkspaceLabel, + resolveDefaultWorktreeBaseBranch, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, resolveEnvModeLabel, @@ -89,6 +90,31 @@ describe("resolveBranchToolbarValue", () => { }); }); +describe("resolveDefaultWorktreeBaseBranch", () => { + it("prefers the local default branch over the checkout's current branch", () => { + expect( + resolveDefaultWorktreeBaseBranch({ + refs: [ + { name: "threadlines/2d841c42", isDefault: false }, + { name: "origin/main", isDefault: true, isRemote: true }, + { name: "main", isDefault: true }, + ], + currentGitBranch: "threadlines/2d841c42", + }), + ).toBe("main"); + }); + + it("falls back to the current branch when no default branch is known", () => { + expect( + resolveDefaultWorktreeBaseBranch({ + refs: [{ name: "feature/x", isDefault: false }], + currentGitBranch: "feature/x", + }), + ).toBe("feature/x"); + expect(resolveDefaultWorktreeBaseBranch({ refs: [], currentGitBranch: null })).toBeNull(); + }); +}); + describe("resolveEnvironmentOptionLabel", () => { it("prefers the primary environment's machine label", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 707589d9..d5d081d5 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -114,6 +114,19 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } +/** + * Base branch a worktree draft starts from when the user has not picked one: + * the repository's default branch. "New worktree" means a fresh branch off + * main, not off whatever the project checkout happens to have out. + */ +export function resolveDefaultWorktreeBaseBranch(input: { + refs: ReadonlyArray>; + currentGitBranch: string | null; +}): string | null { + const defaultRef = input.refs.find((ref) => ref.isDefault && !ref.isRemote); + return defaultRef?.name ?? input.currentGitBranch; +} + export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 8634a23d..3e5434a0 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -35,6 +35,7 @@ import { resolveBranchSelectionTarget, resolveBranchToolbarValue, resolveCheckoutPickerRefsCwd, + resolveDefaultWorktreeBaseBranch, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, shouldIncludeBranchPickerItem, @@ -502,16 +503,22 @@ export function BranchToolbarBranchSelector({ }; useEffect(() => { - if ( - effectiveEnvMode !== "worktree" || - activeWorktreePath || - activeThreadBranch || - !currentGitBranch - ) { + if (effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch) { return; } - setThreadBranch(currentGitBranch, null); - }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); + const baseBranch = resolveDefaultWorktreeBaseBranch({ refs, currentGitBranch }); + if (!baseBranch) { + return; + } + setThreadBranch(baseBranch, null); + }, [ + activeThreadBranch, + activeWorktreePath, + currentGitBranch, + effectiveEnvMode, + refs, + setThreadBranch, + ]); // --------------------------------------------------------------------------- // Combobox / list plumbing diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bffe323e..d661cbaa 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -4948,7 +4948,9 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("shows the send state once bootstrap dispatch is in flight", async () => { + // The server cuts the worktree inside the bootstrap request, so that is the + // window in which the user should read "Preparing worktree", not "Sending". + it("shows the worktree preparation state while the bootstrap dispatch is in flight", async () => { useTerminalStateStore.setState({ terminalStateByThreadKey: {}, }); @@ -5009,8 +5011,8 @@ describe("ChatView timeline estimator parity (full app)", () => { expect( wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand), ).toBe(true); - expect(document.querySelector('button[aria-label="Sending"]')).toBeTruthy(); - expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeNull(); + expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeTruthy(); + expect(document.querySelector('button[aria-label="Sending"]')).toBeNull(); }, { timeout: 8_000, interval: 16 }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c7900414..3dbfbfa9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2040,7 +2040,7 @@ export default function ChatView(props: ChatViewProps) { markLocalDispatchAccepted, resetLocalDispatch, localDispatchStartedAt, - isPreparingWorktree, + isPreparingWorktree: isPreparingWorktreeDispatch, isSendBusy, } = useLocalDispatchState({ activeThread, @@ -2050,6 +2050,9 @@ export default function ChatView(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError: activeThread?.error, }); + // Raised for the whole bootstrap request; dropped as soon as the thread + // reports its worktree so the label never outlives the work it describes. + const isPreparingWorktree = isPreparingWorktreeDispatch && !activeThread?.worktreePath; const isWorking = phase === "running" || phase === "connecting" || @@ -4261,9 +4264,15 @@ export default function ChatView(props: ChatViewProps) { activeThread.worktreePath === null && !envLocked, ); - const envMode: DraftThreadEnvMode = canOverrideServerThreadEnvMode - ? (pendingServerThreadEnvMode ?? draftThread?.envMode ?? derivedEnvMode) - : derivedEnvMode; + // The server records the thread a beat before it cuts the worktree, so the + // merged thread briefly carries the project root and its branch. Hold the + // requested mode until the worktree path lands instead of flashing "Current + // checkout" at the user who just picked "New worktree". + const envMode: DraftThreadEnvMode = isPreparingWorktree + ? "worktree" + : canOverrideServerThreadEnvMode + ? (pendingServerThreadEnvMode ?? draftThread?.envMode ?? derivedEnvMode) + : derivedEnvMode; const activeThreadBranch = canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch @@ -4948,7 +4957,9 @@ export default function ChatView(props: ChatViewProps) { : {}), } : undefined; - beginLocalDispatch({ preparingWorktree: false }); + // The worktree is cut inside this request; the flag stays up until the + // thread reports its worktree path. + beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), @@ -6467,9 +6478,15 @@ export default function ChatView(props: ChatViewProps) { return; } if (isLocalDraftThread) { + // Leaving an existing worktree for a new one also drops its branch as + // the base: that branch is the previous thread's work (often a + // throwaway `threadlines/...` name), not what "new worktree" means. + // The branch selector refills the base from the default branch. setDraftThreadContext(composerDraftTarget, { envMode: mode, - ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), + ...(mode === "worktree" && draftThread?.worktreePath + ? { worktreePath: null, branch: null } + : {}), }); } scheduleComposerFocus(); @@ -6973,7 +6990,9 @@ export default function ChatView(props: ChatViewProps) { threadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} onEnvModeChange={onEnvModeChange} - {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} + {...(canOverrideServerThreadEnvMode || isPreparingWorktree + ? { effectiveEnvModeOverride: envMode } + : {})} {...(canOverrideServerThreadEnvMode ? { activeThreadBranchOverride: activeThreadBranch, diff --git a/packages/shared/src/contextSeed.ts b/packages/shared/src/contextSeed.ts index cf70a703..c5d5b124 100644 --- a/packages/shared/src/contextSeed.ts +++ b/packages/shared/src/contextSeed.ts @@ -29,11 +29,18 @@ import type { * deleting it strands the conversation with nowhere to run. Threadlines owns * that lifecycle and removes worktrees on the user's behalf. * - * One sentence on purpose. It is prepended to every provider's instructions, + * The second sentence covers the other thing agents assume about a checkout: + * that it is ready to build. A worktree is a bare checkout of tracked files. + * Installed dependencies, build output, and ignored local files from the main + * checkout are not in it, and Threadlines does not install anything on the + * agent's behalf; whether to install is the agent's call for its task. + * + * Two sentences on purpose. It is prepended to every provider's instructions, * where every extra line competes with the user's actual task. */ export const MANAGED_WORKTREE_INSTRUCTION = - "Your working directory is a git worktree that Threadlines created and manages; never remove or prune it, including as post-merge cleanup."; + "Your working directory is a git worktree that Threadlines created and manages; never remove or prune it, including as post-merge cleanup. " + + "It is a fresh checkout of tracked files only: dependencies, build output, and ignored local files from the main checkout are not present, so install what your task needs before building or testing."; const ROLE_LABEL: Record = { user: "User",