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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user