Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 62a5eac186 test(e2e): keep developer toasts out of concurrent app assertions
Concurrent layout measurements could invoke the same Playwright locator handler before its first dismissal finished, leaving checks waiting for a removed button. Hide only the Expo toast host through test styling while retaining console error capture.
2026-10-20 08:37:57 +02:00
Mohamed Boudra 398c1a3b52 fix(server): avoid duplicate cwd in worktree provisioning request 2026-10-20 08:01:39 +02:00
Mohamed Boudra c138903a9c refactor(workspaces): own setup exclusively at the workspace boundary
Preserve keyed modern agent worktree creation while retiring legacy placement that selected an agent-owned setup lifecycle. Keep output in per-command workspace snapshots and retain empty strings required by existing wire schemas.
2026-10-20 07:55:24 +02:00
Mohamed Boudra 56f0ad3000 perf(workspaces): stop duplicating setup output in progress snapshots
Keep the required combined log field empty for setup clients, which display per-command output. Preserve concatenation only for legacy agent bootstrap timeline cards whose released renderers still read it directly.
2026-10-20 07:32:31 +02:00
55 changed files with 891 additions and 3470 deletions
+2
View File
@@ -71,6 +71,8 @@ normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
@@ -1,4 +1,5 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
@@ -31,6 +32,28 @@ import {
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
async function showDeveloperToast(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById("error-toast")?.remove();
const toast = document.createElement("div");
toast.id = "error-toast";
toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white";
// Expo renders its unnamed dismiss button in an open shadow root.
const button = document.createElement("button");
button.style.cssText = "width:24px;height:24px";
button.addEventListener("click", () => toast.remove());
toast.attachShadow({ mode: "open" }).appendChild(button);
document.body.appendChild(toast);
});
}
test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => {
await gotoAppShell(page);
await showDeveloperToast(page);
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
await expect(page.locator("#error-toast")).toBeHidden();
});
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
@@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
@@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
(payload) =>
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -18,6 +18,7 @@ import {
} from "../support/helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
@@ -92,7 +93,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -170,16 +172,19 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log === "",
(payload) => payload.status === "running" && payload.detail.commands.length === 0,
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
(payload) =>
payload.status === "running" &&
setupCommandOutput(payload.detail).includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
@@ -192,8 +197,8 @@ test.describe("Workspace setup streaming", () => {
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
@@ -215,7 +220,9 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
(payload) =>
payload.status === "failed" &&
setupCommandOutput(payload.detail).includes("setup failed"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
@@ -224,8 +231,8 @@ test.describe("Workspace setup streaming", () => {
});
const failedPayload = await failed;
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
await openHomeWithProject(page, repo.path);
@@ -331,7 +338,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -377,7 +385,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const result = await client.createPaseoWorktree({
+18 -7
View File
@@ -1,10 +1,21 @@
/** @param {import('@playwright/test').Page} page */
export async function installDeveloperToastDismissal(page) {
// Developer chrome must not cover app actions or move the test's pointer/focus.
export async function hideDeveloperToasts(page) {
// Keep developer chrome out of app interactions without Playwright locator
// handlers: parallel app assertions can race while dismissing the same toast.
// Console errors remain in the runner's logs.
const dismiss = page.locator("#error-toast").getByRole("button", { name: "", exact: true });
await page.addLocatorHandler(dismiss, () => dismiss.dispatchEvent("click"), {
// A new logged error can replace the toast before the old one disappears.
noWaitAfter: true,
});
function hideToasts() {
const installStyle = () => {
const style = document.createElement("style");
style.textContent = "#error-toast { display: none !important; }";
document.head.appendChild(style);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", installStyle, { once: true });
} else {
installStyle();
}
}
await page.addInitScript(hideToasts);
// Electron connects to an already loaded page; apply there as well.
await page.evaluate(hideToasts);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test";
import { installDeveloperToastDismissal } from "./developer-ui.mjs";
import { hideDeveloperToasts } from "./developer-ui.mjs";
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
import { startE2EWorker } from "./helpers/e2e-worker";
import { getE2EDaemonPort } from "./helpers/daemon-port";
@@ -131,7 +131,7 @@ const test = daemonTest.extend<{
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await installDeveloperToastDismissal(page);
await hideDeveloperToasts(page);
const entries: string[] = [];
@@ -376,3 +376,7 @@ export function observeSetupPanelTraffic(page: Page) {
},
};
}
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
@@ -1,4 +1,4 @@
import React, { useMemo, type ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import {
View,
Text,
@@ -186,48 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
);
}
interface WorktreeSetupDetailProps {
log: string;
branchName: string;
worktreePath: string;
ds: DetailStyles;
}
function WorktreeSetupDetailSection({
log,
branchName,
worktreePath,
ds,
}: WorktreeSetupDetailProps) {
const setupLog = log.replace(/^\n+/, "");
const hasLog = setupLog.length > 0;
return (
<View style={ds.sectionFillStyle}>
<View style={ds.codeBlockFillStyle}>
<ScrollView
style={ds.codeVerticalScrollStyle}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
</Text>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
}
function resolveSubAgentFallbackHeader(
subAgentType: string | null | undefined,
description: string | null | undefined,
@@ -678,17 +636,7 @@ function buildDetailSections(
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
];
}
if (detail.type === "worktree_setup") {
return [
<WorktreeSetupDetailSection
key="worktree-setup"
log={detail.log}
branchName={detail.branchName}
worktreePath={detail.worktreePath}
ds={ds}
/>,
];
}
if (detail.type === "worktree_setup") return [];
if (detail.type === "sub_agent") {
return [
<SubAgentDetailSection
+4 -42
View File
@@ -183,32 +183,19 @@ function resolveSetupStatusLabel(
return labels.waiting;
}
function resolveCommandLog(
command: SetupCommand,
autoExpandIndex: number | null,
log: string,
): string {
if ("log" in command && typeof command.log === "string") {
return command.log;
}
if (command.index === autoExpandIndex) return log;
return "";
}
interface BuildCommandRowPropsArgs {
command: SetupCommand;
autoExpandIndex: number | null;
log: string;
expandedIndices: Set<number>;
manuallyCollapsed: Set<number>;
snapshotError: string | null | undefined;
}
function buildCommandRowState(args: BuildCommandRowPropsArgs) {
const { command, autoExpandIndex, log, expandedIndices, manuallyCollapsed, snapshotError } = args;
const { command, autoExpandIndex, expandedIndices, manuallyCollapsed, snapshotError } = args;
const isExpanded = expandedIndices.has(command.index);
const hasError = command.status === "failed" && Boolean(snapshotError);
const commandLog = resolveCommandLog(command, autoExpandIndex, log);
const commandLog = command.log;
const hasLog = commandLog.trim().length > 0;
const isExpandable = command.status !== "running" || hasLog || hasError;
const isAutoExpanded = command.index === autoExpandIndex && !manuallyCollapsed.has(command.index);
@@ -285,9 +272,7 @@ function SetupPanel() {
);
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
const log = snapshot?.detail.log ?? "";
const hasNoSetupCommands =
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
const hasNoSetupCommands = snapshot?.status === "completed" && commands.length === 0;
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
const { expandedIndices, manuallyCollapsed, toggleExpanded } = useExpandedSetupCommands();
@@ -323,7 +308,6 @@ function SetupPanel() {
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
@@ -341,7 +325,6 @@ interface SetupPanelBodyProps {
workspaceId: string;
snapshot: WorkspaceSetupSnapshot | null;
commands: SetupCommand[];
log: string;
isWaiting: boolean;
hasNoSetupCommands: boolean;
autoExpandIndex: number | null;
@@ -352,7 +335,7 @@ interface SetupPanelBodyProps {
function SetupPanelBody(props: SetupPanelBodyProps) {
const { t } = useTranslation();
const { snapshot, commands, log } = props;
const { snapshot, commands } = props;
if (snapshot?.status === "blocked" && snapshot.blockedSource) {
return (
<BlockedSetupNotice
@@ -389,7 +372,6 @@ function SetupPanelBody(props: SetupPanelBodyProps) {
const rowState = buildCommandRowState({
command,
autoExpandIndex: props.autoExpandIndex,
log,
expandedIndices: props.expandedIndices,
manuallyCollapsed: props.manuallyCollapsed,
snapshotError: snapshot?.error,
@@ -404,7 +386,6 @@ function SetupPanelBody(props: SetupPanelBodyProps) {
/>
);
})}
<StandaloneLogView commands={commands} log={log} />
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
</View>
);
@@ -523,25 +504,6 @@ function SetupCommandChevron({ showDetail }: { showDetail: boolean }) {
);
}
function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) {
const { t } = useTranslation();
if (commands.length !== 0 || log.trim().length === 0) return null;
return (
<ScrollView
style={styles.logScroll}
contentContainerStyle={styles.logScrollContent}
showsVerticalScrollIndicator
testID="workspace-setup-log"
accessible
accessibilityLabel={t("workspace.setup.accessibility.log")}
>
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
{log}
</Text>
</ScrollView>
);
}
function TopLevelSetupError({
snapshotError,
commands,
@@ -77,3 +77,16 @@ describe("tool-call detail state", () => {
assert.strictEqual(hasMeaningfulToolCallDetail(detail), true);
});
});
it("keeps historical setup rows readable as history without a chat output view", () => {
assert.strictEqual(
hasMeaningfulToolCallDetail({
type: "worktree_setup",
branchName: "old",
worktreePath: "/workspace",
log: "historical output",
commands: [],
}),
false,
);
});
@@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract<ToolCallDetail, { type: "fetch
return Boolean(detail.url || detail.result || detail.codeText);
}
function hasMeaningfulWorktreeSetupDetail(
detail: Extract<ToolCallDetail, { type: "worktree_setup" }>,
): boolean {
return Boolean(detail.branchName || detail.worktreePath || detail.log);
}
function hasMeaningfulSubAgentDetail(
detail: Extract<ToolCallDetail, { type: "sub_agent" }>,
): boolean {
@@ -81,7 +75,7 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
case "fetch":
return hasMeaningfulFetchDetail(detail);
case "worktree_setup":
return hasMeaningfulWorktreeSetupDetail(detail);
return false;
case "sub_agent":
return hasMeaningfulSubAgentDetail(detail);
case "plain_text":
@@ -98,6 +92,7 @@ export function isPendingToolCallDetail(params: {
status: "executing" | "running" | "completed" | "failed" | "canceled";
error: unknown;
}): boolean {
if (params.detail?.type === "worktree_setup") return false;
const isRunning = params.status === "running" || params.status === "executing";
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
}
+56 -170
View File
@@ -3335,62 +3335,6 @@ test("sends create_agent_request with workspace and caller identity", async () =
await expect(createPromise).rejects.toThrow("compat test sentinel");
});
test("sends worktree target and autoArchive in create_agent_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "worktree auto archive sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
});
test("sends internal in create_agent_request when the host advertises internal agents", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -3609,120 +3553,6 @@ test("sends structured attachments with create_agent_request", async () => {
await expect(createPromise).rejects.toThrow("attachment test sentinel");
});
test("sends worktree base-ref fields in create_agent_request git options", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
requestId: "req-agent-ref",
git: {
createWorktree: true,
worktreeSlug: "review-pr-123",
refName: "feature/worktree-base-ref",
action: "checkout",
githubPrNumber: 123,
},
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.git).toEqual({
createWorktree: true,
worktreeSlug: "review-pr-123",
refName: "feature/worktree-base-ref",
action: "checkout",
githubPrNumber: 123,
});
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "git ref fields sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("git ref fields sentinel");
});
test("omitting create_agent_request worktree base-ref fields preserves legacy wire shape", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
requestId: "req-agent-legacy",
git: {
createWorktree: true,
worktreeSlug: "feature-a",
},
});
expect(assertStr(mock.sent[0])).toBe(
JSON.stringify({
type: "session",
message: {
type: "create_agent_request",
config: {
provider: "codex",
cwd: "/tmp/project",
},
git: {
createWorktree: true,
worktreeSlug: "feature-a",
},
labels: {},
requestId: "req-agent-legacy",
},
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: "req-agent-legacy",
error: "legacy git shape sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("legacy git shape sentinel");
});
test("sends structured first-agent context attachments with create_paseo_worktree_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -7844,3 +7674,59 @@ test("measures an outbound message in UTF-8 bytes against the daemon's message l
expect(mock.sent).toHaveLength(1);
expect(client.getConnectionState().status).toBe("connected");
});
test("sends worktree target and autoArchive in create_agent_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "worktree auto archive sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
});
+7 -2
View File
@@ -459,12 +459,12 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
outputSchema?: Record<string, unknown>;
images?: CreateAgentRequestMessage["images"];
attachments?: CreateAgentRequestMessage["attachments"];
/** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */
git?: GitSetupOptions;
worktree?: CreateAgentRequestMessage["worktree"];
autoArchive?: CreateAgentRequestMessage["autoArchive"];
background?: CreateAgentRequestMessage["background"];
// COMPAT(createAgentWorktree): low-level old callers may still send the
// create-agent worktree field. Added in v0.2.0; remove after 2027-01-17.
/** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */
worktreeName?: string;
requestId?: string;
labels?: Record<string, string>;
@@ -2924,6 +2924,11 @@ export class DaemonClient {
}
async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> {
if (options.git !== undefined || options.worktreeName !== undefined) {
throw new Error(
"Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) or create a workspace first and pass workspaceId.",
);
}
if (options.background !== undefined) this.requireBackgroundWorkspaces();
const result = await this.creations.createAgent({
...options,
+2 -2
View File
@@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { chromium } from "playwright";
import { installDeveloperToastDismissal } from "../../app/e2e/support/developer-ui.mjs";
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
@@ -1189,7 +1189,7 @@ async function main() {
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`);
const page = await waitForAppPage(browser, expoPort);
await installDeveloperToastDismissal(page);
await hideDeveloperToasts(page);
const status = await waitForDesktopStatus(page);
const checkPluginLinks = () =>
@@ -474,3 +474,54 @@ test("usage entries accept older failures and additive successful snapshots", ()
}).success,
).toBe(false);
});
// Frozen setup detail shape from v0.8.0: log is required, command output is optional.
const ReleasedSetupDetailSchema = z.object({
type: z.literal("worktree_setup"),
worktreePath: z.string(),
branchName: z.string(),
log: z.string(),
commands: z.array(
z.object({
index: z.number().int().positive(),
command: z.string(),
cwd: z.string(),
log: z.string().optional().default(""),
status: z.enum(["running", "completed", "failed"]),
exitCode: z.number().nullable(),
durationMs: z.number().nonnegative().optional(),
}),
),
truncated: z.boolean().optional(),
});
test("released setup clients parse empty combined logs and retain command output", () => {
const detail = {
type: "worktree_setup",
worktreePath: "/workspace",
branchName: "feature",
log: "",
commands: [
{
index: 1,
command: "echo ready",
cwd: "/workspace",
log: "ready\n",
status: "completed",
exitCode: 0,
},
],
};
const parsed = ReleasedSetupDetailSchema.parse(JSON.parse(JSON.stringify(detail)));
expect(parsed).toEqual(detail);
expect(parsed.commands[0]?.log).toBe("ready\n");
const { log: _log, ...withoutLegacyField } = detail;
expect(ReleasedSetupDetailSchema.safeParse(withoutLegacyField).success).toBe(false);
expect(
WorkspaceSetupSnapshotSchema.parse({
status: "completed",
detail: { ...detail, log: "old daemon combined output" },
error: null,
}).detail.log,
).toBe("old daemon combined output");
});
@@ -787,7 +787,7 @@ describe("agent MCP end-to-end (offline)", () => {
}
}, 30_000);
test("create_agent with worktree is async and boots terminals only after setup success", async () => {
test("workspace creation owns async setup and terminal bootstrap before agent attachment", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
@@ -843,21 +843,30 @@ describe("agent MCP end-to-end (offline)", () => {
stdio: "pipe",
});
const result = await withTimeout({
const workspaceResult = await withTimeout({
promise: client.callTool({
name: "create_agent",
name: "create_workspace",
args: {
cwd: repoRoot,
title: "MCP worktree setup terminals",
provider: "claude/claude-test-model",
mode: "bypassPermissions",
initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test",
isolation: "worktree",
path: repoRoot,
worktreeSlug: "mcp-worktree-setup-test",
baseBranch: "main",
},
}),
timeoutMs: 2500,
label: "create_agent should not block on setup",
label: "create_workspace should not block on setup",
});
const workspace = getStructuredContent(workspaceResult);
if (typeof workspace?.workspaceId !== "string") throw new Error("Expected created workspace");
const result = await client.callTool({
name: "create_agent",
args: {
workspaceId: workspace.workspaceId,
title: "MCP worktree setup terminals",
provider: "claude/claude-test-model",
settings: { modeId: "bypassPermissions" },
initialPrompt: "say done and stop",
},
});
const payload = getStructuredContent(result);
@@ -1,3 +1,4 @@
import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
import { randomUUID } from "node:crypto";
import type pino from "pino";
@@ -7,11 +8,13 @@ import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-se
import type {
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
CreatePaseoWorktreeSetupContinuationInput,
NormalizedGitOptions,
} from "../worktree-session.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { FirstAgentContext, SessionOutboundMessage } from "../messages.js";
import type {
FirstAgentContext,
CreateAgentWorktreeTarget,
SessionOutboundMessage,
} from "../messages.js";
import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
@@ -57,33 +60,39 @@ export class CreateAgentLifecycleDispatch {
async createWorktreeForRequest(input: {
cwd: string;
target: NormalizedGitOptions | null;
target: CreateAgentWorktreeTarget | undefined;
firstAgentContext: FirstAgentContext;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
background?: boolean;
callerWorkspaceId?: string;
}): Promise<CreatePaseoWorktreeWorkflowResult | null> {
const target = input.target;
if (!target?.createWorktree) return null;
if (!target) return null;
const placement: CreatePaseoWorktreeInput = { cwd: input.cwd };
switch (target.mode) {
case "branch-off":
placement.action = "branch-off";
placement.worktreeSlug = target.newBranch;
placement.refName = target.base;
break;
case "checkout-branch":
placement.action = "checkout";
placement.refName = target.branch;
break;
case "checkout-pr":
placement.githubPrNumber = target.prNumber;
break;
}
const base = target.mode === "branch-off" ? target.base : undefined;
return this.dependencies.createPaseoWorktreeWorkflow(
{
cwd: input.cwd,
...placement,
background: input.background,
callerWorkspaceId: input.callerWorkspaceId,
firstAgentContext: input.firstAgentContext,
worktreeSlug: target.worktreeSlug,
refName: target.refName,
action: target.action,
checkoutSource: target.checkoutSource,
githubPrNumber: target.githubPrNumber,
runSetup: false,
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
},
{
...(target.baseBranch ? { resolveDefaultBranch: async () => target.baseBranch! } : {}),
setupContinuation: input.setupContinuation,
},
base ? { resolveDefaultBranch: async () => base } : undefined,
);
}
@@ -11,7 +11,6 @@ import { AgentStorage } from "../agent-storage.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import { createAgentCommand } from "./create.js";
import type { ManagedAgent } from "../agent-manager.js";
import type { AgentSessionConfig } from "../agent-sdk-types.js";
const logger = createTestLogger();
@@ -53,7 +52,6 @@ function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: strin
workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd },
repoRoot: args.repoRoot,
created: true,
setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} },
}) as unknown as CreatePaseoWorktreeWorkflowResult;
}
@@ -88,7 +86,6 @@ test("session create forwards clientMessageId to the initial prompt run options"
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
});
expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", {
@@ -125,7 +122,6 @@ test("session create validates the requested mode against the provider's modes",
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
}),
).rejects.toThrow("Invalid mode 'plan'");
@@ -169,7 +165,6 @@ test("session create applies the resolved mode from the provider create config",
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
});
expect(createAgent).toHaveBeenCalledWith(
@@ -247,7 +242,6 @@ test("session create stamps the requested workspaceId when no worktree setup run
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
);
@@ -258,41 +252,6 @@ test("session create stamps the requested workspaceId when no worktree setup run
}
});
test("session create stamps the new worktree's workspaceId when a setup continuation runs", async () => {
const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const agentManager = createRealAgentManager(storage);
try {
const { snapshot } = await createAgentCommand(
{
agentManager,
agentStorage: storage,
logger,
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
},
{
kind: "session",
config: { provider: "codex", cwd: workdir },
workspaceId: "ws-source",
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({
sessionConfig: config,
setupContinuation: { kind: "agent", startAfterAgentCreate: () => {} },
createdWorkspaceId: "ws-new-worktree",
}),
},
);
const stored = await storage.get(snapshot.id);
expect(stored?.workspaceId).toBe("ws-new-worktree");
} finally {
await removeRealAgentManagerWorkdir({ agentManager, storage, workdir });
}
});
test("mcp create stamps the new worktree's workspaceId, not the parent's", async () => {
const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
@@ -309,7 +268,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
);
@@ -416,7 +374,6 @@ test("session create keeps the prompt title after the initial prompt settles", a
labels: {},
provisionalTitle: title,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
);
@@ -454,7 +411,6 @@ test("session create keeps an explicit title after the initial prompt settles",
labels: {},
provisionalTitle: title,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
);
@@ -492,7 +448,6 @@ test("session create with an internal config skips provider session persistence"
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config: AgentSessionConfig) => ({ sessionConfig: config }),
};
await createAgentCommand(dependencies, {
@@ -6,12 +6,10 @@ import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
import { toWorktreeRequestError } from "../../worktree-errors.js";
import type {
AgentWorktreeSetupContinuation,
CreatePaseoWorktreeSetupContinuationInput,
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
} from "../../worktree-session.js";
import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js";
import type { AgentAttachment, FirstAgentContext } from "../../messages.js";
import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js";
import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js";
import type { AgentStorage } from "../agent-storage.js";
@@ -22,20 +20,8 @@ import { resolveCreateAgentTitles } from "../create-agent-title.js";
import { buildAgentPrompt } from "../prompt-attachments.js";
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "../timeline-append.js";
import { resolveCreateAgentIntent } from "./intent.js";
export interface CreateAgentSessionWorktreeResult {
sessionConfig: AgentSessionConfig;
setupContinuation?: AgentWorktreeSetupContinuation;
// Set when this build created a fresh worktree workspace. The agent must be
// stamped with it so workspaceId-scoped archive can find the agent later.
createdWorkspaceId?: string;
}
export interface CreateAgentCommandDependencies {
agentManager: AgentManager;
agentStorage: AgentStorage;
@@ -61,24 +47,16 @@ export interface CreateAgentFromSessionInput {
agentId?: string;
config: AgentSessionConfig;
workspaceId: string;
worktreeName?: string;
initialPrompt?: string;
source?: AgentPromptSource;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
images?: Array<{ data: string; mimeType: string }>;
attachments?: AgentAttachment[];
git?: GitSetupOptions;
labels: Record<string, string>;
env?: Record<string, string>;
provisionalTitle: string | null;
firstAgentContext: FirstAgentContext;
buildSessionConfig: (
config: AgentSessionConfig,
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
firstAgentContext?: FirstAgentContext,
) => Promise<CreateAgentSessionWorktreeResult>;
}
export interface CreateAgentFromMcpInput {
@@ -111,6 +89,7 @@ export interface CreateAgentFromMcpInput {
allowCustomCwd?: boolean;
childAgentDefaultLabels?: Record<string, string>;
} | null;
worktree?: {
worktreeName?: string;
branchName?: string;
@@ -167,7 +146,6 @@ interface ResolvedCreateAgent {
prompt?: AgentPromptInput;
source?: AgentPromptSource;
runOptions?: AgentRunOptions;
setupContinuation?: AgentWorktreeSetupContinuation;
promptFailure: CreateAgentPromptFailureMode;
promptLogger?: Logger;
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
@@ -200,7 +178,7 @@ export async function createAgentCommand(
input.kind === "mcp" &&
input.notifyOnFinish &&
input.callerAgentId &&
(resolved.prompt !== undefined || resolved.setupContinuation !== undefined)
resolved.prompt !== undefined
? setupFinishNotification({
agentManager: dependencies.agentManager,
agentStorage: dependencies.agentStorage,
@@ -211,9 +189,6 @@ export async function createAgentCommand(
})
: undefined;
resolved.setupContinuation?.startAfterAgentCreate({
agentId: snapshot.id,
});
if (resolved.prompt !== undefined) {
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => {
stopNotification?.();
@@ -238,29 +213,7 @@ async function resolveSessionCreateAgent(
input: CreateAgentFromSessionInput,
): Promise<ResolvedCreateAgent> {
const trimmedPrompt = input.initialPrompt?.trim();
const {
sessionConfig: builtSessionConfig,
setupContinuation,
createdWorkspaceId,
} = await input.buildSessionConfig(
input.config,
input.git,
input.worktreeName,
input.firstAgentContext,
);
// Validate the requested mode against the provider's modes for the resolved
// cwd. The app remembers mode preferences globally, so a saved mode can be
// stale for a workspace whose provider config no longer defines it — reject
// it here instead of letting the provider fail mid-turn.
//
// This runs after buildSessionConfig, which may already have created a
// worktree and/or workspace record — cwd (required to resolve modes) is
// only known once that step completes. If validation throws, any
// worktree/workspace buildSessionConfig created is the caller's
// responsibility to clean up (session.ts's handleCreateAgentRequest does
// this for the worktree path via cleanupCreatedWorktreeAfterFailedAgentCreate;
// this is a pre-existing gap for directory-only workspace creates, not
// introduced by this validation).
const builtSessionConfig = input.config;
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
cwd: builtSessionConfig.cwd,
provider: builtSessionConfig.provider,
@@ -284,7 +237,7 @@ async function resolveSessionCreateAgent(
...(clientMessageId ? { clientMessageId } : {}),
}
: undefined;
const workspaceId = setupContinuation ? createdWorkspaceId : input.workspaceId;
const workspaceId = input.workspaceId;
return {
config: sessionConfig,
@@ -296,15 +249,11 @@ async function resolveSessionCreateAgent(
// Paseo never persists an internal agent, so nothing could resume its
// provider session either. Same as the daemon's own ephemeral helpers.
...(sessionConfig.internal ? { persistSession: false } : {}),
// A legacy git/worktreeName worktree creates a fresh workspace, so the
// agent belongs to that workspace, not the source one. createdWorkspaceId
// is the freshly created worktree's workspace.
workspaceId: requireResolvedWorkspaceId(workspaceId),
},
prompt: hasPromptContent ? prompt : undefined,
source: input.source,
runOptions,
setupContinuation,
// Registration commits creation. First-turn failures belong to the created agent.
promptFailure: "return-error",
promptLogger: dependencies.logger.child({
@@ -323,18 +272,16 @@ async function resolveMcpCreateAgent(
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
: null;
const cwd = resolveMcpInitialCwd(input, parentAgent);
const { resolvedCwd, setupContinuation, createdWorkspaceId, createdWorktree } =
await resolveMcpCwd({
dependencies,
cwd,
worktree: input.worktree,
initialPrompt: input.initialPrompt ?? "",
callerWorkspaceId: parentAgent?.workspaceId,
});
const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({
dependencies,
cwd,
worktree: input.worktree,
initialPrompt: input.initialPrompt ?? "",
callerWorkspaceId: parentAgent?.workspaceId,
});
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
explicitWorkspaceId: createdWorkspaceId ?? input.workspaceId,
caller: parentAgent
? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId }
: null,
@@ -383,7 +330,6 @@ async function resolveMcpCreateAgent(
source: input.callerAgentId
? { kind: "agent-message", agentId: input.callerAgentId }
: undefined,
setupContinuation,
createdWorktree,
promptFailure: input.promptFailure ?? "log",
};
@@ -535,6 +481,28 @@ function resolveChildAgentCwd(params: {
return resolvePathFromBase(params.parentCwd, requestedCwd);
}
interface CreateMcpWorktreeOptions {
input: CreatePaseoWorktreeInput;
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
}
async function createMcpWorktree(
options: CreateMcpWorktreeOptions,
): Promise<CreatePaseoWorktreeWorkflowResult> {
try {
if (!options.createPaseoWorktree) {
throw new Error("Paseo worktree service is not configured");
}
return await options.createPaseoWorktree(
options.input,
options.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } : {},
);
} catch (error) {
throw toWorktreeRequestError(error);
}
}
async function resolveMcpCwd(params: {
dependencies: CreateAgentCommandDependencies;
cwd: string;
@@ -543,7 +511,6 @@ async function resolveMcpCwd(params: {
worktree: CreateAgentFromMcpInput["worktree"];
}): Promise<{
resolvedCwd: string;
setupContinuation?: AgentWorktreeSetupContinuation;
createdWorkspaceId?: string;
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
}> {
@@ -577,59 +544,16 @@ async function resolveMcpCwd(params: {
action: worktree.action,
githubPrNumber: worktree.githubPrNumber,
firstAgentContext: { prompt: params.initialPrompt },
runSetup: false,
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
},
createPaseoWorktree: dependencies.createPaseoWorktree,
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
setupContinuation: {
kind: "agent",
terminalManager: dependencies.terminalManager ?? null,
appendTimelineItem: ({ agentId, item }) =>
appendTimelineItemIfAgentKnown({
agentManager: dependencies.agentManager,
agentId,
item,
}),
emitLiveTimelineItem: ({ agentId, item }) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: dependencies.agentManager,
agentId,
item,
}),
logger: dependencies.logger,
},
});
return {
resolvedCwd: createdWorktree.workspace.cwd,
setupContinuation: createdWorktree.setupContinuation,
createdWorkspaceId: createdWorktree.workspace.workspaceId,
createdWorktree,
};
}
interface CreateMcpWorktreeOptions {
input: CreatePaseoWorktreeInput;
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
}
async function createMcpWorktree(
options: CreateMcpWorktreeOptions,
): Promise<CreatePaseoWorktreeWorkflowResult> {
try {
if (!options.createPaseoWorktree) {
throw new Error("Paseo worktree service is not configured");
}
return await options.createPaseoWorktree(options.input, {
...(options.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }
: {}),
...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}),
});
} catch (error) {
throw toWorktreeRequestError(error);
}
}
@@ -72,7 +72,6 @@ import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol
import type { DaemonConfigStore } from "../daemon-config-store.js";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
const REPO_CWD = resolvePath("/tmp/repo");
@@ -180,20 +179,6 @@ async function waitForWorkspaceTitle(
});
}
async function waitForWorkspaceBranch(
workspaceRecords: Map<string, PersistedWorkspaceRecord>,
workspaceId: string,
branch: string,
): Promise<void> {
await vi.waitFor(() => expect(workspaceRecords.get(workspaceId)?.branch).toBe(branch), {
timeout: 5_000,
});
}
async function waitForUnexpectedWorkspaceNamingSideEffects(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 25));
}
async function removeTempDir(path: string): Promise<void> {
await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
@@ -693,7 +678,6 @@ function createPaseoWorktreeForMcpTest(options: {
createdWorkspaceIds?: string[];
workspaceRecords?: Map<string, PersistedWorkspaceRecord>;
generateWorkspaceName?: () => Promise<GeneratedWorkspaceName | null>;
setupContinuations?: Array<"workspace" | "agent" | undefined>;
startedAgentSetupIds?: string[];
}): CreatePaseoWorktreeWorkflowFn {
const projects = new Map<string, PersistedProjectRecord>();
@@ -790,7 +774,6 @@ function createPaseoWorktreeForMcpTest(options: {
});
return async (input, serviceOptions) => {
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
const result = await createPaseoWorktreeWorkflow(
{
paseoHome: options.paseoHome,
@@ -824,17 +807,6 @@ function createPaseoWorktreeForMcpTest(options: {
);
options.broadcasts.push(result.workspace.workspaceId);
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
if (serviceOptions?.setupContinuation?.kind === "agent") {
return {
...result,
setupContinuation: {
kind: "agent",
startAfterAgentCreate: ({ agentId }) => {
options.startedAgentSetupIds?.push(agentId);
},
},
};
}
return result;
};
}
@@ -1466,16 +1438,6 @@ describe("create_agent MCP tool", () => {
relationship: { kind: "detached" as const },
workspace: { kind: "create" as const, source: { kind: "directory" as const, path } },
});
const detachedWorktreeWorkspace = (
cwd: string,
target:
| { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string }
| { kind: "checkout-branch"; branch: string }
| { kind: "checkout-pr"; githubPrNumber: number },
) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "create" as const, source: { kind: "worktree" as const, cwd, target } },
});
const subagentCurrentWorkspace = (cwd?: string) => ({
relationship: { kind: "subagent" as const },
workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) },
@@ -1902,29 +1864,6 @@ describe("create_agent MCP tool", () => {
).rejects.toThrow("Unrecognized key");
});
it("accepts worktree workspace intent in create_agent input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
...detachedWorktreeWorkspace(existingCwd, {
kind: "checkout-pr",
githubPrNumber: 42,
}),
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
});
expect(parsed.success).toBe(true);
});
it("exposes workspace tools instead of worktree tools", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
@@ -2082,444 +2021,6 @@ describe("create_agent MCP tool", () => {
);
});
it("registers and broadcasts a workspace when create_agent creates a worktree", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-worktree-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
const startedAgentSetupIds: string[] = [];
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-with-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Worktree agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
setupContinuations,
startedAgentSetupIds,
}),
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
worktreeSlug: "agent-worktree",
branchName: "feature/agent-worktree",
baseBranch: "main",
}),
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
});
expect(broadcasts).toHaveLength(1);
expect(createdWorkspaceIds).toHaveLength(1);
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
expect(setupContinuations).toEqual(["agent"]);
expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]);
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const branchName = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
expect(branchName).toBe("feature/agent-worktree");
// The agent is stamped with the freshly created worktree's workspaceId so
// workspaceId-scoped archive can find and tear it down later.
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: expect.stringContaining("agent-worktree"),
}),
undefined,
{ workspaceId: createdWorkspaceIds[0] },
);
} finally {
await removeTempDir(tempDir);
}
});
it("creates a create_agent branch-off worktree without invoking the legacy metadata branch rename", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
}),
};
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-auto-named-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Worktree agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
baseBranch: "main",
}),
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Fix workspace creation naming",
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const initialBranch = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
expect(initialBranch).not.toBe("");
expect(initialBranch).not.toBe("main");
await waitForUnexpectedWorkspaceNamingSideEffects();
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await removeTempDir(tempDir);
}
});
it("auto-titles and renames an agent-created branch-off worktree from the initial prompt", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-auto-title-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-auto-titled-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Agent title" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => ({
title: "Workspace Auto Title Flow",
branch: "workspace-auto-title-flow",
}),
}),
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
branchName: "feat/placeholder-auto-title",
baseBranch: "main",
}),
title: "Agent title",
provider: "codex/gpt-5.4",
initialPrompt: "Build a workspace auto title flow",
});
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Workspace Auto Title Flow");
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const workspace = workspaceRecords.get(workspaceId);
const branchName = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
const metadata = readPaseoWorktreeMetadata(agentCwd);
expect(metadata).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: "feat/placeholder-auto-title",
},
});
expect(branchName).toBe("workspace-auto-title-flow");
expect(workspace).toMatchObject({
title: "Workspace Auto Title Flow",
branch: "workspace-auto-title-flow",
});
} finally {
await removeTempDir(tempDir);
}
});
it("keeps a manual workspace title when agent-created worktree naming finishes later", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-manual-title-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-manual-title-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Agent title" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => ({
title: "Generated Manual Race Title",
branch: "generated-manual-race-title",
}),
}),
workspaceRegistry: {
get: async (workspaceId) => workspaceRecords.get(workspaceId) ?? null,
upsert: async (record) => {
workspaceRecords.set(record.workspaceId, record);
},
},
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
broadcasts.push(...workspaceIds);
},
logger,
});
const createAgentTool = registeredTool(server, "create_agent");
await createAgentTool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
branchName: "feat/manual-title-placeholder",
baseBranch: "main",
}),
title: "Agent title",
provider: "codex/gpt-5.4",
initialPrompt: "Keep the manually renamed workspace title",
});
const renameWorkspaceTool = registeredTool(server, "rename_workspace");
await renameWorkspaceTool.handler({
workspaceId: createdWorkspaceIds[0],
title: "Manual Workspace Title",
});
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceBranch(workspaceRecords, workspaceId, "generated-manual-race-title");
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const workspace = workspaceRecords.get(workspaceId);
const branchName = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
expect(branchName).toBe("generated-manual-race-title");
expect(workspace).toMatchObject({
title: "Manual Workspace Title",
branch: "generated-manual-race-title",
});
} finally {
await removeTempDir(tempDir);
}
});
it("uses create_agent title for the agent while still auto-titling the worktree workspace", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-title-workspace-title-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-explicit-title-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Explicit Agent Title" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => ({
title: "Generated Workspace Title",
branch: "generated-workspace-title",
}),
}),
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
branchName: "feat/agent-title-placeholder",
baseBranch: "main",
}),
title: "Explicit Agent Title",
provider: "codex/gpt-5.4",
initialPrompt: "Generate the workspace title anyway",
});
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Generated Workspace Title");
const workspace = workspaceRecords.get(workspaceId);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
title: "Explicit Agent Title",
}),
undefined,
{ workspaceId },
);
expect(workspace).toMatchObject({
title: "Generated Workspace Title",
branch: "generated-workspace-title",
});
} finally {
await removeTempDir(tempDir);
}
});
it("auto-titles an agent-created directory workspace from the initial prompt", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-directory-auto-title-"));
@@ -2630,221 +2131,12 @@ describe("create_agent MCP tool", () => {
}
});
it("auto-titles without renaming a create_agent checkout worktree from the initial prompt", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
let generateCalls = 0;
const workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
}),
};
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "-b", "existing-feature"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "feature.txt"), "feature\n");
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-checkout-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Checkout agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => {
generateCalls += 1;
return {
title: "Generated Checkout Workspace Title",
branch: "generated-checkout-workspace-title",
};
},
}),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "checkout-branch",
branch: "existing-feature",
}),
title: "Checkout agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this checkout from the prompt",
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceTitle(
workspaceRecords,
workspaceId,
"Generated Checkout Workspace Title",
);
expect(
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
.toString()
.trim(),
).toBe("existing-feature");
expect(workspaceRecords.get(workspaceId)).toMatchObject({
title: "Generated Checkout Workspace Title",
branch: "existing-feature",
});
expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({
version: 1,
baseRefName: "main",
});
expect(generateCalls).toBe(1);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toEqual([workspaceId, workspaceId]);
} finally {
await removeTempDir(tempDir);
}
});
it("passes create_agent GitHub PR worktrees through workspace creation without metadata branch rename", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const startedAgentSetupIds: string[] = [];
const createPaseoWorktree = vi.fn(
async (
input: CreatePaseoWorktreeInput,
options?: Parameters<CreatePaseoWorktreeWorkflowFn>[1],
) => ({
worktree: {
branchName: "pr-123",
worktreePath: "/tmp/worktrees/pr-123",
},
intent: {
kind: "checkout-github-pr" as const,
githubPrNumber: input.githubPrNumber ?? 123,
headRef: "pr-123",
baseRefName: "main",
},
workspace: {
workspaceId: "ws-pr-123",
projectId: REPO_CWD,
cwd: "/tmp/worktrees/pr-123",
kind: "worktree" as const,
displayName: "pr-123",
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
archivedAt: null,
},
repoRoot: REPO_CWD,
created: true,
...(options?.setupContinuation?.kind === "agent"
? {
setupContinuation: {
kind: "agent" as const,
startAfterAgentCreate: ({ agentId }: { agentId: string }) => {
startedAgentSetupIds.push(agentId);
},
},
}
: {}),
}),
);
const workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
}),
};
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-pr-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "PR agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
createPaseoWorktree,
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(REPO_CWD, {
kind: "checkout-pr",
githubPrNumber: 123,
}),
title: "PR agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this PR branch from prompt",
});
expect(createPaseoWorktree).toHaveBeenCalledWith(
expect.objectContaining({
githubPrNumber: 123,
firstAgentContext: { prompt: "Rename this PR branch from prompt" },
}),
expect.objectContaining({
setupContinuation: expect.objectContaining({ kind: "agent" }),
}),
);
expect(startedAgentSetupIds).toEqual(["agent-pr-worktree"]);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/tmp/worktrees/pr-123" }),
undefined,
{ workspaceId: "ws-pr-123" },
);
await waitForUnexpectedWorkspaceNamingSideEffects();
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
});
it("creates a worktree-isolated workspace", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
@@ -2875,7 +2167,6 @@ describe("create_agent MCP tool", () => {
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
setupContinuations,
}),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
@@ -2896,7 +2187,6 @@ describe("create_agent MCP tool", () => {
expect(response.structuredContent.cwd).toContain("tool-worktree");
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(setupContinuations).toEqual([undefined]);
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
} finally {
@@ -7378,3 +6668,40 @@ describe("agent snapshot MCP serialization", () => {
expect(content).not.toContain("first answer");
});
});
it("rejects hidden agent-worktree placement and directs callers to workspace creation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger: createTestLogger(),
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
provider: "codex/gpt-5.4",
title: "Worktree agent",
initialPrompt: "test",
relationship: { kind: "detached" },
workspace: {
kind: "create",
source: {
kind: "worktree",
cwd: process.cwd(),
target: { kind: "branch-off", worktreeSlug: "legacy" },
},
},
}),
).rejects.toThrow("create_workspace");
await expect(
tool.handler({
provider: "codex/gpt-5.4",
title: "Worktree agent",
initialPrompt: "test",
cwd: process.cwd(),
worktreeName: "legacy",
baseBranch: "main",
}),
).rejects.toThrow("create_workspace");
});
@@ -1,38 +0,0 @@
import type { AgentManager } from "./agent-manager.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
export interface AppendTimelineItemIfAgentKnownOptions {
agentManager: AgentManager;
agentId: string;
item: AgentTimelineItem;
}
export async function appendTimelineItemIfAgentKnown(
options: AppendTimelineItemIfAgentKnownOptions,
): Promise<boolean> {
try {
await options.agentManager.appendTimelineItem(options.agentId, options.item);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Unknown agent")) {
return false;
}
throw error;
}
}
export async function emitLiveTimelineItemIfAgentKnown(
options: AppendTimelineItemIfAgentKnownOptions,
): Promise<boolean> {
try {
await options.agentManager.emitLiveTimelineItem(options.agentId, options.item);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Unknown agent")) {
return false;
}
throw error;
}
}
@@ -33,7 +33,7 @@ import {
requireActiveWorkspaceForArchive,
type ArchiveDependencies,
} from "../../workspace-archive-service.js";
import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js";
import { createAgentCommand } from "../create-agent/create.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
import type { FirstAgentContext } from "../../messages.js";
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
@@ -1030,43 +1030,6 @@ function createToolCatalog(
.strict()
.describe("Create a root agent that does not appear in this agent's subagent track."),
]);
const AgentCreateWorktreeTargetInputSchema = z.discriminatedUnion("kind", [
z
.object({
kind: z.literal("branch-off"),
worktreeSlug: z
.string()
.min(1)
.optional()
.describe("Optional worktree slug/path label. Omit to let Paseo generate one."),
branchName: z
.string()
.min(1)
.optional()
.describe("Optional git branch name. Defaults to the worktree slug."),
baseBranch: z
.string()
.min(1)
.optional()
.describe("Optional base branch. Defaults to the repository default branch."),
})
.strict()
.describe("Create a new branch in a new Paseo worktree."),
z
.object({
kind: z.literal("checkout-branch"),
branch: z.string().min(1).describe("Existing branch to check out."),
})
.strict()
.describe("Check out an existing branch in a new Paseo worktree."),
z
.object({
kind: z.literal("checkout-pr"),
githubPrNumber: z.number().int().positive().describe("GitHub pull request number."),
})
.strict()
.describe("Check out a GitHub pull request in a new Paseo worktree."),
]);
const AgentWorkspaceInputSchema = z.discriminatedUnion("kind", [
z
.object({
@@ -1099,16 +1062,6 @@ function createToolCatalog(
.describe("Optional directory path. Defaults to the caller's cwd."),
})
.strict(),
z
.object({
kind: z.literal("worktree"),
cwd: z
.string()
.optional()
.describe("Optional source repository. Defaults to the caller's cwd."),
target: AgentCreateWorktreeTargetInputSchema,
})
.strict(),
]),
})
.strict()
@@ -1195,32 +1148,6 @@ function createToolCatalog(
.record(z.string(), z.unknown())
.optional()
.describe("Legacy feature values. Prefer settings.features."),
worktreeName: z
.string()
.min(1)
.optional()
.describe("Legacy worktree slug. Prefer workspace.source.target.worktreeSlug."),
branchName: z
.string()
.min(1)
.optional()
.describe("Legacy branch name. Prefer workspace.source.target.branchName."),
baseBranch: z
.string()
.min(1)
.optional()
.describe("Legacy base branch. Prefer workspace.source.target.baseBranch."),
refName: z
.string()
.min(1)
.optional()
.describe("Legacy branch/ref to check out. Prefer workspace.source.target.branch."),
githubPrNumber: z
.number()
.int()
.positive()
.optional()
.describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."),
};
const createAgentInputSchema = z
.object(scope === "agent" ? agentToAgentInputSchema : canonicalTopLevelInputSchema)
@@ -1560,7 +1487,7 @@ function createToolCatalog(
},
async (args: unknown) => {
const resolvedArgs = await resolveCreateAgentToolArgs(args);
const { parsedArgs, worktree } = resolvedArgs;
const { parsedArgs } = resolvedArgs;
let notifyOnFinish: boolean;
if (resolvedArgs.kind === "agent-scoped") {
notifyOnFinish = parsedArgs.notifyOnFinish;
@@ -1600,7 +1527,6 @@ function createToolCatalog(
detached: resolvedArgs.detached,
callerAgentId,
callerContext,
worktree,
},
);
@@ -1634,7 +1560,6 @@ function createToolCatalog(
detached: boolean;
cwd: string | undefined;
workspaceId: string | undefined;
worktree: CreateAgentFromMcpInput["worktree"];
}
| {
kind: "top-level";
@@ -1642,16 +1567,38 @@ function createToolCatalog(
detached: boolean;
cwd: string | undefined;
workspaceId: string | undefined;
worktree: CreateAgentFromMcpInput["worktree"];
};
async function resolveCreateAgentToolArgs(args: unknown): Promise<ResolvedCreateAgentToolArgs> {
const placement = args as {
worktreeName?: unknown;
branchName?: unknown;
baseBranch?: unknown;
refName?: unknown;
githubPrNumber?: unknown;
workspace?: { source?: { kind?: unknown } };
};
if (
placement &&
(placement.workspace?.source?.kind === "worktree" ||
[
placement.worktreeName,
placement.branchName,
placement.baseBranch,
placement.refName,
placement.githubPrNumber,
].some((value) => value !== undefined))
) {
throw new PaseoToolInputError(
"Agent worktree placement was removed. Use create_workspace first, then create_agent with workspaceId.",
);
}
if (callerAgentId) {
if (hasLegacyCreateAgentPlacement(args)) {
// COMPAT(nestedCreateAgentPlacement): accept the old relationship/workspace shape without
// advertising it to models. Added in v0.2.0; remove after 2027-01-17.
const parsed = parseToolArguments(legacyAgentToAgentCreateAgentArgsSchema, args);
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, {
const { cwd, workspaceId } = await resolveCreateAgentWorkspace(parsed.workspace, {
prompt: parsed.initialPrompt,
});
return {
@@ -1660,7 +1607,6 @@ function createToolCatalog(
detached: parsed.relationship.kind === "detached",
cwd,
workspaceId,
worktree,
};
}
const parsed = parseToolArguments(agentToAgentCreateAgentArgsSchema, args);
@@ -1673,7 +1619,6 @@ function createToolCatalog(
detached: false,
cwd,
workspaceId,
worktree: undefined,
};
}
if (hasLegacyCreateAgentPlacement(args)) {
@@ -1689,17 +1634,15 @@ function createToolCatalog(
if (!parsedArgs.workspace) {
throw new PaseoToolInputError("Legacy create_agent placement could not be resolved");
}
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(
parsedArgs.workspace,
{ prompt: parsedArgs.initialPrompt },
);
const { cwd, workspaceId } = await resolveCreateAgentWorkspace(parsedArgs.workspace, {
prompt: parsedArgs.initialPrompt,
});
return {
kind: "top-level",
parsedArgs,
detached: true,
cwd,
workspaceId,
worktree,
};
}
const parsedArgs = parseToolArguments(canonicalTopLevelCreateAgentArgsSchema, args);
@@ -1713,7 +1656,6 @@ function createToolCatalog(
detached: false,
cwd,
workspaceId,
worktree: undefined,
};
}
@@ -1770,18 +1712,7 @@ function createToolCatalog(
function normalizeTopLevelCreateAgentArgs(
args: LegacyTopLevelCreateAgentArgs,
): LegacyTopLevelCreateAgentArgs {
const {
cwd,
mode,
thinking,
features,
worktreeName,
branchName,
baseBranch,
refName,
githubPrNumber,
...canonicalCandidate
} = args;
const { cwd, mode, thinking, features, ...canonicalCandidate } = args;
const settings = {
...canonicalCandidate.settings,
...(mode ? { modeId: mode } : {}),
@@ -1804,29 +1735,10 @@ function createToolCatalog(
throw new PaseoToolInputError("cwd is required for legacy top-level create_agent calls");
}
const legacyWorktreeTarget = resolveLegacyCreateAgentWorktreeTarget({
worktreeName,
branchName,
baseBranch,
refName,
githubPrNumber,
});
const workspace = legacyWorktreeTarget
? {
kind: "create" as const,
source: {
kind: "worktree" as const,
cwd,
target: legacyWorktreeTarget,
},
}
: {
kind: "create" as const,
source: {
kind: "directory" as const,
path: cwd,
},
};
const workspace = {
kind: "create" as const,
source: { kind: "directory" as const, path: cwd },
};
return parseToolArguments(legacyTopLevelCreateAgentArgsSchema, {
...canonicalCandidate,
@@ -1836,39 +1748,6 @@ function createToolCatalog(
});
}
function resolveLegacyCreateAgentWorktreeTarget(input: {
worktreeName?: string;
branchName?: string;
baseBranch?: string;
refName?: string;
githubPrNumber?: number;
}): z.infer<typeof AgentCreateWorktreeTargetInputSchema> | null {
if (input.githubPrNumber !== undefined) {
return {
kind: "checkout-pr",
githubPrNumber: input.githubPrNumber,
};
}
if (input.refName) {
return {
kind: "checkout-branch",
branch: input.refName,
};
}
if (input.worktreeName || input.branchName || input.baseBranch) {
return {
kind: "branch-off",
worktreeSlug: input.worktreeName,
branchName: input.branchName,
baseBranch: input.baseBranch,
};
}
return null;
}
async function resolveCreateAgentWorkspace(
workspace:
| LegacyAgentToAgentCreateAgentArgs["workspace"]
@@ -1877,7 +1756,6 @@ function createToolCatalog(
): Promise<{
cwd: string | undefined;
workspaceId: string | undefined;
worktree: CreateAgentFromMcpInput["worktree"];
}> {
if (workspace.kind === "current") {
if (!callerAgentId) {
@@ -1890,7 +1768,6 @@ function createToolCatalog(
return {
cwd: workspace.cwd,
workspaceId: callerAgent.workspaceId,
worktree: undefined,
};
}
@@ -1916,7 +1793,6 @@ function createToolCatalog(
return {
cwd,
workspaceId: workspace.workspaceId,
worktree: undefined,
};
}
@@ -1930,42 +1806,12 @@ function createToolCatalog(
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, {
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
}),
worktree: undefined,
};
}
const cwd = resolveScopedCwd(workspace.source.cwd, { required: true });
return {
cwd,
workspaceId: undefined,
worktree: resolveCreateAgentWorktree(workspace.source.target),
};
}
function resolveCreateAgentWorktree(
target: z.infer<typeof AgentCreateWorktreeTargetInputSchema>,
): NonNullable<CreateAgentFromMcpInput["worktree"]> {
switch (target.kind) {
case "branch-off":
return {
action: "branch-off",
worktreeName: target.worktreeSlug,
branchName: target.branchName,
baseBranch: target.baseBranch,
};
case "checkout-branch":
return {
action: "checkout",
refName: target.branch,
};
case "checkout-pr":
return {
action: "checkout",
githubPrNumber: target.githubPrNumber,
};
default:
throw new Error("unreachable");
}
throw new PaseoToolInputError(
"Use create_workspace first, then create_agent with workspaceId.",
);
}
const PROMPTED_AGENT_NOTIFICATION_GUIDANCE =
@@ -200,7 +200,7 @@ async function createPaseoOwnedWorktree(
baseBranch: "main",
branchName: worktreeSlug,
},
runSetup: false,
paseoHome,
});
}
@@ -48,7 +48,7 @@ async function createWorkspaceJourney() {
cwd: repoDir,
worktreeSlug: "workspace",
source: { kind: "branch-off", baseBranch: "main", branchName: "workspace" },
runSetup: false,
paseoHome,
});
const workspaceId = "workspace-under-test";
@@ -4,7 +4,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
import { WebSocket } from "ws";
import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test, vi } from "vitest";
@@ -395,3 +395,181 @@ test.each(["agent", "workspace"] as const)(
},
60000,
);
test("combined worktree and agent creation owns setup on the workspace and replays one identity", async () => {
const directory = await mkdtemp(join(tmpdir(), "workspace-owned-setup-"));
const daemon = await createCreationDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await initializeSetupRepository(directory);
await client.connect();
const input = {
idempotencyKey: "combined-workspace-setup",
source: { kind: "worktree" as const, cwd: directory, worktreeSlug: "combined-setup" },
agent: { config: { provider: "codex", cwd: directory } },
};
const first = await client.createWorkspace(input);
const replay = await client.createWorkspace(input);
expect(first.error).toBeNull();
expect(replay.error).toBeNull();
expect(replay.workspace?.id).toBe(first.workspace?.id);
expect(replay.agent?.id).toBe(first.agent?.id);
if (!first.workspace || !first.agent) throw new Error("Expected combined creation resources");
const observation = client.observeWorkspaceSetup(first.workspace.id);
await observation.ready;
await expect
.poll(async () => (await client.fetchWorkspaceSetupStatus(first.workspace!.id)).snapshot, {
timeout: 10000,
})
.toMatchObject({
status: "completed",
detail: {
log: "",
commands: [
expect.objectContaining({
log: expect.stringContaining("workspace setup output\n"),
status: "completed",
}),
],
},
});
await observation.release();
expect(await readFile(join(first.workspace.workspaceDirectory!, "setup-runs"), "utf8")).toBe(
"once\n",
);
const timeline = await client.fetchAgentTimeline(first.agent.id);
expect(
timeline.entries.map((entry) => entry.item).filter((item) => item.type === "tool_call"),
).toEqual([]);
expect(first.agent.workspaceId).toBe(first.workspace.id);
const conflict = await client.createWorkspace({
...input,
source: { ...input.source, worktreeSlug: "different-intent" },
});
expect(conflict.error).toBe("workspace_request_key_conflict");
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}
}, 60000);
test.each(["create_agent_request", "agent.create.request"] as const)(
"%s rejects agent worktree placement at the wire boundary",
async (type) => {
const directory = await mkdtemp(join(tmpdir(), "agent-worktree-rejected-"));
const daemon = await createCreationDaemon();
const peer = await connectCreationPeer(daemon.port);
try {
for (const placement of [{ git: { createWorktree: true } }, { worktreeName: "legacy" }]) {
const response = await peer.request({
type,
requestId: randomUUID(),
config: { provider: "codex", cwd: directory },
...placement,
});
if (response.type !== "agent.create.response" && response.type !== "status")
throw new Error("Expected agent creation response");
const error = response.payload.error;
expect(error).toContain("createWorkspace");
}
} finally {
peer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
},
);
async function initializeSetupRepository(directory: string): Promise<void> {
execFileSync("git", ["init", "-b", "main", directory], { stdio: "pipe" });
await writeFile(
join(directory, "paseo.json"),
JSON.stringify({
worktree: {
setup: [
"node -e \"require('fs').appendFileSync('setup-runs', 'once\\n'); console.log('workspace setup output')\"",
],
},
}),
);
execFileSync("git", ["add", "paseo.json"], { cwd: directory });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"setup fixture",
],
{ cwd: directory, stdio: "pipe" },
);
}
test("Hub-shaped keyed agent worktree creation uses workspace setup and preserves resource identity", async () => {
const directory = await mkdtemp(join(tmpdir(), "keyed-agent-workspace-setup-"));
const daemon = await createCreationDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const peer = await connectCreationPeer(daemon.port);
try {
await initializeSetupRepository(directory);
await client.connect();
const input = {
type: "create_agent_request" as const,
idempotencyKey: "keyed-agent-worktree",
config: { provider: "codex", cwd: directory },
worktree: { mode: "branch-off" as const, newBranch: "keyed-agent-setup", base: "main" },
};
const first = await peer.request({ ...input, requestId: "first" });
const replay = await peer.request({ ...input, requestId: "replay" });
if (
first.type !== "status" ||
first.payload.status !== "agent_created" ||
replay.type !== "status" ||
replay.payload.status !== "agent_created"
)
throw new Error("Expected agent creation responses");
expect(replay.payload.agent.id).toBe(first.payload.agent.id);
expect(replay.payload.agent.workspaceId).toBe(first.payload.agent.workspaceId);
const agent = first.payload.agent;
if (!agent.workspaceId) throw new Error("Expected agent workspace ownership");
await expect
.poll(async () => (await client.fetchWorkspaceSetupStatus(agent.workspaceId!)).snapshot, {
timeout: 10000,
})
.toMatchObject({
status: "completed",
detail: {
log: "",
commands: [
expect.objectContaining({ log: expect.stringContaining("workspace setup output\n") }),
],
},
});
expect(await readFile(join(agent.cwd, "setup-runs"), "utf8")).toBe("once\n");
expect(
(await client.fetchAgentTimeline(agent.id)).entries
.map((entry) => entry.item)
.filter((item) => item.type === "tool_call"),
).toEqual([]);
const conflict = await peer.request({
...input,
requestId: "conflict",
worktree: { ...input.worktree, newBranch: "different-intent" },
});
expect(conflict).toMatchObject({
type: "status",
payload: { status: "agent_create_failed", error: "agent_request_key_conflict" },
});
} finally {
peer.close();
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}
}, 60000);
@@ -106,14 +106,14 @@ githubTest(
cwd: repository,
worktreeSlug: "merged-feature",
source: { kind: "branch-off", baseBranch: "main", branchName: "merged-feature" },
runSetup: false,
paseoHome: context.daemon.paseoHome,
});
const unrelated = await createWorktree({
cwd: repository,
worktreeSlug: "never-pushed-unrelated",
source: { kind: "branch-off", baseBranch: "main", branchName: "never-pushed-unrelated" },
runSetup: false,
paseoHome: context.daemon.paseoHome,
});
const mergedWorkspace = await context.client.createWorkspace({
@@ -215,7 +215,7 @@ describe("daemon checkout PR merge loop", () => {
baseBranch: "main",
branchName: "merge-pr-squash",
},
runSetup: true,
paseoHome: ctx.daemon.paseoHome,
});
worktreePath = worktree.worktreePath;
@@ -20,7 +20,7 @@ interface LegacyCreateWorktreeTestOptions {
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
}
@@ -39,7 +39,7 @@ function createLegacyWorktreeForTest(
baseBranch: options.baseBranch,
branchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
});
}
@@ -12,74 +12,14 @@ import { execFileSync } from "node:child_process";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
import { withTimeout } from "../../utils/promise-timeout.js";
import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
import { isPlatform } from "../../test-utils/platform.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
function findTimelineToolCall(
messages: SessionOutboundMessage[],
agentId: string,
predicate: (item: AgentTimelineItem) => boolean,
): AgentTimelineItem | null {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i];
if (msg?.type !== "agent_stream") {
continue;
}
if (msg.payload.agentId !== agentId) {
continue;
}
const event = msg.payload.event as { type?: string; item?: AgentTimelineItem };
if (event?.type !== "timeline") {
continue;
}
const item = event.item as AgentTimelineItem;
if (item?.type === "tool_call" && predicate(item)) {
return item;
}
}
return null;
}
async function waitForTimelineToolCall(
messages: SessionOutboundMessage[],
agentId: string,
predicate: (item: AgentTimelineItem) => boolean,
timeoutMs = 10000,
): Promise<Extract<AgentTimelineItem, { type: "tool_call" }>> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const existing = findTimelineToolCall(messages, agentId, predicate);
if (existing && existing.type === "tool_call") {
return existing;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
const recentToolCalls: Array<{ name: string; status?: string; callId?: string }> = [];
for (let i = messages.length - 1; i >= 0 && recentToolCalls.length < 10; i -= 1) {
const msg = messages[i];
if (msg?.type !== "agent_stream") continue;
if (msg.payload.agentId !== agentId) continue;
const event = msg.payload.event as { type?: string; item?: AgentTimelineItem };
if (event?.type !== "timeline") continue;
const item = event.item as AgentTimelineItem;
if (item?.type !== "tool_call") continue;
recentToolCalls.push({ name: item.name, status: item.status, callId: item.callId });
}
throw new Error(
`Timed out waiting for timeline tool_call (${agentId}). Recent tool_calls: ${JSON.stringify(
recentToolCalls,
)}`,
);
}
async function waitForPathExists(options: {
targetPath: string;
timeoutMs: number;
@@ -121,43 +61,17 @@ async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
}
}
interface WorktreeTerminalBootstrapEntry {
name: string | null;
command: string;
status: "started" | "failed";
terminalId: string | null;
error: string | null;
}
function getWorktreeTerminalBootstrapEntries(
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
): WorktreeTerminalBootstrapEntry[] | null {
const detail = item.detail;
if (!detail || detail.type !== "unknown" || !detail.output) {
return null;
}
const output = detail.output as Record<string, unknown>;
const terminals = output.terminals;
if (!Array.isArray(terminals)) {
return null;
}
return terminals as WorktreeTerminalBootstrapEntry[];
}
// Use gpt-5.4-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
let ctx: DaemonTestContext;
let collector: MessageCollector;
beforeEach(async () => {
ctx = await createDaemonTestContext();
collector = createMessageCollector(ctx.client);
});
afterEach(async () => {
collector.unsubscribe();
await ctx.cleanup();
}, 60000);
@@ -423,7 +337,7 @@ test("returns isGit false for non-git directory", async () => {
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
test("runs paseo.json setup asynchronously and reports status via timeline tool_call", async () => {
test("runs paseo.json setup asynchronously and reports workspace status", async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
@@ -461,13 +375,7 @@ test("runs paseo.json setup asynchronously and reports status via timeline tool_
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Async Worktree Setup Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "async-setup-test",
worktreeSlug: "async-setup-test",
},
worktree: { mode: "branch-off", newBranch: "async-setup-test", base: "main" },
}),
timeoutMs: 2500,
label: "createAgent should not block on setup",
@@ -478,19 +386,11 @@ test("runs paseo.json setup asynchronously and reports status via timeline tool_
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
const completed = await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
20000,
);
expect(completed.callId).toBeTruthy();
expect(completed.detail.type).toBe("worktree_setup");
if (completed.detail.type === "worktree_setup") {
expect(completed.detail.commands.length).toBeGreaterThan(0);
expect(completed.detail.log.length).toBeGreaterThan(0);
}
const completed = await waitForWorkspaceSetup(agent.workspaceId!, "completed");
expect(completed.detail.log).toBe("");
expect(completed.detail.commands).toEqual([
expect.objectContaining({ status: "completed", exitCode: 0 }),
]);
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true);
await ctx.client.deleteAgent(agent.id);
@@ -549,13 +449,7 @@ test("bootstraps configured worktree terminals after setup succeeds", async () =
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Async Worktree Setup + Terminals Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "async-setup-terminals-test",
worktreeSlug: "async-setup-terminals-test",
},
worktree: { mode: "branch-off", newBranch: "async-setup-terminals-test", base: "main" },
}),
timeoutMs: 2500,
label: "createAgent should not block on setup",
@@ -568,25 +462,12 @@ test("bootstraps configured worktree terminals after setup succeeds", async () =
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
20000,
);
const terminalsBootstrapToolCall = await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_terminals" && item.status === "completed",
30000,
);
const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries(terminalsBootstrapToolCall);
expect(bootstrappedTerminals).toBeTruthy();
expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2);
const failedBootstraps =
bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? [];
expect(failedBootstraps).toEqual([]);
await waitForWorkspaceSetup(agent.workspaceId!, "completed");
await expect
.poll(async () => (await ctx.client.listTerminals(agent.cwd)).terminals.length, {
timeout: 30000,
})
.toBe(2);
const list = await ctx.client.listTerminals(agent.cwd);
expect(list.error).toBeUndefined();
expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true);
@@ -639,7 +520,7 @@ test("bootstraps configured worktree terminals after setup succeeds", async () =
});
}, 60000);
test("reports failures via timeline tool_call without deleting the created worktree", async () => {
test("reports workspace setup failures without deleting the created worktree", async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
@@ -687,13 +568,7 @@ test("reports failures via timeline tool_call without deleting the created workt
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Async Worktree Setup Failure Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "async-setup-failure-test",
worktreeSlug: "async-setup-failure-test",
},
worktree: { mode: "branch-off", newBranch: "async-setup-failure-test", base: "main" },
}),
timeoutMs: 2500,
label: "createAgent should not block on failing setup",
@@ -702,23 +577,7 @@ test("reports failures via timeline tool_call without deleting the created workt
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(agent.cwd)).toBe(true);
const started = await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_setup" && item.status === "running",
10000,
);
const failed = await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) =>
item.name === "paseo_worktree_setup" &&
item.callId === started.callId &&
item.status === "failed",
20000,
);
const failed = await waitForWorkspaceSetup(agent.workspaceId!, "failed");
expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true);
expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false);
@@ -726,7 +585,8 @@ test("reports failures via timeline tool_call without deleting the created workt
if (failed.detail.type === "worktree_setup") {
expect(Array.isArray(failed.detail.commands)).toBe(true);
expect(failed.detail.commands[0]?.exitCode).toBe(7);
expect(failed.detail.log).toContain("Exit 7");
expect(failed.detail.log).toBe("");
expect(failed.detail.commands[0]?.log).toContain("boom");
}
await ctx.client.deleteAgent(agent.id);
@@ -756,13 +616,7 @@ test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", as
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Worktree Agent Test",
git: {
createWorktree: true,
createNewBranch: true,
newBranchName: "worktree-test",
worktreeSlug: "worktree-test",
baseBranch: "main",
},
worktree: { mode: "branch-off", newBranch: "worktree-test", base: "main" },
});
expect(agent.id).toBeTruthy();
@@ -776,7 +630,7 @@ test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", as
rmSync(cwd, { recursive: true, force: true });
}, 60000);
test("archiving a worktree shuts down its terminals but leaves the worktree on disk", async () => {
test("archiving a worktree shuts down terminals and removes its final workspace backing", async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
@@ -823,13 +677,7 @@ test("archiving a worktree shuts down its terminals but leaves the worktree on d
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Worktree Archive Cleanup Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "archive-cleanup-test",
worktreeSlug: "archive-cleanup-test",
},
worktree: { mode: "branch-off", newBranch: "archive-cleanup-test", base: "main" },
}),
timeoutMs: 2500,
label: "createAgent should not block on setup",
@@ -858,14 +706,23 @@ test("archiving a worktree shuts down its terminals but leaves the worktree on d
expect(archive.success).toBe(true);
expect(archive.removedAgents).toContain(agent.id);
// Archiving tears down the workspace's terminals but never deletes the
// worktree from disk and never runs teardown commands — on-disk removal is a
// separate, explicit step.
expect(existsSync(agent.cwd)).toBe(true);
expect(existsSync(teardownMarkerPath)).toBe(false);
// The final workspace reference owns teardown and backing-worktree removal.
expect(existsSync(agent.cwd)).toBe(false);
expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd);
const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
expect(afterArchiveDirectories).not.toContain(agent.cwd);
rmSync(repoRoot, { recursive: true, force: true });
}, 60000);
async function waitForWorkspaceSetup(workspaceId: string, status: "completed" | "failed") {
await expect
.poll(async () => (await ctx.client.fetchWorkspaceSetupStatus(workspaceId)).snapshot?.status, {
timeout: 20000,
})
.toBe(status);
const snapshot = (await ctx.client.fetchWorkspaceSetupStatus(workspaceId)).snapshot;
if (!snapshot) throw new Error("Expected workspace setup snapshot");
return snapshot;
}
@@ -59,7 +59,7 @@ test("creates a worktree and registers it in the source workspace project withou
cwd: repoDir,
worktreeSlug: "feature-one",
title: "Feature One",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
@@ -114,7 +114,7 @@ test("refreshes a source project that became Git while creating a worktree", asy
{
cwd: repoDir,
worktreeSlug: "project-became-git",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
@@ -148,7 +148,7 @@ test("repairs a legacy source workspace whose project record is missing", async
{
cwd: repoDir,
worktreeSlug: "repaired-source",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
@@ -192,7 +192,7 @@ test("uses an equivalent source workspace path when creating a worktree", async
{
cwd: sourceDir,
worktreeSlug: "equivalent-source",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
@@ -221,7 +221,7 @@ test("creates a worktree workspace at the selected project subdirectory", async
cwd: sourceDir,
projectId: project.projectId,
worktreeSlug: "selected-subdirectory",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
@@ -249,7 +249,7 @@ test("seeds an uncommitted exact-project config into the mapped worktree directo
{
cwd: sourceDir,
worktreeSlug: "seed-nested-config",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
@@ -277,7 +277,7 @@ test("does not overwrite a committed exact-project config with source checkout e
{
cwd: sourceDir,
worktreeSlug: "preserve-nested-config",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
@@ -314,7 +314,7 @@ test("removes a new worktree when its ref does not contain the selected project
action: "checkout",
refName: "without-subproject",
worktreeSlug: "missing-subproject",
runSetup: false,
paseoHome,
},
deps,
@@ -345,7 +345,7 @@ test("removes a new worktree when workspace persistence fails", async () => {
cwd: repoDir,
projectId: "missing-project",
worktreeSlug: "persistence-failure",
runSetup: false,
paseoHome,
},
createDeps(),
@@ -373,7 +373,7 @@ test("maps a nested cwd from an existing Paseo worktree into the next worktree",
{
cwd: repoDir,
worktreeSlug: "source-worktree",
runSetup: false,
paseoHome,
},
deps,
@@ -384,7 +384,7 @@ test("maps a nested cwd from an existing Paseo worktree into the next worktree",
{
cwd: sourceCwd,
worktreeSlug: "nested-worktree",
runSetup: false,
paseoHome,
},
deps,
@@ -408,7 +408,7 @@ test("rejects source checkout planning before creating a worktree", async () =>
{
cwd: repoDir,
worktreeSlug: "must-not-create",
runSetup: false,
paseoHome,
},
deps,
@@ -443,7 +443,7 @@ test("registers a new worktree in the existing root project after the main check
cwd: repoDir,
projectId: sourceProject.projectId,
worktreeSlug: "second-worktree",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
@@ -472,7 +472,7 @@ test("an explicit project FK remains unchanged when its worktree comes from anot
cwd: repoDir,
projectId: project.projectId,
worktreeSlug: "attached-worktree",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
@@ -502,7 +502,7 @@ test.skipIf(isPlatform("win32"))(
{
cwd: repoDir,
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
firstDeps,
@@ -518,7 +518,7 @@ test.skipIf(isPlatform("win32"))(
{
cwd: repoDir,
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
deps,
@@ -541,7 +541,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
{
cwd: repoDir,
worktreeSlug: "dazzling-yak",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
@@ -610,7 +610,7 @@ test("falls back to a numeric suffix when the desired branch name already exists
{
cwd: repoDir,
worktreeSlug: "dazzling-yak",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
@@ -647,7 +647,7 @@ test("renames the branch even when the app supplies a random placeholder slug",
cwd: repoDir,
worktreeSlug: "dazzling-yak",
firstAgentContext: { prompt: "Investigate the failing login flow" },
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
@@ -695,7 +695,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
},
],
},
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
@@ -740,7 +740,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
cwd: repoDir,
worktreeSlug: "dazzling-yak",
firstAgentContext: { prompt: "Name this branch" },
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
@@ -785,7 +785,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
cwd: repoDir,
action: "checkout",
refName: "dev",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
@@ -825,7 +825,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
cwd: repoDir,
action: "checkout",
githubPrNumber: 123,
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
@@ -862,7 +862,7 @@ test("does not mutate registries or broadcast when core worktree creation fails"
{
cwd: tempDir,
worktreeSlug: "not-git",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
@@ -888,7 +888,7 @@ test.skipIf(isPlatform("win32"))(
cwd: repoDir,
worktreeSlug: "restore-me",
source: { kind: "checkout-branch", branchName: "restore-me" },
runSetup: false,
paseoHome,
});
expect(existsSync(created.worktreePath)).toBe(true);
@@ -903,7 +903,7 @@ test.skipIf(isPlatform("win32"))(
cwd: repoDir,
worktreeSlug: "restore-me",
source: { kind: "checkout-branch", branchName: "restore-me" },
runSetup: false,
paseoHome,
});
@@ -947,7 +947,7 @@ test.skipIf(isPlatform("win32"))(
cwd: repoDir,
worktreeSlug: "restore-me",
source: { kind: "checkout-branch", branchName: "restore-me" },
runSetup: false,
paseoHome,
});
expect(existsSync(created.worktreePath)).toBe(true);
@@ -970,7 +970,7 @@ test.skipIf(isPlatform("win32"))(
cwd: repoDir,
worktreeSlug: "restore-me",
source: { kind: "checkout-branch", branchName: "restore-me" },
runSetup: false,
paseoHome,
}),
).rejects.toThrow("missing but already registered worktree");
@@ -982,7 +982,7 @@ test.skipIf(isPlatform("win32"))(
cwd: repoDir,
worktreeSlug: "restore-me",
source: { kind: "checkout-branch", branchName: "restore-me" },
runSetup: false,
paseoHome,
});
@@ -1010,7 +1010,7 @@ test.skipIf(isPlatform("win32"))(
cwd: repoDir,
worktreeSlug: "gone-branch",
source: { kind: "checkout-branch", branchName: "gone-branch" },
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
}),
).rejects.toMatchObject({ name: "UnknownBranchError" });
@@ -1029,7 +1029,7 @@ test.skipIf(isPlatform("win32"))(
cwd: repoDir,
worktreeSlug: "busy-branch",
source: { kind: "checkout-branch", branchName: "busy-branch" },
runSetup: false,
paseoHome,
});
expect(existsSync(first.worktreePath)).toBe(true);
@@ -1038,7 +1038,7 @@ test.skipIf(isPlatform("win32"))(
cwd: repoDir,
worktreeSlug: "busy-branch-again",
source: { kind: "checkout-branch", branchName: "busy-branch" },
runSetup: false,
paseoHome,
});
@@ -135,7 +135,7 @@ async function createPaseoWorktreeWithPriority(
{
cwd: createdWorktree.repoRoot,
worktreePath: createdWorktree.worktree.worktreePath,
...(input.runSetup === false ? { teardownCwds: [] } : {}),
teardownCwds: [],
paseoHome: input.paseoHome,
worktreesBaseRoot: input.worktreesRoot,
},
@@ -411,7 +411,7 @@ test("create_agent_request rejects legacy git options before creating a worktree
},
initialPrompt: "Say done.",
}),
).rejects.toThrow("worktree cannot be combined with git options");
).rejects.toThrow("createWorkspace");
await expectActiveAgentListEmpty();
await expectWorktreeListEmpty(repoDir);
@@ -447,72 +447,6 @@ function storedAgentIds(): string[] {
.map((entry) => path.basename(entry, ".json"));
}
test.each(["git", "worktreeName"] as const)(
"legacy %s worktree creation resolves final workspace visibility without intermediate records",
async (placement) => {
const cwd = createGitRepo();
const config = { ...getFullAccessConfig("codex"), cwd };
const backgroundCaller = await ctx.client.createAgent({ config, background: true });
const visibleCaller = await ctx.client.createAgent({ config });
const cases = [
{ name: "review", background: true, expected: true },
{ name: "review-child", callerAgentId: backgroundCaller.id, expected: true },
{
name: "visible-child",
callerAgentId: backgroundCaller.id,
background: false,
expected: false,
},
{ name: "hidden-child", callerAgentId: visibleCaller.id, background: true, expected: true },
{ name: "human-default", expected: false },
];
const orderedCases =
placement === "worktreeName" ? [cases[1]!, cases[0]!, ...cases.slice(2)] : cases;
for (const entry of orderedCases) {
const before = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } });
const child = await ctx.client.createAgent({
config,
...(entry.callerAgentId ? { callerAgentId: entry.callerAgentId } : {}),
...(entry.background !== undefined ? { background: entry.background } : {}),
...(placement === "git"
? {
git: {
createWorktree: true,
createNewBranch: true,
newBranchName: entry.name,
baseBranch: "main",
},
}
: { worktreeName: entry.name }),
});
const inclusive = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } });
const created = inclusive.entries.filter(
(workspace) => !before.entries.some((existing) => existing.id === workspace.id),
);
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({
id: child.workspaceId,
workspaceDirectory: child.cwd,
background: entry.expected,
workspaceKind: "worktree",
});
expect(child.cwd).not.toBe(cwd);
expect(child.labels?.["paseo.parent-agent-id"]).toBe(entry.callerAgentId);
const defaultAgents = await ctx.client.fetchAgents();
const allAgents = await ctx.client.fetchAgents({ filter: { includeBackground: true } });
expect(defaultAgents.entries.some((item) => item.agent.id === child.id)).toBe(
!entry.expected,
);
expect(allAgents.entries.some((item) => item.agent.id === child.id)).toBe(true);
const defaultWorkspaces = await ctx.client.fetchWorkspaces();
expect(
defaultWorkspaces.entries.some((workspace) => workspace.id === child.workspaceId),
).toBe(!entry.expected);
}
},
30_000,
);
test("background workspaces keep durable agents addressable through restart and archive", async () => {
const cwd = createGitRepo();
const config = { ...getFullAccessConfig("codex"), cwd };
@@ -4694,7 +4694,6 @@ describe("session paseo worktree creation handling", () => {
await asSessionInternals(session).createPaseoWorktree({
cwd: "/tmp/repo",
worktreeSlug: "new-worktree",
runSetup: false,
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
+10 -84
View File
@@ -29,7 +29,6 @@ import {
type SessionInboundMessage,
type SessionOutboundMessage,
type ScriptStatusUpdateMessage,
type GitSetupOptions,
type StartWorkspaceScriptRequest,
type WorkspaceScriptListRequest,
type WorkspaceScriptStartRequest,
@@ -122,10 +121,6 @@ import {
updateAgentCommand,
} from "./agent/lifecycle-command.js";
import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js";
import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js";
import { parsePluginClientId } from "./plugins/plugin-session-identity.js";
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
@@ -249,10 +244,7 @@ import {
} from "./paseo-worktree-service.js";
import { WorkspaceAutoName } from "./workspace-auto-name.js";
import {
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
normalizeAgentCreationGitOptions,
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
type CreatePaseoWorktreeSetupContinuationInput,
type CreatePaseoWorktreeWorkflowResult,
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
@@ -4293,17 +4285,19 @@ export class Session {
}
}
private validatePublicAgentWorkspaceIntent(
request: CreateAgentRequestMessage,
createsWorktree: boolean,
): void {
private validatePublicAgentWorkspaceIntent(request: CreateAgentRequestMessage): void {
if (request.git !== undefined || request.worktreeName !== undefined) {
throw new Error(
"Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) to create a workspace with an agent, or create the workspace first and pass workspaceId.",
);
}
if (request.internal)
throw new Error(
"Public internal agents were removed; create a background workspace instead.",
);
if (
request.background !== undefined &&
(request.workspaceId || (request.callerAgentId && !createsWorktree))
(request.workspaceId || (request.callerAgentId && !request.worktree))
) {
throw new Error(
"background is workspace creation intent; configure the selected workspace at creation.",
@@ -4322,7 +4316,6 @@ export class Session {
initialPrompt,
clientMessageId,
outputSchema,
git,
worktree,
autoArchive,
images,
@@ -4340,14 +4333,11 @@ export class Session {
let createdAgentId: string | null = null;
try {
const requestedCwd = resolve(config.cwd);
const needsRequestedDirectory =
Boolean(worktreeName || git || worktree) || (!msg.workspaceId && !msg.callerAgentId);
const needsRequestedDirectory = Boolean(worktree) || (!msg.workspaceId && !msg.callerAgentId);
if (needsRequestedDirectory && !(await this.filesystem.isDirectory(requestedCwd))) {
throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`);
}
// Resolve all supported worktree input forms before provisioning workspace ownership.
const gitIntent = normalizeAgentCreationGitOptions(git, worktreeName, worktree);
this.validatePublicAgentWorkspaceIntent(msg, gitIntent?.createWorktree === true);
this.validatePublicAgentWorkspaceIntent(msg);
const trimmedPrompt = initialPrompt?.trim();
const { provisionalTitle } = resolveCreateAgentTitles({
configTitle: config.title,
@@ -4361,9 +4351,8 @@ export class Session {
const workspacePromptTitle = resolveFirstAgentPromptTitle(firstAgentContext);
const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({
cwd: config.cwd,
target: gitIntent,
target: worktree,
firstAgentContext,
setupContinuation: worktree ? undefined : this.agentWorktreeSetupContinuation(),
background: msg.background,
callerWorkspaceId: this.getCallerWorkspaceId(msg.callerAgentId),
});
@@ -4396,7 +4385,6 @@ export class Session {
agentId,
config: resolvedIntent.config,
workspaceId: resolvedIntent.intent.workspaceId,
worktreeName,
initialPrompt,
source: msg.callerAgentId
? { kind: "agent-message", agentId: msg.callerAgentId }
@@ -4405,19 +4393,10 @@ export class Session {
outputSchema,
images,
attachments,
git,
labels: resolvedIntent.intent.labels,
env,
provisionalTitle,
firstAgentContext,
buildSessionConfig: async (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
createdWorktree
? {
sessionConfig,
setupContinuation: createdWorktree.setupContinuation,
createdWorkspaceId: createdWorktree.workspace.workspaceId,
}
: this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
},
);
createdAgentId = snapshot.id;
@@ -4887,58 +4866,6 @@ export class Session {
}
}
private agentWorktreeSetupContinuation(): CreatePaseoWorktreeSetupContinuationInput {
return {
kind: "agent",
terminalManager: this.terminalManager,
appendTimelineItem: ({ agentId, item }) =>
appendTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId,
item,
}),
emitLiveTimelineItem: ({ agentId, item }) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId,
item,
}),
logger: this.sessionLogger,
};
}
private async buildAgentSessionConfig(
config: AgentSessionConfig,
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
firstAgentContext?: FirstAgentContext,
): Promise<{
sessionConfig: AgentSessionConfig;
setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"];
createdWorkspaceId?: string;
}> {
return buildWorktreeAgentSessionConfig(
{
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
sessionLogger: this.sessionLogger,
workspaceGitService: this.workspaceGitService,
createPaseoWorktree: (input, serviceOptions) =>
this.createPaseoWorktreeWorkflow(input, {
...serviceOptions,
setupContinuation: this.agentWorktreeSetupContinuation(),
}),
checkoutExistingBranch: (cwd, branch) =>
this.gitMutation.checkoutExistingBranch(cwd, branch),
createBranchFromBase: (params) => this.gitMutation.createBranchFromBase(params),
},
config,
gitOptions,
legacyWorktreeName,
firstAgentContext,
);
}
private isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
const resolvedRoot = resolve(rootPath);
const resolvedCandidate = resolve(candidatePath);
@@ -7400,7 +7327,6 @@ export class Session {
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
},
): Promise<CreatePaseoWorktreeWorkflowResult> {
return createWorktreeWorkflow(
@@ -5704,7 +5704,7 @@ test("legacy refresh_agent_request restores a real deleted worktree", async () =
cwd: repoDir,
worktreeSlug: "keep",
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome,
worktreesRoot,
});
@@ -5952,7 +5952,7 @@ test("archive_workspace_request archives a worktree-kind workspace and removes t
baseBranch: "main",
branchName: "worktree-kind-archive",
},
runSetup: false,
paseoHome,
});
@@ -1746,7 +1746,6 @@ it("creates a PR from a restored exact base using the host metadata and a forge
paseoHome,
worktreeSlug: "restored-pr",
source: { kind: "branch-off", baseBranch: baseRef, branchName: "restored-pr" },
runSetup: false,
});
writeFileSync(join(created.worktreePath, "feature.txt"), "feature\n");
git(created.worktreePath, "add", ".");
@@ -1757,7 +1756,6 @@ it("creates a PR from a restored exact base using the host metadata and a forge
paseoHome,
worktreeSlug: "restored-pr",
source: { kind: "restore", branchName: created.branchName, baseRef },
runSetup: false,
});
const requests: Array<Parameters<ForgeService["createPullRequest"]>[0]> = [];
const service: ForgeService = {
@@ -153,7 +153,7 @@ describe("workspace recovery", () => {
cwd: repoDir,
worktreeSlug: "mixed-project",
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome,
worktreesRoot,
});
@@ -209,7 +209,7 @@ describe("workspace recovery", () => {
cwd: repoDir,
worktreeSlug: "without-subproject",
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome,
worktreesRoot,
});
@@ -303,7 +303,6 @@ async function createBaseRecoveryFixture(baseBranch: string | null) {
paseoHome,
worktreeSlug: "base-recovery",
source: { kind: "branch-off", branchName: "feature", baseBranch: "main" },
runSetup: false,
});
writeFileSync(join(created.worktreePath, "feature.txt"), "feature\n");
execFileSync("git", ["add", "."], { cwd: created.worktreePath });
@@ -184,7 +184,7 @@ export function createWorkspaceRecoveryService(deps: {
cwd: sourceRepoRoot,
worktreeSlug: basename(previousWorktreePath),
source,
runSetup: false,
paseoHome: deps.paseoHome,
worktreesRoot: deps.worktreesRoot,
});
@@ -550,7 +550,7 @@ describe("wire compatibility", () => {
refName: undefined,
action: undefined,
githubPrNumber: undefined,
runSetup: false,
paseoHome: "/tmp/paseo-home",
});
});
@@ -329,7 +329,7 @@ test("archiving an unregistered worktree in a bare repo clears the main checkout
cwd: mainCheckout,
source: { kind: "branch-off", baseBranch: "main", branchName: "cached-bare-worktree" },
worktreeSlug: "cached-bare-worktree",
runSetup: false,
paseoHome: ctx.daemon.paseoHome,
});
expect(await activeWorkspaceIds()).toEqual(new Set());
@@ -110,7 +110,7 @@ async function createPaseoOwnedWorktree(
baseBranch: "main",
branchName: worktreeSlug,
},
runSetup: false,
paseoHome,
});
}
@@ -604,12 +604,18 @@ async function expectMissingLocalMergeTarget({ client, cwd }: RestorableWorkspac
).toContain("No local merge target is recorded");
}
function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
// Setup output is opt-in even for multiple sockets sharing one logical client.
test("setup details follow one workspace/source and restore after reconnect with minimal directory status", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: true });
const { repoDir, tempRoot } = createGitRepoWithBranch();
const clients: SocketDaemonClient[] = [];
const sockets = new Map<string, WebSocket[]>();
const setupWireTypes = new Set<string>();
const setupWireDetails: Array<{ log: string; commands: Array<{ log: string }> }> = [];
const observations: Array<{ release(): Promise<void> }> = [];
const workspaces: Array<{ id: string; workspaceDirectory?: string }> = [];
const mcp = new Client({ name: "setup-test", version: "1.0.0" });
@@ -653,6 +659,24 @@ const timer = setInterval(() => {
reconnect: { baseDelayMs: 10, maxDelayMs: 10 },
webSocketFactory: (url) => {
const socket = new WebSocket(url);
socket.on("message", (data, isBinary) => {
if (isBinary) return;
const frame = JSON.parse(data.toString());
const message = frame.message ?? frame;
if (message.type === "workspace_setup_progress") {
setupWireTypes.add(message.type);
setupWireDetails.push(message.payload.detail);
}
if (
message.type === "workspace_setup_status_response" ||
message.type === "workspace.setup.subscribe.response"
) {
if (message.payload.snapshot) {
setupWireTypes.add(message.type);
setupWireDetails.push(message.payload.snapshot.detail);
}
}
});
sockets.set(clientId, [...(sockets.get(clientId) ?? []), socket]);
return socket as unknown as WebSocketLike;
},
@@ -683,7 +707,10 @@ const timer = setInterval(() => {
})
.toMatchObject({
status: "running",
detail: { log: expect.stringContaining("setup started") },
detail: {
log: "",
commands: [expect.objectContaining({ log: expect.stringContaining("setup started") })],
},
});
};
for (const worktreeSlug of ["noisy-a", "noisy-b", "noisy-c"]) {
@@ -712,7 +739,10 @@ const timer = setInterval(() => {
const initial = await details.ready;
expect(initial.snapshot).toMatchObject({
status: "running",
detail: { log: expect.stringContaining("setup started") },
detail: {
log: "",
commands: [expect.objectContaining({ log: expect.stringContaining("setup started") })],
},
});
await expect
.poll(
@@ -745,7 +775,7 @@ const timer = setInterval(() => {
viewerMessages.filter(
(message) =>
message.type === "workspace_setup_progress" &&
message.payload.detail.log.includes("wave-0:"),
setupCommandOutput(message.payload.detail).includes("wave-0:"),
).length,
)
.toBeGreaterThan(0);
@@ -776,10 +806,12 @@ const timer = setInterval(() => {
.filter(
(message) =>
message.type === "workspace_setup_progress" &&
message.payload.detail.log.includes("wave-0:"),
setupCommandOutput(message.payload.detail).includes("wave-0:"),
)
.map((message) =>
message.type === "workspace_setup_progress" ? message.payload.detail.log : "",
message.type === "workspace_setup_progress"
? setupCommandOutput(message.payload.detail)
: "",
);
expect(waveLogs.length).toBe(new Set(waveLogs).size);
const restoredSnapshots: unknown[] = [];
@@ -792,7 +824,12 @@ const timer = setInterval(() => {
await expect.poll(() => restoredSnapshots.length, { timeout: 10000 }).toBe(2);
expect(details.subscriptionId).not.toBe(beforeReconnect);
expect(restoredSnapshots[1]).toMatchObject({
snapshot: { detail: { log: expect.stringContaining("wave-0:") } },
snapshot: {
detail: {
log: "",
commands: [expect.objectContaining({ log: expect.stringContaining("wave-0:") })],
},
},
});
const subscriptionId = details.subscriptionId;
await details.release();
@@ -804,7 +841,8 @@ const timer = setInterval(() => {
// Wait until the daemon has consumed the output before checking wire silence.
await expect
.poll(
async () => (await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.log,
async () =>
(await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.commands[0]?.log,
{
timeout: 30000,
},
@@ -815,8 +853,22 @@ const timer = setInterval(() => {
);
const resumed = viewer.observeWorkspaceSetup(workspace.id);
observations.push(resumed);
expect((await resumed.ready).snapshot?.detail.log).toContain("wave-1:");
expect((await resumed.ready).snapshot?.detail.commands[0]?.log).toContain("wave-1:");
expect(resumed.subscriptionId).not.toBe(subscriptionId);
expect(setupWireTypes).toEqual(
new Set([
"workspace_setup_progress",
"workspace_setup_status_response",
"workspace.setup.subscribe.response",
]),
);
expect(setupWireDetails.length).toBeGreaterThan(0);
expect(setupWireDetails.every((detail) => detail.log === "")).toBe(true);
expect(
setupWireDetails.some((detail) =>
detail.commands.some((command) => command.log.includes("wave-1:")),
),
).toBe(true);
writeFileSync(path.join(workspaces[1]!.workspaceDirectory!, "finish.txt"), "fail");
await expect
.poll(
@@ -849,3 +901,30 @@ const timer = setInterval(() => {
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 180000);
test("agent creation rejects legacy worktree intent without provisioning or executing setup", async () => {
const daemon = await createTestPaseoDaemon();
const { repoDir, tempRoot } = createGitRepoWithBranch();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await expect(
client.createAgent({
provider: "codex",
cwd: repoDir,
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "legacy-setup",
worktreeSlug: "legacy-setup",
},
}),
).rejects.toThrow("createWorkspace");
expect((await client.fetchWorkspaces()).entries).toEqual([]);
} finally {
await client.close();
await daemon.close();
await rm(tempRoot, { recursive: true, force: true });
}
});
@@ -13,31 +13,13 @@ import {
} from "fs";
import { join } from "path";
import { tmpdir } from "os";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
import { runAsyncWorktreeBootstrap, spawnWorkspaceScript } from "./worktree-bootstrap.js";
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
import { ScriptRouteStore } from "./script-proxy.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import { isPlatform } from "../test-utils/platform.js";
import {
createWorktree as createWorktreePrimitive,
type WorktreeConfig,
} from "../utils/worktree.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
interface CreateAgentWorktreeTestOptions {
cwd: string;
branchName: string;
baseBranch: string;
worktreeSlug: string;
paseoHome?: string;
}
interface CreateAgentWorktreeTestResult {
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}
async function cleanupTerminalManager(terminalManager: TerminalManager): Promise<void> {
const terminalsByCwd = await Promise.all(
terminalManager.listDirectories().map((cwd) => terminalManager.getTerminals(cwd)),
@@ -54,28 +36,10 @@ function killTerminal(terminalManager: TerminalManager, terminal: TerminalSessio
});
}
async function createBootstrapWorktreeForTest(
options: CreateAgentWorktreeTestOptions,
): Promise<CreateAgentWorktreeTestResult> {
const worktree = await createWorktreePrimitive({
cwd: options.cwd,
worktreeSlug: options.worktreeSlug,
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
branchName: options.branchName,
},
runSetup: false,
paseoHome: options.paseoHome,
});
return { worktree, shouldBootstrap: true };
}
describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
describe("runAsyncWorktreeBootstrap", () => {
describe("workspace scripts", () => {
let tempDir: string;
let repoDir: string;
let paseoHome: string;
let realTerminalManagers: TerminalManager[];
async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise<void> {
@@ -108,7 +72,6 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
realTerminalManagers = [];
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
repoDir = join(tempDir, "repo");
paseoHome = join(tempDir, "paseo-home");
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
@@ -129,290 +92,6 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
await Promise.all(realTerminalManagers.map(cleanupTerminalManager));
rmSync(tempDir, { recursive: true, force: true });
});
it("streams running setup updates live and persists only a final setup timeline row", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'],
},
}),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], {
cwd: repoDir,
stdio: "pipe",
});
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-streaming-setup",
baseBranch: "main",
worktreeSlug: "feature-streaming-setup",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
const live: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-test",
workspaceId: "ws-agent-test",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async (item: AgentTimelineItem) => {
live.push(item);
return true;
},
});
const liveSetupItems = live.filter(
(item) =>
item.type === "tool_call" &&
item.name === "paseo_worktree_setup" &&
item.status === "running",
);
expect(liveSetupItems.length).toBeGreaterThan(0);
const persistedSetupItems = persisted.filter(
(item) => item.type === "tool_call" && item.name === "paseo_worktree_setup",
);
expect(persistedSetupItems).toHaveLength(1);
expect(persistedSetupItems[0]?.type).toBe("tool_call");
if (persistedSetupItems[0]?.type === "tool_call") {
expect(persistedSetupItems[0].status).toBe("completed");
expect(persistedSetupItems[0].detail.type).toBe("worktree_setup");
if (persistedSetupItems[0].detail.type === "worktree_setup") {
expect(persistedSetupItems[0].detail.log).toContain(
'==> [1/2] Running: echo "line-one"; echo "line-two" 1>&2',
);
expect(persistedSetupItems[0].detail.log).toContain("line-one");
expect(persistedSetupItems[0].detail.log).toContain("line-two");
expect(persistedSetupItems[0].detail.log).toContain(
'==> [2/2] Running: echo "line-three"',
);
expect(persistedSetupItems[0].detail.log).toContain("line-three");
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/);
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/);
expect(persistedSetupItems[0].detail.commands).toHaveLength(2);
expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({
index: 1,
command: 'echo "line-one"; echo "line-two" 1>&2',
log: expect.stringContaining("line-one"),
status: "completed",
exitCode: 0,
});
expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two");
expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({
index: 2,
command: 'echo "line-three"',
log: "line-three\n",
status: "completed",
exitCode: 0,
});
expect(typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number").toBe(
true,
);
expect(typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number").toBe(
true,
);
}
}
const liveCallIds = new Set(
liveSetupItems
.filter(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call",
)
.map((item) => item.callId),
);
expect(liveCallIds.size).toBe(1);
if (persistedSetupItems[0]?.type === "tool_call") {
expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true);
}
});
it("keeps only the final carriage-return-updated content in command logs", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: [
`node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`,
],
},
}),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add carriage return setup"],
{
cwd: repoDir,
stdio: "pipe",
},
);
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-carriage-return",
baseBranch: "main",
worktreeSlug: "feature-carriage-return",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-carriage-return",
workspaceId: "ws-carriage-return",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => true,
});
const persistedSetupItem = persisted.find(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call" && item.name === "paseo_worktree_setup",
);
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
throw new Error("Expected worktree_setup tool detail");
}
expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n");
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n");
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n");
expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n");
});
it("shares the same worktree runtime port across setup and bootstrap terminals", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'],
terminals: [
{
name: "Port Terminal",
command: "true",
},
],
},
}),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add port setup and terminals"],
{
cwd: repoDir,
stdio: "pipe",
},
);
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-shared-runtime-port",
baseBranch: "main",
worktreeSlug: "feature-shared-runtime-port",
paseoHome,
});
const registeredEnvs: Array<{ cwd: string; env: Record<string, string> }> = [];
const createTerminalEnvs: Record<string, string>[] = [];
const createTerminalWorkspaceIds: Array<string | undefined> = [];
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-shared-runtime-port",
workspaceId: "ws-shared-runtime-port",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: {
async getTerminals() {
return [];
},
async createTerminal(options) {
createTerminalEnvs.push(options.env ?? {});
createTerminalWorkspaceIds.push(options.workspaceId);
return {
id: "term-1",
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit: () => () => {},
onCommandFinished: () => () => {},
onTitleChange: () => () => {},
getSize: () => ({ rows: 1, cols: 1 }),
getTitle: () => undefined,
getExitInfo: () => null,
getState: () => ({
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {},
killAndWait: async () => {},
};
},
registerCwdEnv(options) {
registeredEnvs.push({ cwd: options.cwd, env: options.env });
},
getTerminal() {
return undefined;
},
killTerminal() {},
async killTerminalAndWait() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
},
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => true,
});
const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt");
await waitForPathExists(setupPortPath);
const setupPort = readFileSync(setupPortPath, "utf8").trim();
expect(setupPort.length).toBeGreaterThan(0);
expect(registeredEnvs).toHaveLength(1);
expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath);
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
expect(createTerminalEnvs.length).toBeGreaterThan(0);
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
expect(createTerminalWorkspaceIds).toEqual(["ws-shared-runtime-port"]);
const terminalToolCall = persisted.find(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call" &&
item.name === "paseo_worktree_terminals" &&
item.status === "completed",
);
expect(terminalToolCall?.status).toBe("completed");
});
it("injects real peer service env into terminal-backed services", async () => {
writeFileSync(
@@ -3,9 +3,11 @@ import { execFileSync } from "child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync, mkdirSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
import { runAsyncWorktreeBootstrap, spawnWorkspaceScript } from "./worktree-bootstrap.js";
import {
buildWorktreeSetupDetail,
runWorktreeAutoTerminals,
spawnWorkspaceScript,
} from "./worktree-bootstrap.js";
import { ensureWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { ScriptRouteStore } from "./script-proxy.js";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
@@ -13,6 +15,7 @@ import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js
import {
createWorktree as createWorktreePrimitive,
type WorktreeConfig,
resolveWorktreeRuntimeEnv,
} from "../utils/worktree.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
@@ -57,13 +60,58 @@ async function createBootstrapWorktreeForTest(
baseBranch: options.baseBranch,
branchName: options.branchName,
},
runSetup: false,
paseoHome: options.paseoHome,
});
return { worktree, shouldBootstrap: true };
}
describe("runAsyncWorktreeBootstrap", () => {
describe("workspace setup detail", () => {
it("serializes output only in command logs while retaining the legacy string field", () => {
const detail = buildWorktreeSetupDetail({
worktree: { worktreePath: "/workspace", branchName: "feature" },
results: [
{
command: "echo ready",
cwd: "/workspace",
stdout: "ready\n",
stderr: "",
exitCode: 0,
durationMs: 10,
},
],
});
const serialized = JSON.stringify(detail);
expect(JSON.parse(serialized)).toMatchObject({
log: "",
commands: [{ log: "ready\n", status: "completed", exitCode: 0 }],
});
expect(serialized.split("ready\\n")).toHaveLength(2);
});
it("retains bounded per-command output and truncation without an aggregate copy", () => {
const detail = buildWorktreeSetupDetail({
worktree: { worktreePath: "/workspace", branchName: "feature" },
results: [
{
command: "large-output",
cwd: "/workspace",
stdout: "prefix-" + "x".repeat(100000) + "-suffix",
stderr: "",
exitCode: 0,
durationMs: 10,
},
],
});
expect(detail.log).toBe("");
expect(detail.truncated).toBe(true);
expect(detail.commands[0]?.log).toContain("prefix-");
expect(detail.commands[0]?.log).toContain("-suffix");
expect(detail.commands[0]?.log).toContain("...<output truncated in the middle>...");
expect(Buffer.byteLength(detail.commands[0]!.log)).toBeLessThanOrEqual(64 * 1024);
});
});
describe("runWorktreeAutoTerminals", () => {
let tempDir: string;
let repoDir: string;
let paseoHome: string;
@@ -92,116 +140,6 @@ describe("runAsyncWorktreeBootstrap", () => {
rmSync(tempDir, { recursive: true, force: true });
});
it("does not fail setup when live timeline emission throws", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: ['echo "ok"'],
},
}),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], {
cwd: repoDir,
stdio: "pipe",
});
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-live-failure",
baseBranch: "main",
worktreeSlug: "feature-live-failure",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
await expect(
runAsyncWorktreeBootstrap({
agentId: "agent-live-failure",
workspaceId: "ws-live-failure",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => {
throw new Error("live emit failed");
},
}),
).resolves.toBeUndefined();
const persistedSetupItems = persisted.filter(
(item) => item.type === "tool_call" && item.name === "paseo_worktree_setup",
);
expect(persistedSetupItems).toHaveLength(1);
if (persistedSetupItems[0]?.type === "tool_call") {
expect(persistedSetupItems[0].status).toBe("completed");
}
});
it("truncates each command output to 64kb in the middle", async () => {
const largeOutputCommand =
"node -e \"process.stdout.write('prefix-'); process.stdout.write('x'.repeat(70000)); process.stdout.write('-suffix')\"";
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: [largeOutputCommand],
},
}),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add large output setup"], {
cwd: repoDir,
stdio: "pipe",
});
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-large-output",
baseBranch: "main",
worktreeSlug: "feature-large-output",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-large-output",
workspaceId: "ws-large-output",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => true,
});
const persistedSetupItem = persisted.find(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call" && item.name === "paseo_worktree_setup",
);
expect(persistedSetupItem).toBeDefined();
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
throw new Error("Expected worktree_setup tool detail");
}
expect(persistedSetupItem.detail.truncated).toBe(true);
expect(persistedSetupItem.detail.log).toContain("prefix-");
expect(persistedSetupItem.detail.log).toContain("-suffix");
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
expect(persistedSetupItem.detail.commands[0]?.log).toContain("prefix-");
expect(persistedSetupItem.detail.commands[0]?.log).toContain("-suffix");
expect(persistedSetupItem.detail.commands[0]?.log).toContain(
"...<output truncated in the middle>...",
);
});
it("waits for terminal output before sending bootstrap commands", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
@@ -238,11 +176,11 @@ describe("runAsyncWorktreeBootstrap", () => {
let sendAt = 0;
let outputListener: ((chunk: { data: string }) => void) | null = null;
await runAsyncWorktreeBootstrap({
agentId: "agent-terminal-readiness",
await runWorktreeAutoTerminals({
workspaceId: "ws-terminal-readiness",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
workspaceCwd: worktreeBootstrap.worktree.worktreePath,
runtimeEnv: await resolveWorktreeRuntimeEnv(worktreeBootstrap.worktree),
terminalManager: {
async getTerminals() {
return [];
@@ -296,8 +234,6 @@ describe("runAsyncWorktreeBootstrap", () => {
return () => {};
},
},
appendTimelineItem: async () => true,
emitLiveTimelineItem: async () => true,
});
expect(readyAt).toBeGreaterThan(0);
+21 -376
View File
@@ -1,4 +1,3 @@
import { v4 as uuidv4 } from "uuid";
import type { Logger } from "pino";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
@@ -9,9 +8,7 @@ import {
paseoConfigParseError,
processCarriageReturns,
readPaseoConfig,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
WorktreeSetupError,
type WorktreeConfig,
type WorktreeSetupCommandResult,
type WorktreeRuntimeEnv,
@@ -19,7 +16,7 @@ import {
import type { ServiceProxySubsystem } from "./service-proxy.js";
import { allocateWorkspaceServicePort } from "./workspace-service-port-allocator.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
import type { ToolCallDetail } from "./agent/agent-sdk-types.js";
import {
assertNoServiceEnvNameCollisions,
buildWorkspaceServiceEnv,
@@ -32,28 +29,6 @@ import {
} from "./workspace-service-port-registry.js";
import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema";
export interface WorktreeBootstrapTerminalResult {
name: string | null;
command: string;
status: "started" | "failed";
terminalId: string | null;
error: string | null;
}
export interface RunAsyncWorktreeBootstrapOptions {
agentId: string;
// Workspace the bootstrapped terminals belong to. Stamping it lets
// workspaceId-scoped archive tear these terminals down.
workspaceId: string;
worktree: WorktreeConfig;
workspaceCwd?: string;
shouldBootstrap?: boolean;
terminalManager: TerminalManager | null;
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
logger?: Logger;
}
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
@@ -170,10 +145,6 @@ function renderMiddleTruncationAccumulator(accumulator: MiddleTruncationAccumula
};
}
function formatDurationMs(durationMs: number): string {
return `${(durationMs / 1000).toFixed(2)}s`;
}
function commandStatusFromResult(
result: WorktreeSetupCommandResult,
): "running" | "completed" | "failed" {
@@ -183,46 +154,6 @@ function commandStatusFromResult(
return result.exitCode === 0 ? "completed" : "failed";
}
function buildWorktreeSetupLog(input: {
results: WorktreeSetupCommandResult[];
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
}): { log: string; truncated: boolean } {
const { results, outputAccumulatorsByIndex } = input;
if (results.length === 0) {
return {
log: "",
truncated: false,
};
}
const lines: string[] = [];
let anyTruncated = false;
const total = results.length;
for (const [index, result] of results.entries()) {
lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`);
const output = buildWorktreeSetupCommandLog({
index: index + 1,
result,
outputAccumulatorsByIndex,
});
if (output.log.length > 0) {
lines.push(output.log.replace(/\n$/, ""));
}
if (output.truncated) {
anyTruncated = true;
}
if (result.exitCode !== null) {
lines.push(
`<== [${index + 1}/${total}] Exit ${result.exitCode} in ${formatDurationMs(result.durationMs)}`,
);
}
}
return {
log: lines.join("\n"),
truncated: anyTruncated,
};
}
function buildWorktreeSetupCommandLog(input: {
index: number;
result: WorktreeSetupCommandResult;
@@ -325,124 +256,15 @@ export function buildWorktreeSetupDetail(input: {
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}),
};
});
const renderedLog = buildWorktreeSetupLog({
results: input.results,
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
});
return {
type: "worktree_setup",
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
log: renderedLog.log,
// COMPAT(workspaceSetupCombinedLog): added in v0.12.0, remove after 2027-04-20 once legacy wire schemas no longer require log.
// Setup clients render commands[].log; retain the required wire field without duplicating output.
log: "",
commands,
...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}),
};
}
function buildSetupTimelineItem(input: {
callId: string;
status: "running" | "completed" | "failed";
worktree: WorktreeConfig;
results: WorktreeSetupCommandResult[];
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
errorMessage: string | null;
}): AgentTimelineItem {
const detail = buildWorktreeSetupDetail({
worktree: input.worktree,
results: input.results,
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
});
if (input.status === "running") {
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "running",
detail,
error: null,
};
}
if (input.status === "completed") {
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "completed",
detail,
error: null,
};
}
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "failed",
detail,
error: { message: input.errorMessage ?? "Worktree setup failed" },
};
}
function buildTerminalTimelineItem(input: {
callId: string;
status: "running" | "completed" | "failed";
worktree: WorktreeConfig;
results: WorktreeBootstrapTerminalResult[];
errorMessage: string | null;
}): AgentTimelineItem {
const detailInput = {
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
};
const detailOutput = {
worktreePath: input.worktree.worktreePath,
terminals: input.results,
};
if (input.status === "running") {
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "running",
detail: {
type: "unknown",
input: detailInput,
output: null,
},
error: null,
};
}
if (input.status === "completed") {
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "completed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: null,
};
}
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "failed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: { message: input.errorMessage ?? "Worktree terminal bootstrap failed" },
...(anyCommandTruncated ? { truncated: true } : {}),
};
}
@@ -502,93 +324,6 @@ function terminalHasOutput(state: ReturnType<TerminalSession["getState"]>): bool
return false;
}
async function runWorktreeTerminalBootstrap(
options: RunAsyncWorktreeBootstrapOptions,
runtimeEnv: WorktreeRuntimeEnv,
): Promise<void> {
const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath;
const terminalSpecs = getWorktreeTerminalSpecs(workspaceCwd);
if (terminalSpecs.length === 0) {
return;
}
const callId = uuidv4();
const started = await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "running",
worktree: options.worktree,
results: [],
errorMessage: null,
}),
);
if (!started) {
return;
}
if (!options.terminalManager) {
await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "failed",
worktree: options.worktree,
results: [],
errorMessage: "Terminal manager not available",
}),
);
return;
}
const terminalManager = options.terminalManager;
const results = await Promise.all(
terminalSpecs.map(async (spec): Promise<WorktreeBootstrapTerminalResult> => {
try {
const terminal = await terminalManager.createTerminal({
cwd: workspaceCwd,
name: spec.name,
env: runtimeEnv,
workspaceId: options.workspaceId,
});
await waitForTerminalBootstrapReadiness(terminal);
terminal.send({
type: "input",
data: `${spec.command}\r`,
});
return {
name: terminal.name ?? spec.name ?? null,
command: spec.command,
status: "started",
terminalId: terminal.id,
error: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.logger?.warn(
{ agentId: options.agentId, command: spec.command, err: error },
"Failed to bootstrap worktree terminal",
);
return {
name: spec.name ?? null,
command: spec.command,
status: "failed",
terminalId: null,
error: message,
};
}
}),
);
await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "completed",
worktree: options.worktree,
results,
errorMessage: null,
}),
);
}
export async function runWorktreeAutoTerminals(options: {
workspaceId: string;
worktree: WorktreeConfig;
@@ -597,117 +332,27 @@ export async function runWorktreeAutoTerminals(options: {
terminalManager: TerminalManager | null;
logger?: Logger;
}): Promise<void> {
await runWorktreeTerminalBootstrap(
{
agentId: options.workspaceId,
workspaceId: options.workspaceId,
worktree: options.worktree,
workspaceCwd: options.workspaceCwd,
terminalManager: options.terminalManager,
appendTimelineItem: async () => true,
logger: options.logger,
},
options.runtimeEnv,
);
}
export async function runAsyncWorktreeBootstrap(
options: RunAsyncWorktreeBootstrapOptions,
): Promise<void> {
if (options.shouldBootstrap === false) {
return;
}
const setupCallId = uuidv4();
let setupResults: WorktreeSetupCommandResult[] = [];
let runtimeEnv: WorktreeRuntimeEnv | null = null;
const emitLiveTimelineItem = options.emitLiveTimelineItem;
const progressAccumulator = createWorktreeSetupProgressAccumulator();
const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath;
let liveEmitQueue = Promise.resolve();
const queueLiveRunningEmit = () => {
if (!emitLiveTimelineItem) {
return;
}
const runningResults = getWorktreeSetupProgressResults(progressAccumulator);
liveEmitQueue = liveEmitQueue.then(async () => {
if (!options.terminalManager) return;
const terminalManager = options.terminalManager;
await Promise.all(
getWorktreeTerminalSpecs(options.workspaceCwd).map(async (spec) => {
try {
await emitLiveTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "running",
worktree: options.worktree,
results: runningResults,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
errorMessage: null,
}),
);
const terminal = await terminalManager.createTerminal({
cwd: options.workspaceCwd,
name: spec.name,
env: options.runtimeEnv,
workspaceId: options.workspaceId,
});
await waitForTerminalBootstrapReadiness(terminal);
terminal.send({ type: "input", data: `${spec.command}\r` });
} catch (error) {
options.logger?.warn(
{ err: error, agentId: options.agentId },
"Failed to emit live worktree setup timeline update",
{ workspaceId: options.workspaceId, command: spec.command, err: error },
"Failed to bootstrap worktree terminal",
);
}
return;
});
};
try {
runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: options.worktree.worktreePath,
branchName: options.worktree.branchName,
});
options.terminalManager?.registerCwdEnv({
cwd: workspaceCwd,
env: runtimeEnv,
});
setupResults = await runWorktreeSetupCommands({
worktreePath: workspaceCwd,
branchName: options.worktree.branchName,
cleanupOnFailure: false,
runtimeEnv,
onEvent: (event) => {
applyWorktreeSetupProgressEvent(progressAccumulator, event);
queueLiveRunningEmit();
},
});
await liveEmitQueue;
const completed = await options.appendTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "completed",
worktree: options.worktree,
results: setupResults,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
errorMessage: null,
}),
);
if (!completed) {
return;
}
} catch (error) {
if (error instanceof WorktreeSetupError) {
setupResults = error.results;
}
await liveEmitQueue;
const message = error instanceof Error ? error.message : String(error);
await options.appendTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "failed",
worktree: options.worktree,
results: setupResults,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
errorMessage: message,
}),
);
return;
}
await runWorktreeTerminalBootstrap(options, runtimeEnv);
}),
);
}
// ---------------------------------------------------------------------------
@@ -405,7 +405,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "legacy-rpc",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
@@ -431,7 +430,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "branch-off",
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
@@ -452,7 +450,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
{
cwd: repoDir,
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
@@ -475,7 +472,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
githubPrNumber: 123,
refName: "feature/review-pr",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
@@ -502,7 +498,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
githubPrNumber: 123,
refName: "feature/review-pr",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
@@ -520,7 +515,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "mcp-standalone",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
@@ -547,7 +541,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "branch-off",
refName: "dev",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
@@ -576,7 +569,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
refName: "dev",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
@@ -604,7 +596,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 123,
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
@@ -656,7 +647,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -694,7 +684,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 14,
paseoHome,
runSetup: false,
},
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
);
@@ -746,7 +735,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 14,
paseoHome,
runSetup: false,
},
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
);
@@ -796,7 +784,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 14,
paseoHome,
runSetup: false,
},
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
);
@@ -841,7 +828,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 14,
paseoHome,
runSetup: false,
},
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
);
@@ -852,7 +838,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 14,
paseoHome,
runSetup: false,
},
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
);
@@ -910,7 +895,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -955,7 +939,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -997,7 +980,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -1008,7 +990,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -1118,7 +1099,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -1129,7 +1109,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
githubPrNumber: 1790,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -1179,7 +1158,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
githubPrNumber: 526,
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -1285,7 +1263,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
githubPrNumber: 526,
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -1383,7 +1360,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
githubPrNumber: 526,
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -1395,7 +1371,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
githubPrNumber: 526,
refName: "main",
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -1417,7 +1392,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
action: "checkout",
refName: "missing-branch",
paseoHome,
runSetup: false,
},
createCoreDeps(),
),
@@ -1433,7 +1407,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "agent-worktree",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
@@ -1452,11 +1425,11 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
const deps = createCoreDeps();
const first = await createCoreWorktree(
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false },
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome },
deps,
);
const second = await createCoreWorktree(
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false },
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome },
deps,
);
@@ -1475,7 +1448,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
githubPrNumber: 123,
refName: "feature/review-pr",
paseoHome,
runSetup: false,
};
const first = await createCoreWorktree(input, deps);
@@ -1515,7 +1487,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
worktreeSlug: "stubbed-github",
githubPrNumber: 123,
paseoHome,
runSetup: false,
},
createCoreDeps({ github }),
);
@@ -28,7 +28,6 @@ export interface CreateWorktreeCoreInput {
firstAgentContext?: FirstAgentContext;
paseoHome?: string;
worktreesRoot?: string;
runSetup?: boolean;
}
export interface CreateWorktreeCoreDeps {
@@ -120,7 +119,6 @@ async function createWorktreeCoreWithPriority(
cwd: repoRoot,
worktreeSlug: normalizedSlug,
source: intent,
runSetup: input.runSetup ?? true,
paseoHome: input.paseoHome,
worktreesRoot: input.worktreesRoot,
}),
@@ -15,11 +15,9 @@ import pino, { type Logger } from "pino";
import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js";
import {
buildAgentSessionConfig,
createPaseoWorktreeWorkflow,
handlePaseoWorktreeArchiveRequest,
handlePaseoWorktreeListRequest,
resolveGitCreateBaseBranch,
runWorktreeSetupInBackground,
handleCreatePaseoWorktreeRequest,
handleWorkspaceSetupStatusRequest,
@@ -57,7 +55,7 @@ interface LegacyCreateWorktreeTestOptions {
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
}
@@ -76,7 +74,7 @@ function createLegacyWorktreeForTest(
baseBranch: options.baseBranch,
branchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
});
}
@@ -122,7 +120,6 @@ function createWorkflowForRequestTest(options: {
onScriptsChanged: null,
},
input,
{ setupContinuation: { kind: "workspace" } },
).then((result) => {
options.onSetupStarted?.({
requestCwd: input.cwd,
@@ -428,30 +425,6 @@ describe("handlePaseoWorktreeListRequest", () => {
});
});
describe("resolveGitCreateBaseBranch", () => {
test("resolves the default branch through the workspace git service", async () => {
const { tempDir, repoDir } = createGitRepo();
const cwd = path.join(repoDir, "packages", "app");
const workspaceGitService = {
resolveDefaultBranch: vi.fn().mockResolvedValue("main"),
getSnapshot: vi.fn(async () => {
throw new Error("getSnapshot should not be used for default-branch resolution");
}),
};
try {
await expect(
resolveGitCreateBaseBranch(cwd, workspaceGitService as unknown as WorkspaceGitService),
).resolves.toBe("main");
expect(workspaceGitService.resolveDefaultBranch).toHaveBeenCalledWith(cwd);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("create-agent worktree setup boundary", () => {
test("blocked worktrees keep their workspace but skip setup and automatic terminals", async () => {
const { tempDir, repoDir } = createGitRepo();
@@ -469,7 +442,7 @@ describe("create-agent worktree setup boundary", () => {
);
try {
const result = await createPaseoWorktreeWorkflow(
await createPaseoWorktreeWorkflow(
{
paseoHome,
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
@@ -494,19 +467,9 @@ describe("create-agent worktree setup boundary", () => {
getDaemonTcpHost: null,
onScriptsChanged: null,
},
{ cwd: repoDir, worktreeSlug: "blocked-fork", runSetup: false, paseoHome },
{
setupContinuation: {
kind: "agent",
terminalManager: createTerminalManagerStub().manager,
appendTimelineItem: async () => true,
logger: createLogger(),
},
},
{ cwd: repoDir, worktreeSlug: "blocked-fork", paseoHome },
);
expect(result.setupContinuation?.kind).toBe("agent");
result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-fork" });
expect(existsSync(setupMarker)).toBe(false);
expect(emitted).toContainEqual(
expect.objectContaining({
@@ -518,79 +481,6 @@ describe("create-agent worktree setup boundary", () => {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("agent setup continuation starts setup for the created agent timeline", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const appendedItems: Array<{ name: string; status: string }> = [];
const liveItems: Array<{ name: string; status: string }> = [];
const workspaceSetupEvents: SessionOutboundMessage[] = [];
try {
const result = await createPaseoWorktreeWorkflow(
{
paseoHome,
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
warmWorkspaceGitData: async () => {},
autoNameWorkspaceBranchForFirstAgent: () => {},
emitWorkspaceUpdateForWorkspaceId: async () => {},
cacheWorkspaceSetupSnapshot: () => {},
emit: (message) => workspaceSetupEvents.push(message),
sessionLogger: createLogger(),
terminalManager: null,
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
onScriptsChanged: null,
},
{
cwd: repoDir,
worktreeSlug: "agent-setup-after-create",
runSetup: false,
paseoHome,
},
{
setupContinuation: {
kind: "agent",
terminalManager: createTerminalManagerStub().manager,
appendTimelineItem: async ({ agentId, item }) => {
expect(agentId).toBe("agent-after-create");
if (item.type !== "tool_call") {
throw new Error(`Expected tool call timeline item, got ${item.type}`);
}
appendedItems.push({ name: item.name, status: item.status });
return true;
},
emitLiveTimelineItem: async ({ agentId, item }) => {
expect(agentId).toBe("agent-after-create");
if (item.type !== "tool_call") {
throw new Error(`Expected tool call timeline item, got ${item.type}`);
}
liveItems.push({ name: item.name, status: item.status });
return true;
},
logger: createLogger(),
},
},
);
expect(result.setupContinuation?.kind).toBe("agent");
expect(workspaceSetupEvents).toEqual([]);
result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-after-create" });
await vi.waitFor(() => {
expect(appendedItems).toContainEqual({
name: "paseo_worktree_setup",
status: "completed",
});
});
expect(liveItems).toEqual([]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
function createAgentStorageStub(): Pick<AgentStorage, "list"> {
@@ -698,7 +588,7 @@ describe("runWorktreeSetupInBackground", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "feature-subdirectory-setup",
runSetup: false,
paseoHome,
});
const workspaceCwd = path.join(createdWorktree.worktreePath, "packages", "app");
@@ -746,7 +636,7 @@ describe("runWorktreeSetupInBackground", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "feature-no-setup",
runSetup: false,
paseoHome,
});
const worktreePath = createdWorktree.worktreePath;
@@ -842,7 +732,7 @@ describe("runWorktreeSetupInBackground", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "broken-feature",
runSetup: false,
paseoHome,
});
const worktreePath = createdWorktree.worktreePath;
@@ -916,7 +806,7 @@ describe("runWorktreeSetupInBackground", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "feature-running-setup",
runSetup: false,
paseoHome,
});
const worktreePath = createdWorktree.worktreePath;
@@ -983,7 +873,7 @@ describe("runWorktreeSetupInBackground", () => {
const setupOutputMessage = runningMessages.find((message) =>
message.payload.detail.commands[0]?.log.includes("phase-one"),
);
expect(setupOutputMessage?.payload.detail.log).toContain("phase-one");
expect(setupOutputMessage?.payload.detail.log).toBe("");
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
index: 1,
command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"",
@@ -1001,7 +891,7 @@ describe("runWorktreeSetupInBackground", () => {
branchName: "feature-running-setup",
},
});
expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two");
expect(progressMessages.at(-1)?.payload.detail.log).toBe("");
expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({
index: 1,
command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"",
@@ -1037,7 +927,7 @@ describe("runWorktreeSetupInBackground", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "reused-worktree",
runSetup: false,
paseoHome,
});
@@ -1125,7 +1015,7 @@ describe("runWorktreeSetupInBackground", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "feature-service-failure",
runSetup: false,
paseoHome,
});
const worktreePath = createdWorktree.worktreePath;
@@ -1208,7 +1098,7 @@ describe("runWorktreeSetupInBackground", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "feature-socket-mode",
runSetup: false,
paseoHome,
});
const worktreePath = createdWorktree.worktreePath;
@@ -1508,221 +1398,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
expect(readme).toContain("review branch");
});
test("buildAgentSessionConfig checks out the GitHub PR branch for agent worktrees", async () => {
const { tempDir, repoDir } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const events: string[] = [];
const result = await buildAgentSessionConfig(
{
paseoHome: path.join(tempDir, ".paseo"),
sessionLogger: createLogger(),
workspaceGitService: {
resolveRepoRoot: vi.fn(async () => repoDir),
resolveDefaultBranch: vi.fn(async () => "main"),
} as unknown as WorkspaceGitService,
createPaseoWorktree: createPaseoWorktreeForTest({
paseoHome: path.join(tempDir, ".paseo"),
events,
}),
checkoutExistingBranch: async () => {
throw new Error("should not checkout existing branch");
},
createBranchFromBase: async () => {
throw new Error("should not create a new branch from base");
},
},
{
provider: "codex",
cwd: repoDir,
},
{
createWorktree: true,
worktreeSlug: "agent-review-pr-123",
action: "checkout",
githubPrNumber: 123,
refName: "feature/review-pr",
},
);
expect(result.sessionConfig.cwd).toContain("agent-review-pr-123");
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
const branch = execFileSync("git", ["branch", "--show-current"], {
cwd: result.sessionConfig.cwd,
stdio: "pipe",
})
.toString()
.trim();
expect(branch).toBe("feature/review-pr");
});
test("buildAgentSessionConfig uses the normalized new branch name as the worktree slug fallback", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const result = await buildAgentSessionConfig(
{
paseoHome,
sessionLogger: createLogger(),
workspaceGitService: {
resolveRepoRoot: vi.fn(async () => repoDir),
resolveDefaultBranch: vi.fn(async () => "main"),
} as unknown as WorkspaceGitService,
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
checkoutExistingBranch: async () => {
throw new Error("should not checkout existing branch");
},
createBranchFromBase: async () => {
throw new Error("should not create a branch outside the worktree service");
},
},
{
provider: "codex",
cwd: repoDir,
},
{
createWorktree: true,
createNewBranch: true,
newBranchName: "feature-x",
},
);
expect(path.basename(result.sessionConfig.cwd)).toBe("feature-x");
});
test("buildAgentSessionConfig passes prompt and attachment context into worktree creation", async () => {
const createPaseoWorktree = vi.fn(async () => ({
worktree: {
branchName: "fix-attached-pr-context",
worktreePath: "/tmp/worktrees/fix-attached-pr-context",
},
intent: {
kind: "branch-off" as const,
baseBranch: "main",
branchName: "fix-attached-pr-context",
},
workspace: {
workspaceId: "ws-fix-attached-pr-context",
projectId: "/tmp/repo",
cwd: "/tmp/worktrees/fix-attached-pr-context/packages/app",
kind: "worktree" as const,
displayName: "fix-attached-pr-context",
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
archivedAt: null,
},
repoRoot: "/tmp/repo",
created: true,
}));
const firstAgentContext = {
prompt: "Create a worktree name from this prompt",
attachments: [
{
type: "github_pr" as const,
mimeType: "application/github-pr",
number: 123,
title: "Fix worktree naming",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "fix/worktree-naming",
},
],
};
const result = await buildAgentSessionConfig(
{
sessionLogger: createLogger(),
workspaceGitService: {
resolveDefaultBranch: vi.fn(async () => "main"),
} as unknown as WorkspaceGitService,
createPaseoWorktree,
checkoutExistingBranch: async () => {
throw new Error("should not checkout existing branch");
},
createBranchFromBase: async () => {
throw new Error("should not create a branch outside the worktree service");
},
},
{
provider: "codex",
cwd: "/tmp/repo",
},
{
createWorktree: true,
action: "branch-off",
},
undefined,
firstAgentContext,
);
expect(createPaseoWorktree).toHaveBeenCalledWith(
expect.objectContaining({
firstAgentContext,
}),
expect.anything(),
);
expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context/packages/app");
});
test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => {
const invalidate = vi.fn();
const createBranchFromBase = vi.fn(async () => {});
const checkoutExistingBranch = vi.fn(async () => ({ source: "local" as const }));
const createPaseoWorktree = vi.fn(async () => {
throw new Error("should not create worktree");
});
await buildAgentSessionConfig(
{
sessionLogger: createLogger(),
createPaseoWorktree,
checkoutExistingBranch,
createBranchFromBase,
workspaceGitService: { invalidateForge: invalidate } as unknown as WorkspaceGitService,
},
{
provider: "codex",
cwd: "/tmp/repo",
},
{
createNewBranch: true,
baseBranch: "main",
newBranchName: "feature-x",
},
);
expect(createBranchFromBase).toHaveBeenCalledWith({
cwd: "/tmp/repo",
baseBranch: "main",
newBranchName: "feature-x",
});
expect(invalidate).toHaveBeenCalledWith("/tmp/repo");
invalidate.mockClear();
await buildAgentSessionConfig(
{
sessionLogger: createLogger(),
createPaseoWorktree,
checkoutExistingBranch,
createBranchFromBase,
workspaceGitService: { invalidateForge: invalidate } as unknown as WorkspaceGitService,
},
{
provider: "codex",
cwd: "/tmp/repo",
},
{
baseBranch: "release",
},
);
expect(checkoutExistingBranch).toHaveBeenCalledWith("/tmp/repo", "release");
expect(invalidate).toHaveBeenCalledWith("/tmp/repo");
});
test("createPaseoWorktreeForTest forwards the default branch resolver for branch-off intents", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
@@ -1734,7 +1409,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
cwd: repoDir,
worktreeSlug: "resolver-feature",
action: "branch-off",
runSetup: false,
paseoHome,
},
{ resolveDefaultBranch },
@@ -1996,7 +1671,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-worktree-scope",
runSetup: false,
paseoHome,
});
const sharedCwd = created.worktreePath;
@@ -2072,7 +1747,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-default-scope",
runSetup: false,
paseoHome,
});
const workspaceId = "ws-default-scope";
@@ -2146,7 +1821,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-default-scope-sibling",
runSetup: false,
paseoHome,
});
const sharedCwd = created.worktreePath;
@@ -2223,7 +1898,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-delete-flag",
runSetup: false,
paseoHome,
});
const sharedCwd = created.worktreePath;
+21 -363
View File
@@ -1,12 +1,7 @@
import type { Logger } from "pino";
import { basename } from "node:path";
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
import {
type GitSetupOptions,
type CreateAgentWorktreeTarget,
type FirstAgentContext,
type ChangeRequestCheckoutSource,
type SessionInboundMessage,
type SessionOutboundMessage,
type WorkspaceSetupSnapshot,
@@ -15,7 +10,6 @@ import {
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
runAsyncWorktreeBootstrap,
runWorktreeAutoTerminals,
applyWorktreeSetupProgressEvent,
buildWorktreeSetupDetail,
@@ -25,14 +19,10 @@ import {
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
getWorktreeSetupCommands,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
slugify,
validateBranchSlug,
type WorktreeConfig,
type WorktreeSetupCommandResult,
WorktreeSetupError,
@@ -57,49 +47,7 @@ import {
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
export interface NormalizedGitOptions {
baseBranch?: string;
createNewBranch: boolean;
newBranchName?: string;
createWorktree: boolean;
worktreeSlug?: string;
requestedWorktreeSlug?: string;
refName?: string;
action?: "branch-off" | "checkout";
checkoutSource?: ChangeRequestCheckoutSource;
githubPrNumber?: number;
}
type EmitSessionMessage = (message: SessionOutboundMessage) => void;
type AgentWorktreeSetupTimelineItem = Parameters<
typeof runAsyncWorktreeBootstrap
>[0]["appendTimelineItem"] extends (item: infer Item) => unknown
? Item
: never;
type AgentWorktreeSetupTimelineWriter = (input: {
agentId: string;
item: AgentWorktreeSetupTimelineItem;
}) => Promise<boolean>;
interface BuildAgentSessionConfigDependencies {
paseoHome?: string;
worktreesRoot?: string;
sessionLogger: Logger;
workspaceGitService?: WorkspaceGitService;
createPaseoWorktree: (
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
},
) => Promise<CreatePaseoWorktreeWorkflowResult>;
checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>;
createBranchFromBase: (params: {
cwd: string;
baseBranch: string;
newBranchName: string;
}) => Promise<void>;
}
interface CreatePaseoWorktreeInBackgroundDependencies {
paseoHome?: string;
@@ -133,32 +81,12 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB
assertWorkspaceAutomationAllowed?: (workspaceId: string) => Promise<void>;
}
interface AgentWorktreeSetupContinuationInput {
kind: "agent";
terminalManager: TerminalManager | null;
appendTimelineItem: AgentWorktreeSetupTimelineWriter;
emitLiveTimelineItem: AgentWorktreeSetupTimelineWriter;
logger: Logger;
}
export type CreatePaseoWorktreeSetupContinuationInput =
| { kind: "workspace" }
| AgentWorktreeSetupContinuationInput;
export interface AgentWorktreeSetupContinuation {
kind: "agent";
startAfterAgentCreate: (input: { agentId: string }) => void;
}
export type CreatePaseoWorktreeWorkflowResult = CreatePaseoWorktreeResult & {
setupContinuation?: AgentWorktreeSetupContinuation;
};
export type CreatePaseoWorktreeWorkflowResult = CreatePaseoWorktreeResult;
export type CreatePaseoWorktreeWorkflowFn = (
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
},
) => Promise<CreatePaseoWorktreeWorkflowResult>;
@@ -204,245 +132,12 @@ function normalizeFirstAgentContext(
return undefined;
}
export async function buildAgentSessionConfig(
dependencies: BuildAgentSessionConfigDependencies,
config: AgentSessionConfig,
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
firstAgentContext?: FirstAgentContext,
): Promise<{
sessionConfig: AgentSessionConfig;
setupContinuation?: AgentWorktreeSetupContinuation;
createdWorkspaceId?: string;
}> {
let cwd = expandTilde(config.cwd);
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
let createdWorkspaceId: string | undefined;
if (!normalized) {
return {
sessionConfig: {
...config,
cwd,
},
};
}
if (normalized.createWorktree) {
dependencies.sessionLogger.info(
{ worktreeSlug: normalized.requestedWorktreeSlug },
"Creating worktree through createWorktreeCore",
);
const createdWorktree = await dependencies.createPaseoWorktree(
{
cwd,
worktreeSlug: normalized.worktreeSlug,
refName: normalized.refName,
action: normalized.action,
checkoutSource: normalized.checkoutSource,
githubPrNumber: normalized.githubPrNumber,
firstAgentContext,
runSetup: false,
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
},
{
resolveDefaultBranch: normalized.baseBranch
? async () => normalized.baseBranch!
: (repoRoot) =>
resolveGitCreateBaseBranch(
repoRoot,
dependencies.workspaceGitService,
dependencies.paseoHome,
),
},
);
cwd = createdWorktree.workspace.cwd;
setupContinuation = createdWorktree.setupContinuation;
createdWorkspaceId = createdWorktree.workspace.workspaceId;
} else if (normalized.createNewBranch) {
const baseBranch =
normalized.baseBranch ??
(await resolveGitCreateBaseBranch(
cwd,
dependencies.workspaceGitService,
dependencies.paseoHome,
));
await dependencies.createBranchFromBase({
cwd,
baseBranch,
newBranchName: normalized.newBranchName!,
});
dependencies.workspaceGitService?.invalidateForge(cwd);
} else if (normalized.baseBranch) {
await dependencies.checkoutExistingBranch(cwd, normalized.baseBranch);
dependencies.workspaceGitService?.invalidateForge(cwd);
}
return {
sessionConfig: {
...config,
cwd,
},
setupContinuation,
createdWorkspaceId,
};
}
interface ValidateNormalizedGitOptionsInput {
baseBranch: string | undefined;
createNewBranch: boolean;
normalizedBranchName: string | undefined;
normalizedWorktreeSlug: string | undefined;
}
function validateNormalizedGitOptions(input: ValidateNormalizedGitOptionsInput): void {
if (input.baseBranch) {
assertSafeGitRef(input.baseBranch, "base branch");
}
if (input.createNewBranch) {
if (!input.normalizedBranchName) {
throw new Error("New branch name is required");
}
const validation = validateBranchSlug(input.normalizedBranchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
}
if (input.normalizedWorktreeSlug) {
const validation = validateBranchSlug(input.normalizedWorktreeSlug);
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`);
}
}
}
export function normalizeAgentCreationGitOptions(
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
worktree?: CreateAgentWorktreeTarget,
): NormalizedGitOptions | null {
if (worktree && gitOptions) {
throw new Error("create_agent_request worktree cannot be combined with git options");
}
if (worktree) {
switch (worktree.mode) {
case "branch-off":
gitOptions = {
createWorktree: true,
createNewBranch: true,
newBranchName: worktree.newBranch,
baseBranch: worktree.base,
action: "branch-off",
refName: worktree.base,
};
break;
case "checkout-branch":
gitOptions = { createWorktree: true, action: "checkout", refName: worktree.branch };
break;
case "checkout-pr":
gitOptions = {
createWorktree: true,
action: "checkout",
githubPrNumber: worktree.prNumber,
};
break;
}
}
return normalizeGitOptions(gitOptions, legacyWorktreeName);
}
function normalizeGitOptions(
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
): NormalizedGitOptions | null {
const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName
? {
createWorktree: true,
createNewBranch: true,
newBranchName: legacyWorktreeName,
worktreeSlug: legacyWorktreeName,
}
: undefined;
const merged = gitOptions ?? fallbackOptions;
if (!merged) {
return null;
}
const baseBranch = merged.baseBranch?.trim() || undefined;
const createWorktree = Boolean(merged.createWorktree);
const createNewBranch = Boolean(merged.createNewBranch);
const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined;
const requestedWorktreeSlug = merged.worktreeSlug ? slugify(merged.worktreeSlug) : undefined;
const normalizedWorktreeSlug = requestedWorktreeSlug ?? normalizedBranchName;
const refName = merged.refName?.trim() || undefined;
const action = merged.action;
// COMPAT(githubPrNumber): legacy GitHub checkout input retained when
// checkoutSource shipped in v0.2.0-beta.1. Remove after 2027-01-17 once the
// supported client floor is >= v0.2.0.
const checkoutSource =
merged.checkoutSource ??
(merged.githubPrNumber
? ({ kind: "change_request", forge: "github", number: merged.githubPrNumber } as const)
: undefined);
const githubPrNumber = merged.githubPrNumber;
if (
!createWorktree &&
!createNewBranch &&
!baseBranch &&
!refName &&
!action &&
!checkoutSource &&
!githubPrNumber
) {
return null;
}
validateNormalizedGitOptions({
baseBranch,
createNewBranch,
normalizedBranchName,
normalizedWorktreeSlug,
});
return {
baseBranch,
createNewBranch,
newBranchName: normalizedBranchName,
createWorktree,
worktreeSlug: normalizedWorktreeSlug,
requestedWorktreeSlug,
refName,
action,
checkoutSource,
githubPrNumber,
};
}
export function assertSafeGitRef(ref: string, label: string): void {
if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) {
throw new Error(`Invalid ${label}: ${ref}`);
}
}
export async function resolveGitCreateBaseBranch(
cwd: string,
workspaceGitService?: WorkspaceGitService,
_paseoHome?: string,
): Promise<string> {
if (!workspaceGitService) {
throw new Error("WorkspaceGitService is required to resolve the repository root");
}
return workspaceGitService.resolveDefaultBranch(cwd);
}
export async function handlePaseoWorktreeListRequest(
dependencies: {
emit: EmitSessionMessage;
@@ -648,13 +343,12 @@ export async function createPaseoWorktreeWorkflow(
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
},
): Promise<CreatePaseoWorktreeWorkflowResult> {
const createdWorktree = await dependencies.createPaseoWorktree(
{
...input,
runSetup: false,
paseoHome: input.paseoHome ?? dependencies.paseoHome,
worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot,
},
@@ -664,7 +358,6 @@ export async function createPaseoWorktreeWorkflow(
);
const slug = basename(createdWorktree.worktree.worktreePath);
const workspace = createdWorktree.workspace;
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
try {
await dependencies.assertWorkspaceAutomationAllowed?.(workspace.workspaceId);
@@ -681,15 +374,6 @@ export async function createPaseoWorktreeWorkflow(
type: "workspace_setup_progress",
payload: { workspaceId: workspace.workspaceId, ...snapshot },
});
if (setupContinuation.kind === "agent") {
return {
...createdWorktree,
setupContinuation: {
kind: "agent",
startAfterAgentCreate: () => undefined,
},
};
}
return createdWorktree;
}
@@ -706,53 +390,28 @@ export async function createPaseoWorktreeWorkflow(
"Failed to warm workspace git data after creating worktree",
);
});
if (setupContinuation.kind === "workspace") {
const runSetup = (signal: AbortSignal) =>
runWorktreeSetupInBackground(
dependencies,
{
requestCwd: input.cwd,
repoRoot: createdWorktree.repoRoot,
workspaceId: workspace.workspaceId,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.created,
slug,
worktreePath: createdWorktree.worktree.worktreePath,
workspaceCwd: workspace.cwd,
},
signal,
);
if (dependencies.startWorkspaceSetup) {
dependencies.startWorkspaceSetup(workspace.workspaceId, runSetup);
} else {
void runSetup(new AbortController().signal);
}
const runSetup = (signal: AbortSignal) =>
runWorktreeSetupInBackground(
dependencies,
{
requestCwd: input.cwd,
repoRoot: createdWorktree.repoRoot,
workspaceId: workspace.workspaceId,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.created,
slug,
worktreePath: createdWorktree.worktree.worktreePath,
workspaceCwd: workspace.cwd,
},
signal,
);
if (dependencies.startWorkspaceSetup) {
dependencies.startWorkspaceSetup(workspace.workspaceId, runSetup);
} else {
void runSetup(new AbortController().signal);
}
}, 0);
if (setupContinuation.kind === "agent") {
return {
...createdWorktree,
setupContinuation: {
kind: "agent",
startAfterAgentCreate: ({ agentId }) => {
void runAsyncWorktreeBootstrap({
agentId,
workspaceId: workspace.workspaceId,
worktree: createdWorktree.worktree,
workspaceCwd: workspace.cwd,
shouldBootstrap: createdWorktree.created,
terminalManager: setupContinuation.terminalManager,
appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }),
emitLiveTimelineItem: (item) =>
setupContinuation.emitLiveTimelineItem({ agentId, item }),
logger: setupContinuation.logger,
});
},
},
};
}
return createdWorktree;
}
@@ -911,7 +570,6 @@ export async function runWorktreeSetupInBackground(
setupResults = await runWorktreeSetupCommands({
worktreePath: workspaceCwd,
branchName: worktree.branchName,
cleanupOnFailure: false,
repoRootPath: options.repoRoot,
runtimeEnv,
signal,
@@ -45,10 +45,7 @@ export interface CreatePaseoWorktreeCommandDependencies<
createPaseoWorktreeWorkflow?: CreatePaseoWorktreeWorkflow<Result>;
}
export type CreatePaseoWorktreeCommandInput = Omit<
CreatePaseoWorktreeInput,
"paseoHome" | "runSetup"
> & {
export type CreatePaseoWorktreeCommandInput = Omit<CreatePaseoWorktreeInput, "paseoHome"> & {
paseoHome?: string;
worktreesRoot?: string;
};
@@ -75,7 +72,7 @@ export async function createPaseoWorktreeCommand<Result extends CreatePaseoWorkt
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
...input,
runSetup: false,
paseoHome: input.paseoHome ?? dependencies.paseoHome,
worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot,
});
@@ -67,7 +67,7 @@ interface LegacyCreateWorktreeTestOptions {
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
}
@@ -86,7 +86,7 @@ function createLegacyWorktreeForTest(
baseBranch: options.baseBranch,
branchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
});
}
@@ -144,7 +144,6 @@ describe("worktree shell selection", () => {
await runWorktreeSetupCommands({
worktreePath,
branchName: "main",
cleanupOnFailure: false,
runtimeEnv: {
PASEO_SOURCE_CHECKOUT_PATH: worktreePath,
PASEO_ROOT_PATH: worktreePath,
@@ -58,7 +58,7 @@ interface LegacyCreateWorktreeTestOptions {
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
worktreesRoot?: string;
}
@@ -78,7 +78,7 @@ function createLegacyWorktreeForTest(
baseBranch: options.baseBranch,
branchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
});
@@ -239,7 +239,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "my-feature",
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" },
runSetup: true,
paseoHome,
});
@@ -282,7 +282,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "existing-feature",
source: { kind: "checkout-branch", branchName: "feature/existing" },
runSetup: false,
paseoHome,
});
const status = await getCheckoutStatus(result.worktreePath, { paseoHome });
@@ -304,7 +304,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "dev-worktree",
source: { kind: "checkout-branch", branchName: "dev" },
runSetup: true,
paseoHome,
});
@@ -346,7 +346,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "stale-default",
source: { kind: "checkout-branch", branchName: "feature/stale-default" },
runSetup: false,
paseoHome,
});
const diff = await getCheckoutDiff(created.worktreePath, { mode: "base" }, { paseoHome });
@@ -365,7 +365,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "feature-x-worktree",
source: { kind: "checkout-branch", branchName: "feature-x" },
runSetup: true,
paseoHome,
});
@@ -382,7 +382,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "release-worktree",
source: { kind: "checkout-branch", branchName: "release/1.1.15" },
runSetup: true,
paseoHome,
});
@@ -400,7 +400,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "dev-worktree",
source: { kind: "checkout-branch", branchName: "main" },
runSetup: true,
paseoHome,
});
@@ -412,7 +412,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
});
});
it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => {
it("fetches a GitHub PR branch and writes metadata without executing setup", async () => {
const remoteDir = join(tempDir, "remote.git");
const remoteCloneDir = join(tempDir, "remote-clone");
execFileSync("git", ["clone", "--bare", repoDir, remoteDir]);
@@ -446,12 +446,12 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
headRef: "user/feature",
baseRefName: "main",
},
runSetup: true,
paseoHome,
});
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n");
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe("setup ran\n");
expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false);
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
cwd: result.worktreePath,
})
@@ -494,7 +494,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
headRef: "Feature.X",
baseRefName: "main",
},
runSetup: true,
paseoHome,
});
@@ -540,7 +540,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
baseBranch: "refs/heads/main",
worktreeSlug: "prefer-local-feature",
runSetup: false,
paseoHome,
});
const originResult = await createLegacyWorktreeForTest({
@@ -548,7 +548,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
baseBranch: "refs/remotes/origin/main",
worktreeSlug: "prefer-origin-feature",
runSetup: false,
paseoHome,
});
@@ -598,7 +598,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
baseBranch: "refs/remotes/upstream/main",
worktreeSlug: "fork-base-feature",
runSetup: false,
paseoHome,
});
@@ -689,7 +689,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
baseBranch: "refs/remotes/upstream/release+hotfix",
worktreeSlug: "release-hotfix-feature",
runSetup: false,
paseoHome,
});
@@ -717,7 +717,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "prefer-local-fallback-feature",
runSetup: false,
paseoHome,
});
@@ -731,7 +731,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
baseBranch: "does-not-exist",
worktreeSlug: "missing-base-feature",
runSetup: false,
paseoHome,
}),
).rejects.toThrow("Base branch not found: does-not-exist");
@@ -755,7 +755,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "invalid-existing-branch",
source: { kind: "checkout-branch", branchName: "bad..name" },
runSetup: true,
paseoHome,
});
} catch (error) {
@@ -773,7 +773,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "invalid-option-like-branch",
source: { kind: "checkout-branch", branchName: "-bad" },
runSetup: true,
paseoHome,
});
} catch (error) {
@@ -853,6 +853,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
expect(existsSync(result.worktreePath)).toBe(true);
await runWorktreeSetupCommands({
worktreePath: result.worktreePath,
branchName: result.branchName,
});
// Verify setup ran and env vars were available
const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8");
expect(setupLog).toContain(`source=${repoDir}`);
@@ -886,6 +890,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
paseoHome,
});
await runWorktreeSetupCommands({
worktreePath: result.worktreePath,
branchName: result.branchName,
});
expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([
'greeting="hello from string setup"\necho "$greeting" > setup.log',
]);
@@ -926,7 +934,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
await runWorktreeSetupCommands({
worktreePath: repoDir,
branchName: "main",
cleanupOnFailure: false,
runtimeEnv: {
PASEO_SOURCE_CHECKOUT_PATH: repoDir,
PASEO_ROOT_PATH: repoDir,
@@ -1002,7 +1009,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
]);
});
it("does not run setup commands when runSetup=false", async () => {
it("only provisions the worktree without executing setup", async () => {
const paseoConfig = {
worktree: {
setup: ['echo "setup ran" > setup.log'],
@@ -1019,7 +1026,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "no-setup-test",
runSetup: false,
paseoHome,
});
@@ -1043,7 +1050,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
const results = await runWorktreeSetupCommands({
worktreePath: repoDir,
branchName: "main",
cleanupOnFailure: false,
onEvent: (event) => {
progressEvents.push(event);
},
@@ -1061,7 +1067,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "runtime-env-port-reuse",
runSetup: false,
paseoHome,
});
@@ -1083,7 +1089,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "runtime-env-port-conflict",
runSetup: false,
paseoHome,
});
@@ -1117,35 +1123,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
});
});
it("cleans up worktree if setup command fails", async () => {
// Create paseo.json with failing setup command
const paseoConfig = {
worktree: {
setup: ["exit 1"],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], {
cwd: repoDir,
});
const expectedWorktreePath = join(paseoHome, "worktrees", "test-repo", "fail-test");
await expect(
createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "fail-test",
paseoHome,
}),
).rejects.toThrow("Worktree setup command failed");
// Verify worktree was cleaned up
expect(existsSync(expectedWorktreePath)).toBe(false);
});
it("reads worktree terminal specs from paseo.json with optional name", async () => {
const paseoConfig = {
worktree: {
@@ -1278,7 +1255,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "seed-uncommitted",
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" },
runSetup: false,
paseoHome,
});
@@ -1324,7 +1301,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
baseBranch: "origin/main",
branchName: "feature/upstream-config",
},
runSetup: false,
paseoHome,
});
@@ -1364,7 +1341,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
baseBranch: "symlink-config",
branchName: "feature/symlink-config",
},
runSetup: false,
paseoHome,
});
@@ -1385,7 +1362,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: repoDir,
worktreeSlug: "no-config",
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/no-config" },
runSetup: false,
paseoHome,
});
+3 -3
View File
@@ -31,7 +31,7 @@ interface LegacyCreateWorktreeTestOptions {
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
}
@@ -50,7 +50,7 @@ function createLegacyWorktreeForTest(
baseBranch: options.baseBranch,
branchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
});
}
@@ -132,7 +132,7 @@ describe("paseo worktree manager", () => {
cwd: repoDir,
paseoHome,
worktreeSlug: "fork-review",
runSetup: false,
source: {
kind: "checkout-change-request",
forge: "gitea",
+1 -29
View File
@@ -1,13 +1,6 @@
import { execFile } from "child_process";
import { promisify } from "util";
import {
constants as fsConstants,
existsSync,
mkdirSync,
realpathSync,
rmSync,
statSync,
} from "fs";
import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs";
import { copyFile, rm, stat } from "fs/promises";
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
import net from "node:net";
@@ -217,7 +210,6 @@ export interface CreateWorktreeOptions {
cwd: string;
worktreeSlug: string;
source: WorktreeSource;
runSetup: boolean;
paseoHome?: string;
worktreesRoot?: string;
}
@@ -643,7 +635,6 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
export async function runWorktreeSetupCommands(options: {
worktreePath: string;
branchName: string;
cleanupOnFailure: boolean;
repoRootPath?: string;
runtimeEnv?: WorktreeRuntimeEnv;
signal?: AbortSignal;
@@ -683,16 +674,6 @@ export async function runWorktreeSetupCommands(options: {
results.push(result);
if (result.exitCode !== 0) {
if (options.cleanupOnFailure) {
try {
await runGitCommand(["worktree", "remove", options.worktreePath, "--force"], {
cwd: options.worktreePath,
timeout: 120_000,
});
} catch {
rmSync(options.worktreePath, { recursive: true, force: true });
}
}
throw new WorktreeSetupError(
`Worktree setup command failed: ${cmd}\n${result.stderr}`.trim(),
results,
@@ -1221,7 +1202,6 @@ export const createWorktree = async ({
cwd,
source,
worktreeSlug,
runSetup,
paseoHome,
worktreesRoot,
}: CreateWorktreeOptions): Promise<CreatedWorktree> => {
@@ -1269,14 +1249,6 @@ export const createWorktree = async ({
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
if (runSetup) {
await runWorktreeSetupCommands({
worktreePath,
branchName: sourcePlan.branchName,
cleanupOnFailure: true,
});
}
return {
branchName: sourcePlan.branchName,
worktreePath,