fix(spawn): stop sending branch on spawn, render API errors, wire worker name (#171)

* fix(spawn): stop sending branch on spawn, render API errors, wire worker name

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 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

* fix(gitworktree): base new session branches on the local default branch when no remote exists

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/<defaultBranch>), 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/<defaultBranch> now follows origin/<defaultBranch> 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Ashish Huddar 2026-06-11 12:13:30 +05:30 committed by GitHub
parent 40bd2dfb2b
commit d60c49f5fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 221 additions and 69 deletions

View File

@ -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/<defaultBranch>.
candidates = append(candidates, defaultBranch)
} else {
candidates = append(candidates, "origin/"+defaultBranch)
// The local head comes after origin/<defaultBranch> 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)
}

View File

@ -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")

View File

@ -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)
}

View File

@ -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(
<QueryClientProvider client={queryClient}>
<App />
<TooltipProvider>
<App />
</TooltipProvider>
</QueryClientProvider>,
);
}
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();
});

View File

@ -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",
},

View File

@ -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<void>;
onCreateTask: (input: { projectId: string; prompt: string; name?: string; harness?: AgentProvider }) => Promise<void>;
};
export function SpawnWorkerModal({
@ -47,7 +42,6 @@ export function SpawnWorkerModal({
const [projectId, setProjectId] = useState(fallbackProjectId);
const [agent, setAgent] = useState<AgentProvider>("claude-code");
const [basedOn, setBasedOn] = useState<BasedOn>("Branch");
const [branch, setBranch] = useState("main");
const [tab, setTab] = useState<"Prompt" | "Workspace">("Prompt");
const [prompt, setPrompt] = useState("");
const [error, setError] = useState<string | null>(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({
</div>
</div>
<div className="p-2.5">
{basedOn === "Branch" ? (
<SelectControl
aria-label="Based on branch"
className="flex w-full"
onChange={setBranch}
value={branch}
options={branchOptions.map((option) => ({ value: option, label: option }))}
/>
) : (
<p className="px-1 py-1.5 text-[12.5px] text-passive">
{basedOn === "Issue" ? "Pick an issue to start from." : "Pick a pull request to start from."}
</p>
)}
<p className="px-1 py-1.5 text-[12.5px] text-passive">
{/* 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."}
</p>
</div>
</div>

View File

@ -1,57 +1,50 @@
/* 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
// 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.
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";
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";
// Create/Update Routes
const IndexRouteWithChildren = IndexRoute.update({
const IndexRoute = IndexRouteImport.update({
id: "/",
path: "/",
getParentRoute: () => rootRoute,
getParentRoute: () => rootRouteImport,
} as any);
const WorkspacesWorkspaceIdRouteWithChildren = WorkspacesWorkspaceIdRoute.update({
const WorkspacesWorkspaceIdRoute = WorkspacesWorkspaceIdRouteImport.update({
id: "/workspaces/$workspaceId",
path: "/workspaces/$workspaceId",
getParentRoute: () => rootRoute,
getParentRoute: () => rootRouteImport,
} as any);
const WorkspacesWorkspaceIdSessionsSessionIdRouteWithChildren = WorkspacesWorkspaceIdSessionsSessionIdRoute.update({
const WorkspacesWorkspaceIdSessionsSessionIdRoute = WorkspacesWorkspaceIdSessionsSessionIdRouteImport.update({
id: "/workspaces/$workspaceId_/sessions/$sessionId",
path: "/workspaces/$workspaceId/sessions/$sessionId",
getParentRoute: () => rootRoute,
getParentRoute: () => rootRouteImport,
} as any);
// Populate FileRoutes
export interface FileRoutesByFullPath {
"/": 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;
}
export interface FileRoutesById {
__root__: typeof rootRoute;
__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";
@ -60,17 +53,41 @@ export interface FileRouteTypes {
id: "__root__" | "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId_/sessions/$sessionId";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
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<FileRouteTypes>();
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute,
WorkspacesWorkspaceIdSessionsSessionIdRoute: WorkspacesWorkspaceIdSessionsSessionIdRoute,
};
export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes<FileRouteTypes>();

View File

@ -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,
});

View File

@ -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";

View File

@ -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";