Create agents in worktrees with auto-archive (#1120)

* Add daemon worktree auto-archive spawning

* Refactor create agent lifecycle dispatch

* Stabilize Claude autonomous turn test
This commit is contained in:
Mohamed Boudra
2026-05-20 22:41:15 +08:00
committed by GitHub
parent 68d88f0928
commit 3bca1a72e8
10 changed files with 722 additions and 9 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -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<string, string>;
@@ -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 }

View File

@@ -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<unknown>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: (message: SessionOutboundMessage) => void;
emitAgentRemove: (agentId: string) => void;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
logger: pino.Logger;
}
export class CreateAgentLifecycleDispatch {
private readonly autoArchiveAgentIds = new Set<string>();
constructor(private readonly dependencies: CreateAgentLifecycleDispatchDependencies) {}
async createWorktreeForRequest(input: {
cwd: string;
target: CreateAgentWorktreeTarget | undefined;
firstAgentContext: FirstAgentContext;
hasLegacyGitOptions: boolean;
}): Promise<CreatePaseoWorktreeWorkflowResult | null> {
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<void> {
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<CreatePaseoWorktreeWorkflowResult> {
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<void> {
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<void> {
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);
}
}
}

View File

@@ -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<AgentStreamEvent[]> {
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",

View File

@@ -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<void> {
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<void> {
const active = await ctx.client.fetchAgents();
expect(active.entries.map((entry) => entry.agent.id)).toContain(agentId);
}
async function expectActiveAgentListEmpty(): Promise<void> {
const active = await ctx.client.fetchAgents();
expect(active.entries).toEqual([]);
}
async function expectWorktreeAbsentFromList(repoDir: string, worktreePath: string): Promise<void> {
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<void> {
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<void> {
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);
});

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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: {},
});
});
});

View File

@@ -1058,6 +1058,24 @@ const GitSetupOptionsSchema = z.object({
export type GitSetupOptions = z.infer<typeof GitSetupOptionsSchema>;
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<typeof CreateAgentWorktreeTargetSchema>;
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(),
});