From 36eb77eaf181c4dc50abaa5d88840dcd04b4b353 Mon Sep 17 00:00:00 2001 From: Ashish Huddar Date: Thu, 11 Jun 2026 11:12:21 +0530 Subject: [PATCH 1/3] fix(spawn): stop sending branch on spawn, render API errors, wire worker name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three spawn-modal bugs, re-landed from the closed redesign branch (#156): - createTask no longer sends `branch`: the API field names the session's NEW worktree branch, so submitting the modal's default ("main") made the daemon 409 with BRANCH_CHECKED_OUT_ELSEWHERE on every spawn. The "Based on" pane is informational — workers branch off the project's default branch in a fresh worktree. - API errors render their envelope message instead of "[object Object]": openapi-fetch resolves non-2xx responses to a plain {code,error,message} object, not an Error; new apiErrorMessage unwraps it (message + code). - The "Worker name" field is actually used: after spawn, a best-effort PATCH rename sets the displayName (a failed rename must not look like a failed spawn — the worker is already running). Also revives the renderer test suite, which made these tests (and 7 of 9 suite files) impossible to run on main: - vitest.config.ts re-exports vite.renderer.config so `vitest run` actually loads the jsdom environment + setup file. Forge's per-target vite.*.config.ts names are invisible to vitest, so the existing `test` block was dead config and every DOM-touching test died on "window is not defined". - vite.renderer.config.ts imports defineConfig from vitest/config so its `test` key typechecks. - routeTree.gen.ts + the session route are regenerated by the pinned @tanstack/router-plugin (it runs as part of loading the renderer config; the checked-in tree predated the installed plugin version and its route-ID drift caused 3 of main's 7 typecheck errors). - App.test.tsx wraps App in TooltipProvider, mirroring routes/__root.tsx. Frontend: 9/9 test files, 99/99 tests pass (was 2/9 files). Typecheck is down from 7 errors to 3 — the survivors (forge.config notarize/maker types, update-electron-app call signature) predate this branch and are untouched. Co-Authored-By: Claude Fable 5 --- frontend/src/renderer/App.test.tsx | 76 ++++++++- frontend/src/renderer/App.tsx | 44 +++++- .../renderer/components/SpawnWorkerModal.tsx | 36 ++--- frontend/src/renderer/routeTree.gen.ts | 145 +++++++++++------- ...aces.$workspaceId_.sessions.$sessionId.tsx | 2 +- frontend/vite.renderer.config.ts | 4 +- frontend/vitest.config.ts | 6 + 7 files changed, 219 insertions(+), 94 deletions(-) create mode 100644 frontend/vitest.config.ts diff --git a/frontend/src/renderer/App.test.tsx b/frontend/src/renderer/App.test.tsx index 347bd51f..1d3b9e39 100644 --- a/frontend/src/renderer/App.test.tsx +++ b/frontend/src/renderer/App.test.tsx @@ -3,10 +3,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import { beforeEach, expect, test, vi } from "vitest"; import { App } from "./App"; +import { TooltipProvider } from "./components/ui/tooltip"; import { useUiStore } from "./stores/ui-store"; -const { postMock, mockData } = vi.hoisted(() => ({ +const { postMock, patchMock, mockData } = vi.hoisted(() => ({ postMock: vi.fn(), + patchMock: vi.fn(), mockData: { projectsError: undefined as Error | undefined, projects: [] as { id: string; name: string; path: string; sessionPrefix: string }[], @@ -38,6 +40,7 @@ vi.mock("./lib/api-client", () => ({ return { data: undefined, error: new Error(`unexpected GET ${url}`) }; }), POST: postMock, + PATCH: patchMock, }, })); @@ -47,15 +50,19 @@ vi.mock("./components/TerminalPane", () => ({ function renderApp() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + // TooltipProvider mirrors routes/__root.tsx, which wraps App in production. return render( - + + + , ); } beforeEach(() => { postMock.mockReset(); + patchMock.mockReset(); mockData.projectsError = undefined; mockData.projects = []; mockData.sessions = []; @@ -158,15 +165,51 @@ test("spawns a worker from the New worker modal", async () => { await user.type(await screen.findByLabelText("Prompt"), "Make task creation work"); await user.click(screen.getByRole("button", { name: /Spawn worker/ })); + // No `branch` field: it names the worktree branch, and sending the default + // base ("main") makes the daemon 409 with BRANCH_CHECKED_OUT_ELSEWHERE. expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", { body: { projectId: "proj-1", kind: "worker", harness: "claude-code", prompt: "Make task creation work", - branch: "main", }, }); + // No worker name given, so no rename round-trip. + expect(patchMock).not.toHaveBeenCalled(); +}); + +test("renames the spawned worker when a name is given", async () => { + const user = userEvent.setup(); + mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }]; + postMock.mockResolvedValueOnce({ + data: { + session: { + id: "new-task", + projectId: "proj-1", + harness: "claude-code", + isTerminated: false, + }, + }, + }); + patchMock.mockResolvedValueOnce({ + data: { ok: true, sessionId: "new-task", displayName: "fix-login" }, + }); + + renderApp(); + + await screen.findByRole("button", { name: "Select my-app" }); + + await user.click(screen.getByRole("button", { name: "New worker" })); + await user.type(await screen.findByLabelText("Worker name"), "fix-login"); + await user.type(screen.getByLabelText("Prompt"), "Fix the login bug"); + await user.click(screen.getByRole("button", { name: /Spawn worker/ })); + + expect(patchMock).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}", { + params: { path: { sessionId: "new-task" } }, + body: { displayName: "fix-login" }, + }); + expect(await screen.findByRole("button", { name: "fix-login" })).toBeInTheDocument(); }); test("surfaces an error when spawning fails", async () => { @@ -188,8 +231,33 @@ test("surfaces an error when spawning fails", async () => { kind: "worker", harness: "claude-code", prompt: "Failing task", - branch: "main", }, }); expect(await screen.findByText("Failed to fetch")).toBeInTheDocument(); }); + +test("surfaces the daemon error envelope message, not [object Object]", async () => { + const user = userEvent.setup(); + mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }]; + // openapi-fetch resolves non-2xx bodies as a plain APIError envelope. + postMock.mockResolvedValueOnce({ + error: { + code: "BRANCH_CHECKED_OUT_ELSEWHERE", + error: "Conflict", + message: "main is checked out at /home/me/my-app", + }, + }); + + renderApp(); + + await screen.findByRole("button", { name: "Select my-app" }); + + await user.click(screen.getByRole("button", { name: "New worker" })); + await user.type(await screen.findByLabelText("Prompt"), "Failing task"); + await user.click(screen.getByRole("button", { name: /Spawn worker/ })); + + expect( + await screen.findByText("main is checked out at /home/me/my-app (BRANCH_CHECKED_OUT_ELSEWHERE)"), + ).toBeInTheDocument(); + expect(screen.queryByText("[object Object]")).not.toBeInTheDocument(); +}); diff --git a/frontend/src/renderer/App.tsx b/frontend/src/renderer/App.tsx index 5128fe3a..4ea22e08 100644 --- a/frontend/src/renderer/App.tsx +++ b/frontend/src/renderer/App.tsx @@ -24,6 +24,25 @@ function errorMessage(error: unknown) { return error instanceof Error ? error.message : "Could not load projects"; } +/** + * openapi-fetch resolves non-2xx responses to a plain APIError envelope + * ({ code, error, message, ... }), not an Error — String() on it renders + * "[object Object]". + */ +function apiErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error) return error.message; + if (typeof error === "string" && error) return error; + if (error && typeof error === "object") { + const envelope = error as { message?: unknown; code?: unknown }; + if (typeof envelope.message === "string" && envelope.message) { + return typeof envelope.code === "string" && envelope.code + ? `${envelope.message} (${envelope.code})` + : envelope.message; + } + } + return fallback; +} + export function App({ routeSessionId, routeWorkspaceId }: AppProps) { const queryClient = useQueryClient(); const { @@ -106,7 +125,7 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) { const createProject = async (input: { path: string }) => { const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path } }); - if (error) throw error; + if (error) throw new Error(apiErrorMessage(error, "Could not add project")); if (!data?.project) throw new Error("Project creation returned no project"); const workspace: WorkspaceSummary = { @@ -121,23 +140,36 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) { selectWorkspace(workspace.id); }; - const createTask = async (input: { projectId: string; prompt: string; branch?: string; harness?: AgentProvider }) => { + const createTask = async (input: { projectId: string; prompt: string; name?: string; harness?: AgentProvider }) => { + // No `branch` here: the API's branch field names the session's worktree + // branch (sending a checked-out branch like "main" 409s); the base branch + // comes from the project config. const { data, error } = await apiClient.POST("/api/v1/sessions", { body: { projectId: input.projectId, kind: "worker", harness: input.harness, prompt: input.prompt, - branch: input.branch || undefined, }, }); if (error || !data?.session) { - throw new Error(error instanceof Error ? error.message : error ? String(error) : "No session returned"); + throw new Error(apiErrorMessage(error, "No session returned")); } const session = data.session; + // Best-effort: the session is already running, so a failed rename should + // not surface as a failed spawn (retrying would create a duplicate). + let displayName: string | undefined; + if (input.name) { + const renamed = await apiClient.PATCH("/api/v1/sessions/{sessionId}", { + params: { path: { sessionId: session.id } }, + body: { displayName: input.name }, + }); + displayName = renamed.data?.displayName; + } + updateWorkspaces((current) => current.map((item) => item.id === input.projectId @@ -149,9 +181,9 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) { terminalHandleId: session.terminalHandleId, workspaceId: item.id, workspaceName: item.name, - title: input.prompt, + title: displayName ?? input.prompt, provider: toAgentProvider(session.harness), - branch: input.branch ?? "", + branch: "", status: toSessionStatus(session.status, session.isTerminated), updatedAt: "now", }, diff --git a/frontend/src/renderer/components/SpawnWorkerModal.tsx b/frontend/src/renderer/components/SpawnWorkerModal.tsx index 3c73ce5c..b09e4673 100644 --- a/frontend/src/renderer/components/SpawnWorkerModal.tsx +++ b/frontend/src/renderer/components/SpawnWorkerModal.tsx @@ -27,12 +27,7 @@ type SpawnWorkerModalProps = { onOpenChange: (open: boolean) => void; workspaces: WorkspaceSummary[]; defaultProjectId?: string; - onCreateTask: (input: { - projectId: string; - prompt: string; - branch?: string; - harness?: AgentProvider; - }) => Promise; + onCreateTask: (input: { projectId: string; prompt: string; name?: string; harness?: AgentProvider }) => Promise; }; export function SpawnWorkerModal({ @@ -47,7 +42,6 @@ export function SpawnWorkerModal({ const [projectId, setProjectId] = useState(fallbackProjectId); const [agent, setAgent] = useState("claude-code"); const [basedOn, setBasedOn] = useState("Branch"); - const [branch, setBranch] = useState("main"); const [tab, setTab] = useState<"Prompt" | "Workspace">("Prompt"); const [prompt, setPrompt] = useState(""); const [error, setError] = useState(null); @@ -62,9 +56,6 @@ export function SpawnWorkerModal({ }, [open, fallbackProjectId]); const selectedWorkspace = workspaces.find((workspace) => workspace.id === projectId) ?? workspaces[0]; - const branchOptions = Array.from( - new Set(["main", ...(selectedWorkspace?.sessions.map((session) => session.branch).filter(Boolean) ?? [])]), - ); const nameValid = name === "" || NAME_RULE.test(name); const canSubmit = prompt.trim().length > 0 && projectId !== "" && nameValid && !isSubmitting; @@ -77,12 +68,11 @@ export function SpawnWorkerModal({ await onCreateTask({ projectId, prompt: prompt.trim(), - branch: basedOn === "Branch" ? branch.trim() || undefined : undefined, + name: name || undefined, harness: agent, }); setName(""); setPrompt(""); - setBranch("main"); onOpenChange(false); } catch (err) { setError(err instanceof Error ? err.message : "Could not spawn worker"); @@ -169,19 +159,15 @@ export function SpawnWorkerModal({
- {basedOn === "Branch" ? ( - ({ value: option, label: option }))} - /> - ) : ( -

- {basedOn === "Issue" ? "Pick an issue to start from." : "Pick a pull request to start from."} -

- )} +

+ {/* The API has no per-spawn base branch — the worker branches off the + project's configured default branch in a fresh worktree. */} + {basedOn === "Branch" + ? "Branches off the project's default branch in a fresh worktree." + : basedOn === "Issue" + ? "Pick an issue to start from." + : "Pick a pull request to start from."} +

diff --git a/frontend/src/renderer/routeTree.gen.ts b/frontend/src/renderer/routeTree.gen.ts index 1e39633e..fb30a11d 100644 --- a/frontend/src/renderer/routeTree.gen.ts +++ b/frontend/src/renderer/routeTree.gen.ts @@ -1,76 +1,107 @@ /* eslint-disable */ -// @ts-nocheck -// noinspection JSUnusedGlobalSymbols -// This file is auto-generated by @tanstack/router-plugin -// Do not edit this file manually — it is regenerated on every dev/build. - -// Import Routes - -import { Route as rootRoute } from "./routes/__root"; -import { Route as IndexRoute } from "./routes/index"; -import { Route as WorkspacesWorkspaceIdRoute } from "./routes/workspaces.$workspaceId"; -import { Route as WorkspacesWorkspaceIdSessionsSessionIdRoute } from "./routes/workspaces.$workspaceId_.sessions.$sessionId"; -// Create/Update Routes +// @ts-nocheck -const IndexRouteWithChildren = IndexRoute.update({ - id: "/", - path: "/", - getParentRoute: () => rootRoute, -} as any); +// noinspection JSUnusedGlobalSymbols -const WorkspacesWorkspaceIdRouteWithChildren = WorkspacesWorkspaceIdRoute.update({ - id: "/workspaces/$workspaceId", - path: "/workspaces/$workspaceId", - getParentRoute: () => rootRoute, -} as any); +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -const WorkspacesWorkspaceIdSessionsSessionIdRouteWithChildren = WorkspacesWorkspaceIdSessionsSessionIdRoute.update({ - id: "/workspaces/$workspaceId_/sessions/$sessionId", - path: "/workspaces/$workspaceId/sessions/$sessionId", - getParentRoute: () => rootRoute, -} as any); +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces.$workspaceId' +import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from './routes/workspaces.$workspaceId_.sessions.$sessionId' -// Populate FileRoutes +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const WorkspacesWorkspaceIdRoute = WorkspacesWorkspaceIdRouteImport.update({ + id: '/workspaces/$workspaceId', + path: '/workspaces/$workspaceId', + getParentRoute: () => rootRouteImport, +} as any) +const WorkspacesWorkspaceIdSessionsSessionIdRoute = + WorkspacesWorkspaceIdSessionsSessionIdRouteImport.update({ + id: '/workspaces/$workspaceId_/sessions/$sessionId', + path: '/workspaces/$workspaceId/sessions/$sessionId', + getParentRoute: () => rootRouteImport, + } as any) export interface FileRoutesByFullPath { - "/": typeof IndexRoute; - "/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute; - "/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute; + '/': typeof IndexRoute + '/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute + '/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute } - export interface FileRoutesByTo { - "/": typeof IndexRoute; - "/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute; - "/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute; + '/': typeof IndexRoute + '/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute + '/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute } - export interface FileRoutesById { - __root__: typeof rootRoute; - "/": typeof IndexRoute; - "/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute; - "/workspaces/$workspaceId_/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute; + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute + '/workspaces/$workspaceId_/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute } - export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId/sessions/$sessionId"; - fileRoutesByTo: FileRoutesByTo; - to: "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId/sessions/$sessionId"; - id: "__root__" | "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId_/sessions/$sessionId"; - fileRoutesById: FileRoutesById; + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/workspaces/$workspaceId' + | '/workspaces/$workspaceId/sessions/$sessionId' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/workspaces/$workspaceId' + | '/workspaces/$workspaceId/sessions/$sessionId' + id: + | '__root__' + | '/' + | '/workspaces/$workspaceId' + | '/workspaces/$workspaceId_/sessions/$sessionId' + fileRoutesById: FileRoutesById } - export interface RootRouteChildren { - IndexRoute: typeof IndexRoute; - WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute; - WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute; + IndexRoute: typeof IndexRoute + WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute + WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute } -const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRouteWithChildren, - WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRouteWithChildren, - WorkspacesWorkspaceIdSessionsSessionIdRoute: WorkspacesWorkspaceIdSessionsSessionIdRouteWithChildren, -}; +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/workspaces/$workspaceId': { + id: '/workspaces/$workspaceId' + path: '/workspaces/$workspaceId' + fullPath: '/workspaces/$workspaceId' + preLoaderRoute: typeof WorkspacesWorkspaceIdRouteImport + parentRoute: typeof rootRouteImport + } + '/workspaces/$workspaceId_/sessions/$sessionId': { + id: '/workspaces/$workspaceId_/sessions/$sessionId' + path: '/workspaces/$workspaceId/sessions/$sessionId' + fullPath: '/workspaces/$workspaceId/sessions/$sessionId' + preLoaderRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRouteImport + parentRoute: typeof rootRouteImport + } + } +} -export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileTypes(); +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute, + WorkspacesWorkspaceIdSessionsSessionIdRoute: + WorkspacesWorkspaceIdSessionsSessionIdRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/frontend/src/renderer/routes/workspaces.$workspaceId_.sessions.$sessionId.tsx b/frontend/src/renderer/routes/workspaces.$workspaceId_.sessions.$sessionId.tsx index a5493ebc..4ebff989 100644 --- a/frontend/src/renderer/routes/workspaces.$workspaceId_.sessions.$sessionId.tsx +++ b/frontend/src/renderer/routes/workspaces.$workspaceId_.sessions.$sessionId.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { App } from "../App"; -export const Route = createFileRoute("/workspaces/$workspaceId/sessions/$sessionId")({ +export const Route = createFileRoute("/workspaces/$workspaceId_/sessions/$sessionId")({ component: SessionRoute, }); diff --git a/frontend/vite.renderer.config.ts b/frontend/vite.renderer.config.ts index 869f2299..9db36de8 100644 --- a/frontend/vite.renderer.config.ts +++ b/frontend/vite.renderer.config.ts @@ -1,4 +1,6 @@ -import { defineConfig } from "vite"; +// defineConfig comes from vitest/config (a superset of vite's) so the `test` +// block below typechecks; the produced config is a plain vite config object. +import { defineConfig } from "vitest/config"; import type { Plugin } from "vite"; import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react"; diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..96cade1c --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,6 @@ +// `vitest run` only auto-loads vite.config.ts / vitest.config.ts — it never +// sees Forge's per-target vite.*.config.ts files, so the renderer config's +// `test` block (jsdom environment, setup file) was dead config and every +// DOM-touching test failed with "window is not defined". Re-export the +// renderer config so tests run under the same plugins and test settings. +export { default } from "./vite.renderer.config"; From fa7a567a47d8d02ba692a4b2006adae9557b0dc0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Jun 2026 05:42:40 +0000 Subject: [PATCH 2/3] chore: format with prettier [skip ci] --- frontend/src/renderer/routeTree.gen.ts | 144 +++++++++++-------------- 1 file changed, 65 insertions(+), 79 deletions(-) diff --git a/frontend/src/renderer/routeTree.gen.ts b/frontend/src/renderer/routeTree.gen.ts index fb30a11d..20a1f6f6 100644 --- a/frontend/src/renderer/routeTree.gen.ts +++ b/frontend/src/renderer/routeTree.gen.ts @@ -8,100 +8,86 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root' -import { Route as IndexRouteImport } from './routes/index' -import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces.$workspaceId' -import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from './routes/workspaces.$workspaceId_.sessions.$sessionId' +import { Route as rootRouteImport } from "./routes/__root"; +import { Route as IndexRouteImport } from "./routes/index"; +import { Route as WorkspacesWorkspaceIdRouteImport } from "./routes/workspaces.$workspaceId"; +import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from "./routes/workspaces.$workspaceId_.sessions.$sessionId"; const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => rootRouteImport, -} as any) + id: "/", + path: "/", + getParentRoute: () => rootRouteImport, +} as any); const WorkspacesWorkspaceIdRoute = WorkspacesWorkspaceIdRouteImport.update({ - id: '/workspaces/$workspaceId', - path: '/workspaces/$workspaceId', - getParentRoute: () => rootRouteImport, -} as any) -const WorkspacesWorkspaceIdSessionsSessionIdRoute = - WorkspacesWorkspaceIdSessionsSessionIdRouteImport.update({ - id: '/workspaces/$workspaceId_/sessions/$sessionId', - path: '/workspaces/$workspaceId/sessions/$sessionId', - getParentRoute: () => rootRouteImport, - } as any) + id: "/workspaces/$workspaceId", + path: "/workspaces/$workspaceId", + getParentRoute: () => rootRouteImport, +} as any); +const WorkspacesWorkspaceIdSessionsSessionIdRoute = WorkspacesWorkspaceIdSessionsSessionIdRouteImport.update({ + id: "/workspaces/$workspaceId_/sessions/$sessionId", + path: "/workspaces/$workspaceId/sessions/$sessionId", + getParentRoute: () => rootRouteImport, +} as any); export interface FileRoutesByFullPath { - '/': typeof IndexRoute - '/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute - '/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute + "/": typeof IndexRoute; + "/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute; + "/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute; } export interface FileRoutesByTo { - '/': typeof IndexRoute - '/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute - '/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute + "/": typeof IndexRoute; + "/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute; + "/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute; } export interface FileRoutesById { - __root__: typeof rootRouteImport - '/': typeof IndexRoute - '/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute - '/workspaces/$workspaceId_/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute + __root__: typeof rootRouteImport; + "/": typeof IndexRoute; + "/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute; + "/workspaces/$workspaceId_/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute; } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/workspaces/$workspaceId' - | '/workspaces/$workspaceId/sessions/$sessionId' - fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/workspaces/$workspaceId' - | '/workspaces/$workspaceId/sessions/$sessionId' - id: - | '__root__' - | '/' - | '/workspaces/$workspaceId' - | '/workspaces/$workspaceId_/sessions/$sessionId' - fileRoutesById: FileRoutesById + fileRoutesByFullPath: FileRoutesByFullPath; + fullPaths: "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId/sessions/$sessionId"; + fileRoutesByTo: FileRoutesByTo; + to: "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId/sessions/$sessionId"; + id: "__root__" | "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId_/sessions/$sessionId"; + fileRoutesById: FileRoutesById; } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute - WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute - WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute + IndexRoute: typeof IndexRoute; + WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute; + WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute; } -declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport - parentRoute: typeof rootRouteImport - } - '/workspaces/$workspaceId': { - id: '/workspaces/$workspaceId' - path: '/workspaces/$workspaceId' - fullPath: '/workspaces/$workspaceId' - preLoaderRoute: typeof WorkspacesWorkspaceIdRouteImport - parentRoute: typeof rootRouteImport - } - '/workspaces/$workspaceId_/sessions/$sessionId': { - id: '/workspaces/$workspaceId_/sessions/$sessionId' - path: '/workspaces/$workspaceId/sessions/$sessionId' - fullPath: '/workspaces/$workspaceId/sessions/$sessionId' - preLoaderRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRouteImport - parentRoute: typeof rootRouteImport - } - } +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/": { + id: "/"; + path: "/"; + fullPath: "/"; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/workspaces/$workspaceId": { + id: "/workspaces/$workspaceId"; + path: "/workspaces/$workspaceId"; + fullPath: "/workspaces/$workspaceId"; + preLoaderRoute: typeof WorkspacesWorkspaceIdRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/workspaces/$workspaceId_/sessions/$sessionId": { + id: "/workspaces/$workspaceId_/sessions/$sessionId"; + path: "/workspaces/$workspaceId/sessions/$sessionId"; + fullPath: "/workspaces/$workspaceId/sessions/$sessionId"; + preLoaderRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRouteImport; + parentRoute: typeof rootRouteImport; + }; + } } const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, - WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute, - WorkspacesWorkspaceIdSessionsSessionIdRoute: - WorkspacesWorkspaceIdSessionsSessionIdRoute, -} -export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes() + IndexRoute: IndexRoute, + WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute, + WorkspacesWorkspaceIdSessionsSessionIdRoute: WorkspacesWorkspaceIdSessionsSessionIdRoute, +}; +export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes(); From 74c66406d0121fd3925d9de98f1cad9f839f8a50 Mon Sep 17 00:00:00 2001 From: Ashish Huddar Date: Thu, 11 Jun 2026 11:53:14 +0530 Subject: [PATCH 3/3] fix(gitworktree): base new session branches on the local default branch when no remote exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-lands the remoteless fallback from the closed redesign branch (archived in 641b712). Creating a session worktree resolved the base for a NEW branch only via the remote-tracking ref (origin/), so a registered repo with no remote failed every spawn with BRANCH_NOT_FETCHED — an error that misleadingly names the new session branch and suggests `git fetch`, which is impossible without a remote. refs/heads/ now follows origin/ in the candidate list: remote-tracking still wins whenever it exists, and a remoteless repo bases session branches on its local default branch. Verified live: a plain `git init` repo (no remote) that previously failed now spawns, and the integration suite covers it (TestWorkspaceIntegrationCreateInRemotelessRepo). Co-Authored-By: Claude Fable 5 --- .../workspace/gitworktree/commands.go | 7 +++- .../gitworktree/workspace_integration_test.go | 36 +++++++++++++++++++ .../workspace/gitworktree/workspace_test.go | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/backend/internal/adapters/workspace/gitworktree/commands.go b/backend/internal/adapters/workspace/gitworktree/commands.go index fe696443..287bc661 100644 --- a/backend/internal/adapters/workspace/gitworktree/commands.go +++ b/backend/internal/adapters/workspace/gitworktree/commands.go @@ -45,9 +45,14 @@ func worktreeListPorcelainArgs(repo string) []string { func baseRefCandidates(branch, defaultBranch string) []string { candidates := []string{"origin/" + branch} if strings.Contains(defaultBranch, "/") { + // A qualified default ("upstream/main") is used verbatim; git's refname + // disambiguation already falls back to refs/heads/. candidates = append(candidates, defaultBranch) } else { - candidates = append(candidates, "origin/"+defaultBranch) + // The local head comes after origin/ so remote-tracking + // still wins when present, but a remoteless repo can base new branches + // on its local default branch instead of failing BRANCH_NOT_FETCHED. + candidates = append(candidates, "origin/"+defaultBranch, "refs/heads/"+defaultBranch) } return append(candidates, branch) } diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go index 78c87590..6c5f47d2 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go @@ -154,6 +154,42 @@ func TestWorkspaceIntegrationDestroyDirtyWorktree(t *testing.T) { } } +// TestWorkspaceIntegrationCreateInRemotelessRepo guards the BRANCH_NOT_FETCHED +// regression: a repo with no remote configured must still spawn worktrees for +// new branches by basing them on the local default-branch head +// (refs/heads/main) once no origin/* candidate resolves. +func TestWorkspaceIntegrationCreateInRemotelessRepo(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := filepath.Join(tmp, "repo") + run(t, git, "init", repo) + runGit(t, git, repo, "config", "user.email", "ao@example.com") + runGit(t, git, repo, "config", "user.name", "Ao Agents") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("seed\n"), 0o644); err != nil { + t.Fatalf("write seed: %v", err) + } + runGit(t, git, repo, "add", "README.md") + runGit(t, git, repo, "commit", "-m", "seed") + runGit(t, git, repo, "branch", "-M", "main") + + root := filepath.Join(tmp, "managed") + ws, err := New(Options{Binary: git, ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ctx := context.Background() + info, err := ws.Create(ctx, ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "feature/remoteless"}) + if err != nil { + t.Fatalf("create in remoteless repo: %v", err) + } + if _, err := os.Stat(filepath.Join(info.Path, "README.md")); err != nil { + t.Fatalf("created worktree missing seed file: %v", err) + } + if err := ws.Destroy(ctx, info); err != nil { + t.Fatalf("destroy: %v", err) + } +} + func requireGit(t *testing.T) string { t.Helper() git, err := exec.LookPath("git") diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_test.go index ed906895..d6654728 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_test.go @@ -46,7 +46,7 @@ func TestCommandArgs(t *testing.T) { func TestBaseRefCandidates(t *testing.T) { got := baseRefCandidates("feature/test", "main") - want := []string{"origin/feature/test", "origin/main", "feature/test"} + want := []string{"origin/feature/test", "origin/main", "refs/heads/main", "feature/test"} if !reflect.DeepEqual(got, want) { t.Fatalf("candidates = %#v, want %#v", got, want) }