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.
This commit is contained in:
@@ -5,9 +5,10 @@
|
|||||||
* dashboard provisions a managed git worktree via `POST /api/lanes/worktree`
|
* dashboard provisions a managed git worktree via `POST /api/lanes/worktree`
|
||||||
* — the dashboard invents the lane's own directory and branch name, the same
|
* — 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
|
* way Shipyard's "+ Add lane" never asks a human to name a folder. The lane
|
||||||
* returned is `status: "provisioning"`; the existing `lane_update` WebSocket
|
* returned is `status: "provisioning"`: the route answers before the actual
|
||||||
* subscription in the Workspace page flips it to idle when the worktree is
|
* `git worktree add` runs, so this component polls `GET /api/lanes/:id`
|
||||||
* actually ready, so this component does not poll.
|
* 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ĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -19,6 +20,22 @@ import { api } from "../../lib/api";
|
|||||||
import type { CwdSuggestion } from "../../lib/api";
|
import type { CwdSuggestion } from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
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<Lane | null> {
|
||||||
|
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({
|
export function AddLaneModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -102,6 +119,19 @@ export function AddLaneModal({
|
|||||||
title: name,
|
title: name,
|
||||||
base: base || undefined,
|
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([
|
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
|
||||||
api.lanes.profileInit(result.lane.id),
|
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
|
// 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.
|
// than racing a timer that can close before they've looked at it.
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
onAdded(result.lane);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
lanes: {
|
lanes: {
|
||||||
branches: vi.fn(),
|
branches: vi.fn(),
|
||||||
worktree: vi.fn(),
|
worktree: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
profileInit: vi.fn(),
|
profileInit: vi.fn(),
|
||||||
agentsInstall: vi.fn(),
|
agentsInstall: vi.fn(),
|
||||||
mcpSync: vi.fn(),
|
mcpSync: vi.fn(),
|
||||||
@@ -84,12 +85,16 @@ async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElem
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(api.lanes.branches).mockReset();
|
vi.mocked(api.lanes.branches).mockReset();
|
||||||
vi.mocked(api.lanes.worktree).mockReset();
|
vi.mocked(api.lanes.worktree).mockReset();
|
||||||
vi.mocked(api.lanes.profileInit).mockResolvedValue({
|
// Provisioning finishes instantly by default - tests that care about the
|
||||||
scaffolded: false,
|
// provisioning-in-progress race override this per-test.
|
||||||
reason: "no detectable Node.js project",
|
vi.mocked(api.lanes.get)
|
||||||
});
|
.mockReset()
|
||||||
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({ installed: [] });
|
.mockResolvedValue({ lane: laneFixture({ status: "idle" }) });
|
||||||
vi.mocked(api.lanes.mcpSync).mockResolvedValue({ servers: [], profilesSeeded: [] });
|
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", () => {
|
describe("AddLaneModal", () => {
|
||||||
@@ -257,4 +262,33 @@ describe("AddLaneModal", () => {
|
|||||||
await user.click(dismissButton);
|
await user.click(dismissButton);
|
||||||
expect(onClose).toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"addLaneBaseLabel": "Branch to fork from",
|
"addLaneBaseLabel": "Branch to fork from",
|
||||||
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
|
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
|
||||||
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
|
"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.",
|
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
|
||||||
"addLaneRepoLabel": "Source repository",
|
"addLaneRepoLabel": "Source repository",
|
||||||
"addLaneSetupAgents": "Agents",
|
"addLaneSetupAgents": "Agents",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
|
"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.",
|
"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).",
|
"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.",
|
"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",
|
"addLaneRepoLabel": "Repo nguồn",
|
||||||
"addLaneSetupAgents": "Agent",
|
"addLaneSetupAgents": "Agent",
|
||||||
|
|||||||
Reference in New Issue
Block a user