Make workspace, agent, and schedule automation consistent (#2186)

* feat(workspaces): align agent and schedule automation

Make workspace identity the shared placement boundary for MCP and CLI, while caller identity determines parentage. Keep heartbeats minimal and cron-based without changing legacy rolling interval semantics.

* test(cli): expect canonical schedule cadence

* fix(automation): preserve workspace and schedule compatibility

* fix(automation): preserve compatibility edges

* fix(workspaces): align MCP lifecycle resolution

* fix(workspaces): preserve creation intent

* fix(workspaces): preserve branch and schedule identity
This commit is contained in:
Mohamed Boudra
2026-07-18 14:04:48 +02:00
committed by GitHub
parent 98f6611362
commit ffe76a7e57
66 changed files with 2791 additions and 924 deletions
@@ -38,13 +38,17 @@ function createCapturedLogger(): CapturedLogger {
interface FinishNotificationScenarioOptions {
childLastAssistantMessage?: string | null;
childParentAgentId?: string | null;
requireParentOwnership?: boolean;
parentPromptError?: Error;
logger?: Logger;
}
interface FinishNotificationScenario {
startWatchingChild(): void;
finishChild(): void;
finishChildAndReadParentPrompt(): Promise<string>;
wasParentPrompted(): boolean;
}
function createFinishNotificationScenario(
@@ -52,6 +56,7 @@ function createFinishNotificationScenario(
): FinishNotificationScenario {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
let resolveParentPrompt: ((prompt: string) => void) | null = null;
let parentPrompted = false;
const childAgent: ManagedAgent = Object.create(null);
Reflect.set(childAgent, "id", "child-agent");
@@ -85,6 +90,7 @@ function createFinishNotificationScenario(
Reflect.set(agentManager, "tryRunOutOfBand", () => false);
Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError));
Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => {
parentPrompted = true;
resolveParentPrompt?.(prompt);
return (async function* noop() {})();
});
@@ -96,7 +102,12 @@ function createFinishNotificationScenario(
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
Reflect.set(agentStorage, "get", async (agentId: string) => {
if (agentId === "child-agent") {
return { title: "Child Agent" };
const parentAgentId =
options?.childParentAgentId === undefined ? "caller-agent" : options.childParentAgentId;
return {
title: "Child Agent",
labels: parentAgentId ? { "paseo.parent-agent-id": parentAgentId } : {},
};
}
return null;
});
@@ -108,14 +119,11 @@ function createFinishNotificationScenario(
agentStorage,
childAgentId: "child-agent",
callerAgentId: "caller-agent",
requireParentOwnership: options?.requireParentOwnership,
logger: options?.logger ?? createTestLogger(),
});
},
async finishChildAndReadParentPrompt() {
const parentPrompt = new Promise<string>((resolve) => {
resolveParentPrompt = resolve;
});
finishChild() {
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
@@ -127,9 +135,18 @@ function createFinishNotificationScenario(
type: "agent_state",
agent: childAgent,
});
},
async finishChildAndReadParentPrompt() {
const parentPrompt = new Promise<string>((resolve) => {
resolveParentPrompt = resolve;
});
this.finishChild();
return parentPrompt;
},
wasParentPrompted() {
return parentPrompted;
},
};
}
@@ -192,6 +209,26 @@ test("finish notifications tell the parent the child's last assistant message",
);
});
test("detaching a child ends its parent-owned finish notification", async () => {
const scenario = createFinishNotificationScenario({
childParentAgentId: null,
requireParentOwnership: true,
});
scenario.startWatchingChild();
scenario.finishChild();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(scenario.wasParentPrompted()).toBe(false);
});
test("follow-up finish notifications do not require a parent relationship", async () => {
const scenario = createFinishNotificationScenario({ childParentAgentId: "another-agent" });
scenario.startWatchingChild();
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
expect(parentPrompt).toContain("Agent child-agent (Child Agent) finished.");
});
test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => {
const captured = createCapturedLogger();
const scenario = createFinishNotificationScenario({
@@ -4,6 +4,7 @@ import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
@@ -244,6 +245,7 @@ export interface SetupFinishNotificationParams {
agentStorage: AgentStorage;
childAgentId: string;
callerAgentId: string;
requireParentOwnership?: boolean;
logger: Logger;
}
@@ -264,7 +266,14 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri
}
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
const { agentManager, agentStorage, childAgentId, callerAgentId, logger } = params;
const {
agentManager,
agentStorage,
childAgentId,
callerAgentId,
requireParentOwnership = false,
logger,
} = params;
let hasSeenRunning = false;
let fired = false;
let unsubscribe: (() => void) | null = null;
@@ -282,6 +291,9 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
}
const record = await agentStorage.get(childAgentId);
if (requireParentOwnership && getParentAgentIdFromLabels(record?.labels) !== callerAgentId) {
return;
}
const title = record?.title ?? childAgentId;
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
const body = formatFinishNotificationBody({
@@ -1,6 +1,5 @@
import type { Logger } from "pino";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
@@ -26,6 +25,7 @@ import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "../timeline-append.js";
import { resolveCreateAgentIntent } from "./intent.js";
export interface CreateAgentSessionWorktreeResult {
sessionConfig: AgentSessionConfig;
@@ -208,6 +208,7 @@ export async function createAgentCommand(
agentStorage: dependencies.agentStorage,
childAgentId: snapshot.id,
callerAgentId: input.callerAgentId,
requireParentOwnership: true,
logger: dependencies.logger,
});
}
@@ -317,13 +318,21 @@ async function resolveMcpCreateAgent(
});
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
const workspaceId = await resolveMcpWorkspaceId({
dependencies,
input,
parentAgent,
setupContinuation,
createdWorkspaceId,
resolvedCwd,
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
caller: parentAgent
? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId }
: null,
labels: input.labels,
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
legacyDetached: input.detached ?? false,
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: resolvedCwd }),
createWorkspace: async () => ({
workspaceId: requireResolvedWorkspaceId(
await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt ?? ""),
),
cwd: resolvedCwd,
}),
});
const resolvedCreateConfig = await resolveMcpProviderCreateConfig({
dependencies,
@@ -333,27 +342,20 @@ async function resolveMcpCreateAgent(
parentAgent,
});
const labels = mergeLabels({
callerAgentId: input.callerAgentId,
detached: input.detached ?? false,
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
labels: input.labels,
});
const trimmedPrompt = input.initialPrompt?.trim() ?? "";
return {
config: buildMcpSessionConfig({
input,
resolvedProviderModel,
provider,
resolvedCwd,
resolvedCwd: intent.cwd,
trimmedPrompt,
resolvedMode: resolvedCreateConfig.modeId,
resolvedFeatures: resolvedCreateConfig.featureValues,
}),
createOptions: {
...(labels ? { labels } : {}),
workspaceId: requireResolvedWorkspaceId(workspaceId),
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
workspaceId: intent.workspaceId,
owner: input.owner,
env: input.env,
},
@@ -380,34 +382,6 @@ function resolveMcpInitialCwd(
});
}
async function resolveMcpWorkspaceId(params: {
dependencies: CreateAgentCommandDependencies;
input: CreateAgentFromMcpInput;
parentAgent: ManagedAgent | null;
setupContinuation?: AgentWorktreeSetupContinuation;
createdWorkspaceId?: string;
resolvedCwd: string;
}): Promise<string | undefined> {
// MCP callers resolve workspace ownership before this point. Worktree
// creation wins because the new agent lives in the fresh worktree workspace.
// Otherwise use the explicit workspace id, then the parent workspace for
// direct internal callers. Ownership is never resolved from cwd.
if (params.setupContinuation) {
return params.createdWorkspaceId;
}
if (params.input.workspaceId) {
return params.input.workspaceId;
}
if (params.parentAgent?.workspaceId) {
return params.parentAgent.workspaceId;
}
return ensureWorkspaceForMcpCreate(
params.dependencies,
params.resolvedCwd,
params.input.initialPrompt ?? "",
);
}
async function resolveMcpProviderCreateConfig(params: {
dependencies: CreateAgentCommandDependencies;
input: CreateAgentFromMcpInput;
@@ -624,22 +598,3 @@ async function createMcpWorktree(
throw toWorktreeRequestError(error);
}
}
function mergeLabels(params: {
callerAgentId: string | undefined;
detached: boolean;
childAgentDefaultLabels: Record<string, string> | undefined;
labels: Record<string, string> | undefined;
}): Record<string, string> | undefined {
const mergedLabels = {
...(!params.detached && params.callerAgentId
? { [PARENT_AGENT_ID_LABEL]: params.callerAgentId }
: {}),
...params.childAgentDefaultLabels,
...params.labels,
};
if (params.detached) {
delete mergedLabels[PARENT_AGENT_ID_LABEL];
}
return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined;
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { resolveCreateAgentIntent } from "./intent.js";
describe("resolveCreateAgentIntent", () => {
it("keeps caller parentage when an explicit workspace changes placement", async () => {
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: "workspace-isolated",
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
labels: { purpose: "review" },
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/isolated" }),
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
});
expect(intent).toEqual({
workspaceId: "workspace-isolated",
cwd: "/isolated",
parentAgentId: "parent-agent",
labels: {
purpose: "review",
[PARENT_AGENT_ID_LABEL]: "parent-agent",
},
});
});
it("defaults an agent caller to its workspace without creating one", async () => {
let createCount = 0;
const intent = await resolveCreateAgentIntent({
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
createWorkspace: async () => {
createCount += 1;
return { workspaceId: "workspace-created", cwd: "/created" };
},
});
expect(intent.workspaceId).toBe("workspace-parent");
expect(intent.cwd).toBe("/parent");
expect(intent.parentAgentId).toBe("parent-agent");
expect(createCount).toBe(0);
});
it("creates a workspace for a human caller with no workspace context", async () => {
const intent = await resolveCreateAgentIntent({
caller: null,
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
});
expect(intent).toEqual({
workspaceId: "workspace-created",
cwd: "/created",
parentAgentId: null,
labels: {},
});
});
it("keeps legacy detached creation independent", async () => {
const intent = await resolveCreateAgentIntent({
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
labels: { [PARENT_AGENT_ID_LABEL]: "spoofed-parent" },
legacyDetached: true,
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
});
expect(intent).toEqual({
workspaceId: "workspace-parent",
cwd: "/parent",
parentAgentId: null,
labels: {},
});
});
});
@@ -0,0 +1,64 @@
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
export interface CreateAgentCaller {
id: string;
cwd: string;
workspaceId?: string;
}
export interface CreateAgentPlacement {
workspaceId: string;
cwd: string;
}
export interface CreateAgentIntent {
workspaceId: string;
cwd: string;
parentAgentId: string | null;
labels: Record<string, string>;
}
export async function resolveCreateAgentIntent(input: {
explicitWorkspaceId?: string;
caller: CreateAgentCaller | null;
labels?: Record<string, string>;
childAgentDefaultLabels?: Record<string, string>;
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
createWorkspace: () => Promise<CreateAgentPlacement>;
legacyDetached?: boolean;
}): Promise<CreateAgentIntent> {
const parentAgentId = input.legacyDetached ? null : (input.caller?.id ?? null);
const placement = await resolvePlacement(input);
const labels = {
...input.childAgentDefaultLabels,
...input.labels,
...(parentAgentId ? { [PARENT_AGENT_ID_LABEL]: parentAgentId } : {}),
};
// COMPAT(detachedCreate): legacy callers may still request detached creation.
// Added in v0.2.0; remove after 2027-01-17 once detached creation is outside the floor.
// The delete also strips a parent label injected through input.labels.
if (input.legacyDetached) {
delete labels[PARENT_AGENT_ID_LABEL];
}
return { ...placement, parentAgentId, labels };
}
async function resolvePlacement(input: {
explicitWorkspaceId?: string;
caller: CreateAgentCaller | null;
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
createWorkspace: () => Promise<CreateAgentPlacement>;
}): Promise<CreateAgentPlacement> {
if (input.explicitWorkspaceId) {
return input.resolveWorkspace(input.explicitWorkspaceId);
}
if (input.caller) {
if (!input.caller.workspaceId) {
throw new Error(`Caller agent ${input.caller.id} has no workspace`);
}
return { workspaceId: input.caller.workspaceId, cwd: input.caller.cwd };
}
return input.createWorkspace();
}
@@ -1202,6 +1202,42 @@ describe("create_agent MCP tool", () => {
).toBe(true);
});
it("creates a fresh local workspace for canonical top-level creation", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "top-level-agent",
provider: "codex",
cwd: existingCwd,
workspaceId: "workspace-created",
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Top-level agent" },
} as ManagedAgent);
const ensureWorkspace = vi.fn(async () => "workspace-created");
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate: ensureWorkspace,
logger,
});
await registeredTool(server, "create_agent").handler({
title: "Top-level agent",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
});
expect(ensureWorkspace).toHaveBeenCalledWith(existingCwd, { prompt: "Do work" });
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ cwd: existingCwd }),
undefined,
{ workspaceId: "workspace-created" },
);
});
it("rejects partial explicit workspace shape", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
@@ -1508,7 +1544,7 @@ describe("create_agent MCP tool", () => {
expect(parsed.success).toBe(true);
});
it("accepts each create_worktree target kind", async () => {
it("exposes workspace tools instead of worktree tools", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
@@ -1516,31 +1552,14 @@ describe("create_agent MCP tool", () => {
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_worktree");
for (const target of [
{ kind: "branch-off", worktreeSlug: "feature-x", baseBranch: "main" },
{ kind: "branch-off" },
{ kind: "checkout-branch", branch: "head-ref" },
{ kind: "checkout-pr", githubPrNumber: 42 },
] as const) {
const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, target });
expect(parsed.success).toBe(true);
}
});
it("rejects create_worktree without a target", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_worktree");
const parsed = await tool.inputSchema.safeParseAsync({});
expect(parsed.success).toBe(false);
expect(lookupTool(server, "create_workspace")).toBeDefined();
expect(lookupTool(server, "list_workspaces")).toBeDefined();
expect(lookupTool(server, "archive_workspace")).toBeDefined();
expect(lookupTool(server, "create_worktree")).toBeUndefined();
expect(lookupTool(server, "list_worktrees")).toBeUndefined();
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
expect(lookupTool(server, "detach_agent")).toBeUndefined();
expect(lookupTool(server, "update_heartbeat")).toBeUndefined();
});
it("surfaces createAgent validation failures", async () => {
@@ -2443,7 +2462,7 @@ describe("create_agent MCP tool", () => {
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
});
it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => {
it("creates a worktree-isolated workspace", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
const repoDir = join(tempDir, "repo");
@@ -2488,25 +2507,19 @@ describe("create_agent MCP tool", () => {
>,
logger,
});
const tool = registeredTool(server, "create_worktree");
const tool = registeredTool(server, "create_workspace");
const response = await tool.handler({
cwd: repoDir,
target: {
kind: "branch-off",
worktreeSlug: "tool-worktree",
branchName: "feature/tool-worktree",
baseBranch: "main",
},
isolation: "worktree",
path: repoDir,
worktreeSlug: "tool-worktree",
branchName: "feature/tool-worktree",
baseBranch: "main",
});
expect(response.structuredContent.branchName).toBe("feature/tool-worktree");
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
expect(response.structuredContent.isolation).toBe("worktree");
expect(response.structuredContent.cwd).toContain("tool-worktree");
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:create-worktree",
});
expect(setupContinuations).toEqual([undefined]);
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
@@ -2515,7 +2528,149 @@ describe("create_agent MCP tool", () => {
}
});
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
it("creates a worktree workspace from a project root without a path", async () => {
const { agentManager, agentStorage } = createTestDeps();
const project = createPersistedProjectRecord({
projectId: "project-source",
rootPath: REPO_CWD,
kind: "git",
displayName: "source",
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
});
const receivedInputs: CreatePaseoWorktreeInput[] = [];
const createPaseoWorktree: CreatePaseoWorktreeWorkflowFn = async (input) => {
receivedInputs.push(input);
return {
worktree: { branchName: "project-worktree", worktreePath: TARGET_CWD },
intent: { kind: "branch-off", branchName: "project-worktree", baseBranch: "main" },
workspace: createPersistedWorkspaceRecord({
workspaceId: "ws-project-source",
projectId: project.projectId,
cwd: TARGET_CWD,
kind: "worktree",
displayName: "project-worktree",
title: input.title ?? null,
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
}),
repoRoot: REPO_CWD,
created: true,
};
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
projectRegistry: {
get: async (projectId) => (projectId === project.projectId ? project : null),
},
createPaseoWorktree,
logger,
});
const response = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), {
isolation: "worktree",
projectId: project.projectId,
worktreeSlug: "project-worktree",
title: "Project workspace",
});
expect(response.structuredContent.workspaceId).toBe("ws-project-source");
expect(receivedInputs).toEqual([
expect.objectContaining({
cwd: REPO_CWD,
projectId: project.projectId,
title: "Project workspace",
}),
]);
});
it("preserves branch checkout and pull request checkout workspace modes", async () => {
const { agentManager, agentStorage } = createTestDeps();
const createPaseoWorktree = vi.fn(async (input: CreatePaseoWorktreeInput) => ({
worktree: {
branchName: input.refName ?? "pr-42",
worktreePath: "/tmp/worktrees/selected",
},
intent: {
kind: "checkout-branch" as const,
branchName: input.refName ?? "pr-42",
},
workspace: createPersistedWorkspaceRecord({
workspaceId: "ws-selected",
projectId: "project-1",
cwd: "/tmp/worktrees/selected",
kind: "worktree",
displayName: "selected",
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
}),
repoRoot: REPO_CWD,
created: true,
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
createPaseoWorktree,
logger,
});
const tool = registeredTool(server, "create_workspace");
await invokeToolWithParsedInput(tool, {
isolation: "worktree",
path: REPO_CWD,
mode: "checkout-branch",
branch: "existing-work",
worktreeSlug: "existing-work-copy",
});
await invokeToolWithParsedInput(tool, {
isolation: "worktree",
path: REPO_CWD,
mode: "checkout-pr",
prNumber: 42,
forge: "gitlab",
});
await invokeToolWithParsedInput(tool, {
isolation: "worktree",
path: REPO_CWD,
mode: "checkout-pr",
prNumber: 43,
});
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
action: "checkout",
refName: "existing-work",
worktreeSlug: "existing-work-copy",
}),
);
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
action: "checkout",
checkoutSource: {
kind: "change_request",
forge: "gitlab",
number: 42,
},
}),
);
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
action: "checkout",
checkoutSource: {
kind: "change_request",
number: 43,
},
}),
);
});
it("archives a worktree-isolated workspace by workspace id", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
@@ -2568,13 +2723,15 @@ describe("create_agent MCP tool", () => {
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const createTool = registeredTool(server, "create_workspace");
const archiveTool = registeredTool(server, "archive_workspace");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-tool-worktree", baseBranch: "main" },
isolation: "worktree",
path: repoDir,
worktreeSlug: "archive-tool-worktree",
baseBranch: "main",
});
const createdWorktreePath = z.string().parse(created.structuredContent.worktreePath);
const createdWorktreePath = z.string().parse(created.structuredContent.cwd);
listActiveWorkspaces.mockImplementation(async () => [
{ workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" },
]);
@@ -2584,19 +2741,13 @@ describe("create_agent MCP tool", () => {
workspaceGitService.getSnapshot.mockClear();
await archiveTool.handler({
cwd: repoDir,
worktreePath: created.structuredContent.worktreePath,
workspaceId: "ws-archive-tool-worktree",
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "archive-worktree",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:archive-worktree",
});
expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
["ws-archive-tool-worktree"],
@@ -2611,7 +2762,22 @@ describe("create_agent MCP tool", () => {
}
});
it("archives every workspace on a directory and removes the directory", async () => {
it("rejects archiving a missing workspace", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
listActiveWorkspaces: async () => [],
logger,
});
await expect(
registeredTool(server, "archive_workspace").handler({ workspaceId: "missing-workspace" }),
).rejects.toThrow("Workspace not found: missing-workspace");
});
it("keeps an owned worktree while another workspace still references it", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-multi-")),
@@ -2670,13 +2836,15 @@ describe("create_agent MCP tool", () => {
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const createTool = registeredTool(server, "create_workspace");
const archiveTool = registeredTool(server, "archive_workspace");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-multi-worktree", baseBranch: "main" },
isolation: "worktree",
path: repoDir,
worktreeSlug: "archive-multi-worktree",
baseBranch: "main",
});
const worktreePath = z.string().parse(created.structuredContent.worktreePath);
const worktreePath = z.string().parse(created.structuredContent.cwd);
// Populate the active workspaces with the real created path so archiveByScope
// matches it against the worktree directory.
@@ -2686,19 +2854,18 @@ describe("create_agent MCP tool", () => {
];
await archiveTool.handler({
cwd: repoDir,
worktreePath,
workspaceId: "ws-mcp-A",
});
expect(archivedWorkspaceIds).toContain("ws-mcp-A");
expect(archivedWorkspaceIds).toContain("ws-mcp-B");
await expect(access(worktreePath)).rejects.toThrow();
expect(archivedWorkspaceIds).not.toContain("ws-mcp-B");
await expect(access(worktreePath)).resolves.toBeUndefined();
} finally {
await removeTempDir(tempDir);
}
});
it("archives a worktree by slug", async () => {
it("does not expose worktree path or slug operations", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")),
@@ -2746,71 +2913,41 @@ describe("create_agent MCP tool", () => {
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-slug-worktree", baseBranch: "main" },
});
const response = await archiveTool.handler({
cwd: repoDir,
worktreeSlug: "archive-slug-worktree",
});
expect(response.structuredContent).toEqual({ success: true });
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "archive-worktree",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:archive-worktree",
});
await expect(
access(z.string().parse(created.structuredContent.worktreePath)),
).rejects.toThrow();
expect(lookupTool(server, "create_worktree")).toBeUndefined();
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
} finally {
await removeTempDir(tempDir);
}
});
it("routes list_worktrees through WorkspaceGitService", async () => {
it("lists active workspace descriptors", async () => {
const { agentManager, agentStorage } = createTestDeps();
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => [
{
path: "/tmp/paseo/worktrees/repo/feature",
branchName: "feature",
createdAt: "2026-04-12T00:00:00.000Z",
},
]),
};
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-feature",
projectId: "project-1",
cwd: "/tmp/paseo/worktrees/repo/feature",
kind: "worktree",
displayName: "feature",
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:00:00.000Z",
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
workspaceRegistry: {
get: vi.fn(async () => workspace),
list: vi.fn(async () => [workspace]),
upsert: vi.fn(async () => undefined),
},
logger,
});
const tool = registeredTool(server, "list_worktrees");
const tool = registeredTool(server, "list_workspaces");
const response = await tool.handler({ cwd: REPO_CWD });
const response = await tool.handler({});
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
reason: "mcp:list-worktrees",
});
expect(response.structuredContent.worktrees).toEqual([
{
path: "/tmp/paseo/worktrees/repo/feature",
branchName: "feature",
createdAt: "2026-04-12T00:00:00.000Z",
},
expect(response.structuredContent.workspaces).toEqual([
expect.objectContaining({ workspaceId: "ws-feature", isolation: "worktree" }),
]);
});
@@ -3835,11 +3972,11 @@ describe("create_schedule MCP tool", () => {
cron: "*/5 * * * *",
name: "Default schedule",
}),
).rejects.toThrow("provider is required when target is new-agent");
).rejects.toThrow("provider");
expect(createOrReplace).not.toHaveBeenCalled();
});
it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => {
it("keeps provider forms compatible without materializing default schedule isolation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
createStoredSchedule(input),
@@ -3863,6 +4000,12 @@ describe("create_schedule MCP tool", () => {
cron: "*/10 * * * *",
provider: "codex/gpt-5.4",
});
await tool.handler({
prompt: "say hello in a worktree",
cron: "*/15 * * * *",
provider: "codex",
isolation: "worktree",
});
expect(createOrReplace).toHaveBeenNthCalledWith(
1,
@@ -3889,9 +4032,22 @@ describe("create_schedule MCP tool", () => {
},
}),
);
expect(createOrReplace).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
target: {
type: "new-agent",
config: {
provider: "codex",
cwd: process.cwd(),
isolation: "worktree",
},
},
}),
);
});
it("returns create_schedule structured content with inherited feature values", async () => {
it("inherits the caller provider, model, and features when provider is omitted", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
@@ -3922,12 +4078,17 @@ describe("create_schedule MCP tool", () => {
const response = await tool.handler({
prompt: "say hello",
cron: "*/5 * * * *",
provider: "opencode/openai/gpt-5.5",
});
expect(response.structuredContent.target).toMatchObject({
expect(response.structuredContent.target).toEqual({
type: "new-agent",
config: { featureValues: { auto_accept: true } },
config: {
provider: "opencode",
cwd: REPO_CWD,
modeId: "build",
model: "openai/gpt-5.5",
featureValues: { auto_accept: true },
},
});
});
@@ -4103,6 +4264,60 @@ describe("create_heartbeat MCP tool", () => {
});
});
describe("heartbeat ownership MCP tools", () => {
const logger = createTestLogger();
it("deletes the caller's heartbeat", async () => {
const { agentManager, agentStorage } = createTestDeps();
const heartbeat = createStoredSchedule({
prompt: "check status",
cadence: { type: "cron", expression: "*/15 * * * *" },
target: { type: "agent", agentId: "parent-agent" },
});
const inspect = vi.fn(async () => heartbeat);
const deleteSchedule = vi.fn(async () => undefined);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: {
inspect,
delete: deleteSchedule,
} as unknown as ScheduleService,
callerAgentId: "parent-agent",
logger,
});
await registeredTool(server, "delete_heartbeat").handler({ id: heartbeat.id });
expect(deleteSchedule).toHaveBeenCalledWith(heartbeat.id);
});
it("rejects another agent's heartbeat", async () => {
const { agentManager, agentStorage } = createTestDeps();
const foreignHeartbeat = createStoredSchedule({
prompt: "foreign",
cadence: { type: "cron", expression: "0 * * * *" },
target: { type: "agent", agentId: "other-agent" },
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: {
inspect: vi.fn(async () => foreignHeartbeat),
delete: vi.fn(),
} as unknown as ScheduleService,
callerAgentId: "parent-agent",
logger,
});
await expect(
registeredTool(server, "delete_heartbeat").handler({ id: foreignHeartbeat.id }),
).rejects.toThrow("does not belong to caller");
});
});
describe("update_schedule MCP tool", () => {
const logger = createTestLogger();
@@ -4125,6 +4340,16 @@ describe("update_schedule MCP tool", () => {
};
}
function scheduleServiceWithUpdate(
update: (input: UpdateScheduleInput) => Promise<StoredSchedule>,
stored = makeStoredSchedule(),
): ScheduleService {
return {
update,
inspect: vi.fn(async () => stored),
} as unknown as ScheduleService;
}
it("calls scheduleService.update with correct input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
@@ -4138,7 +4363,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4164,7 +4389,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4176,7 +4401,7 @@ describe("update_schedule MCP tool", () => {
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence: { type: "every", everyMs: 600000 },
cadence: { type: "cron", expression: "*/10 * * * *" },
});
});
@@ -4188,7 +4413,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4217,7 +4442,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4230,7 +4455,7 @@ describe("update_schedule MCP tool", () => {
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence: { type: "every", everyMs: 600000 },
cadence: { type: "cron", expression: "*/10 * * * *" },
});
});
@@ -4238,7 +4463,7 @@ describe("update_schedule MCP tool", () => {
{
label: "whitespace cron field",
input: { id: "schedule-1", every: "10m", cron: " " },
cadence: { type: "every", everyMs: 600000 },
cadence: { type: "cron", expression: "*/10 * * * *" },
},
{
label: "blank every field for cron cadence",
@@ -4258,7 +4483,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4278,7 +4503,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4300,7 +4525,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4323,7 +4548,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4347,7 +4572,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4381,7 +4606,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4412,7 +4637,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4455,11 +4680,15 @@ describe("schedule_logs MCP tool", () => {
const { agentManager, agentStorage } = createTestDeps();
const runs = [makeRun({ id: "run-1" }), makeRun({ id: "run-2", status: "failed" })];
const logs = vi.fn(async (_id: string) => runs);
const inspect = vi.fn(async () => ({
id: "schedule-1",
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp" } },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { logs } as unknown as ScheduleService,
scheduleService: { logs, inspect } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "schedule_logs");
File diff suppressed because it is too large Load Diff
+10
View File
@@ -1210,6 +1210,16 @@ export async function createPaseoDaemon(
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
workspaceRegistry,
projectRegistry,
createDirectoryWorkspace: async (cwd, title, projectId) => {
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
cwd,
title,
projectId,
);
await emitWorkspaceUpdatesExternal([workspace.workspaceId]);
return workspace;
},
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate,
@@ -6,6 +6,7 @@ import path from "node:path";
import { DaemonClient } from "./test-utils/index.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
// The daemon-level workspace contract that `paseo run` depends on: each
// local-backed createWorkspace for a cwd mints a fresh, distinct workspace,
@@ -28,9 +29,10 @@ async function mintLocalWorkspace(client: DaemonClient, cwd: string): Promise<st
return result.workspace.id;
}
test("daemon mints a distinct local workspace per run and stamps agents by id", async () => {
test("daemon resolves human and managed CLI workspace ownership", async () => {
const daemon = await createTestPaseoDaemon();
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-cwd-"));
const otherCwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-other-cwd-"));
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
@@ -56,14 +58,13 @@ test("daemon mints a distinct local workspace per run and stamps agents by id",
const fetchedFirst = await client.fetchAgent({ agentId: firstAgent.id });
expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId);
// A second bare run in the SAME cwd mints a DISTINCT workspace; each run
// owns its own workspace rather than reattaching to the first.
const secondWorkspaceId = await mintLocalWorkspace(client, cwd);
// A second bare run mints a distinct workspace with its own authoritative cwd.
const secondWorkspaceId = await mintLocalWorkspace(client, otherCwd);
expect(secondWorkspaceId).not.toBe(firstWorkspaceId);
const secondAgent = await client.createAgent({
...getFullAccessConfig("codex"),
cwd,
cwd: otherCwd,
workspaceId: secondWorkspaceId,
title: "Second run agent",
});
@@ -80,15 +81,48 @@ test("daemon mints a distinct local workspace per run and stamps agents by id",
const idsBeforeAttach = await workspaceIds(client);
const attachedAgent = await client.createAgent({
...getFullAccessConfig("codex"),
cwd,
cwd: path.join(otherCwd, "stale-client-directory"),
workspaceId: firstWorkspaceId,
title: "Attached agent",
});
expect(attachedAgent.workspaceId).toBe(firstWorkspaceId);
expect(attachedAgent.cwd).toBe(cwd);
expect(await workspaceIds(client)).toEqual(idsBeforeAttach);
await expect(
client.createAgent({
...getFullAccessConfig("codex"),
cwd,
workspaceId: "wks_missing",
title: "Missing workspace agent",
}),
).rejects.toThrow("Workspace wks_missing not found");
const sameWorkspaceChild = await client.createAgent({
...getFullAccessConfig("codex"),
cwd: otherCwd,
callerAgentId: firstAgent.id,
title: "Same workspace child",
});
expect(sameWorkspaceChild.workspaceId).toBe(firstWorkspaceId);
expect(sameWorkspaceChild.cwd).toBe(firstAgent.cwd);
expect(sameWorkspaceChild.labels[PARENT_AGENT_ID_LABEL]).toBe(firstAgent.id);
expect(await workspaceIds(client)).toEqual(idsBeforeAttach);
const crossWorkspaceChild = await client.createAgent({
...getFullAccessConfig("codex"),
cwd,
workspaceId: secondWorkspaceId,
callerAgentId: firstAgent.id,
title: "Cross workspace child",
});
expect(crossWorkspaceChild.workspaceId).toBe(secondWorkspaceId);
expect(crossWorkspaceChild.cwd).toBe(otherCwd);
expect(crossWorkspaceChild.labels[PARENT_AGENT_ID_LABEL]).toBe(firstAgent.id);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
rmSync(otherCwd, { recursive: true, force: true });
}
}, 180000);
@@ -57,6 +57,7 @@ test("creates a worktree and registers it in the source workspace project withou
{
cwd: repoDir,
worktreeSlug: "feature-one",
title: "Feature One",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
@@ -70,6 +71,7 @@ test("creates a worktree and registers it in the source workspace project withou
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one");
expect(result.workspace.baseBranch).toBe("main");
expect(result.workspace.title).toBe("Feature One");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
@@ -31,6 +31,7 @@ import type { FirstAgentContext } from "@getpaseo/protocol/messages";
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
projectId?: string;
title?: string;
}
export interface CreatePaseoWorktreeResult {
@@ -89,7 +90,7 @@ export async function createPaseoWorktree(
worktreeRoot: createdWorktree.worktree.worktreePath,
branch: createdWorktree.worktree.branchName || null,
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
});
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
@@ -87,6 +87,8 @@ export function validateScheduleCadence(cadence: ScheduleCadence): void {
export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date {
if (cadence.type === "every") {
// COMPAT(scheduleEveryMs): execute legacy persisted rolling intervals until the
// compatibility floor reaches v0.2.0. Added in v0.2.0; remove after 2027-01-17.
return new Date(after.getTime() + cadence.everyMs);
}
+87 -55
View File
@@ -77,6 +77,7 @@ import type {
ManagedAgent,
} from "./agent/agent-manager.js";
import { createAgentCommand } from "./agent/create-agent/create.js";
import { resolveCreateAgentIntent, type CreateAgentIntent } from "./agent/create-agent/intent.js";
import {
archiveAgentCommand,
cancelAgentRunCommand,
@@ -160,6 +161,7 @@ import { PushTokenStore } from "./push/token-store.js";
import {
archivePersistedWorkspaceRecord,
archiveWorkspaceContents,
requireActiveWorkspaceForArchive,
} from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
@@ -236,6 +238,7 @@ import {
} from "./project-directory-service.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
import { resolveWorktreeSourceCwd } from "./workspace-source.js";
// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings.
// Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject
@@ -330,6 +333,14 @@ type FetchAgentsResponsePayload = Extract<
type FetchAgentsResponseEntry = FetchAgentsResponsePayload["entries"][number];
type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
type AgentUpdatesFilter = FetchAgentsRequestFilter;
type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_agent_request" }>;
interface ResolvedSessionCreateAgentIntent {
config: AgentSessionConfig;
intent: CreateAgentIntent;
createdDirectoryWorkspace: boolean;
}
type FetchWorkspacesRequestMessage = Extract<
SessionInboundMessage,
{ type: "fetch_workspaces_request" }
@@ -2778,9 +2789,7 @@ export class Session {
/**
* Handle create agent request
*/
private async handleCreateAgentRequest(
msg: Extract<SessionInboundMessage, { type: "create_agent_request" }>,
): Promise<void> {
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
const {
config,
worktreeName,
@@ -2793,7 +2802,6 @@ export class Session {
autoArchive,
images,
attachments,
labels,
env,
} = msg;
this.sessionLogger.info(
@@ -2807,7 +2815,9 @@ export class Session {
let createdAgentId: string | null = null;
try {
const requestedCwd = resolve(config.cwd);
if (!(await this.filesystem.isDirectory(requestedCwd))) {
const needsRequestedDirectory =
Boolean(worktreeName || git || worktree) || (!msg.workspaceId && !msg.callerAgentId);
if (needsRequestedDirectory && !(await this.filesystem.isDirectory(requestedCwd))) {
throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`);
}
const trimmedPrompt = initialPrompt?.trim();
@@ -2828,18 +2838,15 @@ export class Session {
hasLegacyGitOptions: Boolean(git),
});
createdWorktreeForCleanup = createdWorktree;
const createAgentConfig: AgentSessionConfig = createdWorktree
? { ...config, cwd: createdWorktree.workspace.cwd }
: config;
const workspaceId = await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent(
{
createdWorktree,
requestedWorkspaceId: msg.workspaceId,
cwd: createAgentConfig.cwd,
initialTitle: workspacePromptTitle,
},
);
const createdDirectoryWorkspaceForAgent = !createdWorktree && !msg.workspaceId;
const resolvedIntent = await this.resolveSessionCreateAgentIntent({
request: msg,
createdWorktree,
workspacePromptTitle,
});
const resolvedCwd = resolve(resolvedIntent.config.cwd);
if (!(await this.filesystem.isDirectory(resolvedCwd))) {
throw new Error(`Working directory does not exist or is not a directory: ${resolvedCwd}`);
}
const { snapshot, liveSnapshot } = await createAgentCommand(
{
@@ -2852,8 +2859,8 @@ export class Session {
},
{
kind: "session",
config: createAgentConfig,
workspaceId,
config: resolvedIntent.config,
workspaceId: resolvedIntent.intent.workspaceId,
worktreeName,
initialPrompt,
clientMessageId,
@@ -2861,7 +2868,7 @@ export class Session {
images,
attachments,
git,
labels,
labels: resolvedIntent.intent.labels,
env,
provisionalTitle,
firstAgentContext,
@@ -2871,14 +2878,14 @@ export class Session {
);
createdAgentId = snapshot.id;
await this.agentUpdates.forwardLiveAgent(snapshot);
if (createdDirectoryWorkspaceForAgent && trimmedPrompt) {
if (resolvedIntent.createdDirectoryWorkspace && trimmedPrompt) {
this.workspaceAutoName.scheduleForDirectory(
{
workspaceId,
cwd: createAgentConfig.cwd,
workspaceId: resolvedIntent.intent.workspaceId,
cwd: resolvedIntent.config.cwd,
firstAgentContext,
},
{ currentSelection: this.getFocusedAgentSelectionForCwd(createAgentConfig.cwd) },
{ currentSelection: this.getFocusedAgentSelectionForCwd(resolvedIntent.config.cwd) },
);
}
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
@@ -2933,6 +2940,55 @@ export class Session {
}
}
private async resolveSessionCreateAgentIntent(input: {
request: CreateAgentRequestMessage;
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
workspacePromptTitle: string | null;
}): Promise<ResolvedSessionCreateAgentIntent> {
const { request, createdWorktree } = input;
const callerAgent = request.callerAgentId
? this.agentManager.getAgent(request.callerAgentId)
: null;
if (request.callerAgentId && !callerAgent) {
throw new Error(`Caller agent ${request.callerAgentId} not found`);
}
let config = request.config;
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: createdWorktree?.workspace.workspaceId ?? request.workspaceId,
caller: callerAgent
? { id: callerAgent.id, cwd: callerAgent.cwd, workspaceId: callerAgent.workspaceId }
: null,
labels: request.labels,
resolveWorkspace: async (workspaceId) => {
if (createdWorktree?.workspace.workspaceId === workspaceId) {
return { workspaceId, cwd: createdWorktree.workspace.cwd };
}
const workspace = await this.workspaceRegistry.get(workspaceId);
if (!workspace || workspace.archivedAt) {
throw new Error(`Workspace ${workspaceId} not found`);
}
return { workspaceId, cwd: workspace.cwd };
},
createWorkspace: async () => ({
workspaceId: await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent({
createdWorktree: null,
cwd: config.cwd,
initialTitle: input.workspacePromptTitle,
}),
cwd: config.cwd,
}),
});
config = { ...config, cwd: intent.cwd };
return {
config,
intent,
createdDirectoryWorkspace: !createdWorktree && !request.workspaceId && !callerAgent,
};
}
private async handleResumeAgentRequest(
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>,
): Promise<void> {
@@ -4874,10 +4930,7 @@ export class Session {
return;
}
const sourceCwd = await this.resolveWorktreeSourceCwd({
cwd: source.cwd,
projectId: source.projectId,
});
const sourceCwd = await resolveWorktreeSourceCwd(source, this.projectRegistry);
const result = await this.createPaseoWorktreeWorkflow(
{
@@ -4886,24 +4939,17 @@ export class Session {
worktreeSlug: source.worktreeSlug,
action: source.action,
refName: source.refName,
branchName: source.branchName,
checkoutSource: source.checkoutSource,
githubPrNumber: source.githubPrNumber,
firstAgentContext: request.firstAgentContext,
title: request.title,
},
source.baseBranch
? { resolveDefaultBranch: async () => source.baseBranch as string }
: undefined,
);
if (request.title?.trim()) {
await this.workspaceRegistry.upsert({
...result.workspace,
title: request.title.trim(),
updatedAt: new Date().toISOString(),
});
result.workspace.title = request.title.trim();
}
const descriptor = await this.describeCreatedWorktreeWorkspace(result);
this.emit({
type: "workspace.create.response",
@@ -4917,20 +4963,6 @@ export class Session {
await this.emitCreatedWorkspaceUpdate(descriptor);
}
private async resolveWorktreeSourceCwd(input: {
cwd?: string;
projectId?: string;
}): Promise<string> {
if (input.cwd) {
return expandTilde(input.cwd);
}
const project = await this.projectRegistry.get(input.projectId as string);
if (!project || project.archivedAt) {
throw new Error(`Project not found: ${input.projectId}`);
}
return project.rootPath;
}
private async handleOpenProjectRequest(
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
): Promise<void> {
@@ -5399,10 +5431,10 @@ export class Session {
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
): Promise<void> {
try {
const existing = await this.workspaceRegistry.get(request.workspaceId);
if (!existing) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
const existing = await requireActiveWorkspaceForArchive(
{ listActiveWorkspaces: () => this.listActiveWorkspaceRefs() },
request.workspaceId,
);
await archiveByScope(
{
@@ -67,6 +67,19 @@ export interface ArchiveByScopeRequest {
requestId: string;
}
export async function requireActiveWorkspaceForArchive(
dependencies: Pick<ArchiveDependencies, "listActiveWorkspaces">,
workspaceId: string,
): Promise<ActiveWorkspaceRef> {
const workspace = (await dependencies.listActiveWorkspaces()).find(
(candidate) => candidate.workspaceId === workspaceId,
);
if (!workspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
return workspace;
}
interface BackingDirectory {
path: string;
isPaseoOwnedWorktree: boolean;
@@ -8,7 +8,7 @@ import { DaemonClient } from "./test-utils/index.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
// The reshaped workspace.create.request forwards its worktree `source`
// (action/refName/githubPrNumber/worktreeSlug) verbatim into createWorktreeCore.
// (action/refName/branchName/githubPrNumber/worktreeSlug) into createWorktreeCore.
// The regression these tests guard against is the daemon dropping action/refName
// while subsetting the request. We prove forwarding through the real workflow:
// the created worktree's observable branch is the only honest evidence the
@@ -64,7 +64,7 @@ test("workspace.create worktree source forwards action=checkout + refName into t
}
}, 180000);
test("workspace.create worktree source forwards branch-off + refName as the new branch", async () => {
test("workspace.create keeps a branch-off name separate from its worktree slug", async () => {
const daemon = await createTestPaseoDaemon();
const { repoDir, tempRoot } = createGitRepoWithBranch();
const client = new DaemonClient({
@@ -80,15 +80,15 @@ test("workspace.create worktree source forwards branch-off + refName as the new
kind: "worktree",
cwd: repoDir,
action: "branch-off",
worktreeSlug: "brand-new-branch",
branchName: "feature/auth",
worktreeSlug: "feature-auth",
baseBranch: "main",
},
});
expect(result.error).toBeNull();
// branch-off cuts a new branch named after worktreeSlug from baseBranch; the
// worktree landing on that branch proves worktreeSlug/baseBranch forwarded.
expect(result.workspace?.gitRuntime?.currentBranch).toBe("brand-new-branch");
expect(result.workspace?.gitRuntime?.currentBranch).toBe("feature/auth");
expect(path.basename(result.workspace?.workspaceDirectory ?? "")).toBe("feature-auth");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
@@ -0,0 +1,25 @@
import { expandTilde } from "../utils/path.js";
import type { ProjectRegistry } from "./workspace-registry.js";
export interface WorktreeWorkspaceSource {
cwd?: string;
projectId?: string;
}
export async function resolveWorktreeSourceCwd(
source: WorktreeWorkspaceSource,
projectRegistry: Pick<ProjectRegistry, "get">,
): Promise<string> {
if (source.cwd) {
return expandTilde(source.cwd);
}
if (!source.projectId) {
throw new Error("cwd or projectId is required for a worktree-backed workspace");
}
const project = await projectRegistry.get(source.projectId);
if (!project || project.archivedAt) {
throw new Error(`Project not found: ${source.projectId}`);
}
return project.rootPath;
}