From 3bca1a72e8c8b414cb9e055da24adeacedb6dcfb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 20 May 2026 22:41:15 +0800 Subject: [PATCH] Create agents in worktrees with auto-archive (#1120) * Add daemon worktree auto-archive spawning * Refactor create agent lifecycle dispatch * Stabilize Claude autonomous turn test --- docs/agent-lifecycle.md | 2 + .../server/src/client/daemon-client.test.ts | 56 ++++ packages/server/src/client/daemon-client.ts | 4 + .../agent/create-agent-lifecycle-dispatch.ts | 227 +++++++++++++++ .../claude/agent.integration.test.ts | 21 +- ...ate-agent-worktree-autoarchive.e2e.test.ts | 274 ++++++++++++++++++ packages/server/src/server/session.ts | 57 +++- .../server/test-utils/fake-agent-client.ts | 11 + ....create-agent-worktree-autoarchive.test.ts | 59 ++++ packages/server/src/shared/messages.ts | 20 ++ 10 files changed, 722 insertions(+), 9 deletions(-) create mode 100644 packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts create mode 100644 packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts create mode 100644 packages/server/src/shared/messages.create-agent-worktree-autoarchive.test.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 14a4ba393..1cf9ba242 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -27,6 +27,8 @@ Both look the same in storage. This is an accepted limitation — see [Limitatio Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client. +`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree. + Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`): 1. Snapshot the current session into the registry diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 6fb456990..2f71f2f5c 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -608,6 +608,62 @@ test("sends create_agent_request with string workspace ids", 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 structured attachments with create_agent_request", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 382eb3d0f..dc18ccb4e 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -257,6 +257,8 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { images?: CreateAgentRequestMessage["images"]; attachments?: CreateAgentRequestMessage["attachments"]; git?: GitSetupOptions; + worktree?: CreateAgentRequestMessage["worktree"]; + autoArchive?: CreateAgentRequestMessage["autoArchive"]; worktreeName?: string; requestId?: string; labels?: Record; @@ -1848,6 +1850,8 @@ export class DaemonClient { ? { attachments: options.attachments } : {}), ...(options.git ? { git: options.git } : {}), + ...(options.worktree ? { worktree: options.worktree } : {}), + ...(options.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}), ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}), ...(options.labels && Object.keys(options.labels).length > 0 ? { labels: options.labels } diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts new file mode 100644 index 000000000..4658c3543 --- /dev/null +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -0,0 +1,227 @@ +import { randomUUID } from "node:crypto"; +import type pino from "pino"; + +import type { GitHubService } from "../../services/github-service.js"; +import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; +import { archivePaseoWorktree } from "../paseo-worktree-archive-service.js"; +import type { + CreatePaseoWorktreeWorkflowFn, + CreatePaseoWorktreeWorkflowResult, +} from "../worktree-session.js"; +import type { WorkspaceGitService } from "../workspace-git-service.js"; +import type { + CreateAgentWorktreeTarget, + FirstAgentContext, + SessionOutboundMessage, +} from "../messages.js"; +import type { AgentManager } from "./agent-manager.js"; +import type { AgentStorage } from "./agent-storage.js"; + +interface CreateAgentLifecycleDispatchDependencies { + paseoHome: string; + agentManager: AgentManager; + agentStorage: AgentStorage; + github: GitHubService; + workspaceGitService: WorkspaceGitService; + createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn; + archiveAgentForClose: (agentId: string) => Promise; + archiveWorkspaceRecord: (workspaceId: string) => Promise; + emit: (message: SessionOutboundMessage) => void; + emitAgentRemove: (agentId: string) => void; + emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; + markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; + clearWorkspaceArchiving: (workspaceIds: Iterable) => void; + isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; + killTerminalsUnderPath: (rootPath: string) => Promise; + logger: pino.Logger; +} + +export class CreateAgentLifecycleDispatch { + private readonly autoArchiveAgentIds = new Set(); + + constructor(private readonly dependencies: CreateAgentLifecycleDispatchDependencies) {} + + async createWorktreeForRequest(input: { + cwd: string; + target: CreateAgentWorktreeTarget | undefined; + firstAgentContext: FirstAgentContext; + hasLegacyGitOptions: boolean; + }): Promise { + if (input.target && input.hasLegacyGitOptions) { + throw new Error("create_agent_request worktree cannot be combined with git options"); + } + if (!input.target) { + return null; + } + + return this.createWorktreeForTarget(input.cwd, input.target, input.firstAgentContext); + } + + registerAutoArchiveIfRequested(input: { + autoArchive: boolean | undefined; + agentId: string; + createdWorktree: CreatePaseoWorktreeWorkflowResult | null; + }): void { + if (input.autoArchive !== true) { + return; + } + + this.registerAutoArchiveOnTerminalState(input.agentId, { + worktreePath: input.createdWorktree?.worktree.worktreePath ?? null, + repoRoot: input.createdWorktree?.repoRoot ?? null, + }); + } + + async cleanupCreatedWorktreeAfterFailedAgentCreate(input: { + createdWorktree: CreatePaseoWorktreeWorkflowResult | null; + createdAgentId: string | null; + }): Promise { + const { createdWorktree, createdAgentId } = input; + if (!createdWorktree || createdAgentId) { + return; + } + + await this.archiveAutoCreatedWorktree({ + agentId: null, + worktreePath: createdWorktree.worktree.worktreePath, + repoRoot: createdWorktree.repoRoot, + }).catch((archiveError) => { + this.dependencies.logger.warn( + { + err: archiveError, + worktreePath: createdWorktree.worktree.worktreePath, + }, + "Failed to clean up worktree after create_agent_request failed", + ); + }); + } + + private async createWorktreeForTarget( + cwd: string, + target: CreateAgentWorktreeTarget, + firstAgentContext: FirstAgentContext, + ): Promise { + const baseInput = { + cwd, + firstAgentContext, + runSetup: false, + paseoHome: this.dependencies.paseoHome, + } as const; + + switch (target.mode) { + case "branch-off": + return this.dependencies.createPaseoWorktreeWorkflow( + { + ...baseInput, + worktreeSlug: target.newBranch, + action: "branch-off", + ...(target.base ? { refName: target.base } : {}), + }, + target.base ? { resolveDefaultBranch: async () => target.base! } : undefined, + ); + case "checkout-branch": + return this.dependencies.createPaseoWorktreeWorkflow({ + ...baseInput, + action: "checkout", + refName: target.branch, + }); + case "checkout-pr": + return this.dependencies.createPaseoWorktreeWorkflow({ + ...baseInput, + action: "checkout", + githubPrNumber: target.prNumber, + }); + default: + throw new Error("Unsupported create_agent_request worktree target"); + } + } + + private registerAutoArchiveOnTerminalState( + agentId: string, + options: { worktreePath: string | null; repoRoot: string | null }, + ): void { + const unsubscribe = this.dependencies.agentManager.subscribe( + (event) => { + if (event.type !== "agent_stream") { + return; + } + if ( + event.event.type !== "turn_completed" && + event.event.type !== "turn_failed" && + event.event.type !== "turn_canceled" + ) { + return; + } + unsubscribe(); + void this.autoArchiveAgentOnce(agentId, options); + }, + { agentId, replayState: false }, + ); + } + + private async autoArchiveAgentOnce( + agentId: string, + options: { worktreePath: string | null; repoRoot: string | null }, + ): Promise { + if (this.autoArchiveAgentIds.has(agentId)) { + return; + } + this.autoArchiveAgentIds.add(agentId); + + try { + if (options.worktreePath) { + await this.archiveAutoCreatedWorktree({ + agentId, + worktreePath: options.worktreePath, + repoRoot: options.repoRoot, + }); + return; + } + + await this.dependencies.archiveAgentForClose(agentId); + } catch (error) { + this.dependencies.logger.warn({ err: error, agentId }, "Failed to auto-archive agent"); + } + } + + private async archiveAutoCreatedWorktree(options: { + agentId: string | null; + worktreePath: string; + repoRoot: string | null; + }): Promise { + const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, { + paseoHome: this.dependencies.paseoHome, + }); + if (!ownership.allowed) { + throw new Error("Auto-created worktree is not a Paseo-owned worktree"); + } + + await archivePaseoWorktree( + { + paseoHome: this.dependencies.paseoHome, + github: this.dependencies.github, + workspaceGitService: this.dependencies.workspaceGitService, + agentManager: this.dependencies.agentManager, + agentStorage: this.dependencies.agentStorage, + archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, + emit: this.dependencies.emit, + emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, + markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, + clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving, + isPathWithinRoot: this.dependencies.isPathWithinRoot, + killTerminalsUnderPath: this.dependencies.killTerminalsUnderPath, + sessionLogger: this.dependencies.logger, + }, + { + targetPath: options.worktreePath, + repoRoot: options.repoRoot ?? ownership.repoRoot ?? null, + worktreesRoot: ownership.worktreeRoot, + requestId: randomUUID(), + }, + ); + + if (options.agentId) { + this.dependencies.emitAgentRemove(options.agentId); + } + } +} diff --git a/packages/server/src/server/agent/providers/claude/agent.integration.test.ts b/packages/server/src/server/agent/providers/claude/agent.integration.test.ts index 3e39c9ba9..9e9bc0c8f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.integration.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.integration.test.ts @@ -91,7 +91,7 @@ async function collectUntil( function collectSubscribedUntil( session: AgentSession, - predicate: (event: AgentStreamEvent) => boolean, + predicate: (event: AgentStreamEvent, events: AgentStreamEvent[]) => boolean, timeoutMs = 45_000, ): Promise { return new Promise((resolve, reject) => { @@ -103,7 +103,7 @@ function collectSubscribedUntil( const unsubscribe = session.subscribe((event) => { events.push(event); - if (!predicate(event)) { + if (!predicate(event, events)) { return; } clearTimeout(timeout); @@ -252,7 +252,7 @@ describe("ClaudeAgentSession integration", () => { expect.objectContaining({ value: "default", displayName: "Default (recommended)", - supportedEffortLevels: ["low", "medium", "high", "max"], + supportedEffortLevels: expect.arrayContaining(["low", "medium", "high", "max"]), }), ); expect(models).toContainEqual( @@ -374,6 +374,14 @@ describe("ClaudeAgentSession integration", () => { const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; try { + const liveEventsPromise = collectSubscribedUntil( + handle.session, + (event, events) => + isTerminalEvent(event) && + compactText(getAssistantText(events)).includes(compactText(autonomousWakeToken)), + 90_000, + ); + const foregroundEvents = await collectUntilTerminal( streamSession( handle.session, @@ -390,15 +398,12 @@ describe("ClaudeAgentSession integration", () => { expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned"); - const liveEvents = await collectSubscribedUntil( - handle.session, - (event) => isTerminalEvent(event), - 90_000, - ); + const liveEvents = await liveEventsPromise; expect( liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"), ).toBe(true); + expect(compactText(getAssistantText(liveEvents))).toContain(compactText(autonomousWakeToken)); expect(liveEvents.at(-1)).toMatchObject({ type: "turn_completed", provider: "claude", diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts new file mode 100644 index 000000000..7b050d971 --- /dev/null +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -0,0 +1,274 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; + +import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; +import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js"; +import type { CreateAgentOptions } from "./test-utils/index.js"; +import type { CreateAgentWorktreeTarget } from "./messages.js"; + +let ctx: DaemonTestContext; +const tempRoots: string[] = []; + +beforeEach(async () => { + ctx = await createDaemonTestContext(); +}); + +afterEach(async () => { + await ctx.cleanup(); + for (const tempRoot of tempRoots.splice(0)) { + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +function createGitRepo(): string { + const tempRoot = mkdtempSync(path.join(tmpdir(), "create-agent-worktree-")); + tempRoots.push(tempRoot); + const repoDir = path.join(tempRoot, "repo"); + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); + return repoDir; +} + +async function expectAgentAbsentFromActiveList(agentId: string): Promise { + await expect + .poll( + async () => { + const active = await ctx.client.fetchAgents(); + return active.entries.map((entry) => entry.agent.id).includes(agentId); + }, + { timeout: 15000, interval: 100 }, + ) + .toBe(false); +} + +async function expectAgentPresentInActiveList(agentId: string): Promise { + const active = await ctx.client.fetchAgents(); + expect(active.entries.map((entry) => entry.agent.id)).toContain(agentId); +} + +async function expectActiveAgentListEmpty(): Promise { + const active = await ctx.client.fetchAgents(); + expect(active.entries).toEqual([]); +} + +async function expectWorktreeAbsentFromList(repoDir: string, worktreePath: string): Promise { + await expect + .poll( + async () => { + const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir }); + return listed.worktrees.map((worktree) => worktree.worktreePath).includes(worktreePath); + }, + { timeout: 15000, interval: 100 }, + ) + .toBe(false); +} + +async function expectWorktreePresentInList(repoDir: string, worktreePath: string): Promise { + await expect + .poll( + async () => { + const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir }); + return listed.worktrees.map((worktree) => worktree.worktreePath).includes(worktreePath); + }, + { timeout: 5000, interval: 100 }, + ) + .toBe(true); +} + +async function expectWorktreeListEmpty(repoDir: string): Promise { + const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir }); + expect(listed.worktrees).toEqual([]); +} + +async function createAgentInBranchOffWorktree(options?: { + autoArchive?: boolean; + branchName?: string; +}): Promise<{ repoDir: string; agentId: string; worktreePath: string }> { + const repoDir = createGitRepo(); + const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`; + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd: repoDir, + }, + worktree: { + mode: "branch-off", + newBranch: branchName, + base: "main", + }, + ...(options?.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}), + initialPrompt: "Say done.", + }); + return { repoDir, agentId: created.id, worktreePath: created.cwd }; +} + +test("create_agent_request creates a worktree and auto-archives both after the first turn", async () => { + const repoDir = createGitRepo(); + const worktree: CreateAgentWorktreeTarget = { + mode: "branch-off", + newBranch: "agent-lifecycle-dispatch-test", + base: "main", + }; + const request: CreateAgentOptions & { + worktree: CreateAgentWorktreeTarget; + autoArchive: true; + } = { + config: { + ...getFullAccessConfig("codex"), + cwd: repoDir, + }, + worktree, + autoArchive: true, + initialPrompt: "Say done.", + }; + + const created = await ctx.client.createAgent(request); + + expect(created.cwd).not.toBe(repoDir); + const listedWithWorktree = await ctx.client.getPaseoWorktreeList({ cwd: repoDir }); + expect(listedWithWorktree.worktrees).toEqual([ + expect.objectContaining({ + worktreePath: created.cwd, + branchName: "agent-lifecycle-dispatch-test", + }), + ]); + + await ctx.client.waitForFinish(created.id, 10000); + + await expectAgentAbsentFromActiveList(created.id); + await expectWorktreeAbsentFromList(repoDir, created.cwd); +}, 30000); + +test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => { + const repoDir = createGitRepo(); + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd: repoDir, + }, + autoArchive: true, + initialPrompt: "Say done.", + }); + + await ctx.client.waitForFinish(created.id, 10000); + + await expectAgentAbsentFromActiveList(created.id); + const archived = await ctx.client.fetchAgents({ filter: { includeArchived: true } }); + expect(archived.entries.map((entry) => entry.agent.id)).toContain(created.id); + const worktrees = await ctx.client.getPaseoWorktreeList({ cwd: repoDir }); + expect(worktrees.worktrees).toEqual([]); +}); + +test("create_agent_request with autoArchive archives an agent whose first turn fails", async () => { + const repoDir = createGitRepo(); + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd: repoDir, + }, + autoArchive: true, + initialPrompt: "Emit a turn failure.", + }); + + await ctx.client.waitForFinish(created.id, 10000); + + await expectAgentAbsentFromActiveList(created.id); + const archived = await ctx.client.fetchAgents({ filter: { includeArchived: true } }); + expect(archived.entries.map((entry) => entry.agent.id)).toContain(created.id); +}); + +test("create_agent_request without autoArchive keeps today's active listing behavior", async () => { + const repoDir = createGitRepo(); + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd: repoDir, + }, + initialPrompt: "Say done.", + }); + + await ctx.client.waitForFinish(created.id, 10000); + + await expectAgentPresentInActiveList(created.id); +}); + +test("create_agent_request with worktree but no autoArchive leaves agent and worktree active", async () => { + const created = await createAgentInBranchOffWorktree(); + + await ctx.client.waitForFinish(created.agentId, 10000); + + await expectAgentPresentInActiveList(created.agentId); + await expectWorktreePresentInList(created.repoDir, created.worktreePath); + + await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); +}); + +test("archiving a created worktree still archives nested agents", async () => { + const created = await createAgentInBranchOffWorktree(); + + await ctx.client.waitForFinish(created.agentId, 10000); + await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); + + await expectAgentAbsentFromActiveList(created.agentId); + await expectWorktreeAbsentFromList(created.repoDir, created.worktreePath); +}); + +test("create_agent_request rejects legacy git options before creating a worktree", async () => { + const repoDir = createGitRepo(); + + await expect( + ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd: repoDir, + }, + git: { + createNewBranch: true, + newBranchName: "legacy-agent-branch", + }, + worktree: { + mode: "branch-off", + newBranch: "agent-lifecycle-dispatch-test", + base: "main", + }, + initialPrompt: "Say done.", + }), + ).rejects.toThrow("worktree cannot be combined with git options"); + + await expectActiveAgentListEmpty(); + await expectWorktreeListEmpty(repoDir); +}); + +test("create_agent_request fails cleanly when worktree creation cannot resolve target", async () => { + const repoDir = createGitRepo(); + + await expect( + ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd: repoDir, + }, + worktree: { + mode: "checkout-branch", + branch: "does-not-exist", + }, + initialPrompt: "Say done.", + }), + ).rejects.toThrow(); + + await expectActiveAgentListEmpty(); + await expectWorktreeListEmpty(repoDir); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index edfc7c18c..a1eca86b8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -252,6 +252,7 @@ import { handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, } from "./worktree-session.js"; import { toWorktreeWireError } from "./worktree-errors.js"; +import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; @@ -826,6 +827,7 @@ export class Session { private readonly serverId: string | undefined; private readonly daemonVersion: string | undefined; private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"]; + private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; private voiceModeAgentId: string | null = null; private voiceModeBaseConfig: VoiceModeBaseConfig | null = null; @@ -911,6 +913,35 @@ export class Session { isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), sessionLogger: this.sessionLogger, }); + this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({ + paseoHome: this.paseoHome, + agentManager: this.agentManager, + agentStorage: this.agentStorage, + github: this.github, + workspaceGitService: this.workspaceGitService, + createPaseoWorktreeWorkflow: (input, workflowOptions) => + this.createPaseoWorktreeWorkflow(input, workflowOptions), + archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId), + archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + emit: (message) => this.emit(message), + emitAgentRemove: (agentId) => { + if (this.agentUpdatesSubscription) { + this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, { + kind: "remove", + agentId, + }); + } + }, + emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => + this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds), + markWorkspaceArchiving: (workspaceIds, archivingAt) => + this.markWorkspaceArchiving(workspaceIds, archivingAt), + clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds), + isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), + killTerminalsUnderPath: (rootPath) => + this.terminalController.killTerminalsUnderPath(rootPath), + logger: this.sessionLogger, + }); this.providerSnapshotManager = providerSnapshotManager ?? null; this.scriptRouteStore = scriptRouteStore ?? null; this.scriptRuntimeStore = scriptRuntimeStore ?? null; @@ -3081,6 +3112,8 @@ export class Session { clientMessageId, outputSchema, git, + worktree, + autoArchive, images, attachments, labels, @@ -3093,6 +3126,8 @@ export class Session { }`, ); + let createdWorktreeForCleanup: CreatePaseoWorktreeWorkflowResult | null = null; + let createdAgentId: string | null = null; try { const trimmedPrompt = initialPrompt?.trim(); const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({ @@ -3108,8 +3143,18 @@ export class Session { ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}), ...(attachments && attachments.length > 0 ? { attachments } : {}), }; + const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({ + cwd: config.cwd, + target: worktree, + firstAgentContext, + hasLegacyGitOptions: Boolean(git), + }); + createdWorktreeForCleanup = createdWorktree; + const createAgentConfig: AgentSessionConfig = createdWorktree + ? { ...resolvedConfig, cwd: createdWorktree.worktree.worktreePath } + : resolvedConfig; const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig( - resolvedConfig, + createAgentConfig, git, worktreeName, firstAgentContext, @@ -3127,7 +3172,13 @@ export class Session { initialPrompt: trimmedPrompt, env, }); + createdAgentId = snapshot.id; await this.forwardAgentUpdate(snapshot); + this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({ + autoArchive, + agentId: snapshot.id, + createdWorktree, + }); await this.sendInitialCreateAgentPrompt({ snapshot, @@ -3161,6 +3212,10 @@ export class Session { `Created agent ${snapshot.id} (${snapshot.provider})`, ); } catch (error) { + await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({ + createdWorktree: createdWorktreeForCleanup, + createdAgentId, + }); const wireError = toWorktreeWireError(error); this.sessionLogger.error({ err: error }, "Failed to create agent"); if (requestId) { diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index ac2068568..559a2c381 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -703,6 +703,17 @@ class FakeAgentSession implements AgentSession { await this.appendHistoryEvent(turnStarted); this.notifySubscribers(turnStarted); + if (textPrompt.toLowerCase().includes("emit a turn failure")) { + const failed: AgentStreamEvent = { + type: "turn_failed", + provider: this.providerName, + error: "Requested fake provider failure", + }; + await this.appendHistoryEvent(failed); + this.notifySubscribers(failed); + return; + } + const stress = parseAgentStreamStressPrompt(textPrompt); if (stress !== null) { await this.emitStressTurn(stress); diff --git a/packages/server/src/shared/messages.create-agent-worktree-autoarchive.test.ts b/packages/server/src/shared/messages.create-agent-worktree-autoarchive.test.ts new file mode 100644 index 000000000..5828976d1 --- /dev/null +++ b/packages/server/src/shared/messages.create-agent-worktree-autoarchive.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; + +import { SessionInboundMessageSchema } from "./messages.js"; + +describe("create_agent_request worktree and autoArchive fields", () => { + test("accepts optional worktree branch-off target and autoArchive", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "create_agent_request", + requestId: "create-agent-worktree", + config: { + provider: "codex", + cwd: "/repo/app", + }, + worktree: { + mode: "branch-off", + newBranch: "agent-lifecycle-dispatch", + base: "main", + }, + autoArchive: true, + }); + + expect(parsed).toEqual({ + type: "create_agent_request", + requestId: "create-agent-worktree", + config: { + provider: "codex", + cwd: "/repo/app", + }, + worktree: { + mode: "branch-off", + newBranch: "agent-lifecycle-dispatch", + base: "main", + }, + autoArchive: true, + labels: {}, + }); + }); + + test("keeps legacy create_agent_request defaults unchanged", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "create_agent_request", + requestId: "legacy-create-agent", + config: { + provider: "codex", + cwd: "/repo/app", + }, + }); + + expect(parsed).toEqual({ + type: "create_agent_request", + requestId: "legacy-create-agent", + config: { + provider: "codex", + cwd: "/repo/app", + }, + labels: {}, + }); + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 497b241ad..11ccb7715 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1058,6 +1058,24 @@ const GitSetupOptionsSchema = z.object({ export type GitSetupOptions = z.infer; +export const CreateAgentWorktreeTargetSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("branch-off"), + newBranch: z.string().min(1), + base: z.string().min(1).optional(), + }), + z.object({ + mode: z.literal("checkout-branch"), + branch: z.string().min(1), + }), + z.object({ + mode: z.literal("checkout-pr"), + prNumber: z.number().int().positive(), + }), +]); + +export type CreateAgentWorktreeTarget = z.infer; + export const CreateAgentRequestMessageSchema = z.object({ type: z.literal("create_agent_request"), config: AgentSessionConfigSchema, @@ -1070,6 +1088,8 @@ export const CreateAgentRequestMessageSchema = z.object({ images: z.array(ImageAttachmentSchema).optional(), attachments: AgentAttachmentsSchema, git: GitSetupOptionsSchema.optional(), + worktree: CreateAgentWorktreeTargetSchema.optional(), + autoArchive: z.boolean().optional(), labels: z.record(z.string()).default({}), requestId: z.string(), });