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