refactor: unify worktree creation workflow (#636)

Route app, MCP, and agent worktree creation through one workflow boundary. Move branch auto-naming into workspace creation and keep agent metadata title-only.
This commit is contained in:
Mohamed Boudra
2026-04-30 21:37:17 +08:00
committed by GitHub
parent 93c6c3e5f8
commit c2ad4a02ef
15 changed files with 1328 additions and 569 deletions

View File

@@ -243,7 +243,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
export interface CreatePaseoWorktreeInput extends Pick<
CreatePaseoWorktreeRequest,
"cwd" | "worktreeSlug" | "attachments" | "refName" | "action" | "githubPrNumber"
"cwd" | "worktreeSlug" | "nameContext" | "attachments" | "refName" | "action" | "githubPrNumber"
> {}
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
@@ -2757,6 +2757,7 @@ export class DaemonClient {
type: "create_paseo_worktree_request",
cwd: input.cwd,
worktreeSlug: input.worktreeSlug,
...(input.nameContext !== undefined ? { nameContext: input.nameContext } : {}),
...(input.attachments && input.attachments.length > 0
? { attachments: input.attachments }
: {}),

View File

@@ -1,4 +1,3 @@
import { basename } from "path";
import { z } from "zod";
import type { Logger } from "pino";
@@ -9,15 +8,10 @@ import {
StructuredAgentResponseError,
generateStructuredAgentResponseWithFallback,
} from "./agent-response-loop.js";
import { validateBranchSlug } from "../../utils/worktree.js";
import { renameCurrentBranch } from "../../utils/checkout-git.js";
import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
export interface AgentMetadataGeneratorDeps {
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
renameCurrentBranch?: typeof renameCurrentBranch;
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
}
export interface AgentMetadataGenerationOptions {
@@ -34,7 +28,6 @@ export interface AgentMetadataGenerationOptions {
interface AgentMetadataNeeds {
prompt: string | null;
needsTitle: boolean;
needsBranch: boolean;
}
function hasExplicitTitle(title?: string | null): boolean {
@@ -49,33 +42,6 @@ function normalizeAutoTitle(title: string): string | null {
return normalized.slice(0, MAX_AUTO_AGENT_TITLE_CHARS).trim() || null;
}
async function canRenameBranch(
cwd: string,
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot"> | undefined,
): Promise<boolean> {
if (!workspaceGitService) {
return false;
}
let snapshot: WorkspaceGitRuntimeSnapshot;
try {
snapshot = await workspaceGitService.getSnapshot(cwd);
} catch {
return false;
}
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree) {
return false;
}
if (!snapshot.git.currentBranch || !snapshot.git.repoRoot) {
return false;
}
const worktreeDirName = basename(snapshot.git.repoRoot);
return snapshot.git.currentBranch === worktreeDirName;
}
export async function determineAgentMetadataNeeds(
options: Pick<
AgentMetadataGenerationOptions,
@@ -84,23 +50,21 @@ export async function determineAgentMetadataNeeds(
): Promise<AgentMetadataNeeds> {
const prompt = options.initialPrompt?.trim();
if (!prompt) {
return { prompt: null, needsTitle: false, needsBranch: false };
return { prompt: null, needsTitle: false };
}
const needsTitle = !hasExplicitTitle(options.explicitTitle);
const needsBranch = await canRenameBranch(options.cwd, options.deps?.workspaceGitService);
return {
prompt,
needsTitle,
needsBranch,
};
}
function buildMetadataSchema(
needs: AgentMetadataNeeds,
): z.ZodObject<Record<string, z.ZodTypeAny>> | null {
if (!needs.needsTitle && !needs.needsBranch) {
if (!needs.needsTitle) {
return null;
}
@@ -108,33 +72,16 @@ function buildMetadataSchema(
if (needs.needsTitle) {
shape.title = z.string().min(1).max(MAX_AUTO_AGENT_TITLE_CHARS);
}
if (needs.needsBranch) {
shape.branch = z.string().min(1).max(100);
}
return z.object(shape);
}
function buildPrompt(needs: AgentMetadataNeeds): string {
const fields = [needs.needsTitle ? "title" : null, needs.needsBranch ? "branch" : null].filter(
Boolean,
) as string[];
const instructions: string[] = ["Generate metadata for a coding agent based on the user prompt."];
if (needs.needsTitle) {
instructions.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`);
}
if (needs.needsBranch) {
instructions.push(
"Branch: lowercase slug using letters, numbers, hyphens, and slashes only; no spaces, no uppercase, no leading/trailing hyphen, no consecutive hyphens.",
);
}
if (fields.length === 1) {
instructions.push(`Return JSON only with a single field '${fields[0]}'.`);
} else {
instructions.push(`Return JSON only with fields '${fields.join("' and '")}'.`);
}
instructions.push("Return JSON only with a single field 'title'.");
instructions.push("", "User prompt:", needs.prompt ?? "");
return instructions.join("\n");
@@ -156,9 +103,8 @@ export async function generateAndApplyAgentMetadata(
const generator =
options.deps?.generateStructuredAgentResponseWithFallback ??
generateStructuredAgentResponseWithFallback;
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
let result: { title?: string; branch?: string };
let result: { title?: string };
try {
result = await generator({
@@ -198,78 +144,6 @@ export async function generateAndApplyAgentMetadata(
await options.agentManager.setTitle(options.agentId, normalizedTitle);
}
}
if (needs.needsBranch && typeof result.branch === "string") {
await applyGeneratedBranchRename({
options,
branch: result.branch,
renameCurrentBranchImpl,
});
}
}
async function applyGeneratedBranchRename(params: {
options: AgentMetadataGenerationOptions;
branch: string;
renameCurrentBranchImpl: typeof renameCurrentBranch;
}): Promise<void> {
const { options, branch, renameCurrentBranchImpl } = params;
const normalizedBranch = branch.trim();
const validation = validateBranchSlug(normalizedBranch);
if (!validation.valid) {
options.logger.warn(
{ agentId: options.agentId, branch: normalizedBranch, error: validation.error },
"Generated branch name is invalid",
);
return;
}
const workspaceGitService = options.deps?.workspaceGitService;
if (!workspaceGitService) {
return;
}
let snapshot: WorkspaceGitRuntimeSnapshot;
try {
snapshot = await workspaceGitService.getSnapshot(options.cwd);
} catch (error) {
options.logger.warn(
{ err: error, agentId: options.agentId },
"Failed to re-check branch eligibility",
);
return;
}
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree || !snapshot.git.currentBranch) {
return;
}
const worktreeDirName = snapshot.git.repoRoot ? basename(snapshot.git.repoRoot) : null;
if (snapshot.git.currentBranch !== worktreeDirName) {
return;
}
try {
await renameCurrentBranchImpl(options.cwd, normalizedBranch);
try {
await workspaceGitService.getSnapshot(options.cwd, {
force: true,
reason: "rename-branch",
});
} catch (error) {
options.logger.warn(
{ err: error, agentId: options.agentId, cwd: options.cwd },
"Failed to force-refresh workspace git snapshot after branch rename",
);
}
options.agentManager.notifyAgentState(options.agentId);
await options.agentManager.flush();
} catch (error) {
options.logger.warn(
{ err: error, agentId: options.agentId, branch: normalizedBranch },
"Failed to rename branch",
);
}
}
export function scheduleAgentMetadataGeneration(options: AgentMetadataGenerationOptions): void {

View File

@@ -7,34 +7,9 @@ import {
type AgentMetadataGeneratorDeps,
} from "./agent-metadata-generator.js";
import type { AgentManager } from "./agent-manager.js";
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
const logger = createTestLogger();
const ELIGIBLE_WORKTREE_SNAPSHOT: WorkspaceGitRuntimeSnapshot = {
cwd: "/tmp/repo/metadata-worktree",
git: {
isGit: true,
repoRoot: "/tmp/repo/metadata-worktree",
mainRepoRoot: "/tmp/repo",
currentBranch: "metadata-worktree",
remoteUrl: null,
isPaseoOwnedWorktree: true,
isDirty: false,
baseRef: "main",
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
};
function createDeps(
generateStructuredAgentResponseWithFallback: NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
@@ -89,134 +64,30 @@ describe("agent metadata generator auto-title", () => {
expect(setTitle).not.toHaveBeenCalled();
});
it("notifies agent state after successfully renaming a generated branch", async () => {
it("generates titles independently from workspace branch naming", async () => {
const setTitle = vi.fn().mockResolvedValue(undefined);
const notifyAgentState = vi.fn();
const flush = vi.fn().mockResolvedValue(undefined);
const manager = {
setTitle,
notifyAgentState,
flush,
} as unknown as AgentManager;
const renameCurrentBranch = vi.fn().mockResolvedValue({
previousBranch: "metadata-worktree",
currentBranch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
const generateStructured = vi.fn().mockResolvedValue({
branch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
};
const manager = { setTitle } as unknown as AgentManager;
const generateStructured = vi
.fn()
.mockResolvedValue({ title: "Generated title" }) as NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
>;
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: "agent-branch",
agentId: "agent-suppressed-branch",
cwd: "/tmp/repo/metadata-worktree",
initialPrompt: "Rename this worktree branch.",
explicitTitle: "Keep explicit title",
paseoHome: "/tmp/paseo-home",
initialPrompt: "Implement this feature",
explicitTitle: null,
logger,
deps: {
generateStructuredAgentResponseWithFallback: generateStructured,
renameCurrentBranch,
workspaceGitService,
},
});
expect(renameCurrentBranch).toHaveBeenCalledWith(
"/tmp/repo/metadata-worktree",
"feature/metadata-worktree",
);
expect(notifyAgentState).toHaveBeenCalledWith("agent-branch");
expect(setTitle).not.toHaveBeenCalled();
});
it("forces a workspace git snapshot refresh after renaming a generated branch", async () => {
const manager = {
setTitle: vi.fn().mockResolvedValue(undefined),
notifyAgentState: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentManager;
const renameCurrentBranch = vi.fn().mockResolvedValue({
previousBranch: "metadata-worktree",
currentBranch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
const generateStructured = vi.fn().mockResolvedValue({
branch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
};
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: "agent-branch-refresh",
cwd: "/tmp/repo/metadata-worktree",
initialPrompt: "Rename this worktree branch.",
explicitTitle: "Keep explicit title",
logger,
deps: {
generateStructuredAgentResponseWithFallback: generateStructured,
renameCurrentBranch,
workspaceGitService: workspaceGitService as unknown as Pick<
import("../workspace-git-service.js").WorkspaceGitService,
"getSnapshot"
>,
},
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", {
force: true,
reason: "rename-branch",
});
});
it("uses the workspace git service snapshot for branch rename eligibility checks", async () => {
const setTitle = vi.fn().mockResolvedValue(undefined);
const notifyAgentState = vi.fn();
const manager = {
setTitle,
notifyAgentState,
flush: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentManager;
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
};
const renameCurrentBranch = vi.fn().mockResolvedValue({
previousBranch: "metadata-worktree",
currentBranch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
const generateStructured = vi.fn().mockResolvedValue({
branch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: "agent-service-branch",
cwd: "/tmp/repo/metadata-worktree",
initialPrompt: "Rename this worktree branch.",
explicitTitle: "Keep explicit title",
logger,
deps: {
generateStructuredAgentResponseWithFallback: generateStructured,
renameCurrentBranch,
workspaceGitService: workspaceGitService as unknown as Pick<
import("../workspace-git-service.js").WorkspaceGitService,
"getSnapshot"
>,
},
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(3);
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree");
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", {
force: true,
reason: "rename-branch",
});
expect(renameCurrentBranch).toHaveBeenCalledWith(
"/tmp/repo/metadata-worktree",
"feature/metadata-worktree",
expect(generateStructured).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/tmp/repo/metadata-worktree" }),
);
expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title");
});
});

View File

@@ -18,8 +18,9 @@ import type { AgentProvider } from "./agent-sdk-types.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import {
createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeFn,
type CreatePaseoWorktreeInput,
} from "../paseo-worktree-service.js";
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
import { createWorktreeCoreDeps } from "../worktree-core.js";
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import type { GitHubService } from "../../services/github-service.js";
@@ -292,7 +293,9 @@ function createPaseoWorktreeForMcpTest(options: {
paseoHome: string;
broadcasts: string[];
createdWorkspaceIds?: string[];
}): CreatePaseoWorktreeFn {
setupContinuations?: Array<"workspace" | "agent" | undefined>;
startedAgentSetupIds?: string[];
}): CreatePaseoWorktreeWorkflowFn {
const projects = new Map<string, PersistedProjectRecord>();
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const github = createGitHubServiceStub();
@@ -303,6 +306,7 @@ function createPaseoWorktreeForMcpTest(options: {
});
return async (input, serviceOptions) => {
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
const coreDeps = createWorktreeCoreDeps(github);
const result = await createPaseoWorktreeService(input, {
...coreDeps,
@@ -326,6 +330,17 @@ 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;
};
}
@@ -478,13 +493,26 @@ describe("create_agent MCP tool", () => {
expect(parsed.success).toBe(true);
});
it("accepts optional name context in create_worktree input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_worktree");
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
nameContext: "Fix workspace creation naming",
});
expect(parsed.success).toBe(true);
});
it("rejects create_worktree without a branch name or checkout intent", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_worktree");
await expect(tool.callback({})).rejects.toThrow(
"create_worktree requires branchName, refName, or githubPrNumber",
"create_worktree requires branchName, nameContext, refName, or githubPrNumber",
);
});
@@ -608,6 +636,8 @@ describe("create_agent MCP tool", () => {
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
const startedAgentSetupIds: string[] = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
@@ -636,6 +666,8 @@ describe("create_agent MCP tool", () => {
paseoHome,
broadcasts,
createdWorkspaceIds,
setupContinuations,
startedAgentSetupIds,
}),
logger,
});
@@ -653,6 +685,8 @@ describe("create_agent MCP tool", () => {
expect(broadcasts).toHaveLength(1);
expect(createdWorkspaceIds).toHaveLength(1);
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
expect(setupContinuations).toEqual(["agent"]);
expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: expect.stringContaining("agent-worktree"),
@@ -665,12 +699,243 @@ describe("create_agent MCP tool", () => {
}
});
it("auto-names a create_agent branch-off worktree from the initial prompt without 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 {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("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,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
cwd: repoDir,
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Fix workspace creation naming",
action: "branch-off",
baseBranch: "main",
background: true,
});
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
expect(
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
).toBe("fix-workspace-creation-naming");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not auto-rename 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 workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
}),
};
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
execSync("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,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
cwd: repoDir,
title: "Checkout agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this checkout from the prompt",
action: "checkout",
refName: "existing-feature",
background: true,
});
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
expect(
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
).toBe("existing-feature");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
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: "/tmp/worktrees/pr-123",
projectId: "/tmp/repo",
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: "/tmp/repo",
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,
createPaseoWorktree,
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
cwd: "/tmp/repo",
title: "PR agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this PR branch from prompt",
githubPrNumber: 123,
background: true,
});
expect(createPaseoWorktree).toHaveBeenCalledWith(
expect.objectContaining({
githubPrNumber: 123,
nameContext: "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,
undefined,
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
});
it("registers and broadcasts a workspace when create_worktree creates a worktree", 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 {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
@@ -689,7 +954,11 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
setupContinuations,
}),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
@@ -705,17 +974,8 @@ describe("create_agent MCP tool", () => {
expect(response.structuredContent.branchName).toBe("tool-worktree");
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "create-worktree",
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(
response.structuredContent.worktreePath,
{
force: true,
reason: "create-worktree",
},
);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(setupContinuations).toEqual([undefined]);
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toContain("tool-worktree");
} finally {
@@ -723,6 +983,106 @@ describe("create_agent MCP tool", () => {
}
});
it("auto-names a standalone branch-off worktree from create_worktree name context", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
logger,
});
const tool = registeredTool(server, "create_worktree");
const response = await tool.callback({
cwd: repoDir,
nameContext: "Fix workspace creation naming",
baseBranch: "main",
});
expect(response.structuredContent.branchName).toBe("fix-workspace-creation-naming");
expect(
execSync("git branch --show-current", {
cwd: response.structuredContent.worktreePath as string,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("fix-workspace-creation-naming");
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not rename checkout-created worktrees from create_worktree name context", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-checkout-worktree-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
logger,
});
const tool = registeredTool(server, "create_worktree");
const response = await tool.callback({
cwd: repoDir,
action: "checkout",
refName: "existing-feature",
nameContext: "Should Not Rename Checkout",
});
expect(response.structuredContent.branchName).toBe("existing-feature");
expect(
execSync("git branch --show-current", {
cwd: response.structuredContent.worktreePath as string,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("existing-feature");
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));

View File

@@ -27,7 +27,7 @@ import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./timeline-append.js";
import { getPaseoWorktreesRoot, type WorktreeConfig } from "../../utils/worktree.js";
import { getPaseoWorktreesRoot } from "../../utils/worktree.js";
import {
archivePaseoWorktree,
killTerminalsUnderPath,
@@ -39,7 +39,12 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { captureTerminalLines } from "../../terminal/terminal.js";
import { runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type {
AgentWorktreeSetupContinuation,
CreatePaseoWorktreeSetupContinuationInput,
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
} from "../worktree-session.js";
import type { ScheduleService } from "../schedule/service.js";
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
import type { ProviderDefinition } from "./provider-registry.js";
@@ -61,11 +66,7 @@ import {
} from "./mcp-shared.js";
import type { GitHubService } from "../../services/github-service.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type {
CreatePaseoWorktreeFn,
CreatePaseoWorktreeInput,
CreatePaseoWorktreeResult,
} from "../paseo-worktree-service.js";
import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
import { toWorktreeRequestError } from "../worktree-errors.js";
import { join } from "node:path";
@@ -83,7 +84,7 @@ export interface AgentMcpServerOptions {
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"];
createPaseoWorktree?: CreatePaseoWorktreeFn;
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
paseoHome?: string;
/**
* ID of the agent that is connecting to this MCP server.
@@ -625,8 +626,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
notifyOnFinish: boolean;
resolvedCwd: string;
resolvedMode: string | undefined;
worktreeConfig: WorktreeConfig | undefined;
shouldBootstrapWorktree: boolean | undefined;
setupContinuation: AgentWorktreeSetupContinuation | undefined;
}
const resolveCallerCreateAgentArgs = (
@@ -661,8 +661,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
notifyOnFinish: callerArgs.notifyOnFinish ?? false,
resolvedCwd,
resolvedMode,
worktreeConfig: undefined,
shouldBootstrapWorktree: undefined,
setupContinuation: undefined,
};
};
@@ -673,11 +672,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs;
let resolvedCwd = expandUserPath(cwd);
let worktreeConfig: WorktreeConfig | undefined;
let shouldBootstrapWorktree: boolean | undefined;
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
if (worktreeName) {
if (!baseBranch && !refName && !action && githubPrNumber === undefined) {
const shouldCreateWorktree = Boolean(worktreeName || refName || action || githubPrNumber);
if (shouldCreateWorktree) {
if (worktreeName && !baseBranch && !refName && !action && githubPrNumber === undefined) {
throw new Error("baseBranch is required when creating a worktree");
}
const createdWorktree = await createMcpWorktree({
@@ -687,17 +686,32 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
refName,
action,
githubPrNumber,
nameContext: topLevelArgs.initialPrompt,
runSetup: false,
paseoHome: options.paseoHome,
},
createPaseoWorktree: options.createPaseoWorktree,
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
workspaceGitService: options.workspaceGitService,
logger: options.logger,
setupContinuation: {
kind: "agent",
terminalManager: terminalManager ?? null,
appendTimelineItem: ({ agentId, item }) =>
appendTimelineItemIfAgentKnown({
agentManager,
agentId,
item,
}),
emitLiveTimelineItem: ({ agentId, item }) =>
emitLiveTimelineItemIfAgentKnown({
agentManager,
agentId,
item,
}),
logger: childLogger,
},
});
resolvedCwd = createdWorktree.worktree.worktreePath;
worktreeConfig = createdWorktree.worktree;
shouldBootstrapWorktree = createdWorktree.created;
setupContinuation = createdWorktree.setupContinuation;
}
return {
@@ -711,8 +725,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
resolvedCwd,
resolvedMode: mode,
worktreeConfig,
shouldBootstrapWorktree,
setupContinuation,
};
};
@@ -755,8 +768,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
notifyOnFinish,
resolvedCwd,
resolvedMode,
worktreeConfig,
shouldBootstrapWorktree,
setupContinuation,
} = resolved;
const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels;
@@ -778,27 +790,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined,
);
if (worktreeConfig) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
shouldBootstrap: shouldBootstrapWorktree,
terminalManager: terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
emitLiveTimelineItem: (item) =>
emitLiveTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
logger: childLogger,
});
}
setupContinuation?.startAfterAgentCreate({
agentId: snapshot.id,
});
const trimmedPrompt = initialPrompt.trim();
scheduleAgentMetadataGeneration({
@@ -809,11 +803,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
explicitTitle: snapshot.config.title,
paseoHome: options.paseoHome,
logger: childLogger,
deps: options.workspaceGitService
? {
workspaceGitService: options.workspaceGitService,
}
: undefined,
});
try {
@@ -1779,6 +1768,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.optional()
.describe("Optional repository cwd. Defaults to the caller agent cwd."),
branchName: z.string().optional(),
nameContext: z.string().optional(),
baseBranch: z.string().optional(),
refName: z.string().min(1).optional(),
action: z.enum(["branch-off", "checkout"]).optional(),
@@ -1789,15 +1779,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
worktreePath: z.string(),
},
},
async ({ cwd, branchName, baseBranch, refName, action, githubPrNumber }) => {
if (!branchName && !refName && githubPrNumber === undefined) {
throw new Error("create_worktree requires branchName, refName, or githubPrNumber");
async ({ cwd, branchName, nameContext, baseBranch, refName, action, githubPrNumber }) => {
if (!branchName && !nameContext && !refName && githubPrNumber === undefined) {
throw new Error(
"create_worktree requires branchName, nameContext, refName, or githubPrNumber",
);
}
const repoRoot = resolveScopedCwd(cwd, { required: true });
const createdWorktree = await createMcpWorktree({
input: {
cwd: repoRoot,
worktreeSlug: branchName,
nameContext,
refName,
action,
githubPrNumber,
@@ -1806,8 +1799,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
createPaseoWorktree: options.createPaseoWorktree,
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
workspaceGitService: options.workspaceGitService,
logger: options.logger,
});
const { worktree } = createdWorktree;
@@ -2050,46 +2041,24 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
interface CreateMcpWorktreeOptions {
input: CreatePaseoWorktreeInput;
createPaseoWorktree: CreatePaseoWorktreeFn | undefined;
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
logger: Logger;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
}
async function createMcpWorktree(
options: CreateMcpWorktreeOptions,
): Promise<CreatePaseoWorktreeResult> {
): Promise<CreatePaseoWorktreeWorkflowResult> {
try {
if (!options.createPaseoWorktree) {
throw new Error("Paseo worktree service is not configured");
}
const result = await options.createPaseoWorktree(options.input, {
resolveDefaultBranch: options.resolveDefaultBranch,
...(options.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }
: {}),
...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}),
});
if (options.workspaceGitService) {
const refreshResults = await Promise.allSettled([
options.workspaceGitService.getSnapshot(options.input.cwd, {
force: true,
reason: "create-worktree",
}),
options.workspaceGitService.getSnapshot(result.worktree.worktreePath, {
force: true,
reason: "create-worktree",
}),
]);
for (const [index, refreshResult] of refreshResults.entries()) {
if (refreshResult.status === "fulfilled") {
continue;
}
options.logger.warn(
{
err: refreshResult.reason,
cwd: index === 0 ? options.input.cwd : result.worktree.worktreePath,
},
"Failed to force-refresh workspace git snapshot after creating worktree",
);
}
}
return result;
} catch (error) {
throw toWorktreeRequestError(error);

View File

@@ -88,7 +88,8 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null {
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { createGitHubService } from "../services/github-service.js";
import { createPaseoWorktree } from "./paseo-worktree-service.js";
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
@@ -562,24 +563,54 @@ export async function createPaseoDaemon(
clearWorkspaceArchiving: clearWorkspaceArchivingForMcpArchive,
emitSessionMessage: emitMcpArchiveSessionMessage,
createPaseoWorktree: async (input, serviceOptions) => {
const coreDeps = createWorktreeCoreDeps(github);
const result = await createPaseoWorktree(input, {
...coreDeps,
...(serviceOptions?.resolveDefaultBranch
? {
resolveDefaultBranch: serviceOptions.resolveDefaultBranch,
}
: {}),
projectRegistry,
workspaceRegistry,
workspaceGitService,
});
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.warmWorkspaceGitDataForWorkspace(result.workspace)) ?? [],
return createPaseoWorktreeWorkflow(
{
paseoHome: config.paseoHome,
createPaseoWorktree: async (workflowInput, workflowOptions) => {
const coreDeps = createWorktreeCoreDeps(github);
return createRegisteredPaseoWorktree(workflowInput, {
...coreDeps,
...(workflowOptions?.resolveDefaultBranch
? {
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
}
: {}),
projectRegistry,
workspaceRegistry,
workspaceGitService,
});
},
warmWorkspaceGitData: async (workspace) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
);
void emitOptions;
},
cacheWorkspaceSetupSnapshot: () => {},
emit: emitMcpArchiveSessionMessage,
sessionLogger: logger,
terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordForMcp,
scriptRouteStore,
scriptRuntimeStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
getDaemonTcpHost: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
onScriptsChanged: null,
},
input,
serviceOptions,
);
return result;
},
paseoHome: config.paseoHome,
callerAgentId,

View File

@@ -1,15 +1,19 @@
import { execSync } from "node:child_process";
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, expect, test, vi } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { createPaseoWorktree, type CreatePaseoWorktreeDeps } from "./paseo-worktree-service.js";
import {
attemptFirstAgentBranchAutoName,
createPaseoWorktree,
type CreatePaseoWorktreeDeps,
} from "./paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
const cleanupPaths: string[] = [];
@@ -98,6 +102,150 @@ test("reuses an existing worktree and still upserts the workspace", async () =>
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
});
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createDeps({
generateBranchName: (seed) => (seed ? "renamed-from-agent-context" : "unnamed-placeholder"),
});
const created = await createPaseoWorktree(
{
cwd: repoDir,
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
expect(created.worktree.branchName).toBe("unnamed-placeholder");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "pending",
placeholderBranchName: "unnamed-placeholder",
},
});
const first = await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
nameContext: "Build the agent context name",
generateBranchName: deps.generateBranchName,
});
const branchAfterFirst = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(first).toEqual({
attempted: true,
renamed: true,
branchName: "renamed-from-agent-context",
});
expect(branchAfterFirst).toBe("renamed-from-agent-context");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: "unnamed-placeholder",
},
});
const second = await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
nameContext: "Try another name",
generateBranchName: () => "second-agent-name",
});
const branchAfterSecond = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(second).toEqual({ attempted: false, renamed: false, branchName: null });
expect(branchAfterSecond).toBe("renamed-from-agent-context");
});
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
const created = await createPaseoWorktree(
{
cwd: repoDir,
action: "checkout",
refName: "dev",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
);
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 1,
baseRefName: "dev",
});
await expect(
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
nameContext: "Rename checkout branch",
generateBranchName: () => "must-not-rename",
}),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect(
execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("dev");
});
test("does not mark GitHub PR checkout worktrees as eligible for first-agent rename", async () => {
const { repoDir, tempDir } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const created = await createPaseoWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 123,
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
);
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 1,
baseRefName: "main",
});
await expect(
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
nameContext: "Rename PR checkout",
generateBranchName: () => "must-not-rename",
}),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect(
execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("pr-123");
});
test("does not mutate registries or broadcast when core worktree creation fails", async () => {
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
cleanupPaths.push(tempDir);
@@ -119,21 +267,6 @@ test("does not mutate registries or broadcast when core worktree creation fails"
expect(deps.workspaces.size).toBe(0);
});
test("keeps direct core worktree creation calls behind the service boundary", () => {
// Keep this literal in the test file so the grep invariant sees createWorktreeCore( here.
const serverSrc = path.dirname(fileURLToPath(import.meta.url));
const matches = listTypeScriptFiles(serverSrc).flatMap((filePath) => {
if (path.basename(filePath) === "worktree-core.ts") {
return [];
}
const contents = readFileSync(filePath, "utf8");
const pattern = new RegExp(["createWorktreeCore", "\\("].join(""), "g");
return Array.from(contents.matchAll(pattern), () => path.relative(serverSrc, filePath));
});
expect(matches).toEqual(["paseo-worktree-service.test.ts", "paseo-worktree-service.ts"]);
});
interface TestDeps extends CreatePaseoWorktreeDeps {
projects: Map<string, PersistedProjectRecord>;
workspaces: Map<string, PersistedWorkspaceRecord>;
@@ -143,6 +276,7 @@ function createDeps(options?: {
events?: string[];
projects?: Map<string, PersistedProjectRecord>;
workspaces?: Map<string, PersistedWorkspaceRecord>;
generateBranchName?: (seed: string | undefined) => string;
}): TestDeps {
const events = options?.events ?? [];
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
@@ -150,6 +284,7 @@ function createDeps(options?: {
return {
...createWorktreeCoreDeps(createGitHubServiceStub()),
...(options?.generateBranchName ? { generateBranchName: options.generateBranchName } : {}),
projects,
workspaces,
projectRegistry: {
@@ -310,14 +445,24 @@ function createGitRepo(): { tempDir: string; repoDir: string } {
return { tempDir, repoDir };
}
function listTypeScriptFiles(directory: string): string[] {
const entries = readdirSync(directory);
return entries.flatMap((entry) => {
const fullPath = path.join(directory, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
return listTypeScriptFiles(fullPath);
}
return fullPath.endsWith(".ts") ? [fullPath] : [];
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
const { tempDir, repoDir } = createGitRepo();
execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" });
const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim();
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" });
const remoteDir = path.join(tempDir, "remote.git");
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
stdio: "pipe",
});
execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${prHead}`, {
stdio: "pipe",
});
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir };
}

View File

@@ -1,3 +1,5 @@
import { basename } from "node:path";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
type PersistedWorkspaceRecord,
@@ -13,6 +15,13 @@ import {
type CreateWorktreeCoreInput,
} from "./worktree-core.js";
import type { WorktreeConfig } from "../utils/worktree.js";
import { validateBranchSlug } from "../utils/worktree.js";
import { renameCurrentBranch } from "../utils/checkout-git.js";
import {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
readPaseoWorktreeMetadata,
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
} from "../utils/worktree-metadata.js";
import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js";
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {}
@@ -32,6 +41,12 @@ export type CreatePaseoWorktreeFn = (
},
) => Promise<CreatePaseoWorktreeResult>;
export interface AttemptFirstAgentBranchAutoNameResult {
attempted: boolean;
renamed: boolean;
branchName: string | null;
}
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
@@ -43,17 +58,23 @@ export async function createPaseoWorktree(
deps: CreatePaseoWorktreeDeps,
): Promise<CreatePaseoWorktreeResult> {
const createdWorktree = await createWorktreeCore(input, deps);
maybeMarkFirstAgentBranchAutoNameEligible({ input, createdWorktree });
const worktree = await maybeAutoNameCreatedWorktree({
input,
createdWorktree,
deps,
});
const workspace = await upsertWorkspaceForWorktree({
inputCwd: input.cwd,
repoRoot: createdWorktree.repoRoot,
worktree: createdWorktree.worktree,
worktree,
deps,
});
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
deps.github.invalidate({ cwd: worktree.worktreePath });
return {
worktree: createdWorktree.worktree,
worktree,
intent: createdWorktree.intent,
workspace,
repoRoot: createdWorktree.repoRoot,
@@ -61,6 +82,108 @@ export async function createPaseoWorktree(
};
}
export async function attemptFirstAgentBranchAutoName(options: {
cwd: string;
nameContext?: string;
generateBranchName: (seed: string | undefined) => string;
renameCurrentBranch?: typeof renameCurrentBranch;
}): Promise<AttemptFirstAgentBranchAutoNameResult> {
const nameContext = options.nameContext?.trim();
if (!nameContext) {
return { attempted: false, renamed: false, branchName: null };
}
let metadata: ReturnType<typeof readPaseoWorktreeMetadata>;
try {
metadata = readPaseoWorktreeMetadata(options.cwd);
} catch {
return { attempted: false, renamed: false, branchName: null };
}
if (
!metadata ||
metadata.version !== 2 ||
metadata.firstAgentBranchAutoName?.status !== "pending"
) {
return { attempted: false, renamed: false, branchName: null };
}
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
const branchName = options.generateBranchName(nameContext);
const validation = validateBranchSlug(branchName);
if (!validation.valid || branchName === metadata.firstAgentBranchAutoName.placeholderBranchName) {
return { attempted: true, renamed: false, branchName: null };
}
const renameCurrentBranchImpl = options.renameCurrentBranch ?? renameCurrentBranch;
const renamedBranch = await renameCurrentBranchImpl(options.cwd, branchName);
return {
attempted: true,
renamed: true,
branchName: renamedBranch.currentBranch ?? branchName,
};
}
function maybeMarkFirstAgentBranchAutoNameEligible(options: {
input: CreatePaseoWorktreeInput;
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
}): void {
const { input, createdWorktree } = options;
if (
!createdWorktree.created ||
input.worktreeSlug ||
createdWorktree.intent.kind !== "branch-off"
) {
return;
}
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
placeholderBranchName: createdWorktree.worktree.branchName,
});
}
async function maybeAutoNameCreatedWorktree(options: {
input: CreatePaseoWorktreeInput;
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
deps: Pick<CreatePaseoWorktreeDeps, "generateBranchName">;
}): Promise<WorktreeConfig> {
const { input, createdWorktree, deps } = options;
const nameContext = input.nameContext?.trim();
if (
!nameContext ||
input.worktreeSlug ||
!createdWorktree.created ||
createdWorktree.intent.kind !== "branch-off"
) {
return createdWorktree.worktree;
}
const generatedPlaceholderName = basename(createdWorktree.worktree.worktreePath);
if (
!generatedPlaceholderName ||
createdWorktree.worktree.branchName !== generatedPlaceholderName
) {
return createdWorktree.worktree;
}
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(createdWorktree.worktree.worktreePath);
const branchName = deps.generateBranchName(nameContext);
const validation = validateBranchSlug(branchName);
if (!validation.valid || branchName === createdWorktree.worktree.branchName) {
return createdWorktree.worktree;
}
const renamedBranch = await renameCurrentBranch(
createdWorktree.worktree.worktreePath,
branchName,
);
return {
...createdWorktree.worktree,
branchName: renamedBranch.currentBranch ?? branchName,
};
}
async function upsertWorkspaceForWorktree(options: {
inputCwd: string;
repoRoot: string;

View File

@@ -165,13 +165,11 @@ import {
} from "./file-explorer/service.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import { type WorktreeConfig } from "../utils/worktree.js";
import {
readPaseoConfigForEdit,
writePaseoConfigForEdit,
type ProjectConfigRpcError,
} from "../utils/paseo-config-file.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ScriptRouteStore } from "./script-proxy.js";
@@ -206,6 +204,7 @@ import {
type PullRequestTimelineItem,
} from "../services/github-service.js";
import {
attemptFirstAgentBranchAutoName,
createPaseoWorktree,
type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult,
@@ -214,7 +213,10 @@ import { createWorktreeCoreDeps } from "./worktree-core.js";
import {
assertSafeGitRef as assertWorktreeSafeGitRef,
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
runWorktreeSetupInBackground as runWorktreeSetupInBackgroundSession,
buildAgentWorktreeNameContext,
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
type CreatePaseoWorktreeSetupContinuationInput,
type CreatePaseoWorktreeWorkflowResult,
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
@@ -274,6 +276,7 @@ type GitMutationRefreshReason =
| "create-pr"
| "switch-branch"
| "create-branch"
| "rename-branch"
| "stash-push"
| "stash-pop"
| "create-worktree";
@@ -3105,19 +3108,28 @@ export class Session {
...(provisionalTitle ? { title: provisionalTitle } : {}),
};
const { sessionConfig, worktreeBootstrap } = await this.buildAgentSessionConfig(
const agentNameContext = buildAgentWorktreeNameContext({
initialPrompt: trimmedPrompt,
attachments,
});
const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig(
resolvedConfig,
git,
worktreeName,
attachments,
agentNameContext,
);
const resolvedWorkspace = msg.workspaceId
let resolvedWorkspace = msg.workspaceId
? await this.workspaceRegistry.get(msg.workspaceId)
: ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ??
(await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd)));
if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${msg.workspaceId}`);
}
resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({
workspace: resolvedWorkspace,
nameContext: agentNameContext,
});
const snapshot = await this.agentManager.createAgent(
{
...sessionConfig,
@@ -3155,27 +3167,9 @@ export class Session {
});
}
if (worktreeBootstrap) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: this.terminalManager,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId: snapshot.id,
item,
}),
emitLiveTimelineItem: (item) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId: snapshot.id,
item,
}),
logger: this.sessionLogger,
});
}
setupContinuation?.startAfterAgentCreate({
agentId: snapshot.id,
});
this.sessionLogger.info(
{ agentId: snapshot.id, provider: snapshot.provider },
@@ -3231,9 +3225,6 @@ export class Session {
explicitTitle: params.explicitTitle,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
deps: {
workspaceGitService: this.workspaceGitService,
},
});
const started = await this.handleSendAgentMessage(
@@ -3460,9 +3451,10 @@ export class Session {
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
attachments?: AgentAttachment[],
nameContext?: string,
): Promise<{
sessionConfig: AgentSessionConfig;
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"];
}> {
return buildWorktreeAgentSessionConfig(
{
@@ -3470,7 +3462,26 @@ export class Session {
sessionLogger: this.sessionLogger,
workspaceGitService: this.workspaceGitService,
createPaseoWorktree: (input, serviceOptions) =>
this.createPaseoWorktree(input, serviceOptions),
this.createPaseoWorktreeWorkflow(input, {
...serviceOptions,
setupContinuation: {
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,
},
}),
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
createBranchFromBase: (params) => this.createBranchFromBase(params),
github: this.github,
@@ -3479,9 +3490,35 @@ export class Session {
gitOptions,
legacyWorktreeName,
attachments,
nameContext,
);
}
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
workspace: PersistedWorkspaceRecord;
nameContext?: string;
}): Promise<PersistedWorkspaceRecord> {
const coreDeps = createWorktreeCoreDeps(this.github);
const result = await attemptFirstAgentBranchAutoName({
cwd: input.workspace.cwd,
nameContext: input.nameContext,
generateBranchName: coreDeps.generateBranchName,
});
if (!result.renamed || !result.branchName) {
return input.workspace;
}
const updatedWorkspace: PersistedWorkspaceRecord = {
...input.workspace,
displayName: result.branchName,
updatedAt: new Date().toISOString(),
};
await this.workspaceRegistry.upsert(updatedWorkspace);
await this.notifyGitMutation(input.workspace.cwd, "rename-branch");
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
return updatedWorkspace;
}
private emitProviderDisabledResponse(
kind: "models" | "modes",
provider: AgentProvider,
@@ -7447,27 +7484,26 @@ export class Session {
paseoHome: this.paseoHome,
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
emit: (message) => this.emit(message),
createPaseoWorktree: (input) => this.createPaseoWorktree(input),
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
sessionLogger: this.sessionLogger,
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
createPaseoWorktreeWorkflow: (input) => this.createPaseoWorktreeWorkflow(input),
},
request,
);
}
private async runWorktreeSetupInBackground(options: {
requestCwd: string;
repoRoot: string;
workspaceId: string;
worktree: { branchName: string; worktreePath: string };
shouldBootstrap: boolean;
slug: string;
worktreePath: string;
}): Promise<void> {
return runWorktreeSetupInBackgroundSession(
private async createPaseoWorktreeWorkflow(
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
},
): Promise<CreatePaseoWorktreeWorkflowResult> {
return createWorktreeWorkflow(
{
paseoHome: this.paseoHome,
createPaseoWorktree: (workflowInput, serviceOptions) =>
this.createPaseoWorktree(workflowInput, serviceOptions),
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
@@ -7485,6 +7521,7 @@ export class Session {
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
},
},
input,
options,
);
}

View File

@@ -2,7 +2,6 @@ import { execSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
@@ -10,7 +9,6 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test, afterEach, vi } from "vitest";
import type { GitHubService } from "../services/github-service.js";
@@ -60,42 +58,6 @@ function createCoreDeps(options?: {
};
}
function findDirectCreateWorktreeCallSites(serverSrc: string): string[] {
const matches: string[] = [];
function walk(directory: string) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
walk(absolutePath);
continue;
}
if (!entry.isFile()) {
continue;
}
const relativePath = path
.relative(serverSrc, absolutePath)
.split(path.sep)
.join(path.posix.sep);
if (relativePath === "utils/worktree.ts" || relativePath.endsWith(".test.ts")) {
continue;
}
// Keep this literal in the test file so the invariant proves tests are allowed to inspect createWorktree(.
if (/createWorktree\(/.test(readFileSync(absolutePath, "utf8"))) {
matches.push(relativePath);
}
}
}
walk(serverSrc);
return matches.sort();
}
function createGitRepo(): { tempDir: string; repoDir: string; paseoHome: string } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-")));
const repoDir = path.join(tempDir, "repo");
@@ -651,11 +613,6 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
});
expect(result.worktree.branchName).toBe("feature/from-service");
});
test("keeps direct createWorktree calls isolated to the core layer", () => {
const serverSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
expect(findDirectCreateWorktreeCallSites(serverSrc)).toEqual(["server/worktree-core.ts"]);
});
});
describe("resolveWorktreeRepoRoot", () => {

View File

@@ -17,6 +17,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
cwd: string;
nameContext?: string;
paseoHome?: string;
runSetup?: boolean;
}

View File

@@ -16,6 +16,8 @@ import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messa
import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js";
import {
buildAgentSessionConfig,
buildAgentWorktreeNameContext,
createPaseoWorktreeWorkflow,
handlePaseoWorktreeArchiveRequest,
handlePaseoWorktreeListRequest,
resolveGitCreateBaseBranch,
@@ -79,6 +81,53 @@ function createLogger(): Logger {
return logger;
}
function createWorkflowForRequestTest(options: {
paseoHome: string;
createPaseoWorktree?: CreatePaseoWorktreeFn;
warmWorkspaceGitData?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
onSetupStarted?: (input: {
requestCwd: string;
repoRoot: string;
workspaceId: string;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}) => void;
}) {
return async (input: Parameters<CreatePaseoWorktreeFn>[0]) => {
const createPaseoWorktree =
options.createPaseoWorktree ?? createPaseoWorktreeForTest({ paseoHome: options.paseoHome });
return createPaseoWorktreeWorkflow(
{
paseoHome: options.paseoHome,
createPaseoWorktree,
warmWorkspaceGitData: options.warmWorkspaceGitData ?? (async () => {}),
emitWorkspaceUpdateForCwd: async () => {},
cacheWorkspaceSetupSnapshot: () => {},
emit: () => {},
sessionLogger: createLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
scriptRouteStore: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
onScriptsChanged: null,
},
input,
{ setupContinuation: { kind: "workspace" } },
).then((result) => {
options.onSetupStarted?.({
requestCwd: input.cwd,
repoRoot: result.repoRoot,
workspaceId: result.workspace.workspaceId,
worktree: result.worktree,
shouldBootstrap: result.created,
});
return result;
});
};
}
function createGitHubServiceStub(): GitHubService {
return {
listPullRequests: async () => [],
@@ -347,6 +396,81 @@ describe("resolveGitCreateBaseBranch", () => {
});
});
describe("create-agent worktree setup boundary", () => {
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 () => {},
emitWorkspaceUpdateForCwd: async () => {},
cacheWorkspaceSetupSnapshot: () => {},
emit: (message) => workspaceSetupEvents.push(message),
sessionLogger: createLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
scriptRouteStore: 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" | "remove"> {
return {
list: async (): Promise<StoredAgentRecord[]> => [],
@@ -1048,10 +1172,8 @@ describe("handleCreatePaseoWorktreeRequest", () => {
describeWorkspaceRecord: async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
warmWorkspaceGitData: async () => {},
sessionLogger: logger,
runWorktreeSetupInBackground: async () => {},
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
},
{
type: "create_paseo_worktree_request",
@@ -1139,8 +1261,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
],
);
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature/review-pr");
expect(result.worktreeBootstrap?.worktree.worktreePath).toContain("agent-review-pr-123");
expect(result.sessionConfig.cwd).toContain("agent-review-pr-123");
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
const branch = execSync("git branch --show-current", {
@@ -1184,8 +1305,93 @@ describe("handleCreatePaseoWorktreeRequest", () => {
},
);
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature-x");
expect(path.basename(result.worktreeBootstrap?.worktree.worktreePath ?? "")).toBe("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",
newBranchName: "fix-attached-pr-context",
},
workspace: {
workspaceId: "/tmp/worktrees/fix-attached-pr-context",
projectId: "/tmp/repo",
cwd: "/tmp/worktrees/fix-attached-pr-context",
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 nameContext = buildAgentWorktreeNameContext({
initialPrompt: "Create a worktree name from this prompt",
attachments: [
{
type: "github_pr",
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,
[
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Fix worktree naming",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "fix/worktree-naming",
},
],
nameContext,
);
expect(createPaseoWorktree).toHaveBeenCalledWith(
expect.objectContaining({
nameContext:
"Create a worktree name from this prompt\n\nGitHub PR #123: Fix worktree naming\nhttps://github.com/getpaseo/paseo/pull/123\nBase: main\nHead: fix/worktree-naming",
}),
expect.anything(),
);
expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context");
});
test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => {
@@ -1284,7 +1490,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
paseoHome,
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({
paseoHome,
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
}),
describeWorkspaceRecord: vi.fn(async (result) => ({
id: result.workspace.workspaceId,
projectId: result.workspace.projectId,
@@ -1308,8 +1517,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
},
githubRuntime: null,
})),
warmWorkspaceGitData: async () => {},
runWorktreeSetupInBackground: vi.fn(async () => {}),
},
{
type: "create_paseo_worktree_request",
@@ -1346,17 +1553,20 @@ describe("handleCreatePaseoWorktreeRequest", () => {
paseoHome,
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
createPaseoWorktree: async (input) => {
const result = await createPaseoWorktreeForTest({ paseoHome })(input);
expect(existsSync(result.worktree.worktreePath)).toBe(true);
registeredWorktreePath = result.worktree.worktreePath;
return result;
},
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({
paseoHome,
createPaseoWorktree: async (input) => {
const result = await createPaseoWorktreeForTest({ paseoHome })(input);
expect(existsSync(result.worktree.worktreePath)).toBe(true);
registeredWorktreePath = result.worktree.worktreePath;
return result;
},
warmWorkspaceGitData,
onSetupStarted: backgroundWork,
}),
describeWorkspaceRecord: vi.fn(async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
),
warmWorkspaceGitData,
runWorktreeSetupInBackground: backgroundWork,
},
{
type: "create_paseo_worktree_request",
@@ -1388,6 +1598,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
});
expect(registeredWorktreePath).toBeTruthy();
expect(existsSync(registeredWorktreePath!)).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(warmWorkspaceGitData).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: response?.payload.workspace?.id,
@@ -1421,12 +1632,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
paseoHome,
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
describeWorkspaceRecord: vi.fn(async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
),
warmWorkspaceGitData: async () => {},
runWorktreeSetupInBackground: vi.fn(async () => {}),
},
{
type: "create_paseo_worktree_request",
@@ -1462,12 +1671,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
paseoHome,
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
describeWorkspaceRecord: vi.fn(async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
),
warmWorkspaceGitData: async () => {},
runWorktreeSetupInBackground: vi.fn(async () => {}),
},
{
type: "create_paseo_worktree_request",

View File

@@ -13,6 +13,7 @@ import {
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
runAsyncWorktreeBootstrap,
applyWorktreeSetupProgressEvent,
buildWorktreeSetupDetail,
createWorktreeSetupProgressAccumulator,
@@ -23,6 +24,7 @@ import type { ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { GitHubService } from "../services/github-service.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
import { renderPromptAttachmentAsText } from "./agent/prompt-attachments.js";
import { expandTilde } from "../utils/path.js";
import {
getWorktreeSetupCommands,
@@ -61,6 +63,15 @@ export interface NormalizedGitOptions {
}
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;
@@ -70,8 +81,9 @@ interface BuildAgentSessionConfigDependencies {
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
},
) => Promise<CreatePaseoWorktreeResult>;
) => Promise<CreatePaseoWorktreeWorkflowResult>;
checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>;
createBranchFromBase: (params: {
cwd: string;
@@ -96,6 +108,45 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null;
}
interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInBackgroundDependencies {
createPaseoWorktree: (
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
},
) => Promise<CreatePaseoWorktreeResult>;
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => 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 CreatePaseoWorktreeWorkflowFn = (
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
},
) => Promise<CreatePaseoWorktreeWorkflowResult>;
interface HandleWorkspaceSetupStatusRequestDependencies {
emit: EmitSessionMessage;
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
@@ -107,18 +158,10 @@ interface HandleCreatePaseoWorktreeRequestDependencies {
result: CreatePaseoWorktreeResult,
) => Promise<WorkspaceDescriptorPayload>;
emit: EmitSessionMessage;
createPaseoWorktree: (input: CreatePaseoWorktreeInput) => Promise<CreatePaseoWorktreeResult>;
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
sessionLogger: Logger;
runWorktreeSetupInBackground: (options: {
requestCwd: string;
repoRoot: string;
workspaceId: string;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
slug: string;
worktreePath: string;
}) => Promise<void>;
createPaseoWorktreeWorkflow: (
input: CreatePaseoWorktreeInput,
) => Promise<CreatePaseoWorktreeWorkflowResult>;
}
export async function buildAgentSessionConfig(
@@ -127,13 +170,14 @@ export async function buildAgentSessionConfig(
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
attachments?: AgentAttachment[],
nameContext?: string,
): Promise<{
sessionConfig: AgentSessionConfig;
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
setupContinuation?: AgentWorktreeSetupContinuation;
}> {
let cwd = expandTilde(config.cwd);
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
let worktreeBootstrap: { worktree: WorktreeConfig; shouldBootstrap: boolean } | undefined;
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
if (!normalized) {
return {
@@ -157,6 +201,7 @@ export async function buildAgentSessionConfig(
refName: normalized.refName,
action: normalized.action,
githubPrNumber: normalized.githubPrNumber,
nameContext,
attachments,
runSetup: false,
paseoHome: dependencies.paseoHome,
@@ -173,10 +218,7 @@ export async function buildAgentSessionConfig(
},
);
cwd = createdWorktree.worktree.worktreePath;
worktreeBootstrap = {
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.created,
};
setupContinuation = createdWorktree.setupContinuation;
} else if (normalized.createNewBranch) {
const baseBranch =
normalized.baseBranch ??
@@ -201,10 +243,28 @@ export async function buildAgentSessionConfig(
...config,
cwd,
},
worktreeBootstrap,
setupContinuation,
};
}
export function buildAgentWorktreeNameContext(input: {
initialPrompt?: string | null;
attachments?: readonly AgentAttachment[];
}): string | undefined {
const parts: string[] = [];
const prompt = input.initialPrompt?.trim();
if (prompt) {
parts.push(prompt);
}
for (const attachment of input.attachments ?? []) {
const rendered = renderPromptAttachmentAsText(attachment).trim();
if (rendered) {
parts.push(rendered);
}
}
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
interface ValidateNormalizedGitOptionsInput {
baseBranch: string | undefined;
createNewBranch: boolean;
@@ -450,9 +510,10 @@ export async function handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
try {
const createdWorktree = await dependencies.createPaseoWorktree({
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
cwd: request.cwd,
worktreeSlug: request.worktreeSlug,
nameContext: request.nameContext,
refName: request.refName,
action: request.action,
githubPrNumber: request.githubPrNumber,
@@ -460,8 +521,6 @@ export async function handleCreatePaseoWorktreeRequest(
runSetup: false,
paseoHome: dependencies.paseoHome,
});
const slug = basename(createdWorktree.worktree.worktreePath);
const workspace = createdWorktree.workspace;
const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree);
dependencies.emit({
@@ -480,22 +539,6 @@ export async function handleCreatePaseoWorktreeRequest(
workspace: descriptor,
},
});
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
dependencies.sessionLogger.warn(
{ err: error, workspaceId: workspace.workspaceId },
"Failed to warm workspace git data after creating worktree",
);
});
void dependencies.runWorktreeSetupInBackground({
requestCwd: request.cwd,
repoRoot: createdWorktree.repoRoot,
workspaceId: workspace.workspaceId,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.created,
slug,
worktreePath: createdWorktree.worktree.worktreePath,
});
} catch (error) {
const wireError = toWorktreeWireError(error);
dependencies.sessionLogger.error(
@@ -515,6 +558,72 @@ export async function handleCreatePaseoWorktreeRequest(
}
}
export async function createPaseoWorktreeWorkflow(
dependencies: CreatePaseoWorktreeWorkflowDependencies,
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,
},
options?.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }
: undefined,
);
const slug = basename(createdWorktree.worktree.worktreePath);
const workspace = createdWorktree.workspace;
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
setTimeout(() => {
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
dependencies.sessionLogger.warn(
{ err: error, workspaceId: workspace.workspaceId },
"Failed to warm workspace git data after creating worktree",
);
});
if (setupContinuation.kind === "workspace") {
void runWorktreeSetupInBackground(dependencies, {
requestCwd: input.cwd,
repoRoot: createdWorktree.repoRoot,
workspaceId: workspace.workspaceId,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.created,
slug,
worktreePath: createdWorktree.worktree.worktreePath,
});
}
}, 0);
if (setupContinuation.kind === "agent") {
return {
...createdWorktree,
setupContinuation: {
kind: "agent",
startAfterAgentCreate: ({ agentId }) => {
void runAsyncWorktreeBootstrap({
agentId,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.created,
terminalManager: setupContinuation.terminalManager,
appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }),
emitLiveTimelineItem: (item) =>
setupContinuation.emitLiveTimelineItem({ agentId, item }),
logger: setupContinuation.logger,
});
},
},
};
}
return createdWorktree;
}
export async function handleWorkspaceSetupStatusRequest(
dependencies: HandleWorkspaceSetupStatusRequestDependencies,
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,

View File

@@ -1385,6 +1385,7 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
type: z.literal("create_paseo_worktree_request"),
cwd: z.string(),
worktreeSlug: z.string().optional(),
nameContext: z.string().optional(),
attachments: AgentAttachmentsSchema,
refName: z.string().min(1).optional(),
action: z.enum(["branch-off", "checkout"]).optional(),

View File

@@ -10,6 +10,19 @@ const PaseoWorktreeMetadataV1Schema = z.object({
const PaseoWorktreeMetadataV2Schema = z.object({
version: z.literal(2),
baseRefName: z.string().min(1),
firstAgentBranchAutoName: z
.discriminatedUnion("status", [
z.object({
status: z.literal("pending"),
placeholderBranchName: z.string().min(1),
}),
z.object({
status: z.literal("attempted"),
placeholderBranchName: z.string().min(1),
attemptedAt: z.string().min(1),
}),
])
.optional(),
runtime: z
.object({
worktreePort: z.number().int().positive(),
@@ -101,6 +114,9 @@ export function writePaseoWorktreeRuntimeMetadata(
const next: PaseoWorktreeMetadata = {
version: 2,
baseRefName: current.baseRefName,
...(current.version === 2 && current.firstAgentBranchAutoName
? { firstAgentBranchAutoName: current.firstAgentBranchAutoName }
: {}),
runtime: {
worktreePort: options.worktreePort,
},
@@ -108,6 +124,54 @@ export function writePaseoWorktreeRuntimeMetadata(
writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
}
export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
worktreeRoot: string,
options: { placeholderBranchName: string },
): void {
const placeholderBranchName = options.placeholderBranchName.trim();
if (!placeholderBranchName) {
throw new Error("Placeholder branch name is required");
}
const current = readPaseoWorktreeMetadata(worktreeRoot);
if (!current) {
throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata");
}
writePaseoWorktreeMetadataFile(worktreeRoot, {
version: 2,
baseRefName: current.baseRefName,
firstAgentBranchAutoName: {
status: "pending",
placeholderBranchName,
},
...(current.version === 2 && current.runtime ? { runtime: current.runtime } : {}),
});
}
export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
worktreeRoot: string,
options: { attemptedAt?: string } = {},
): PaseoWorktreeMetadata | null {
const current = readPaseoWorktreeMetadata(worktreeRoot);
if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") {
return current;
}
const next: PaseoWorktreeMetadata = {
version: 2,
baseRefName: current.baseRefName,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
},
...(current.runtime ? { runtime: current.runtime } : {}),
};
writePaseoWorktreeMetadataFile(worktreeRoot, next);
return next;
}
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
if (!existsSync(metadataPath)) {
@@ -136,3 +200,12 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul
}
return null;
}
function writePaseoWorktreeMetadataFile(
worktreeRoot: string,
metadata: PaseoWorktreeMetadata,
): void {
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
}