From 2438d6ab103ff2b6125523c3803d802e11ffefda Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:10:41 -0400 Subject: [PATCH 1/7] fix(server): worktrees remove cleanly on Windows and start from the latest base Deleting a worktree after dependencies were installed failed on Windows with "Filename too long" because git only handles such paths with core.longpaths. The removal command now sets that flag, so the folder goes with the thread. Cutting a worktree "from main" used the local branch even when its upstream had moved on. The bootstrap now fetches the base branch's upstream (bounded, best effort) and starts from it when the local branch is strictly behind; a local branch with its own commits still wins. New thread branches are created with --no-track so a remote start point never becomes their upstream. --- apps/server/src/git/GitWorkflowService.ts | 10 +++ apps/server/src/server.test.ts | 2 + apps/server/src/vcs/GitVcsDriver.ts | 17 ++++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 68 ++++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 89 +++++++++++++++++++- apps/server/src/ws.ts | 8 +- 6 files changed, 190 insertions(+), 4 deletions(-) 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/server.test.ts b/apps/server/src/server.test.ts index 15d34a92..138666d7 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] }), 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..f5d37858 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -898,12 +898,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 diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 59e76a01..d632e7bb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -85,6 +85,8 @@ 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); const BACKGROUND_GIT_FETCH_ENV = Object.freeze({ GCM_INTERACTIVE: "Never", GIT_TERMINAL_PROMPT: "0", @@ -4344,8 +4346,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 +4434,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,7 +4583,12 @@ 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"); } @@ -4888,6 +4972,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, }); From 6abb324ea93f491861321e1795bd6300909384c5 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:10:42 -0400 Subject: [PATCH 2/7] fix(server): settle sessions left mid-turn by the previous server process A restart while a turn was starting or running left the session row saying so with nothing behind it. The thread then sat on "Preparing turn" forever and deleting it was the only way out. On startup the provider command reactor now marks those sessions interrupted and records why in the thread. --- .../Layers/ProviderCommandReactor.test.ts | 84 ++++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 55 ++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) 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" || From 557c59eb8df58998496d43a595ff345786f5fb4b Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:10:42 -0400 Subject: [PATCH 3/7] feat(server): tell agents a worktree starts without installed dependencies Agents assume a checkout is ready to build. A fresh worktree has no node_modules, build output, or ignored local files, and Threadlines does not install anything for them. The managed-worktree instruction now says so and leaves the decision to install with the agent. --- packages/shared/src/contextSeed.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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", From f4e35d87c63b3e05e4fa612cf5c6413521332bad Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:17:36 -0400 Subject: [PATCH 4/7] fix(web): worktree drafts keep their mode while preparing and start from the default branch After Send, the server records the thread a moment before the worktree exists, and the draft view merged that record in. For about three seconds the composer flipped to "Current checkout / main" and the "Preparing worktree..." label never showed because the flag was cleared right before the request. The requested mode now holds, and the label stays, until the thread reports its worktree. Switching a draft from an existing worktree to "New worktree" kept the previous thread's branch as the base, usually a throwaway threadlines/... name. The base now resets and the branch selector fills it from the repository's default branch instead of whatever the project checkout has out. The browser test from the server-side bootstrap move asserted "Sending" during that window; it now asserts "Preparing worktree", which is the point of the fix. --- .../components/BranchToolbar.logic.test.ts | 26 +++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 13 ++++++++ .../BranchToolbarBranchSelector.tsx | 23 ++++++++----- apps/web/src/components/ChatView.browser.tsx | 8 +++-- apps/web/src/components/ChatView.tsx | 33 +++++++++++++++---- 5 files changed, 85 insertions(+), 18 deletions(-) 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 af78e60c..b9a9e0c1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2039,7 +2039,7 @@ export default function ChatView(props: ChatViewProps) { markLocalDispatchAccepted, resetLocalDispatch, localDispatchStartedAt, - isPreparingWorktree, + isPreparingWorktree: isPreparingWorktreeDispatch, isSendBusy, } = useLocalDispatchState({ activeThread, @@ -2049,6 +2049,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" || @@ -4233,9 +4236,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 @@ -4920,7 +4929,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(), @@ -6439,9 +6450,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(); @@ -6945,7 +6962,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, From 773990ca51d7dbdc2de6a906ed7d53f71a455aad Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:00:54 -0400 Subject: [PATCH 5/7] fix(server): closing a terminal no longer crashes the dev server on Windows Deleting a thread that owned a terminal took the whole server down under the dev runner. node-pty's Windows kill path forks a helper to list the shell's console processes and treats the first IPC message back as the answer. Under `node --watch` the server carries WATCH_REPORT_DEPENDENCIES, the helper inherits it, its module loader reports its own requires over that channel, and node-pty reads `consoleProcessList` off the wrong message. The unhandled rejection exited the process. The pty adapter now unsets that variable on Windows when it loads node-pty. The fork is deferred until the pty reports ready, so hiding it around the kill call was not enough. The server loads its module graph eagerly, so watch mode still sees every file it needs to. --- apps/server/src/terminal/Layers/NodePTY.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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), From a400b3123b783ca9fbd500ceb783b9ba90e290d7 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:00:54 -0400 Subject: [PATCH 6/7] fix(server): worktree removal outlasts a shell still sitting in the folder Thread deletion kills the worktree's terminals and removes the worktree at the same time. On Windows the shell's working directory pins the folder for the moment it takes to exit, so git unregistered the worktree, failed with "Permission denied" on the final delete, and the app reported the removal as failed with an empty folder left behind. When git reports a failed delete and the worktree is already unregistered, the driver now retries deleting the folder for up to five seconds before giving up. --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 36 ++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 69 ++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f5d37858..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"; @@ -2037,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 d632e7bb..58481b2b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -87,6 +87,14 @@ 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", @@ -4593,10 +4601,44 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* 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", @@ -4609,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) { From da84d7f3efc818fc2afa03acdd5bd2083eb85d53 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:21:37 -0400 Subject: [PATCH 7/7] test(server): bootstrap router tests stub the fresh-base lookup The worktree bootstrap now asks the git driver where to start the new branch before creating it. The four router tests that mock createWorktree left that method on the harness's unimplemented stub, so CI failed before reaching the worktree step. --- apps/server/src/server.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 138666d7..d0436685 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4464,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, @@ -4584,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) => @@ -4685,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) => { @@ -4788,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) =>