mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user