From 54299f119e6a90151ee5cb4766f27443f9a77c1e Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 6 Aug 2026 14:28:21 +0700 Subject: [PATCH] fix(lanes): wait for worktree provisioning before auto-setup POST /worktree answers with 202 before the background git worktree add finishes, so profileInit/agentsInstall/mcpSync were racing the lane's own directory into existence and mostly failing. Poll GET /api/lanes/:id until provisioning leaves "provisioning" first. --- client/src/components/lanes/AddLaneModal.tsx | 37 +++++++++++++-- .../lanes/__tests__/AddLaneModal.test.tsx | 46 ++++++++++++++++--- client/src/i18n/locales/en/lanes.json | 1 + client/src/i18n/locales/vi/lanes.json | 1 + 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index 67307d9..b7fa0c7 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -5,9 +5,10 @@ * dashboard provisions a managed git worktree via `POST /api/lanes/worktree` * — the dashboard invents the lane's own directory and branch name, the same * way Shipyard's "+ Add lane" never asks a human to name a folder. The lane - * returned is `status: "provisioning"`; the existing `lane_update` WebSocket - * subscription in the Workspace page flips it to idle when the worktree is - * actually ready, so this component does not poll. + * returned is `status: "provisioning"`: the route answers before the actual + * `git worktree add` runs, so this component polls `GET /api/lanes/:id` + * until that finishes before running the auto-setup calls against a cwd + * that must actually exist on disk first. * @author Nguyễn Ngọc Trí Vĩ */ @@ -19,6 +20,22 @@ import { api } from "../../lib/api"; import type { CwdSuggestion } from "../../lib/api"; import type { Lane } from "../../lib/types"; +/** Polls the lane until the background `git worktree add` finishes (status + * leaves "provisioning"), or gives up after `timeoutMs`. Returns the final + * lane record, or `null` on timeout. */ +async function waitForProvisioned( + laneId: number, + { intervalMs = 500, timeoutMs = 30000 } = {} +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const { lane } = await api.lanes.get(laneId); + if (lane.status !== "provisioning") return lane; + if (Date.now() >= deadline) return null; + await new Promise((r) => window.setTimeout(r, intervalMs)); + } +} + export function AddLaneModal({ open, onClose, @@ -102,6 +119,19 @@ export function AddLaneModal({ title: name, base: base || undefined, }); + onAdded(result.lane); + + // POST /worktree returns as soon as the DB row exists (202) — the + // actual `git worktree add` runs afterward, in the background, on the + // server. Firing setup against the lane's cwd before that finishes + // means agents/mcp write into a directory that doesn't exist yet, so + // wait for provisioning to leave the "provisioning" status first. + const provisionedLane = await waitForProvisioned(result.lane.id); + if (!provisionedLane || provisionedLane.status === "failed") { + setBusy(false); + setError(t("addLaneProvisionFailed")); + return; + } const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([ api.lanes.profileInit(result.lane.id), @@ -132,7 +162,6 @@ export function AddLaneModal({ // user dismisses it themselves (Cancel/X) once they've seen it, rather // than racing a timer that can close before they've looked at it. setBusy(false); - onAdded(result.lane); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setBusy(false); diff --git a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx index 47d3564..a0a4d86 100644 --- a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx +++ b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx @@ -22,6 +22,7 @@ vi.mock("../../../lib/api", () => ({ lanes: { branches: vi.fn(), worktree: vi.fn(), + get: vi.fn(), profileInit: vi.fn(), agentsInstall: vi.fn(), mcpSync: vi.fn(), @@ -84,12 +85,16 @@ async function focusField(user: ReturnType, el: HTMLElem beforeEach(() => { vi.mocked(api.lanes.branches).mockReset(); vi.mocked(api.lanes.worktree).mockReset(); - vi.mocked(api.lanes.profileInit).mockResolvedValue({ - scaffolded: false, - reason: "no detectable Node.js project", - }); - vi.mocked(api.lanes.agentsInstall).mockResolvedValue({ installed: [] }); - vi.mocked(api.lanes.mcpSync).mockResolvedValue({ servers: [], profilesSeeded: [] }); + // Provisioning finishes instantly by default - tests that care about the + // provisioning-in-progress race override this per-test. + vi.mocked(api.lanes.get) + .mockReset() + .mockResolvedValue({ lane: laneFixture({ status: "idle" }) }); + vi.mocked(api.lanes.profileInit) + .mockReset() + .mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" }); + vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] }); + vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] }); }); describe("AddLaneModal", () => { @@ -257,4 +262,33 @@ describe("AddLaneModal", () => { await user.click(dismissButton); expect(onClose).toHaveBeenCalled(); }); + + it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => { + vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); + vi.mocked(api.lanes.worktree).mockResolvedValue({ + lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane, + }); + // First poll still provisioning, second poll reports the background + // `git worktree add` failed. + vi.mocked(api.lanes.get) + .mockReset() + .mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) }) + .mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) }); + const onAdded = vi.fn(); + renderModal({ onAdded }); + const user = userEvent.setup(); + + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, "/Users/tester/projects/repo"); + await screen.findByLabelText("Branch to fork from"); + await user.type(screen.getByLabelText("Title"), "demo4"); + await user.click(screen.getByRole("button", { name: "Add lane" })); + + await waitFor(() => expect(onAdded).toHaveBeenCalled()); + expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument(); + expect(api.lanes.profileInit).not.toHaveBeenCalled(); + expect(api.lanes.agentsInstall).not.toHaveBeenCalled(); + expect(api.lanes.mcpSync).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 0f46486..28e728e 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -21,6 +21,7 @@ "addLaneBaseLabel": "Branch to fork from", "addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.", "addLaneNotARepo": "Not a git repository (or no read access) yet.", + "addLaneProvisionFailed": "Worktree provisioning failed or timed out — the lane exists but auto-setup was skipped. Check the lane's git facts, then run agents/mcp setup manually.", "addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.", "addLaneRepoLabel": "Source repository", "addLaneSetupAgents": "Agents", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 45ecb66..dd469aa 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -21,6 +21,7 @@ "addLaneBaseLabel": "Nhánh để tạo nhánh mới", "addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.", "addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).", + "addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — lane vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của lane, rồi chạy thiết lập agents/mcp thủ công.", "addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.", "addLaneRepoLabel": "Repo nguồn", "addLaneSetupAgents": "Agent",