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
+58
View File
@@ -0,0 +1,58 @@
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import type {
CommandError,
CommandOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
interface AgentDetachResult {
agentId: string;
status: "detached";
}
const detachSchema: OutputSchema<AgentDetachResult> = {
idField: "agentId",
columns: [
{ header: "AGENT ID", field: "agentId" },
{ header: "STATUS", field: "status" },
],
};
export async function runDetachCommand(
agentIdArg: string,
options: CommandOptions,
_command: Command,
): Promise<SingleResult<AgentDetachResult>> {
const host = getDaemonHost({ host: options.host });
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
} satisfies CommandError;
}
try {
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
const agentId = resolveAgentId(
agentIdArg,
payload.entries.map((entry) => entry.agent),
);
if (!agentId) {
throw {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdArg}`,
} satisfies CommandError;
}
await client.detachAgent(agentId);
return { type: "single", data: { agentId, status: "detached" }, schema: detachSchema };
} finally {
await client.close().catch(() => undefined);
}
}
+8
View File
@@ -13,6 +13,7 @@ import { addAttachOptions, runAttachCommand } from "./attach.js";
import { addReloadOptions, runReloadCommand } from "./reload.js";
import { addImportOptions, runImportCommand } from "./import.js";
import { runUpdateCommand } from "./update.js";
import { runDetachCommand } from "./detach.js";
import { withOutput } from "../../output/index.js";
import {
addDaemonHostOption,
@@ -76,6 +77,13 @@ export function createAgentCommand(): Command {
withOutput(runReloadCommand),
);
addJsonAndDaemonHostOptions(
agent
.command("detach")
.description("Make a subagent independent without stopping or moving it")
.argument("<id>", "Agent ID, prefix, or name"),
).action(withOutput(runDetachCommand));
addJsonAndDaemonHostOptions(
agent
.command("update")
+59 -16
View File
@@ -1,5 +1,52 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { runRunCommand, type AgentRunOptions } from "./run";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
resolveExistingRunWorkspace,
resolveRunCallerAgentId,
runRunCommand,
type AgentRunOptions,
} from "./run";
describe("managed agent caller context", () => {
it("propagates a trimmed PASEO_AGENT_ID", () => {
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent");
});
it("omits blank caller ids", () => {
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined();
});
});
describe("existing run workspace resolution", () => {
it("queries the daemon for an exact workspace id and uses its directory", async () => {
const fetchWorkspaces = vi.fn().mockResolvedValue({
entries: [{ id: "workspace-2", workspaceDirectory: "/workspace/two" }],
pageInfo: { nextCursor: null },
});
await expect(resolveExistingRunWorkspace({ fetchWorkspaces }, "workspace-2")).resolves.toEqual({
id: "workspace-2",
cwd: "/workspace/two",
});
expect(fetchWorkspaces).toHaveBeenCalledWith({
filter: { query: "workspace-2" },
page: { limit: 200 },
});
});
it("rejects a workspace id absent from daemon state", async () => {
const fetchWorkspaces = vi.fn().mockResolvedValue({
entries: [],
pageInfo: { nextCursor: null },
});
await expect(resolveExistingRunWorkspace({ fetchWorkspaces }, "missing")).rejects.toMatchObject(
{
code: "WORKSPACE_NOT_FOUND",
message: "Workspace not found: missing",
},
);
});
});
// validateRunOptions runs before the CLI ever connects to a daemon, so these
// invalid combinations reject without one running.
@@ -25,27 +72,23 @@ describe("runRunCommand option validation", () => {
});
}
it("rejects --worktree combined with --workspace", async () => {
it("rejects --isolation combined with --workspace", async () => {
await expectInvalidOptions(
{ worktree: "feat", workspace: "ws-1" },
/--worktree and --workspace cannot be combined/,
{ isolation: "worktree", workspace: "ws-1" },
/--isolation and --workspace cannot be combined/,
);
});
it("rejects --worktree combined with an ambient PASEO_WORKSPACE_ID", async () => {
process.env.PASEO_WORKSPACE_ID = "ws-ambient";
await expectInvalidOptions(
{ worktree: "feat" },
/--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID/,
);
});
it("allows a bare --worktree through validation when no workspace is selected", async () => {
// A bare --worktree with no --workspace and no ambient PASEO_WORKSPACE_ID
it("allows explicit worktree isolation through validation", async () => {
// Explicit isolation with no --workspace
// must clear validation. It still fails later (provider resolution), which
// is enough to prove the new guard did not reject it.
await expect(
runRunCommand("do something", { worktree: "feat", provider: undefined }, {} as never),
runRunCommand("do something", { isolation: "worktree", provider: undefined }, {} as never),
).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" });
});
it("rejects unknown workspace isolation", async () => {
await expectInvalidOptions({ isolation: "container" }, /Unsupported workspace isolation/);
});
});
+154 -89
View File
@@ -18,55 +18,61 @@ import { resolveProviderAndModel } from "../../utils/provider-model.js";
export { resolveProviderAndModel } from "../../utils/provider-model.js";
export function addRunOptions(cmd: Command): Command {
return cmd
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --detach", "Run in background (detached)")
.option("--title <title>", "Assign a title to the agent")
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
)
.option(
"--model <model>",
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
)
.option("--thinking <id>", "Thinking option ID to use for this run")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
.option("--worktree <name>", "Create agent in a new git worktree")
.option("--base <branch>", "Base branch for worktree (default: current branch)")
.option(
"--workspace <id>",
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
)
.option(
"--image <path>",
"Attach image(s) to the initial prompt (can be used multiple times)",
collectMultiple,
[],
)
.option("--cwd <path>", "Working directory (default: current)")
.option(
"--env <key=value>",
"Set environment variable(s) for the agent process (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--label <key=value>",
"Add label(s) to the agent (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--wait-timeout <duration>",
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
)
.option(
"--output-schema <schema>",
"Output JSON matching the provided schema file path or inline JSON schema",
);
return (
cmd
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --background", "Run in background")
// COMPAT(detachRunFlag): --detach used to mean background execution, not
// ownership transfer. Added in v0.2.0; remove after 2027-01-17.
.addOption(new Option("--detach", "Legacy alias for --background").hideHelp())
.option("--title <title>", "Assign a title to the agent")
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
)
.option(
"--model <model>",
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
)
.option("--thinking <id>", "Thinking option ID to use for this run")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
.option("--isolation <local|worktree>", "Create a new workspace with this isolation")
.addOption(new Option("--worktree <name>", "Legacy workspace isolation alias").hideHelp())
.option("--base <branch>", "Base branch for an isolated workspace")
.option(
"--workspace <id>",
"Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)",
)
.option(
"--image <path>",
"Attach image(s) to the initial prompt (can be used multiple times)",
collectMultiple,
[],
)
.option("--cwd <path>", "Working directory (default: current)")
.option(
"--env <key=value>",
"Set environment variable(s) for the agent process (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--label <key=value>",
"Add label(s) to the agent (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--wait-timeout <duration>",
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
)
.option(
"--output-schema <schema>",
"Output JSON matching the provided schema file path or inline JSON schema",
)
);
}
/** Result type for agent run command */
@@ -91,6 +97,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = {
};
export interface AgentRunOptions extends CommandOptions {
background?: boolean;
detach?: boolean;
title?: string;
name?: string;
@@ -98,6 +105,7 @@ export interface AgentRunOptions extends CommandOptions {
model?: string;
thinking?: string;
mode?: string;
isolation?: string;
worktree?: string;
base?: string;
workspace?: string;
@@ -270,40 +278,54 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche
} satisfies CommandError;
}
if (options.base && !options.worktree) {
const createsIsolatedWorkspace = options.isolation === "worktree" || Boolean(options.worktree);
if (options.isolation && options.isolation !== "local" && options.isolation !== "worktree") {
throw {
code: "INVALID_OPTIONS",
message: "--base can only be used with --worktree",
details: "Usage: paseo agent run --worktree <name> --base <branch> <prompt>",
message: `Unsupported workspace isolation: ${options.isolation}`,
details: "Use --isolation local or --isolation worktree",
} satisfies CommandError;
}
if (options.base && !createsIsolatedWorkspace) {
throw {
code: "INVALID_OPTIONS",
message: "--base can only be used with --isolation worktree",
details: "Usage: paseo agent run --isolation worktree --base <branch> <prompt>",
} satisfies CommandError;
}
if (options.isolation && options.workspace) {
throw {
code: "INVALID_OPTIONS",
message: "--isolation and --workspace cannot be combined",
details: "Select an existing workspace or create a new one with an isolation choice",
} satisfies CommandError;
}
// COMPAT(worktreeRunFlag): --worktree implies a new worktree-isolated workspace.
// Added in v0.2.0; remove after 2027-01-17.
if (options.worktree && options.workspace) {
throw {
code: "INVALID_OPTIONS",
message: "--worktree and --workspace cannot be combined",
details: "--workspace runs in an existing workspace; --worktree mints a new one",
details: "Use --isolation worktree instead of the legacy --worktree flag",
} satisfies CommandError;
}
if (options.worktree && !options.workspace && process.env.PASEO_WORKSPACE_ID) {
if (outputSchema && runsInBackground(options)) {
throw {
code: "INVALID_OPTIONS",
message: "--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID",
details:
"PASEO_WORKSPACE_ID selects an existing workspace; --worktree mints a new one. Unset PASEO_WORKSPACE_ID to use --worktree.",
} satisfies CommandError;
}
if (outputSchema && options.detach) {
throw {
code: "INVALID_OPTIONS",
message: "--output-schema cannot be used with --detach",
message: "--output-schema cannot be used with --background",
details: "Structured output requires waiting for the agent to finish",
} satisfies CommandError;
}
}
function runsInBackground(options: Pick<AgentRunOptions, "background" | "detach">): boolean {
return Boolean(options.background || options.detach);
}
function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
if (!waitTimeout) return 0;
try {
@@ -409,45 +431,79 @@ async function connectToDaemonOrThrow(
// runs in. The CLI resolves one before creating any agent, so no run leans on
// createAgent's legacy cwd->workspace fallback.
interface RunWorkspace {
id: string;
id?: string;
cwd: string;
}
export interface RunWorkspaceLookupClient {
fetchWorkspaces(options: { filter: { query: string }; page: { limit: number } }): Promise<{
entries: Array<{ id: string; workspaceDirectory: string }>;
pageInfo: { nextCursor: string | null };
}>;
}
export async function resolveExistingRunWorkspace(
client: RunWorkspaceLookupClient,
workspaceId: string,
): Promise<RunWorkspace> {
const result = await client.fetchWorkspaces({
filter: { query: workspaceId },
page: { limit: 200 },
});
const workspace = result.entries.find((entry) => entry.id === workspaceId);
if (workspace) {
return { id: workspace.id, cwd: workspace.workspaceDirectory };
}
throw {
code: "WORKSPACE_NOT_FOUND",
message: `Workspace not found: ${workspaceId}`,
} satisfies CommandError;
}
// Workspace policy for `paseo run`. Precedence:
// 1. --workspace <id> -> run in that existing workspace
// 2. $PASEO_WORKSPACE_ID -> exported by workspace terminals
// 3. --worktree <name> -> mint a new worktree-backed workspace
// 4. bare run -> mint a new local-backed workspace for cwd
// --worktree is rejected alongside both --workspace and an ambient
// $PASEO_WORKSPACE_ID (validateRunOptions), so worktree resolution here never
// races an existing-workspace selection.
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
// 4. --isolation <kind> -> mint a new workspace with explicit isolation
// 5. bare run -> mint a new local-backed workspace for cwd
async function resolveRunWorkspace(
client: ConnectedDaemonClient,
options: AgentRunOptions,
cwd: string,
): Promise<RunWorkspace> {
// An explicit --worktree mints its own workspace; --workspace and an ambient
// PASEO_WORKSPACE_ID are both rejected alongside --worktree upstream.
const explicit = options.worktree
? undefined
: options.workspace?.trim() || process.env.PASEO_WORKSPACE_ID?.trim();
const requestedIsolation = options.isolation ?? (options.worktree ? "worktree" : undefined);
const explicit = requestedIsolation ? undefined : options.workspace?.trim();
if (explicit) {
console.error(`Using workspace ${explicit}`);
return { id: explicit, cwd };
return resolveExistingRunWorkspace(client, explicit);
}
if (!requestedIsolation && resolveRunCallerAgentId()) {
return { cwd };
}
const ambientWorkspaceId = requestedIsolation
? undefined
: process.env.PASEO_WORKSPACE_ID?.trim();
if (ambientWorkspaceId) {
console.error(`Using workspace ${ambientWorkspaceId}`);
return resolveExistingRunWorkspace(client, ambientWorkspaceId);
}
// TODO: thread the run `prompt` as firstAgentContext so workspace-level
// title/branch generation picks up the task description (U8/U6 deferred).
const result = options.worktree
? await client.createWorkspace({
source: {
kind: "worktree",
cwd,
worktreeSlug: options.worktree,
baseBranch: options.base,
},
})
: await client.createWorkspace({ source: { kind: "directory", path: cwd } });
const result =
requestedIsolation === "worktree"
? await client.createWorkspace({
source: {
kind: "worktree",
cwd,
worktreeSlug: options.worktree,
baseBranch: options.base,
},
})
: await client.createWorkspace({ source: { kind: "directory", path: cwd } });
if (!result.workspace) {
throw {
@@ -503,6 +559,7 @@ export async function runRunCommand(
const workspace = await resolveRunWorkspace(client, options, cwd);
const workspaceId = workspace.id;
const callerAgentId = resolveRunCallerAgentId();
const runCwd = workspace.cwd;
if (outputSchema) {
@@ -514,6 +571,7 @@ export async function runRunCommand(
provider: resolvedProviderModel.provider,
cwd: runCwd,
workspaceId,
callerAgentId,
title: resolvedTitle,
modeId: options.mode,
model: resolvedProviderModel.model,
@@ -584,6 +642,7 @@ export async function runRunCommand(
provider: resolvedProviderModel.provider,
cwd: runCwd,
workspaceId,
callerAgentId,
title: resolvedTitle,
modeId: options.mode,
model: resolvedProviderModel.model,
@@ -594,8 +653,8 @@ export async function runRunCommand(
labels: Object.keys(labels).length > 0 ? labels : undefined,
});
// Default run behavior is foreground: wait for completion unless --detach is set.
if (!options.detach) {
// Default run behavior is foreground: wait for completion unless background execution is set.
if (!runsInBackground(options)) {
const state = await client.waitForFinish(agent.id, waitTimeoutMs);
await client.close();
@@ -631,3 +690,9 @@ export async function runRunCommand(
throw error;
}
}
export function resolveRunCallerAgentId(
env: { PASEO_AGENT_ID?: string } = process.env,
): string | undefined {
return env.PASEO_AGENT_ID?.trim() || undefined;
}