mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 22:02:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa06701c9d | ||
|
|
a29729ed48 | ||
|
|
92504cd525 | ||
|
|
726067b437 | ||
|
|
ec4e81ce0f | ||
|
|
bc385b5930 | ||
|
|
ec5ab3fc4d | ||
|
|
69510ac72f |
@@ -52,6 +52,8 @@ Connected clients are trusted operators of the daemon user. File previews follow
|
||||
|
||||
When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
|
||||
|
||||
An explicit `symlink <path>` entry in a repository's `.worktreeinclude` gives a Paseo-created worktree live access to that source-checkout file or directory. It is useful for local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle scripts can modify the source through the link. Paseo validates entries and refuses traversal or destination-link escapes, but the linked source is a deliberate shared-data boundary.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
|
||||
@@ -50,6 +50,7 @@ than downloading a published desktop release.
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy <path>` copy a snapshot into the new worktree; `symlink <path>` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs.
|
||||
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
|
||||
|
||||
Override knobs:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation";
|
||||
|
||||
test.describe("Delayed workspace creation", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
for (const launch of ["chat", "terminal", "empty"] as const) {
|
||||
test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "leave");
|
||||
});
|
||||
test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree");
|
||||
});
|
||||
test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "stay");
|
||||
});
|
||||
}
|
||||
|
||||
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { expect, test, type Page } from "../fixtures";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { scrollTimelineToOldestLoadedEdge } from "./timeline-pagination";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
countWorkspaceAgents,
|
||||
countWorkspaceTerminals,
|
||||
delayBrowserWorkspaceCreatedResponse,
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
selectWorkspaceIsolation,
|
||||
submitNewWorkspaceEmpty,
|
||||
submitNewWorkspacePrompt,
|
||||
waitForCreatedWorkspace,
|
||||
} from "./new-workspace";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { getServerId } from "./server-id";
|
||||
import {
|
||||
expectTerminalOutputContains,
|
||||
expectWorkspaceOpensWithTerminalTab,
|
||||
fillTerminalPrompt,
|
||||
seedTerminalProfiles,
|
||||
selectLaunchOption,
|
||||
submitTerminalLaunch,
|
||||
type TerminalProfile,
|
||||
} from "./new-workspace-launch";
|
||||
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./workspace-ui";
|
||||
import { dropFileOnComposer, expectAttachmentPill } from "./composer";
|
||||
|
||||
const PROMPT = "Hello from the navigation guard";
|
||||
const NEXT_PROMPT = "Keep this newer draft";
|
||||
const CONTEXT = {
|
||||
name: "context.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from('{"preserve":"attachment"}'),
|
||||
};
|
||||
const PROFILE: TerminalProfile = {
|
||||
id: "e2e-nav-guard-echo",
|
||||
name: "Nav Guard Echo",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo captured: "$0"; sleep 120', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
export async function verifyDelayedWorkspaceCreation(
|
||||
page: Page,
|
||||
launch: "chat" | "terminal" | "empty",
|
||||
destination: "leave" | "stay" | "new-draft",
|
||||
isolation: "local" | "worktree" = "local",
|
||||
): Promise<void> {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const repo = await createTempGitRepo("workspace-focus-");
|
||||
const profileSeed = await seedTerminalProfiles([PROFILE]);
|
||||
const delay = await delayBrowserWorkspaceCreatedResponse(page);
|
||||
const serverId = getServerId();
|
||||
let localWorkspaceId: string | undefined;
|
||||
let createdDirectory: string | undefined;
|
||||
let newDraftRoute = "";
|
||||
|
||||
try {
|
||||
const project = await openProjectViaDaemon(client, repo.path);
|
||||
localWorkspaceId = project.workspaceId;
|
||||
const knownIds = new Set((await client.fetchWorkspaces()).entries.map((entry) => entry.id));
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
|
||||
await test.step("Submit workspace creation with the requested content", async () => {
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await selectWorkspaceIsolation(page, isolation);
|
||||
if (launch === "chat") {
|
||||
await dropFileOnComposer(page, CONTEXT);
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
await submitNewWorkspacePrompt(page, PROMPT);
|
||||
} else if (launch === "terminal") {
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await fillTerminalPrompt(page, PROMPT);
|
||||
await submitTerminalLaunch(page);
|
||||
} else {
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
}
|
||||
await delay.waitForCreateRequest();
|
||||
});
|
||||
|
||||
await test.step("Choose where to work while the creation response is held", async () => {
|
||||
if (destination !== "stay") {
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
}
|
||||
if (destination === "new-draft") {
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await fillNewWorkspaceDraft(page, NEXT_PROMPT);
|
||||
const url = new URL(page.url());
|
||||
newDraftRoute = url.pathname + url.search;
|
||||
}
|
||||
// Recording pacing only; the response is held deterministically above.
|
||||
if (process.env.E2E_RECORD_VIDEO === "1") {
|
||||
await page.mouse.move(1100, 80);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
delay.release();
|
||||
});
|
||||
|
||||
const created = await waitForCreatedWorkspace(client, knownIds);
|
||||
createdDirectory = created.workspaceDirectory;
|
||||
expect(created.workspaceKind).toBe(isolation === "worktree" ? "worktree" : "local_checkout");
|
||||
await test.step("Completion creates exactly the requested resource and preserves focus", async () => {
|
||||
await expect
|
||||
.poll(() => countWorkspaceAgents(client, created.id))
|
||||
.toBe(launch === "chat" ? 1 : 0);
|
||||
await expect
|
||||
.poll(() => countWorkspaceTerminals(client, created.id))
|
||||
.toBe(launch === "terminal" ? 1 : 0);
|
||||
// A negative navigation assertion needs an observation window after completion.
|
||||
await page.waitForTimeout(5000);
|
||||
const expectedRoute =
|
||||
destination === "new-draft"
|
||||
? newDraftRoute
|
||||
: buildHostWorkspaceRoute(
|
||||
serverId,
|
||||
destination === "stay" ? created.id : project.workspaceId,
|
||||
);
|
||||
await expectAppRoute(page, expectedRoute);
|
||||
if (destination === "new-draft") await expectNewWorkspaceDraft(page, NEXT_PROMPT);
|
||||
if (launch === "chat") {
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
const upload = delay.agentRequests[0]?.attachments?.find(
|
||||
(item) => item.type === "uploaded_file",
|
||||
);
|
||||
if (!upload) throw new Error("The create request did not contain the submitted file");
|
||||
expect(await readFile(upload.path)).toEqual(CONTEXT.buffer);
|
||||
expect(delay.agentRequests[0]).toMatchObject({
|
||||
workspaceId: created.id,
|
||||
initialPrompt: PROMPT,
|
||||
config: {
|
||||
provider: "mock",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
thinkingOptionId: "low",
|
||||
cwd: created.workspaceDirectory,
|
||||
},
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
fileName: CONTEXT.name,
|
||||
mimeType: CONTEXT.mimeType,
|
||||
size: CONTEXT.buffer.length,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("Visit the new workspace and return without submitting a second draft", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
|
||||
if (launch === "chat") {
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await expect(page.getByText(PROMPT, { exact: true }).first()).toBeVisible();
|
||||
} else if (launch === "terminal") {
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, `captured: ${PROMPT}`);
|
||||
}
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
|
||||
await expect
|
||||
.poll(() => countWorkspaceAgents(client, created.id))
|
||||
.toBe(launch === "chat" ? 1 : 0);
|
||||
await expect
|
||||
.poll(() => countWorkspaceTerminals(client, created.id))
|
||||
.toBe(launch === "terminal" ? 1 : 0);
|
||||
if (launch === "chat") {
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await expectNewWorkspaceDraft(page, destination === "new-draft" ? NEXT_PROMPT : "");
|
||||
}
|
||||
if (process.env.E2E_RECORD_VIDEO === "1") {
|
||||
await page.mouse.move(1100, 80);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: test.info().outputPath("before-cleanup.png") });
|
||||
console.error(error);
|
||||
throw error;
|
||||
} finally {
|
||||
delay.release();
|
||||
if (createdDirectory) await archiveWorkspaceFromDaemon(client, createdDirectory);
|
||||
if (localWorkspaceId) await archiveLocalWorkspaceFromDaemon(client, localWorkspaceId);
|
||||
await profileSeed.restore();
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
@@ -16,6 +17,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
@@ -24,6 +26,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "enablePlugin"
|
||||
| "inspectWorkspaceRecovery"
|
||||
| "listProjects"
|
||||
| "listTerminals"
|
||||
| "on"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
@@ -622,3 +625,120 @@ export async function delayBrowserAgentCreatedStatus(
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceCreatedDelayControl {
|
||||
agentRequests: readonly CreateAgentRequestMessage[];
|
||||
release(): void;
|
||||
waitForCreateRequest(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds `workspace.create.response` so a test can act as the user does while the daemon is still
|
||||
* running `git worktree add` — most importantly, navigate somewhere else.
|
||||
*/
|
||||
export async function delayBrowserWorkspaceCreatedResponse(
|
||||
page: Page,
|
||||
): Promise<WorkspaceCreatedDelayControl> {
|
||||
const daemonPortPattern = daemonWsRoutePattern();
|
||||
const agentRequests: CreateAgentRequestMessage[] = [];
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request")
|
||||
agentRequests.push(sessionMessage as CreateAgentRequestMessage);
|
||||
if (sessionMessage?.type === "workspace.create.request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "workspace.create.response" &&
|
||||
typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (requestId && createRequestIds.has(requestId) && !releaseRequested) {
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
agentRequests,
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceEntry = Awaited<
|
||||
ReturnType<NewWorkspaceDaemonClient["fetchWorkspaces"]>
|
||||
>["entries"][number];
|
||||
|
||||
/**
|
||||
* Waits for a workspace the caller did not already know about, and returns it. Tests that create a
|
||||
* workspace without navigating to it have no route to assert on, so the daemon's own list is the
|
||||
* signal that creation finished.
|
||||
*/
|
||||
export async function waitForCreatedWorkspace(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
knownWorkspaceIds: ReadonlySet<string>,
|
||||
options?: { timeout?: number },
|
||||
): Promise<WorkspaceEntry> {
|
||||
let created: WorkspaceEntry | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
created = payload.entries.find((entry) => !knownWorkspaceIds.has(entry.id));
|
||||
return created !== undefined;
|
||||
},
|
||||
{ timeout: options?.timeout ?? 60_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
if (!created) {
|
||||
throw new Error("Workspace list reported a new workspace but it could not be read back");
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function countWorkspaceAgents(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<number> {
|
||||
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length;
|
||||
}
|
||||
|
||||
export async function countWorkspaceTerminals(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<number> {
|
||||
const payload = await client.listTerminals(undefined, undefined, { workspaceId });
|
||||
return payload.terminals.length;
|
||||
}
|
||||
|
||||
@@ -80,8 +80,11 @@ function QuestionOptionRow({
|
||||
);
|
||||
|
||||
const optionLabelStyle = useMemo(
|
||||
() => [styles.optionLabel, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
() => [
|
||||
styles.optionLabel,
|
||||
{ color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted },
|
||||
],
|
||||
[isSelected, theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
);
|
||||
const optionDescriptionStyle = useMemo(
|
||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||
@@ -96,11 +99,11 @@ function QuestionOptionRow({
|
||||
styles.selectionControl,
|
||||
multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio,
|
||||
{
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted,
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundExtraMuted,
|
||||
backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent",
|
||||
},
|
||||
],
|
||||
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted],
|
||||
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundExtraMuted],
|
||||
);
|
||||
const radioDotStyle = useMemo(
|
||||
() => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }],
|
||||
@@ -628,7 +631,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
questionText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionsWrap: {
|
||||
@@ -653,7 +656,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
questionNavText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
optionItem: {
|
||||
flexDirection: "row",
|
||||
@@ -677,7 +680,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionDescription: {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload, CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
|
||||
export interface WorkspaceDraftAgentRequest {
|
||||
workspaceId: string;
|
||||
config: AgentSessionConfig;
|
||||
text: string;
|
||||
clientMessageId: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: CreateAgentRequestMessage["attachments"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the workspace draft tab and by the new-workspace screen when it finishes creation
|
||||
* after the user has already navigated away and no draft tab will ever mount.
|
||||
*/
|
||||
export async function requestWorkspaceDraftAgent(
|
||||
client: DaemonClient,
|
||||
request: WorkspaceDraftAgentRequest,
|
||||
): Promise<AgentSnapshotPayload> {
|
||||
const images = await encodeImages(request.images);
|
||||
return await client.createAgent({
|
||||
config: request.config,
|
||||
workspaceId: request.workspaceId,
|
||||
clientMessageId: request.clientMessageId,
|
||||
...(request.text ? { initialPrompt: request.text } : {}),
|
||||
...(images && images.length > 0 ? { images } : {}),
|
||||
...(request.attachments && request.attachments.length > 0
|
||||
? { attachments: request.attachments }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import {
|
||||
@@ -194,15 +194,14 @@ async function submitDraftCreateRequest(input: {
|
||||
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
|
||||
});
|
||||
|
||||
const imagesData = await encodeImages(images);
|
||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||
const result = await client.createAgent({
|
||||
const result = await requestWorkspaceDraftAgent(client, {
|
||||
config,
|
||||
workspaceId,
|
||||
...(text ? { initialPrompt: text } : {}),
|
||||
text,
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
||||
...(images ? { images } : {}),
|
||||
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -85,10 +85,15 @@ import { useProjectIcons } from "@/projects/icons";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import type { MessagePayload } from "@/composer/types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
CreatePaseoWorktreeInput,
|
||||
DaemonClient,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
||||
@@ -123,6 +128,11 @@ import {
|
||||
type ListTerminalsPayload,
|
||||
upsertCreatedTerminalPayload,
|
||||
} from "./workspace/terminals/state";
|
||||
import {
|
||||
captureWorkspaceDraftCleanup,
|
||||
createWorkspaceAgentInBackground,
|
||||
} from "./new-workspace/background-handoff";
|
||||
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
|
||||
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -747,10 +757,19 @@ function normalizeBranchDetails(
|
||||
return names.map((name) => ({ name, committerDate: 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* "background" means the user left the New workspace screen mid-creation, so nothing navigated
|
||||
* and the screen — if still mounted under another route — has to drop its pending state itself.
|
||||
*/
|
||||
type SubmitOutcome = "navigated" | "background";
|
||||
|
||||
interface SubmitDraftInput {
|
||||
clearConsumedDraft: () => void;
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
draftId?: string;
|
||||
draftContextScopeKey: string | null;
|
||||
initialSetup?: WorkspaceDraftTabSetup;
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
@@ -759,6 +778,8 @@ interface SubmitDraftInput {
|
||||
provider: AgentProvider;
|
||||
composerState: NewWorkspaceComposerState;
|
||||
supportsForgeSearch: boolean;
|
||||
resolveClient: () => DaemonClient;
|
||||
isStillOnCreateScreen: () => boolean;
|
||||
}
|
||||
|
||||
type NewWorkspaceComposerState = NonNullable<
|
||||
@@ -860,9 +881,13 @@ interface CreateChatAgentInput {
|
||||
withInitialAgent: boolean;
|
||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
draftId?: string;
|
||||
draftContextScopeKey: string | null;
|
||||
supportsForgeSearch: boolean;
|
||||
resolveClient: () => DaemonClient;
|
||||
isStillOnCreateScreen: () => boolean;
|
||||
labels: {
|
||||
composerStateRequired: string;
|
||||
selectModel: string;
|
||||
@@ -918,8 +943,9 @@ function buildComposerInitialValues(input: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
|
||||
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
|
||||
const clearConsumedDraft = captureWorkspaceDraftCleanup(input);
|
||||
const { text, attachments, cwd } = payload;
|
||||
if (!composerState) {
|
||||
throw new Error(input.labels.composerStateRequired);
|
||||
@@ -947,10 +973,13 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
provider,
|
||||
composerState,
|
||||
});
|
||||
submitWorkspaceDraft({
|
||||
return await submitWorkspaceDraft({
|
||||
clearConsumedDraft,
|
||||
serverId,
|
||||
clearDraft,
|
||||
draftKey: input.draftKey,
|
||||
draftId: input.draftId,
|
||||
draftContextScopeKey: input.draftContextScopeKey,
|
||||
initialSetup,
|
||||
workspaceId: ensuredWorkspace.id,
|
||||
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||
@@ -959,6 +988,8 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
provider,
|
||||
composerState,
|
||||
supportsForgeSearch: input.supportsForgeSearch,
|
||||
resolveClient: input.resolveClient,
|
||||
isStillOnCreateScreen: input.isStillOnCreateScreen,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1021,7 +1052,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutcome> {
|
||||
const {
|
||||
serverId,
|
||||
clearDraft,
|
||||
@@ -1049,6 +1080,33 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
composerState,
|
||||
initialSetup,
|
||||
});
|
||||
// Creation blocks on a slow daemon RPC. If the user moved on while it ran, the destination
|
||||
// screen's draft tab will never mount to issue create_agent, so this path does it instead.
|
||||
if (!input.isStillOnCreateScreen()) {
|
||||
await createWorkspaceAgentInBackground({
|
||||
clearConsumedDraft: input.clearConsumedDraft,
|
||||
createAgent: () =>
|
||||
requestWorkspaceDraftAgent(input.resolveClient(), {
|
||||
workspaceId,
|
||||
config: buildWorkspaceDraftAgentConfig({
|
||||
provider: submission.provider,
|
||||
cwd: submission.cwd,
|
||||
...(submission.modeId ? { modeId: submission.modeId } : {}),
|
||||
...(submission.model ? { model: submission.model } : {}),
|
||||
...(submission.thinkingOptionId
|
||||
? { thinkingOptionId: submission.thinkingOptionId }
|
||||
: {}),
|
||||
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
|
||||
}),
|
||||
text: text.trim(),
|
||||
clientMessageId,
|
||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
}),
|
||||
});
|
||||
return "background";
|
||||
}
|
||||
|
||||
useCreateFlowStore.getState().setPending({
|
||||
serverId,
|
||||
draftId,
|
||||
@@ -1082,6 +1140,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
workspaceId,
|
||||
target: submission.target,
|
||||
});
|
||||
return "navigated";
|
||||
}
|
||||
|
||||
function useNewWorkspaceHostSelector(input: {
|
||||
@@ -1591,6 +1650,7 @@ export function NewWorkspaceScreen({
|
||||
const isolationPickerAnchorRef = useRef<View>(null);
|
||||
const hostPickerAnchorRef = useRef<View | null>(null);
|
||||
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
|
||||
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
|
||||
|
||||
// Launch target: what the composer submits to (chat agent, or a terminal
|
||||
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
|
||||
@@ -1696,11 +1756,12 @@ export function NewWorkspaceScreen({
|
||||
}, []);
|
||||
|
||||
const withConnectedClient = useCallback(() => {
|
||||
if (!client || !isConnected) {
|
||||
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
|
||||
if (!connectedClient?.isConnected) {
|
||||
throw new Error(t("newWorkspace.errors.hostDisconnected"));
|
||||
}
|
||||
return client;
|
||||
}, [client, isConnected, t]);
|
||||
return connectedClient;
|
||||
}, [selectedServerId, t]);
|
||||
|
||||
const clientReady = isConnected && Boolean(client);
|
||||
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
|
||||
@@ -2043,31 +2104,49 @@ export function NewWorkspaceScreen({
|
||||
await updateFormPreferences({ launchTarget });
|
||||
if (isEmptyWorkspaceSubmission(payload)) {
|
||||
setPendingAction("empty");
|
||||
let outcome: SubmitOutcome = "background";
|
||||
await runCreateEmptyWorkspace({
|
||||
payload,
|
||||
ensureWorkspace,
|
||||
serverId: selectedServerId,
|
||||
navigate: (targetServerId, workspaceId) =>
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId }),
|
||||
navigate: (targetServerId, workspaceId) => {
|
||||
if (!isStillOnCreateScreen()) {
|
||||
return;
|
||||
}
|
||||
outcome = "navigated";
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId });
|
||||
},
|
||||
});
|
||||
// Nothing navigated, so this screen may still be mounted under another route. Release
|
||||
// the pending lock it would otherwise keep forever.
|
||||
if (outcome === "background") {
|
||||
setPendingAction(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction("chat");
|
||||
await runCreateChatAgent({
|
||||
const outcome = await runCreateChatAgent({
|
||||
payload,
|
||||
composerState,
|
||||
forkDraftSetup,
|
||||
ensureWorkspace,
|
||||
serverId: selectedServerId,
|
||||
clearDraft: chatDraft.clear,
|
||||
draftKey,
|
||||
draftId,
|
||||
draftContextScopeKey,
|
||||
supportsForgeSearch,
|
||||
resolveClient: withConnectedClient,
|
||||
isStillOnCreateScreen,
|
||||
labels: {
|
||||
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
|
||||
selectModel: t("newWorkspace.errors.selectModel"),
|
||||
},
|
||||
});
|
||||
if (outcome === "background") {
|
||||
setPendingAction(null);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setPendingAction(null);
|
||||
@@ -2077,16 +2156,20 @@ export function NewWorkspaceScreen({
|
||||
},
|
||||
[
|
||||
composerState,
|
||||
draftContextScopeKey,
|
||||
draftId,
|
||||
chatDraft.clear,
|
||||
draftKey,
|
||||
ensureWorkspace,
|
||||
forkDraftSetup,
|
||||
isStillOnCreateScreen,
|
||||
launchTarget,
|
||||
selectedServerId,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
toast,
|
||||
updateFormPreferences,
|
||||
withConnectedClient,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2095,6 +2178,7 @@ export function NewWorkspaceScreen({
|
||||
setErrorMessage(null);
|
||||
await updateFormPreferences({ launchTarget });
|
||||
setPendingAction("terminal");
|
||||
let outcome: SubmitOutcome = "background";
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: selectedSourceDirectory ?? "",
|
||||
prompt: terminalPromptText,
|
||||
@@ -2128,9 +2212,20 @@ export function NewWorkspaceScreen({
|
||||
withConnectedClient().sendTerminalInput(terminalId, { type: "input", data });
|
||||
},
|
||||
serverId: selectedServerId,
|
||||
navigate: (targetServerId, workspaceId, target) =>
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId, target }),
|
||||
// The terminal is spawned and fed its command before this runs, so skipping the
|
||||
// navigation costs nothing: it is standalone, and `reconcileTabs` auto-opens standalone
|
||||
// terminals when the workspace is next visited.
|
||||
navigate: (targetServerId, workspaceId, target) => {
|
||||
if (!isStillOnCreateScreen()) {
|
||||
return;
|
||||
}
|
||||
outcome = "navigated";
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId, target });
|
||||
},
|
||||
});
|
||||
if (outcome === "background") {
|
||||
setPendingAction(null);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setPendingAction(null);
|
||||
@@ -2139,6 +2234,7 @@ export function NewWorkspaceScreen({
|
||||
}
|
||||
}, [
|
||||
ensureWorkspace,
|
||||
isStillOnCreateScreen,
|
||||
launchTarget,
|
||||
queryClient,
|
||||
selectedServerId,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import {
|
||||
captureWorkspaceDraftCleanup,
|
||||
createWorkspaceAgentInBackground,
|
||||
} from "./background-handoff";
|
||||
|
||||
const DRAFT_ID = "draft_1";
|
||||
const DRAFT_KEY = "new-workspace";
|
||||
const SCOPE_KEY = "draft:draft_1";
|
||||
|
||||
function saveDraft(text: string) {
|
||||
useDraftStore
|
||||
.getState()
|
||||
.saveDraftInput({ draftKey: DRAFT_KEY, draft: { text, attachments: [] } });
|
||||
}
|
||||
|
||||
function draftText(): string | undefined {
|
||||
return useDraftStore.getState().drafts[DRAFT_KEY]?.input.text;
|
||||
}
|
||||
|
||||
function captureCleanup() {
|
||||
return captureWorkspaceDraftCleanup({
|
||||
draftId: DRAFT_ID,
|
||||
draftKey: DRAFT_KEY,
|
||||
clearDraft: (lifecycle) =>
|
||||
useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }),
|
||||
draftContextScopeKey: SCOPE_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
function run(createAgent: () => Promise<unknown>, clearConsumedDraft = captureCleanup()) {
|
||||
return createWorkspaceAgentInBackground({ createAgent, clearConsumedDraft });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} });
|
||||
useCreateFlowStore.setState({ pendingByDraftId: {} });
|
||||
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } });
|
||||
saveDraft("do the thing");
|
||||
});
|
||||
|
||||
describe("createWorkspaceAgentInBackground", () => {
|
||||
it("preserves context added to a newer draft without editing its text", async () => {
|
||||
await run(async () => {
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId: DRAFT_ID,
|
||||
setup: {
|
||||
cwd: "/work/new-repo",
|
||||
provider: "codex",
|
||||
modeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe(
|
||||
"/work/new-repo",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates one agent and clears the consumed draft setup, attachments and input", async () => {
|
||||
let calls = 0;
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId: DRAFT_ID,
|
||||
setup: {
|
||||
cwd: "/work/repo",
|
||||
provider: "claude",
|
||||
modeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
|
||||
await run(async () => {
|
||||
calls += 1;
|
||||
});
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId).toEqual({});
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined();
|
||||
expect(draftText()).toBe("");
|
||||
});
|
||||
|
||||
it("preserves context changed while workspace creation was pending", async () => {
|
||||
const clearConsumedDraft = captureCleanup();
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
await run(async () => undefined, clearConsumedDraft);
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
expect(draftText()).toBe("do the thing");
|
||||
});
|
||||
|
||||
it("leaves no pending submission behind, so no draft tab can create a second agent", async () => {
|
||||
await run(async () => undefined);
|
||||
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().pendingByDraftId).toEqual({});
|
||||
expect(useCreateFlowStore.getState().pendingByDraftId).toEqual({});
|
||||
});
|
||||
|
||||
it("rethrows a failed creation and keeps the prompt for retry", async () => {
|
||||
const failure = new Error("daemon exploded");
|
||||
|
||||
await expect(
|
||||
run(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
).rejects.toThrow(failure);
|
||||
|
||||
expect(draftText()).toBe("do the thing");
|
||||
});
|
||||
|
||||
it("keeps a prompt typed into the shared draft while creation was in flight", async () => {
|
||||
await run(async () => {
|
||||
saveDraft("a different idea");
|
||||
});
|
||||
|
||||
expect(draftText()).toBe("a different idea");
|
||||
});
|
||||
it("keeps a newer draft written while workspace creation was pending", async () => {
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
const clearConsumedDraft = captureCleanup();
|
||||
saveDraft("a newer workspace idea");
|
||||
await run(async () => undefined, clearConsumedDraft);
|
||||
expect(draftText()).toBe("a newer workspace idea");
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
|
||||
interface WorkspaceDraftCleanupInput {
|
||||
draftId?: string;
|
||||
draftKey: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
draftContextScopeKey: string | null;
|
||||
}
|
||||
|
||||
/** Capture ownership before workspace creation starts, not when its response arrives. */
|
||||
export function captureWorkspaceDraftCleanup(input: WorkspaceDraftCleanupInput): () => void {
|
||||
const draftId = input.draftId?.trim() ?? "";
|
||||
const scopeKey = input.draftContextScopeKey ?? "";
|
||||
const draftVersion = useDraftStore.getState().drafts[input.draftKey]?.version;
|
||||
const setup = useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId];
|
||||
const attachments = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey];
|
||||
|
||||
return () => {
|
||||
// These stores update independently. A context-only edit does not bump the text version.
|
||||
// Any newer draft state owns the whole draft, including its unchanged context.
|
||||
if (
|
||||
useDraftStore.getState().drafts[input.draftKey]?.version !== draftVersion ||
|
||||
useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId] !== setup ||
|
||||
useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey] !== attachments
|
||||
) {
|
||||
return;
|
||||
}
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
if (scopeKey) {
|
||||
useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey });
|
||||
}
|
||||
input.clearDraft("sent");
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace draft tab normally issues create_agent once it mounts on the destination screen.
|
||||
* When the user leaves the New workspace screen before creation resolves, that tab never mounts,
|
||||
* so this path issues the request itself. The agent tab opens on its own when the workspace is
|
||||
* next visited — `reconcileTabs` auto-opens agents.
|
||||
*
|
||||
* The caller must not write a pending entry to the draft-submission or create-flow stores on this
|
||||
* path: those drive the draft tab's auto-submit, and the daemon does not dedupe create_agent by
|
||||
* clientMessageId, so a leftover entry would create a second agent when the workspace is opened.
|
||||
*/
|
||||
export async function createWorkspaceAgentInBackground(input: {
|
||||
clearConsumedDraft: () => void;
|
||||
createAgent: () => Promise<unknown>;
|
||||
}): Promise<void> {
|
||||
await input.createAgent();
|
||||
input.clearConsumedDraft();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isNewWorkspaceScreenActive } from "./screen-presence";
|
||||
|
||||
describe("isNewWorkspaceScreenActive", () => {
|
||||
it("is active while the screen is mounted and the route is /new", () => {
|
||||
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/new" })).toBe(true);
|
||||
});
|
||||
|
||||
it("is inactive once the screen has been popped off the stack", () => {
|
||||
expect(isNewWorkspaceScreenActive({ isMounted: false, pathname: "/new" })).toBe(false);
|
||||
});
|
||||
|
||||
it("is inactive while another route sits on top of the screen", () => {
|
||||
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/settings" })).toBe(false);
|
||||
});
|
||||
|
||||
it("is inactive on a workspace route", () => {
|
||||
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/host/workspace/wks_1" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { usePathname } from "expo-router";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
const NEW_WORKSPACE_PATHNAME = "/new";
|
||||
|
||||
export interface NewWorkspaceScreenPresence {
|
||||
isMounted: boolean;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace creation blocks on a slow daemon RPC, so by the time it resolves the user may have
|
||||
* moved on. Both signals are needed: popping to a workspace unmounts the screen while its last
|
||||
* observed pathname stays "/new", and pushing a route on top of it keeps it mounted underneath.
|
||||
*/
|
||||
export function isNewWorkspaceScreenActive(input: NewWorkspaceScreenPresence): boolean {
|
||||
return input.isMounted && input.pathname === NEW_WORKSPACE_PATHNAME;
|
||||
}
|
||||
|
||||
export function useNewWorkspaceScreenPresence(): () => boolean {
|
||||
const pathname = usePathname();
|
||||
const pathnameRef = useRef(pathname);
|
||||
pathnameRef.current = pathname;
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(
|
||||
() =>
|
||||
isNewWorkspaceScreenActive({
|
||||
isMounted: isMountedRef.current,
|
||||
pathname: pathnameRef.current,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -1075,38 +1075,8 @@ export const WorkspaceRecoveryInspectRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const AgentRequestOperationSchema = z.object({
|
||||
key: z.string().min(1).max(512),
|
||||
deadlineAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const AgentRequestsCancelRequestSchema = z.object({
|
||||
type: z.literal("agent.requests.cancel.request"),
|
||||
requestId: z.string(),
|
||||
key: z.string().min(1).max(512),
|
||||
creationKey: z.string().min(1).max(512),
|
||||
});
|
||||
|
||||
export const AgentRequestsInspectRequestSchema = AgentRequestsCancelRequestSchema.extend({
|
||||
type: z.literal("agent.requests.inspect.request"),
|
||||
});
|
||||
|
||||
export const AgentRequestsCancelResponseSchema = z.object({
|
||||
type: z.literal("agent.requests.cancel.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string().nullable(),
|
||||
outcome: z.enum(["settled", "pending", "unknown"]),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AgentRequestsInspectResponseSchema = AgentRequestsCancelResponseSchema.extend({
|
||||
type: z.literal("agent.requests.inspect.response"),
|
||||
});
|
||||
|
||||
export const WorkspaceRecoveryRestoreRequestSchema = z.object({
|
||||
type: z.literal("workspace.recovery.restore.request"),
|
||||
operation: AgentRequestOperationSchema.optional(),
|
||||
workspaceId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
@@ -1424,7 +1394,6 @@ export const FetchAgentRequestMessageSchema = z.object({
|
||||
|
||||
export const SendAgentMessageRequestSchema = z.object({
|
||||
type: z.literal("send_agent_message_request"),
|
||||
operation: AgentRequestOperationSchema.optional(),
|
||||
requestId: z.string(),
|
||||
/** Accepts full ID, unique prefix, or exact full title (server resolves). */
|
||||
agentId: z.string(),
|
||||
@@ -1711,7 +1680,6 @@ export type CreateAgentWorktreeTarget = z.infer<typeof CreateAgentWorktreeTarget
|
||||
|
||||
export const CreateAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("create_agent_request"),
|
||||
operation: AgentRequestOperationSchema.optional(),
|
||||
// A creation key requires initialPrompt to be sent separately with a stable messageId.
|
||||
idempotencyKey: z.string().min(1).max(512).optional(),
|
||||
config: AgentSessionConfigSchema,
|
||||
@@ -1805,7 +1773,6 @@ export const RefreshAgentRequestMessageSchema = z.object({
|
||||
|
||||
export const CancelAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("cancel_agent_request"),
|
||||
operation: AgentRequestOperationSchema.optional(),
|
||||
agentId: z.string(),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
@@ -2578,7 +2545,6 @@ export const ProjectGithubCloneRequestSchema = z.object({
|
||||
|
||||
export const ArchiveWorkspaceRequestSchema = z.object({
|
||||
type: z.literal("archive_workspace_request"),
|
||||
operation: AgentRequestOperationSchema.optional(),
|
||||
workspaceId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
@@ -3011,7 +2977,6 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
|
||||
type: z.literal("hub.execution.agent.create.request"),
|
||||
requestId: z.string(),
|
||||
executionId: z.string(),
|
||||
deadlineAt: z.string().datetime().optional(),
|
||||
provider: z.string(),
|
||||
cwd: z.string(),
|
||||
prompt: z.string(),
|
||||
@@ -3131,8 +3096,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
WorkspaceLabelDeleteInspectRequestSchema,
|
||||
WorkspaceRecoveryInspectRequestSchema,
|
||||
WorkspaceRecoveryRestoreRequestSchema,
|
||||
AgentRequestsCancelRequestSchema,
|
||||
AgentRequestsInspectRequestSchema,
|
||||
SetVoiceModeMessageSchema,
|
||||
SendAgentMessageRequestSchema,
|
||||
WaitForFinishRequestSchema,
|
||||
@@ -3470,7 +3433,6 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
.object({
|
||||
// COMPAT(agentRequestReceipts): added in v0.8.0; remove gate after 2027-03-05.
|
||||
agentRequestReceipts: z.boolean().optional(),
|
||||
agentRequestCancellation: z.boolean().optional(),
|
||||
// COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05.
|
||||
hubAgentRpc: z.boolean().optional(),
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
@@ -6581,8 +6543,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentAttentionRequiredMessageSchema,
|
||||
AgentForkContextResponseMessageSchema,
|
||||
CancelAgentResponseMessageSchema,
|
||||
AgentRequestsCancelResponseSchema,
|
||||
AgentRequestsInspectResponseSchema,
|
||||
ClearAgentAttentionResponseMessageSchema,
|
||||
WorkspaceCreateResponseSchema,
|
||||
WorkspaceClearAttentionResponseSchema,
|
||||
|
||||
@@ -1477,41 +1477,6 @@ function fakeCodexEmitting(args: FakeCodexEmitterArgs): AgentClient {
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("canceling provider startup settles creation and closes a late session without registering it", async () => {
|
||||
const client = new HeldAgentCreationClient();
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger });
|
||||
const controller = new AbortController();
|
||||
let outcome = "pending";
|
||||
const creation = manager
|
||||
.createAgent({ provider: "codex", cwd: process.cwd() }, undefined, {
|
||||
workspaceId: undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
outcome = "created";
|
||||
return;
|
||||
},
|
||||
() => {
|
||||
outcome = "canceled";
|
||||
},
|
||||
);
|
||||
await client.waitForCreationToStart();
|
||||
try {
|
||||
controller.abort(new Error("startup expired"));
|
||||
await creation;
|
||||
expect(outcome).toBe("canceled");
|
||||
client.finishCreating();
|
||||
await expect.poll(() => client.createdSessionClosed).toBe(true);
|
||||
expect(manager.listAgents()).toEqual([]);
|
||||
} finally {
|
||||
client.finishCreating();
|
||||
await creation;
|
||||
manager.prepareForShutdown();
|
||||
await manager.flushForShutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not register a session that finishes starting after shutdown begins", async () => {
|
||||
const client = new HeldAgentCreationClient();
|
||||
const manager = new AgentManager({
|
||||
|
||||
@@ -278,7 +278,6 @@ type ProviderEnabledMap = Partial<Record<AgentProvider, ProviderEnabledFlag>>;
|
||||
type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
signal?: AbortSignal;
|
||||
labels?: Record<string, string>;
|
||||
initialPrompt?: string;
|
||||
env?: Record<string, string>;
|
||||
@@ -1228,16 +1227,8 @@ export class AgentManager {
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
const createOptions = this.buildCreateSessionOptions(options);
|
||||
options.signal?.throwIfAborted();
|
||||
const session = await this.waitForCreatedSession(
|
||||
client.createSession(providerLaunchConfig, launchContext, createOptions),
|
||||
options.signal,
|
||||
);
|
||||
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
||||
await this.requireExternalMcpSupport(session, storedConfig);
|
||||
if (options.signal?.aborted) {
|
||||
await this.closeUnregisteredSession(session);
|
||||
options.signal.throwIfAborted();
|
||||
}
|
||||
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
labels: options.labels,
|
||||
initialTitle: options.initialTitle,
|
||||
@@ -1253,38 +1244,6 @@ export class AgentManager {
|
||||
return agent;
|
||||
}
|
||||
|
||||
private async waitForCreatedSession(
|
||||
operation: Promise<AgentSession>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentSession> {
|
||||
if (!signal) return operation;
|
||||
return new Promise<AgentSession>((accept, reject) => {
|
||||
const abort = () => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
void operation
|
||||
.then(
|
||||
async (session) => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
if (signal.aborted) {
|
||||
await this.closeUnregisteredSession(session);
|
||||
} else accept(session);
|
||||
return;
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
reject(error);
|
||||
},
|
||||
)
|
||||
.catch((error: unknown) =>
|
||||
this.logger.error({ err: error }, "Failed to close canceled provider creation"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private buildCreateSessionOptions(options?: {
|
||||
persistSession?: boolean;
|
||||
}): AgentCreateSessionOptions | undefined {
|
||||
@@ -2926,6 +2885,9 @@ export class AgentManager {
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (agent.inFlightPermissionResponses.has(requestId)) {
|
||||
throw new Error("A response to this permission request is already being submitted");
|
||||
}
|
||||
agent.inFlightPermissionResponses.add(requestId);
|
||||
|
||||
try {
|
||||
@@ -3120,8 +3082,11 @@ export class AgentManager {
|
||||
epoch: this.timelineStore.getEpoch(agentId),
|
||||
});
|
||||
}
|
||||
await this.refreshRuntimeInfo(agent);
|
||||
// Rewind stages provider events under the run lock; publish its final state directly.
|
||||
this.refreshSessionPersistence(agent);
|
||||
await this.refreshSessionState(agent, { emit: false });
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent, { persist: false });
|
||||
this.logger.info(
|
||||
{ agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.complete",
|
||||
@@ -4272,14 +4237,18 @@ export class AgentManager {
|
||||
|
||||
private onStreamThreadStarted(agent: ActiveManagedAgent): void {
|
||||
const previousSessionId = agent.persistence?.sessionId ?? null;
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (agent.persistence?.sessionId !== previousSessionId) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
|
||||
const handle = agent.session.describePersistence();
|
||||
if (handle) {
|
||||
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
||||
if (agent.persistence?.sessionId !== previousSessionId) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
}
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
private async onStreamTimelineEvent(params: {
|
||||
@@ -4484,6 +4453,7 @@ export class AgentManager {
|
||||
): void {
|
||||
const hadPendingPermissions = agent.pendingPermissions.size > 0;
|
||||
agent.pendingPermissions.set(event.request.id, event.request);
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (!hadPendingPermissions && !agent.internal) {
|
||||
this.broadcastAgentAttention(agent, "permission");
|
||||
}
|
||||
@@ -4498,6 +4468,7 @@ export class AgentManager {
|
||||
}): void {
|
||||
const { agent, event, options, flags } = params;
|
||||
agent.pendingPermissions.delete(event.requestId);
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
|
||||
agent.bufferedPermissionResolutions.set(event.requestId, event);
|
||||
flags.shouldDispatchEvent = false;
|
||||
|
||||
@@ -182,7 +182,6 @@ export function isSystemInjectedEnvelope(text: string): boolean {
|
||||
}
|
||||
|
||||
export interface SendPromptToAgentParams {
|
||||
signal?: AbortSignal;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentId: string;
|
||||
@@ -205,7 +204,6 @@ export interface SendPromptToAgentParams {
|
||||
}
|
||||
|
||||
export interface StartCreatedAgentInitialPromptParams {
|
||||
signal?: AbortSignal;
|
||||
agentManager: AgentManager;
|
||||
agentId: string;
|
||||
snapshot?: ManagedAgent;
|
||||
@@ -231,7 +229,6 @@ const AGENT_RUN_START_TIMEOUT_MS = 60_000;
|
||||
export async function waitForAgentRunStartWithTimeout(
|
||||
agentManager: AgentManager,
|
||||
agentId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const provider = agentManager.getAgent(agentId)?.provider ?? "provider";
|
||||
const startAbort = new AbortController();
|
||||
@@ -246,9 +243,7 @@ export async function waitForAgentRunStartWithTimeout(
|
||||
);
|
||||
|
||||
try {
|
||||
await agentManager.waitForAgentRunStart(agentId, {
|
||||
signal: signal ? AbortSignal.any([signal, startAbort.signal]) : startAbort.signal,
|
||||
});
|
||||
await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal });
|
||||
} finally {
|
||||
clearTimeout(startTimeout);
|
||||
}
|
||||
@@ -268,7 +263,6 @@ export async function waitForAgentRunStartWithTimeout(
|
||||
export async function sendPromptToAgent(
|
||||
params: SendPromptToAgentParams,
|
||||
): Promise<{ disposition: PromptDispatchDisposition }> {
|
||||
params.signal?.throwIfAborted();
|
||||
const unarchive = params.unarchive ?? true;
|
||||
|
||||
const record = await params.agentStorage.get(params.agentId);
|
||||
@@ -289,7 +283,6 @@ export async function sendPromptToAgent(
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
}
|
||||
|
||||
params.signal?.throwIfAborted();
|
||||
const runOptions = params.messageId
|
||||
? { ...params.runOptions, clientMessageId: params.messageId }
|
||||
: params.runOptions;
|
||||
@@ -314,7 +307,6 @@ export async function startCreatedAgentInitialPrompt(
|
||||
return currentSnapshot;
|
||||
}
|
||||
|
||||
params.signal?.throwIfAborted();
|
||||
const dispatchResult = await startAgentRun(
|
||||
params.agentManager,
|
||||
params.agentId,
|
||||
@@ -326,7 +318,7 @@ export async function startCreatedAgentInitialPrompt(
|
||||
);
|
||||
|
||||
if (dispatchResult.disposition === "turn_started") {
|
||||
await waitForAgentRunStartWithTimeout(params.agentManager, params.agentId, params.signal);
|
||||
await waitForAgentRunStartWithTimeout(params.agentManager, params.agentId);
|
||||
}
|
||||
|
||||
const refreshedSnapshot = params.agentManager.getAgent(params.agentId) ?? params.snapshot ?? null;
|
||||
|
||||
@@ -55,7 +55,6 @@ export type EnsureWorkspaceForCreate = (
|
||||
|
||||
export interface CreateAgentFromSessionInput {
|
||||
kind: "session";
|
||||
signal?: AbortSignal;
|
||||
agentId?: string;
|
||||
config: AgentSessionConfig;
|
||||
workspaceId: string;
|
||||
@@ -80,7 +79,6 @@ export interface CreateAgentFromSessionInput {
|
||||
|
||||
export interface CreateAgentFromMcpInput {
|
||||
kind: "mcp";
|
||||
signal?: AbortSignal;
|
||||
provider: string;
|
||||
title: string;
|
||||
initialPrompt?: string;
|
||||
@@ -182,12 +180,10 @@ export async function createAgentCommand(
|
||||
? await resolveSessionCreateAgent(dependencies, input)
|
||||
: await resolveMcpCreateAgent(dependencies, input);
|
||||
|
||||
const signal = input.signal;
|
||||
signal?.throwIfAborted();
|
||||
const snapshot = await dependencies.agentManager.createAgent(
|
||||
resolved.config,
|
||||
input.kind === "session" ? input.agentId : undefined,
|
||||
{ ...resolved.createOptions, signal },
|
||||
resolved.createOptions,
|
||||
);
|
||||
|
||||
resolved.setupContinuation?.startAfterAgentCreate({
|
||||
@@ -201,7 +197,7 @@ export async function createAgentCommand(
|
||||
input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null });
|
||||
}
|
||||
if (resolved.prompt !== undefined) {
|
||||
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot, signal);
|
||||
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot);
|
||||
initialPromptStarted = sendResult.started;
|
||||
liveSnapshot = sendResult.liveSnapshot;
|
||||
initialPromptError = sendResult.error ?? null;
|
||||
@@ -453,7 +449,6 @@ async function sendInitialPrompt(
|
||||
dependencies: CreateAgentCommandDependencies,
|
||||
resolved: ResolvedCreateAgent,
|
||||
snapshot: ManagedAgent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ started: boolean; liveSnapshot: ManagedAgent; error?: unknown }> {
|
||||
try {
|
||||
const prompt = resolved.prompt;
|
||||
@@ -463,7 +458,6 @@ async function sendInitialPrompt(
|
||||
const liveSnapshot = await startCreatedAgentInitialPrompt({
|
||||
agentManager: dependencies.agentManager,
|
||||
agentId: snapshot.id,
|
||||
signal,
|
||||
snapshot,
|
||||
prompt,
|
||||
runOptions: resolved.runOptions,
|
||||
|
||||
@@ -24,6 +24,12 @@ interface TestClaudeSession {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function isLoadingCompactionEvent(event: AgentStreamEvent): boolean {
|
||||
return (
|
||||
event.type === "timeline" && event.item.type === "compaction" && event.item.status === "loading"
|
||||
);
|
||||
}
|
||||
|
||||
function isPermissionResolvedEvent(
|
||||
event: AgentStreamEvent,
|
||||
): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> {
|
||||
@@ -1981,6 +1987,15 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createCompactingStatus(): Record<string, unknown> {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "status",
|
||||
status: "compacting",
|
||||
session_id: "session-1",
|
||||
};
|
||||
}
|
||||
|
||||
test("emits turn_started before the submitted user message", async () => {
|
||||
const session = await createSessionForTurns([[]]);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
@@ -2857,6 +2872,110 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("repeated compacting statuses open a single compaction marker", async () => {
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
createCompactingStatus(),
|
||||
createCompactingStatus(),
|
||||
createCompactingStatus(),
|
||||
createCompactBoundary(),
|
||||
createCompactingStatus(),
|
||||
createCompactingStatus(),
|
||||
createCompactBoundary(),
|
||||
createSuccessResult(),
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
const events = await collectStreamEvents(session, "compact twice");
|
||||
const compactions = events.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "compaction" ? [event.item.status] : [],
|
||||
);
|
||||
expect(compactions).toEqual(["loading", "completed", "loading", "completed"]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a compaction abandoned mid-turn does not suppress the next compaction marker", async () => {
|
||||
// The first turn starts compacting and then ends without ever reaching a
|
||||
// compact_boundary, so the marker it opened is never resolved.
|
||||
const session = await createSessionForTurns([
|
||||
[createCompactingStatus(), createSuccessResult()],
|
||||
[createCompactingStatus(), createSuccessResult()],
|
||||
]);
|
||||
|
||||
try {
|
||||
const abandonedTurn = await collectStreamEvents(session, "abandoned compaction");
|
||||
expect(abandonedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
|
||||
const nextTurn = await collectStreamEvents(session, "next compaction");
|
||||
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an interrupted compaction does not suppress the next compaction marker", async () => {
|
||||
// The first turn starts compacting and is then interrupted, so it never reaches a
|
||||
// compact_boundary and the marker it opened is never resolved.
|
||||
const session = await createSessionForTurns([
|
||||
[createCompactingStatus()],
|
||||
[createCompactingStatus(), createSuccessResult()],
|
||||
]);
|
||||
|
||||
try {
|
||||
const interruptedTurn: AgentStreamEvent[] = [];
|
||||
const streaming = (async () => {
|
||||
for await (const event of streamSession(session, "interrupted compaction")) {
|
||||
interruptedTurn.push(event);
|
||||
}
|
||||
})();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(interruptedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
});
|
||||
await session.interrupt();
|
||||
await streaming;
|
||||
|
||||
expect(interruptedTurn).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_canceled", provider: "claude" }),
|
||||
);
|
||||
|
||||
const nextTurn = await collectStreamEvents(session, "next compaction");
|
||||
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a compaction abandoned in an autonomous turn does not suppress the next marker", async () => {
|
||||
// Trailing output after the foreground result opens an autonomous turn, which starts
|
||||
// compacting and is then ended by the next foreground turn, never reaching a boundary.
|
||||
const session = await createSessionForTurns([
|
||||
[createSuccessResult(), createMessageStartEvent(), createCompactingStatus()],
|
||||
[createCompactingStatus(), createSuccessResult()],
|
||||
]);
|
||||
|
||||
try {
|
||||
const observed: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
observed.push(event);
|
||||
});
|
||||
|
||||
await collectStreamEvents(session, "foreground turn");
|
||||
await vi.waitFor(() => {
|
||||
expect(observed.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
const nextTurn = await collectStreamEvents(session, "next compaction");
|
||||
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
|
||||
@@ -2095,6 +2095,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private lastOptionsModel: string | null = null;
|
||||
private lastRuntimeModel: string | null = null;
|
||||
private compacting = false;
|
||||
private compactionMarkerOpen = false;
|
||||
private queryPumpPromise: Promise<void> | null = null;
|
||||
private queryRestartNeeded = false;
|
||||
private pendingInterruptAbort = false;
|
||||
@@ -3572,6 +3573,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundInput = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.compactionMarkerOpen = false;
|
||||
this.syncTurnState("foreground turn terminal");
|
||||
}
|
||||
|
||||
@@ -3583,6 +3585,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (terminalSeen) {
|
||||
this.compactionMarkerOpen = false;
|
||||
if (this.activeForegroundTurnId) {
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeForegroundQuery = null;
|
||||
@@ -3624,6 +3627,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.compactionMarkerOpen = false;
|
||||
this.syncTurnState("autonomous turn completed");
|
||||
}
|
||||
|
||||
@@ -4260,15 +4264,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const status = toObjectRecord(message)?.status;
|
||||
if (status === "compacting") {
|
||||
this.compacting = true;
|
||||
events.push({
|
||||
type: "timeline",
|
||||
item: { type: "compaction", status: "loading" },
|
||||
provider: "claude",
|
||||
});
|
||||
// Claude Code repeats this status every 30 seconds until the compaction
|
||||
// finishes. Each repeat used to open its own marker, and the app only ever
|
||||
// resolves one of them, so the rest stayed on "Compacting..." forever.
|
||||
if (!this.compactionMarkerOpen) {
|
||||
this.compactionMarkerOpen = true;
|
||||
events.push({
|
||||
type: "timeline",
|
||||
item: { type: "compaction", status: "loading" },
|
||||
provider: "claude",
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.subtype === "compact_boundary") {
|
||||
this.compactionMarkerOpen = false;
|
||||
const compactMetadata = readCompactionMetadata(message);
|
||||
events.push({
|
||||
type: "timeline",
|
||||
|
||||
@@ -52,6 +52,7 @@ import { z } from "zod";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import { curateAgentActivity } from "../activity-curator.js";
|
||||
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
|
||||
import {
|
||||
mapCodexToolCallEnvelope,
|
||||
mapCodexToolCallFromThreadItem,
|
||||
@@ -1845,6 +1846,17 @@ function mapCodexThreadImageItem(
|
||||
);
|
||||
}
|
||||
|
||||
function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem {
|
||||
const question = codexAsyncQuestionToTimeline(item);
|
||||
if (question) return question;
|
||||
const messageId = nonEmptyString(item.id);
|
||||
return {
|
||||
type: "assistant_message",
|
||||
text: typeof item.text === "string" ? item.text : "",
|
||||
...(messageId ? { messageId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function threadItemToTimeline(
|
||||
item: unknown,
|
||||
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
||||
@@ -1871,14 +1883,8 @@ export function threadItemToTimeline(
|
||||
switch (normalizedType) {
|
||||
case "userMessage":
|
||||
return mapCodexThreadUserMessageItem(normalizedItem, includeUserMessage);
|
||||
case "agentMessage": {
|
||||
const messageId = nonEmptyString(normalizedItem.id);
|
||||
return {
|
||||
type: "assistant_message",
|
||||
text: typeof normalizedItem.text === "string" ? normalizedItem.text : "",
|
||||
...(messageId ? { messageId } : {}),
|
||||
};
|
||||
}
|
||||
case "agentMessage":
|
||||
return mapCodexAgentMessage(normalizedItem);
|
||||
case "plan":
|
||||
return mapCodexThreadPlanItem(normalizedItem);
|
||||
case "reasoning":
|
||||
@@ -3293,6 +3299,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly config: AgentSessionConfig;
|
||||
private readonly asyncQuestions: CodexAsyncQuestions;
|
||||
private currentMode: string;
|
||||
private hasWorkflowModeOverride: boolean;
|
||||
private readonly providerOptions: CodexProviderOptions;
|
||||
@@ -3405,6 +3412,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID;
|
||||
this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {});
|
||||
this.config = config;
|
||||
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
|
||||
this.serviceTier = "fast";
|
||||
@@ -3799,6 +3807,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
this.resetCodexUserMessageTurns();
|
||||
for (const entry of timeline) {
|
||||
if (entry.item.type === "tool_call" && entry.item.name === "request_user_input_async") {
|
||||
entry.item = this.asyncQuestions.timeline(entry.item.callId) ?? entry.item;
|
||||
}
|
||||
if (entry.item.type === "user_message") {
|
||||
this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId);
|
||||
}
|
||||
@@ -4464,13 +4475,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
return Array.from(this.pendingPermissions.values());
|
||||
return [...this.pendingPermissions.values(), ...this.asyncQuestions.pending()];
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void> {
|
||||
if (this.asyncQuestions.hasPending(requestId)) {
|
||||
return this.respondToAsyncQuestion(requestId, response);
|
||||
}
|
||||
const pending = this.pendingPermissionHandlers.get(requestId);
|
||||
if (!pending) {
|
||||
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
|
||||
@@ -4564,6 +4578,33 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
pending.resolve({ answers: {} });
|
||||
}
|
||||
|
||||
private async respondToAsyncQuestion(
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void> {
|
||||
const prepared = this.asyncQuestions.prepareResponse(requestId, response);
|
||||
let followUpPrompt = prepared.prompt;
|
||||
const expectedTurnId = this.activeForegroundTurnId;
|
||||
if (prepared.prompt && expectedTurnId) {
|
||||
const result = await this.steerActiveTurn(prepared.prompt, {
|
||||
expectedTurnId,
|
||||
clientMessageId: randomUUID(),
|
||||
});
|
||||
if (result.status !== "accepted") {
|
||||
throw new Error("The active Codex turn changed. Retry sending your answer.");
|
||||
}
|
||||
followUpPrompt = undefined;
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() });
|
||||
this.emitEvent({
|
||||
type: "permission_resolved",
|
||||
provider: CODEX_PROVIDER,
|
||||
requestId,
|
||||
resolution: response,
|
||||
});
|
||||
return followUpPrompt ? { followUpPrompt } : undefined;
|
||||
}
|
||||
|
||||
private handlePlanPermissionResponse(params: {
|
||||
requestId: string;
|
||||
response: AgentPermissionResponse;
|
||||
@@ -4700,6 +4741,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
toolPolicy: this.config.toolPolicy,
|
||||
systemPrompt: this.config.systemPrompt,
|
||||
mcpServers: this.config.mcpServers,
|
||||
asyncQuestions: this.asyncQuestions.serialize(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4729,6 +4771,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.persistedHistory = [];
|
||||
this.historyPending = false;
|
||||
await this.loadPersistedHistory();
|
||||
this.reconcileAsyncQuestionsAfterRewind();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5903,6 +5946,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
error: parsed.errorMessage ?? "Codex turn failed",
|
||||
});
|
||||
} else if (parsed.status === "interrupted") {
|
||||
this.dismissInterruptedAsyncQuestions();
|
||||
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
||||
} else {
|
||||
if (this.planModeEnabled && this.latestPlanResult?.text) {
|
||||
@@ -6255,6 +6299,43 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private receiveAsyncQuestion(threadId: string | null, item: unknown): void {
|
||||
if (threadId !== this.currentThreadId) return;
|
||||
const request = this.asyncQuestions.receive(item);
|
||||
if (request)
|
||||
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
||||
}
|
||||
|
||||
private dismissInterruptedAsyncQuestions(): void {
|
||||
const resolution: AgentPermissionResponse = { behavior: "deny", message: "Interrupted" };
|
||||
for (const request of this.asyncQuestions.pending()) {
|
||||
const prepared = this.asyncQuestions.prepareResponse(request.id, resolution);
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() });
|
||||
this.emitEvent({
|
||||
type: "permission_resolved",
|
||||
provider: CODEX_PROVIDER,
|
||||
requestId: request.id,
|
||||
resolution,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private reconcileAsyncQuestionsAfterRewind(): void {
|
||||
const retainedIds = new Set(
|
||||
this.persistedHistory.flatMap(({ item }) =>
|
||||
item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [],
|
||||
),
|
||||
);
|
||||
for (const requestId of this.asyncQuestions.retain(retainedIds)) {
|
||||
this.emitEvent({
|
||||
type: "permission_resolved",
|
||||
provider: CODEX_PROVIDER,
|
||||
requestId,
|
||||
resolution: { behavior: "deny", message: "Removed by rewind" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleItemCompletedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
||||
): void {
|
||||
@@ -6265,6 +6346,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||
return;
|
||||
}
|
||||
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
|
||||
if (this.isUserMessageItem(parsed.item)) {
|
||||
this.handleUserMessageItem(parsed);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { tmpdir } from "node:os";
|
||||
import { expect, test } from "vitest";
|
||||
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
waitForNextEvent,
|
||||
waitForTimelineToolCall,
|
||||
} from "./codex/test-utils/fake-app-server.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
|
||||
const questionItem = {
|
||||
type: "agentMessage",
|
||||
id: "async-question-1",
|
||||
text: "Which color?\n- Blue\n- Green",
|
||||
phase: "final_answer",
|
||||
delivery: "async",
|
||||
questions: [{ title: "Which color?", options: ["Blue", "Green"] }],
|
||||
};
|
||||
|
||||
async function setup(metadata?: Record<string, unknown>, rejectSteer = false) {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/interrupt": () => ({}),
|
||||
"turn/steer": () => {
|
||||
if (rejectSteer) return { __jsonRpcError: { code: -32000, message: "Delivery failed" } };
|
||||
return { turnId: "native-turn" };
|
||||
},
|
||||
"thread/read": () => ({ thread: { id: "thread-1", turns: [] } }),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" },
|
||||
metadata ? { sessionId: "thread-1", metadata } : null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
await session.startTurn("Help me choose a color while you inspect the project.");
|
||||
const started = waitForNextEvent(session, "turn_started");
|
||||
appServer.startsTurn({ threadId: "thread-1", turnId: "native-turn" });
|
||||
await started;
|
||||
async function ask() {
|
||||
const shown = waitForTimelineToolCall(session, questionItem.id);
|
||||
appServer.child.stdout.write(
|
||||
JSON.stringify({
|
||||
method: "item/completed",
|
||||
params: { threadId: "thread-1", turnId: "native-turn", item: questionItem },
|
||||
}) + "\n",
|
||||
);
|
||||
await shown;
|
||||
}
|
||||
async function say(text: string) {
|
||||
const shown = waitForNextEvent(
|
||||
session,
|
||||
"timeline",
|
||||
(event) => event.item.type === "assistant_message" && event.item.text.includes(text),
|
||||
);
|
||||
appServer.says({ threadId: "thread-1", text });
|
||||
await shown;
|
||||
}
|
||||
async function finish(status: "completed" | "interrupted" = "completed") {
|
||||
const finished = waitForNextEvent(
|
||||
session,
|
||||
status === "completed" ? "turn_completed" : "turn_canceled",
|
||||
);
|
||||
appServer.completeTurn({ status });
|
||||
await finished;
|
||||
}
|
||||
function allowAnswers() {
|
||||
rejectSteer = false;
|
||||
}
|
||||
return { session, appServer, events, ask, say, finish, allowAnswers };
|
||||
}
|
||||
|
||||
const answer = { behavior: "allow" as const, updatedInput: { answers: { "Question 1": "Green" } } };
|
||||
|
||||
async function setupRewind(fail = false) {
|
||||
const records = [
|
||||
{ item: { ...questionItem, id: "earlier-pending" } },
|
||||
{ item: { ...questionItem, id: "earlier-answered" }, resolution: ["Blue"] },
|
||||
{ item: { ...questionItem, id: "removed-pending" } },
|
||||
{ item: { ...questionItem, id: "removed-answered" }, resolution: ["Green"] },
|
||||
];
|
||||
function userMessage(id: string) {
|
||||
return { type: "userMessage", id, content: [{ type: "text", text: id }] };
|
||||
}
|
||||
const turns = [
|
||||
{
|
||||
id: "earlier-turn",
|
||||
items: [userMessage("earlier"), ...records.slice(0, 2).map((r) => r.item)],
|
||||
},
|
||||
{
|
||||
id: "removed-turn",
|
||||
items: [userMessage("rewind-here"), ...records.slice(2).map((r) => r.item)],
|
||||
},
|
||||
];
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/read": (params) => {
|
||||
const { threadId } = params as { threadId: string };
|
||||
return {
|
||||
thread: { id: threadId, turns: threadId === "thread-1" ? turns : turns.slice(0, 1) },
|
||||
};
|
||||
},
|
||||
...(fail
|
||||
? {
|
||||
"thread/rollback": () => ({ __jsonRpcError: { code: -32000, message: "Rewind failed" } }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" },
|
||||
{ sessionId: "thread-1", metadata: { asyncQuestions: records } },
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
await session.connect();
|
||||
return { session, events, records };
|
||||
}
|
||||
|
||||
test("rewind removes only questions outside the remaining history, including after resume", async () => {
|
||||
const { session, events, records } = await setupRewind();
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
await session.revertConversation({ messageId: "rewind-here" });
|
||||
expect(session.getPendingPermissions().map((p) => p.id)).toEqual([
|
||||
"permission-earlier-pending",
|
||||
]);
|
||||
metadata = session.describePersistence()!.metadata;
|
||||
expect(metadata.asyncQuestions).toEqual(
|
||||
records.slice(0, 2).map((record) => ({
|
||||
resolution: record.resolution,
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: record.item.id,
|
||||
delivery: "async",
|
||||
questions: questionItem.questions,
|
||||
},
|
||||
})),
|
||||
);
|
||||
expect(events.filter((e) => e.type === "permission_resolved")).toEqual([
|
||||
expect.objectContaining({ requestId: "permission-removed-pending" }),
|
||||
]);
|
||||
const history = [];
|
||||
for await (const event of session.streamHistory()) history.push(event);
|
||||
expect(
|
||||
history.some(
|
||||
(e) =>
|
||||
e.type === "timeline" &&
|
||||
e.item.type === "tool_call" &&
|
||||
e.item.callId === "removed-pending",
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
const resumed = await setup(metadata);
|
||||
try {
|
||||
expect(resumed.session.getPendingPermissions().map((p) => p.id)).toEqual([
|
||||
"permission-earlier-pending",
|
||||
]);
|
||||
} finally {
|
||||
await resumed.session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("failed rewind preserves question state", async () => {
|
||||
const { session, events } = await setupRewind(true);
|
||||
try {
|
||||
const before = session.describePersistence();
|
||||
await expect(session.revertConversation({ messageId: "rewind-here" })).rejects.toThrow(
|
||||
"Rewind failed",
|
||||
);
|
||||
expect(session.describePersistence()).toEqual(before);
|
||||
expect(session.getPendingPermissions().map((p) => p.id)).toEqual([
|
||||
"permission-earlier-pending",
|
||||
"permission-removed-pending",
|
||||
]);
|
||||
expect(events.some((e) => e.type === "permission_resolved")).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function manage(session: CodexAppServerAgentSession) {
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: {
|
||||
provider: "codex",
|
||||
capabilities: session.capabilities,
|
||||
createSession: async () => session,
|
||||
resumeSession: async () => session,
|
||||
isAvailable: async () => true,
|
||||
fetchCatalog: async () => ({ models: [], modes: [] }),
|
||||
},
|
||||
},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
return { manager, agent };
|
||||
}
|
||||
|
||||
test("manager publishes and saves the provider state after rewind", async () => {
|
||||
const { session } = await setupRewind();
|
||||
const { manager, agent } = await manage(session);
|
||||
try {
|
||||
await manager.rewind(agent.id, "rewind-here", "conversation");
|
||||
const snapshot = manager.getAgent(agent.id)!;
|
||||
expect(Array.from(snapshot.pendingPermissions.keys())).toEqual(["permission-earlier-pending"]);
|
||||
expect(snapshot.persistence).toEqual(session.describePersistence());
|
||||
expect(snapshot.persistence?.sessionId).toBe("forked-thread");
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("manager snapshots capture pending and answered question state before the turn ends", async () => {
|
||||
const { session, ask } = await setup();
|
||||
const { manager, agent } = await manage(session);
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([
|
||||
{
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: questionItem.id,
|
||||
delivery: "async",
|
||||
questions: questionItem.questions,
|
||||
},
|
||||
},
|
||||
]);
|
||||
await manager.respondToPermission(agent.id, permission.id, answer);
|
||||
expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([
|
||||
{
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: questionItem.id,
|
||||
delivery: "async",
|
||||
questions: questionItem.questions,
|
||||
},
|
||||
resolution: ["Green"],
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("concurrent answers deliver only one response to the active Codex turn", async () => {
|
||||
const { session, appServer, ask } = await setup();
|
||||
const { manager, agent } = await manage(session);
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
const results = await Promise.allSettled([
|
||||
manager.respondToPermission(agent.id, permission.id, answer),
|
||||
manager.respondToPermission(agent.id, permission.id, answer),
|
||||
]);
|
||||
expect(results.map((result) => result.status)).toEqual(["fulfilled", "rejected"]);
|
||||
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed submission releases the request so the user can retry", async () => {
|
||||
const { session, ask, allowAnswers } = await setup(undefined, true);
|
||||
const { manager, agent } = await manage(session);
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
await expect(manager.respondToPermission(agent.id, permission.id, answer)).rejects.toThrow(
|
||||
"Delivery failed",
|
||||
);
|
||||
expect(session.getPendingPermissions().map((request) => request.id)).toEqual([permission.id]);
|
||||
allowAnswers();
|
||||
await manager.respondToPermission(agent.id, permission.id, answer);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an async question, keeps streaming, and delivers its answer without interrupting", async () => {
|
||||
const { session, appServer, events, ask, say } = await setup();
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
expect(permission).toMatchObject({
|
||||
kind: "question",
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
header: "Question 1",
|
||||
question: "Which color?",
|
||||
isOther: true,
|
||||
options: [{ label: "Blue" }, { label: "Green" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await say("I am still inspecting the project.");
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("still inspecting"),
|
||||
),
|
||||
).toBe(true);
|
||||
await session.respondToPermission(permission.id, answer);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toMatchObject(
|
||||
[
|
||||
{
|
||||
params: {
|
||||
expectedTurnId: "native-turn",
|
||||
clientUserMessageId: expect.any(String),
|
||||
input: [{ type: "text", text: expect.stringContaining("Which color?\nGreen") }],
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
events.some(
|
||||
(event) => event.type === "permission_resolved" && event.requestId === permission.id,
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a failed answer pending for retry", async () => {
|
||||
const { session, ask } = await setup(undefined, true);
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
expect(permission).toBeDefined();
|
||||
await expect(session.respondToPermission(permission.id, answer)).rejects.toThrow();
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("Stop dismisses async questions before cancellation and keeps them dismissed after resume", async () => {
|
||||
const first = await setup();
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
await first.ask();
|
||||
expect(first.session.getPendingPermissions()).toHaveLength(1);
|
||||
first.session.subscribe((event) => {
|
||||
if (event.type === "turn_canceled") {
|
||||
metadata = first.session.describePersistence()!.metadata;
|
||||
}
|
||||
});
|
||||
await first.session.interrupt();
|
||||
await first.finish("interrupted");
|
||||
expect(first.session.getPendingPermissions()).toEqual([]);
|
||||
expect(metadata?.asyncQuestions).toEqual([
|
||||
expect.objectContaining({ resolution: "dismissed" }),
|
||||
]);
|
||||
expect(first.events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "permission_resolved",
|
||||
requestId: "permission-async-question-1",
|
||||
resolution: { behavior: "deny", message: "Interrupted" },
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await first.session.close();
|
||||
}
|
||||
const resumed = await setup(metadata);
|
||||
try {
|
||||
await resumed.ask();
|
||||
expect(resumed.session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await resumed.session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("late answers use the existing follow-up prompt and dismissal does not interrupt", async () => {
|
||||
const { session, appServer, ask, finish } = await setup();
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
expect(permission).toBeDefined();
|
||||
await finish();
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
expect(await session.respondToPermission(permission.id, answer)).toEqual({
|
||||
followUpPrompt: "Answers to your questions:\n\nWhich color?\nGreen",
|
||||
});
|
||||
expect(appServer.requests().some((request) => request.method === "turn/interrupt")).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("restores unanswered questions and does not reopen answered questions on duplicate events", async () => {
|
||||
const first = await setup();
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
await first.ask();
|
||||
expect(first.session.getPendingPermissions()).toHaveLength(1);
|
||||
metadata = first.session.describePersistence()!.metadata;
|
||||
} finally {
|
||||
await first.session.close();
|
||||
}
|
||||
const resumed = await setup(metadata);
|
||||
try {
|
||||
const [permission] = resumed.session.getPendingPermissions();
|
||||
expect(permission).toBeDefined();
|
||||
await resumed.session.respondToPermission(permission.id, { behavior: "deny" });
|
||||
await resumed.ask();
|
||||
expect(resumed.session.getPendingPermissions()).toEqual([]);
|
||||
expect(
|
||||
resumed.appServer.requests().some((request) => request.method === "turn/interrupt"),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await resumed.session.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
ToolCallTimelineItem,
|
||||
} from "../../agent-sdk-types.js";
|
||||
|
||||
const QuestionSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
options: z.array(z.string().min(1)).nullish(),
|
||||
});
|
||||
const ItemSchema = z.object({
|
||||
type: z.literal("agentMessage"),
|
||||
id: z.string().min(1),
|
||||
delivery: z.literal("async"),
|
||||
questions: z.array(QuestionSchema).min(1),
|
||||
});
|
||||
const RecordSchema = z.object({
|
||||
item: ItemSchema,
|
||||
resolution: z.union([z.literal("dismissed"), z.array(z.string())]).optional(),
|
||||
});
|
||||
type QuestionRecord = z.infer<typeof RecordSchema>;
|
||||
|
||||
function requestId(itemId: string): string {
|
||||
return `permission-${itemId}`;
|
||||
}
|
||||
|
||||
function toPermission(record: QuestionRecord): AgentPermissionRequest {
|
||||
return {
|
||||
id: requestId(record.item.id),
|
||||
provider: "codex",
|
||||
name: "request_user_input_async",
|
||||
kind: "question",
|
||||
title: "Question",
|
||||
input: {
|
||||
questions: record.item.questions.map((question, index) => ({
|
||||
id: String(index),
|
||||
header: `Question ${index + 1}`,
|
||||
question: question.title,
|
||||
options: (question.options ?? []).map((label) => ({ label })),
|
||||
isOther: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toTimeline(record: QuestionRecord): ToolCallTimelineItem {
|
||||
const answers = Array.isArray(record.resolution) ? record.resolution : undefined;
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: record.item.id,
|
||||
name: "request_user_input_async",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
icon: "brain",
|
||||
text:
|
||||
record.item.questions
|
||||
.map((question, index) =>
|
||||
[question.title, answers ? answers[index] : (question.options ?? []).join(", ")]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
)
|
||||
.join("\n\n") + (record.resolution === "dismissed" ? "\n\nDismissed" : ""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function codexAsyncQuestionToTimeline(item: unknown): ToolCallTimelineItem | null {
|
||||
const parsed = ItemSchema.safeParse(item);
|
||||
return parsed.success ? toTimeline({ item: parsed.data }) : null;
|
||||
}
|
||||
|
||||
/** Codex emits these as completed messages; the outstanding answer belongs to the session. */
|
||||
export class CodexAsyncQuestions {
|
||||
private readonly records = new Map<string, QuestionRecord>();
|
||||
|
||||
constructor(saved: unknown) {
|
||||
const parsed = z.array(RecordSchema).safeParse(saved);
|
||||
if (parsed.success) {
|
||||
for (const record of parsed.data) this.records.set(requestId(record.item.id), record);
|
||||
}
|
||||
}
|
||||
|
||||
receive(item: unknown): AgentPermissionRequest | null {
|
||||
const parsed = ItemSchema.safeParse(item);
|
||||
if (!parsed.success || this.records.has(requestId(parsed.data.id))) return null;
|
||||
const record = { item: parsed.data };
|
||||
this.records.set(requestId(parsed.data.id), record);
|
||||
return toPermission(record);
|
||||
}
|
||||
|
||||
pending(): AgentPermissionRequest[] {
|
||||
return Array.from(this.records.values())
|
||||
.filter((record) => record.resolution === undefined)
|
||||
.map(toPermission);
|
||||
}
|
||||
|
||||
hasPending(id: string): boolean {
|
||||
const record = this.records.get(id);
|
||||
return record !== undefined && record.resolution === undefined;
|
||||
}
|
||||
|
||||
prepareResponse(
|
||||
id: string,
|
||||
response: AgentPermissionResponse,
|
||||
): { prompt?: string; complete: () => ToolCallTimelineItem } {
|
||||
const record = this.records.get(id);
|
||||
if (!record || record.resolution !== undefined)
|
||||
throw new Error("Question is no longer pending");
|
||||
let resolution: QuestionRecord["resolution"] = "dismissed";
|
||||
let prompt: string | undefined;
|
||||
if (response.behavior === "allow") {
|
||||
const answers = z.record(z.string(), z.string()).parse(response.updatedInput?.answers);
|
||||
resolution = record.item.questions.map((_, index) => {
|
||||
const answer = answers[`Question ${index + 1}`]?.trim();
|
||||
if (!answer) throw new Error(`Answer Question ${index + 1} before submitting`);
|
||||
return answer;
|
||||
});
|
||||
const values = resolution;
|
||||
prompt =
|
||||
"Answers to your questions:\n\n" +
|
||||
record.item.questions
|
||||
.map((question, index) => `${question.title}\n${values[index]}`)
|
||||
.join("\n\n");
|
||||
}
|
||||
return {
|
||||
prompt,
|
||||
complete: () => {
|
||||
record.resolution = resolution;
|
||||
return toTimeline(record);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
timeline(itemId: string): ToolCallTimelineItem | null {
|
||||
const record = this.records.get(requestId(itemId));
|
||||
return record ? toTimeline(record) : null;
|
||||
}
|
||||
|
||||
retain(itemIds: ReadonlySet<string>): string[] {
|
||||
const removedPendingIds: string[] = [];
|
||||
for (const [id, record] of this.records) {
|
||||
if (itemIds.has(record.item.id)) continue;
|
||||
this.records.delete(id);
|
||||
if (record.resolution === undefined) removedPendingIds.push(id);
|
||||
}
|
||||
return removedPendingIds;
|
||||
}
|
||||
|
||||
serialize(): unknown {
|
||||
return Array.from(this.records.values());
|
||||
}
|
||||
}
|
||||
@@ -618,7 +618,7 @@ function toJsonObject(value: unknown): JsonObject {
|
||||
type StreamEventType = AgentStreamEvent["type"];
|
||||
type StreamEventOfType<TType extends StreamEventType> = Extract<AgentStreamEvent, { type: TType }>;
|
||||
|
||||
function waitForNextEvent<TType extends StreamEventType>(
|
||||
export function waitForNextEvent<TType extends StreamEventType>(
|
||||
session: AgentSession,
|
||||
type: TType,
|
||||
accepts?: (event: StreamEventOfType<TType>) => boolean,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { AgentRequests, AgentRequestError } from "./index.js";
|
||||
import { AgentRequests } from "./index.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => {
|
||||
@@ -141,210 +141,3 @@ test("failed local message preparation does not leave an ambiguous receipt", asy
|
||||
await requests.send(input);
|
||||
expect(sends).toBe(1);
|
||||
});
|
||||
|
||||
test("canceling startup rejects its waiter and fences later phases and retries", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
const context = { key: "arrival", deadlineAt: new Date(Date.now() + 60_000).toISOString() };
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const pending = requests.run(context, async (signal) => {
|
||||
if (!signal) throw new Error("missing operation signal");
|
||||
entered();
|
||||
await new Promise<void>((_, reject) =>
|
||||
signal.addEventListener("abort", () => reject(signal.reason), { once: true }),
|
||||
);
|
||||
});
|
||||
const failed = expect(pending).rejects.toThrow("agent_request_canceled");
|
||||
await started;
|
||||
await requests.cancel(context.key);
|
||||
await failed;
|
||||
let sent = false;
|
||||
await expect(
|
||||
new AgentRequests(directory).run(context, async () => {
|
||||
sent = true;
|
||||
}),
|
||||
).rejects.toThrow("agent_request_canceled");
|
||||
expect(sent).toBe(false);
|
||||
expect(await requests.run({ ...context, key: "other-arrival" }, async () => "ok")).toBe("ok");
|
||||
});
|
||||
|
||||
test("an expired startup deadline cannot launch even without a cancel frame", async () => {
|
||||
const { requests } = await fixture();
|
||||
let launched = false;
|
||||
await expect(
|
||||
requests.run({ key: "expired", deadlineAt: "2000-01-01T00:00:00.000Z" }, async () => {
|
||||
launched = true;
|
||||
}),
|
||||
).rejects.toThrow("agent_request_canceled");
|
||||
expect(launched).toBe(false);
|
||||
});
|
||||
|
||||
test("a noncooperative operation leaves explicit pending cleanup without trapping cancel callers", async () => {
|
||||
const { requests } = await fixture();
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const pending = requests.run(
|
||||
{ key: "hung", deadlineAt: new Date(Date.now() + 60_000).toISOString() },
|
||||
async () => {
|
||||
entered();
|
||||
await gate;
|
||||
},
|
||||
);
|
||||
const failed = expect(pending).rejects.toThrow("agent_request_canceled");
|
||||
await started;
|
||||
expect(await requests.cancelOperation("hung", "conversation")).toEqual({
|
||||
agentId: null,
|
||||
outcome: "pending",
|
||||
});
|
||||
await failed;
|
||||
release();
|
||||
await expect
|
||||
.poll(() => requests.cancelOperation("hung", "conversation"))
|
||||
.toEqual({ agentId: null, outcome: "settled" });
|
||||
});
|
||||
|
||||
test("a reconstructed journal reports interrupted startup as unknown and never launches a canceled phase", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const context = { key: "interrupted", deadlineAt: new Date(Date.now() + 60_000).toISOString() };
|
||||
const pending = requests.run(context, async () => {
|
||||
entered();
|
||||
await gate;
|
||||
});
|
||||
await started;
|
||||
const restarted = new AgentRequests(directory);
|
||||
const replays = await Promise.allSettled([
|
||||
restarted.run(context, async () => "must not launch"),
|
||||
restarted.run(context, async () => "must not launch"),
|
||||
]);
|
||||
expect(replays).toEqual([
|
||||
{ status: "rejected", reason: new AgentRequestError("agent_request_outcome_unknown") },
|
||||
{ status: "rejected", reason: new AgentRequestError("agent_request_outcome_unknown") },
|
||||
]);
|
||||
expect(await restarted.cancelOperation(context.key, "conversation")).toEqual({
|
||||
agentId: null,
|
||||
outcome: "unknown",
|
||||
});
|
||||
await expect(restarted.run(context, async () => "must not run")).rejects.toThrow(
|
||||
"agent_request_outcome_unknown",
|
||||
);
|
||||
release();
|
||||
await pending;
|
||||
});
|
||||
|
||||
test("a live deadline settles a hung waiter without a cancel frame and preserves late cleanup", async () => {
|
||||
const { directory } = await fixture();
|
||||
let expire!: () => void;
|
||||
const now = Date.parse("2026-09-09T00:00:00Z");
|
||||
const requests = new AgentRequests(directory, {
|
||||
now: () => now,
|
||||
schedule(callback, delayMs) {
|
||||
expect(delayMs).toBe(120_000);
|
||||
expire = callback;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let enter!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const pending = requests.run(
|
||||
{ key: "deadline", deadlineAt: new Date(now + 120_000).toISOString() },
|
||||
async () => {
|
||||
enter();
|
||||
await gate;
|
||||
},
|
||||
);
|
||||
const rejected = expect(pending).rejects.toThrow("agent_request_canceled");
|
||||
await entered;
|
||||
expire();
|
||||
await rejected;
|
||||
expect(await requests.inspectOperation("deadline", "conversation")).toMatchObject({
|
||||
outcome: "pending",
|
||||
});
|
||||
await expect(requests.run({ key: "deadline" }, async () => "must not launch")).rejects.toThrow(
|
||||
"agent_request_canceled",
|
||||
);
|
||||
release();
|
||||
await requests.cancelAndSettle("deadline");
|
||||
expect(await requests.inspectOperation("deadline", "conversation")).toMatchObject({
|
||||
outcome: "settled",
|
||||
});
|
||||
expect(await requests.run({ key: "next" }, async () => "ready")).toBe("ready");
|
||||
});
|
||||
|
||||
test("retransmitted phases serialize while other operation keys continue", async () => {
|
||||
const { requests } = await fixture();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const order: string[] = [];
|
||||
const first = requests.run({ key: "arrival" }, async () => {
|
||||
order.push("first");
|
||||
entered();
|
||||
await gate;
|
||||
order.push("settled");
|
||||
});
|
||||
await started;
|
||||
const second = requests.run({ key: "arrival" }, async () => {
|
||||
order.push("second");
|
||||
});
|
||||
await requests.run({ key: "other" }, async () => {
|
||||
order.push("other");
|
||||
});
|
||||
expect(order).toEqual(["first", "other"]);
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual(["first", "other", "settled", "second"]);
|
||||
});
|
||||
|
||||
test("successful control receipts survive restart and reject changed targets", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
let archives = 0;
|
||||
const action = async () => {
|
||||
archives++;
|
||||
};
|
||||
await requests.control({ key: "cleanup" }, { workspaceId: "one" }, action);
|
||||
const restarted = new AgentRequests(directory);
|
||||
await restarted.control({ key: "cleanup" }, { workspaceId: "one" }, action);
|
||||
expect(archives).toBe(1);
|
||||
await expect(
|
||||
restarted.control({ key: "cleanup" }, { workspaceId: "two" }, action),
|
||||
).rejects.toThrow("agent_request_key_conflict");
|
||||
});
|
||||
|
||||
test("refused provider cancellation remains unknown after restart", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
await expect(
|
||||
requests.run({ key: "refused" }, async () => {
|
||||
throw new AgentRequestError("agent_request_outcome_unknown");
|
||||
}),
|
||||
).rejects.toThrow("agent_request_outcome_unknown");
|
||||
expect(
|
||||
await new AgentRequests(directory).inspectOperation("refused", "conversation"),
|
||||
).toMatchObject({ outcome: "unknown" });
|
||||
});
|
||||
|
||||
@@ -11,253 +11,11 @@ const ReceiptSchema = z.object({
|
||||
});
|
||||
type Receipt = z.infer<typeof ReceiptSchema>;
|
||||
|
||||
export class AgentRequestError extends Error {
|
||||
constructor(
|
||||
public readonly code:
|
||||
| "agent_request_canceled"
|
||||
| "agent_request_outcome_unknown"
|
||||
| "agent_request_key_conflict",
|
||||
) {
|
||||
super(code);
|
||||
this.name = "AgentRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestClock {
|
||||
now(): number;
|
||||
schedule(callback: () => void, delayMs: number): () => void;
|
||||
}
|
||||
const systemClock: RequestClock = {
|
||||
now: () => Date.now(),
|
||||
schedule(callback, delayMs) {
|
||||
const timer = setTimeout(callback, delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
|
||||
/** One daemon-owned request journal, shared by all of its socket sessions. */
|
||||
export class AgentRequests {
|
||||
private readonly pending = new Map<string, Promise<unknown>>();
|
||||
private readonly operations = new Map<
|
||||
string,
|
||||
{ controllers: Set<AbortController>; initialized: Promise<void>; unknown: boolean }
|
||||
>();
|
||||
private readonly operationWrites = new Map<string, Promise<void>>();
|
||||
private readonly pending = new Map<string, Promise<string>>();
|
||||
|
||||
constructor(
|
||||
private readonly directory: string,
|
||||
private readonly clock: RequestClock = systemClock,
|
||||
) {}
|
||||
|
||||
/** Runtime cancellation belongs to the same journal as idempotent create/send receipts. */
|
||||
async run<T>(
|
||||
context: { key: string; deadlineAt?: string } | undefined,
|
||||
operation: (signal?: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!context) return operation();
|
||||
let active = this.operations.get(context.key);
|
||||
if (!active) {
|
||||
const previousWrite = this.operationWrites.get(context.key);
|
||||
active = {
|
||||
controllers: new Set(),
|
||||
unknown: false,
|
||||
initialized: (async () => {
|
||||
await previousWrite;
|
||||
if (await this.wasInterrupted(context.key))
|
||||
throw new AgentRequestError("agent_request_outcome_unknown");
|
||||
})(),
|
||||
};
|
||||
this.operations.set(context.key, active);
|
||||
}
|
||||
const operationState = active;
|
||||
const controller = new AbortController();
|
||||
const controllers = active.controllers;
|
||||
controllers.add(controller);
|
||||
let clearTimer: (() => void) | undefined;
|
||||
let abort: (() => void) | undefined;
|
||||
let started = false;
|
||||
let recorded = false;
|
||||
try {
|
||||
await active.initialized;
|
||||
const remaining =
|
||||
context.deadlineAt === undefined
|
||||
? undefined
|
||||
: Date.parse(context.deadlineAt) - this.clock.now();
|
||||
if (
|
||||
(remaining !== undefined && (!Number.isFinite(remaining) || remaining <= 0)) ||
|
||||
(await this.wasCanceled(context.key))
|
||||
) {
|
||||
throw new AgentRequestError("agent_request_canceled");
|
||||
}
|
||||
controller.signal.throwIfAborted();
|
||||
if (remaining !== undefined)
|
||||
clearTimer = this.clock.schedule(
|
||||
() => controller.abort(new AgentRequestError("agent_request_canceled")),
|
||||
remaining,
|
||||
);
|
||||
await this.recordOperationState(context.key);
|
||||
recorded = true;
|
||||
controller.signal.throwIfAborted();
|
||||
started = true;
|
||||
const operationKey = digest(["operation", context.key]);
|
||||
const previous = this.pending.get(operationKey) ?? Promise.resolve();
|
||||
const tracked = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
controller.signal.throwIfAborted();
|
||||
return operation(controller.signal);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof AgentRequestError && error.code === "agent_request_outcome_unknown")
|
||||
operationState.unknown = true;
|
||||
throw error;
|
||||
})
|
||||
.finally(async () => {
|
||||
controllers.delete(controller);
|
||||
if (controllers.size === 0) this.operations.delete(context.key);
|
||||
await this.recordOperationState(context.key, operationState.unknown);
|
||||
if (this.pending.get(operationKey) === tracked) this.pending.delete(operationKey);
|
||||
});
|
||||
this.pending.set(operationKey, tracked);
|
||||
return await Promise.race([
|
||||
tracked,
|
||||
new Promise<never>((_, reject) => {
|
||||
abort = () => reject(controller.signal.reason);
|
||||
controller.signal.addEventListener("abort", abort, { once: true });
|
||||
if (controller.signal.aborted) abort();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimer?.();
|
||||
if (abort) controller.signal.removeEventListener("abort", abort);
|
||||
if (controller.signal.aborted) await this.recordCancellation(context.key);
|
||||
if (!started) {
|
||||
controllers.delete(controller);
|
||||
if (controllers.size === 0) this.operations.delete(context.key);
|
||||
if (recorded) await this.recordOperationState(context.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(key: string): Promise<void> {
|
||||
// Persist before acknowledging: a delayed request or a new socket must also be fenced.
|
||||
await this.recordCancellation(key);
|
||||
for (const controller of this.operations.get(key)?.controllers ?? []) {
|
||||
controller.abort(new AgentRequestError("agent_request_canceled"));
|
||||
}
|
||||
}
|
||||
|
||||
async control(
|
||||
context: { key: string; deadlineAt?: string } | undefined,
|
||||
request: unknown,
|
||||
operation: () => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
await this.run(context, async () => {
|
||||
if (!context) {
|
||||
await operation();
|
||||
return;
|
||||
}
|
||||
const file = path.join(this.directory, `control-${digest(context.key)}.json`);
|
||||
const fingerprint = digest(request);
|
||||
try {
|
||||
const receipt = z
|
||||
.object({ fingerprint: z.string() })
|
||||
.parse(JSON.parse(await readFile(file, "utf8")));
|
||||
if (receipt.fingerprint !== fingerprint)
|
||||
throw new AgentRequestError("agent_request_key_conflict");
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
await operation();
|
||||
await writeJsonFileAtomic(file, { fingerprint });
|
||||
});
|
||||
}
|
||||
|
||||
async cancelAndSettle(key: string): Promise<void> {
|
||||
await this.cancel(key);
|
||||
await this.pending.get(digest(["operation", key]))?.catch(() => undefined);
|
||||
await this.operationWrites.get(key);
|
||||
if (await this.wasInterrupted(key))
|
||||
throw new AgentRequestError("agent_request_outcome_unknown");
|
||||
}
|
||||
|
||||
async cancelOperation(
|
||||
key: string,
|
||||
creationKey: string,
|
||||
): Promise<{
|
||||
agentId: string | null;
|
||||
outcome: "settled" | "pending" | "unknown";
|
||||
}> {
|
||||
await this.cancel(key);
|
||||
return this.inspectOperation(key, creationKey);
|
||||
}
|
||||
|
||||
async inspectOperation(
|
||||
key: string,
|
||||
creationKey: string,
|
||||
): Promise<{
|
||||
agentId: string | null;
|
||||
outcome: "settled" | "pending" | "unknown";
|
||||
}> {
|
||||
await this.operationWrites.get(key);
|
||||
const active = this.operations.has(key);
|
||||
const receipt = await readReceipt(
|
||||
path.join(this.directory, `${digest(["create", creationKey])}.json`),
|
||||
);
|
||||
let outcome: "pending" | "unknown" | "settled" = "settled";
|
||||
if (receipt?.state === "pending" || (await this.wasInterrupted(key))) outcome = "unknown";
|
||||
if (active) outcome = "pending";
|
||||
return { agentId: receipt?.agentId ?? null, outcome };
|
||||
}
|
||||
|
||||
private recordOperationState(key: string, unknown = false): Promise<void> {
|
||||
const previous = this.operationWrites.get(key) ?? Promise.resolve();
|
||||
const write = previous
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
writeJsonFileAtomic(path.join(this.directory, `operation-${digest(key)}.json`), {
|
||||
state: unknown || this.operations.has(key) ? "pending" : "settled",
|
||||
}),
|
||||
);
|
||||
this.operationWrites.set(key, write);
|
||||
void write
|
||||
.finally(() => {
|
||||
if (this.operationWrites.get(key) === write) this.operationWrites.delete(key);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return write;
|
||||
}
|
||||
|
||||
private async wasInterrupted(key: string): Promise<boolean> {
|
||||
try {
|
||||
const state = z
|
||||
.object({ state: z.enum(["pending", "settled"]) })
|
||||
.parse(
|
||||
JSON.parse(
|
||||
await readFile(path.join(this.directory, `operation-${digest(key)}.json`), "utf8"),
|
||||
),
|
||||
);
|
||||
return state.state === "pending";
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private recordCancellation(key: string): Promise<void> {
|
||||
return writeJsonFileAtomic(path.join(this.directory, `canceled-${digest(key)}.json`), true);
|
||||
}
|
||||
|
||||
private async wasCanceled(key: string): Promise<boolean> {
|
||||
try {
|
||||
await readFile(path.join(this.directory, `canceled-${digest(key)}.json`));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
constructor(private readonly directory: string) {}
|
||||
|
||||
create(input: {
|
||||
key: string;
|
||||
@@ -331,11 +89,10 @@ export class AgentRequests {
|
||||
const file = path.join(this.directory, `${key}.json`);
|
||||
const existing = await readReceipt(file);
|
||||
if (existing) {
|
||||
if (existing.fingerprint !== fingerprint)
|
||||
throw new AgentRequestError("agent_request_key_conflict");
|
||||
if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict");
|
||||
if (existing.state === "completed") return existing.agentId;
|
||||
if (!(await operation.recover(existing.agentId))) {
|
||||
throw new AgentRequestError("agent_request_outcome_unknown");
|
||||
throw new Error("agent_request_outcome_unknown");
|
||||
}
|
||||
await writeJsonFileAtomic(file, { ...existing, state: "completed" });
|
||||
return existing.agentId;
|
||||
|
||||
@@ -6,8 +6,6 @@ type OutboundOperation = SessionOutboundMessage["type"];
|
||||
export type PermissionRequirement = DaemonPermission | readonly DaemonPermission[] | null;
|
||||
|
||||
const INBOUND_PERMISSION = {
|
||||
"agent.requests.inspect.request": ["workspace.write", "hub.execute"],
|
||||
"agent.requests.cancel.request": ["workspace.write", "hub.execute"],
|
||||
abort_request: "workspace.write",
|
||||
"agent.config.apply.request": ["workspace.write", "hub.execute"],
|
||||
"agent.detach.request": "workspace.write",
|
||||
@@ -208,8 +206,6 @@ const INBOUND_PERMISSION = {
|
||||
} as const satisfies Record<InboundOperation, PermissionRequirement>;
|
||||
|
||||
const OUTBOUND_PERMISSION = {
|
||||
"agent.requests.inspect.response": ["workspace.write", "hub.execute"],
|
||||
"agent.requests.cancel.response": ["workspace.write", "hub.execute"],
|
||||
activity_log: "workspace.read",
|
||||
"agent.config.apply.response": ["workspace.write", "hub.execute"],
|
||||
"agent.detach.response": "workspace.write",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AgentRequests } from "./agent/requests/index.js";
|
||||
import { describeHookWorkspace } from "./plugins/lifecycle/index.js";
|
||||
import express from "express";
|
||||
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
|
||||
@@ -857,7 +856,6 @@ export async function createPaseoDaemon(
|
||||
serviceProxyListenTarget = parseListenString(config.serviceProxy.standaloneListen);
|
||||
}
|
||||
|
||||
const agentRequests = new AgentRequests(path.join(config.paseoHome, "agent-requests"));
|
||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(config.paseoHome, "projects", "projects.json"),
|
||||
@@ -1259,7 +1257,6 @@ export async function createPaseoDaemon(
|
||||
createExecutionAgents: (daemonId) =>
|
||||
new DaemonExecutions({
|
||||
daemonId,
|
||||
agentRequests,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
createAgent,
|
||||
@@ -1720,7 +1717,6 @@ export async function createPaseoDaemon(
|
||||
pluginRuntime,
|
||||
orchestrationSkills,
|
||||
workspaceLabelService,
|
||||
agentRequests,
|
||||
);
|
||||
pluginRuntime.bindPaseoSessionHost(wsServer);
|
||||
await pluginRuntime.start();
|
||||
|
||||
@@ -326,40 +326,3 @@ test("failed create never archives a reused worktree", async () => {
|
||||
});
|
||||
expect(await hub.worktreeState(worktreeCwd!)).toEqual({ exists: true, listed: true });
|
||||
});
|
||||
|
||||
test("control cancels a held legacy provider create and fences a delayed replay", async () => {
|
||||
const hub = await launchRelationship();
|
||||
hub.holdAgentCreation();
|
||||
hub.beginOwnedCreate("held-create", "expired-create");
|
||||
await hub.agentCreationAttempts(1);
|
||||
const creation = hub.ownedCreateResult("held-create");
|
||||
const control = hub.controlExecution("expired-create", "archive");
|
||||
try {
|
||||
expect(await control).toMatchObject({ success: true });
|
||||
expect(await creation).toMatchObject({ payload: { success: false } });
|
||||
} finally {
|
||||
hub.finishAgentCreation();
|
||||
}
|
||||
hub.beginOwnedCreate("late-create", "expired-create");
|
||||
expect(await hub.ownedCreateResult("late-create")).toMatchObject({ payload: { success: false } });
|
||||
});
|
||||
|
||||
test("the legacy absolute deadline cancels provider creation without a control frame", async () => {
|
||||
const hub = await launchRelationship();
|
||||
hub.holdAgentCreation();
|
||||
hub.beginOwnedCreate("deadline-create", "deadline-create", {
|
||||
deadlineAt: new Date(Date.now() + 1_000).toISOString(),
|
||||
});
|
||||
try {
|
||||
await hub.agentCreationAttempts(1);
|
||||
expect(await hub.ownedCreateResult("deadline-create")).toMatchObject({
|
||||
payload: { success: false },
|
||||
});
|
||||
} finally {
|
||||
hub.finishAgentCreation();
|
||||
}
|
||||
hub.beginOwnedCreate("deadline-replay", "deadline-create");
|
||||
expect(await hub.ownedCreateResult("deadline-replay")).toMatchObject({
|
||||
payload: { success: false },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { AgentRequests } from "../agent/requests/index.js";
|
||||
import type {
|
||||
AgentSnapshotPayload,
|
||||
AgentStreamEventPayload,
|
||||
@@ -18,7 +17,6 @@ import { daemonExecutionKey, type DaemonAgentOwner } from "../agent/agent-owner.
|
||||
|
||||
export interface HubExecutionAgentCreateInput {
|
||||
executionId: string;
|
||||
deadlineAt?: string;
|
||||
provider: string;
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
@@ -55,7 +53,6 @@ export type OwnedAgentEvent =
|
||||
|
||||
interface DaemonExecutionsOptions {
|
||||
daemonId: string;
|
||||
agentRequests: AgentRequests;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
createAgent: BoundCreateAgentCommand;
|
||||
@@ -125,8 +122,6 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
const pending = this.pendingControlActions.get(actionKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const canceled = this.options.agentRequests.cancelAndSettle(executionKey);
|
||||
void canceled.catch(() => undefined);
|
||||
const previous =
|
||||
this.controlTails.get(executionKey) ??
|
||||
this.pendingCreates.get(executionKey)?.then(() => undefined) ??
|
||||
@@ -134,7 +129,6 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
const authorityGeneration = this.authorityGeneration;
|
||||
const control = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => canceled)
|
||||
.then(() => this.controlOwnedExecution(owner, input, authorityGeneration));
|
||||
this.pendingControlActions.set(actionKey, control);
|
||||
this.controlTails.set(executionKey, control);
|
||||
@@ -154,9 +148,6 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
this.authorityActive = false;
|
||||
this.authorityGeneration++;
|
||||
await Promise.allSettled([
|
||||
...[...this.pendingCreates.keys()].map((key) =>
|
||||
this.options.agentRequests.cancelAndSettle(key),
|
||||
),
|
||||
...this.pendingCreates.values(),
|
||||
...this.pendingControlActions.values(),
|
||||
]);
|
||||
@@ -189,25 +180,12 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
requireHubMcpNamespace(input.mcpServers);
|
||||
requireToolPolicyServers(input.toolPolicy, input.mcpServers);
|
||||
|
||||
return this.options.agentRequests.run(
|
||||
{ key: daemonExecutionKey(owner), deadlineAt: input.deadlineAt },
|
||||
(signal) => this.createNewOwnedAgent(owner, input, authorityGeneration, signal),
|
||||
);
|
||||
}
|
||||
|
||||
private async createNewOwnedAgent(
|
||||
owner: DaemonAgentOwner,
|
||||
input: HubExecutionAgentCreateInput,
|
||||
authorityGeneration: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OwnedAgentSnapshot> {
|
||||
let createdWorktree: CreatePaseoWorktreeWorkflowResult | null = null;
|
||||
let createdAgentId: string | null = null;
|
||||
let result: Awaited<ReturnType<BoundCreateAgentCommand>>;
|
||||
try {
|
||||
result = await this.createAgentCommand({
|
||||
kind: "mcp",
|
||||
signal,
|
||||
provider: input.model ? `${input.provider}/${input.model}` : input.provider,
|
||||
title: input.prompt,
|
||||
initialPrompt: input.prompt,
|
||||
@@ -237,7 +215,6 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
createdAgentId = created.agentId;
|
||||
},
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
this.requireAuthority(authorityGeneration);
|
||||
requireExecutionWorkspaceId(result.liveSnapshot);
|
||||
} catch (error) {
|
||||
|
||||
@@ -149,7 +149,6 @@ export class HubExecutionController {
|
||||
if (!isAbsolute(message.cwd)) throw new Error("Hub agent cwd must be absolute");
|
||||
const result = await this.agents.create({
|
||||
executionId: message.executionId,
|
||||
deadlineAt: message.deadlineAt,
|
||||
provider: message.provider,
|
||||
cwd: message.cwd,
|
||||
prompt: message.prompt,
|
||||
|
||||
@@ -251,7 +251,7 @@ test("Hub interrupts an owned running execution idempotently", async () => {
|
||||
expect(hub.ownedAgentIsRunning(created.payload.agentId!)).toBe(false);
|
||||
});
|
||||
|
||||
test("Hub control cancels an in-flight create of the same execution", async () => {
|
||||
test("Hub control waits for an in-flight create of the same execution", async () => {
|
||||
const hub = await launchRelationship();
|
||||
hub.holdAgentCreation();
|
||||
hub.beginOwnedCreate("pending-control-create", "execution-pending-control", {
|
||||
@@ -264,8 +264,10 @@ test("Hub control cancels an in-flight create of the same execution", async () =
|
||||
const created = await hub.ownedCreateResult("pending-control-create");
|
||||
const archived = await hub.executionControlResult("pending-control-archive");
|
||||
|
||||
expect(created).toMatchObject({ payload: { success: false, agentId: null } });
|
||||
expect(created).toMatchObject({ payload: { success: true, agentId: expect.any(String) } });
|
||||
expect(archived).toMatchObject({ success: true, error: null, action: "archive" });
|
||||
expect(created.payload.agent?.workspaceId).toEqual(expect.any(String));
|
||||
expect(await hub.ownedAgentArchivedAt(created.payload.agentId!)).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test("Hub archives an execution workspace on a local checkout", async () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AgentRequests } from "../../agent/requests/index.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
@@ -1559,7 +1558,6 @@ export class HubRelationshipHarness {
|
||||
|
||||
private executionsForReconstruction(manager: AgentManager, storage: AgentStorage) {
|
||||
return new DaemonExecutions({
|
||||
agentRequests: new AgentRequests(path.join(this.paseoHome, "agent-requests")),
|
||||
daemonId: this.relationshipFile()!.relationship.daemonId,
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js";
|
||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||
import type { FirstAgentContext } from "@getpaseo/protocol/messages";
|
||||
import { runWithGitCommandPriority } from "../utils/run-git-command.js";
|
||||
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
|
||||
|
||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
projectId?: string;
|
||||
@@ -37,6 +38,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
|
||||
export interface CreatePaseoWorktreeResult {
|
||||
worktree: WorktreeConfig;
|
||||
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||
intent: WorktreeCreationIntent;
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
repoRoot: string;
|
||||
@@ -117,6 +119,7 @@ async function createPaseoWorktreeWithPriority(
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
worktreeIncludeSummary: createdWorktree.worktreeIncludeSummary,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { z } from "zod";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestAgentClient } from "./test-utils/fake-agent-client.js";
|
||||
|
||||
const Envelope = z.object({
|
||||
type: z.literal("session"),
|
||||
message: z.object({ type: z.string(), payload: z.record(z.string(), z.unknown()) }),
|
||||
});
|
||||
|
||||
function exchange(
|
||||
socket: WebSocket,
|
||||
frame: unknown,
|
||||
accepts: (type: string, payload: Record<string, unknown>) => boolean,
|
||||
) {
|
||||
return new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Daemon response missing"));
|
||||
}, 10_000);
|
||||
const receive = (data: WebSocket.RawData) => {
|
||||
const parsed = Envelope.safeParse(JSON.parse(data.toString()));
|
||||
if (parsed.success && accepts(parsed.data.message.type, parsed.data.message.payload)) {
|
||||
cleanup();
|
||||
resolve(parsed.data.message.payload);
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
socket.off("message", receive);
|
||||
};
|
||||
socket.on("message", receive);
|
||||
socket.send(JSON.stringify(frame));
|
||||
});
|
||||
}
|
||||
function request(socket: WebSocket, message: Record<string, unknown>) {
|
||||
const requestId = randomUUID();
|
||||
return exchange(
|
||||
socket,
|
||||
{ type: "session", message: { ...message, requestId } },
|
||||
(_type, payload) => payload["requestId"] === requestId,
|
||||
);
|
||||
}
|
||||
async function connect(port: number) {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("open", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
const info = await exchange(
|
||||
socket,
|
||||
{ type: "hello", clientId: randomUUID(), clientType: "browser", protocolVersion: 1 },
|
||||
(_type, payload) => payload["status"] === "server_info",
|
||||
);
|
||||
expect(info["features"]).toMatchObject({ agentRequestCancellation: true });
|
||||
return socket;
|
||||
}
|
||||
|
||||
test("socket cancellation settles a held create, disposes its late provider session, and fences a reconnect replay", async () => {
|
||||
let started!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let creates = 0;
|
||||
let closes = 0;
|
||||
const provider = createTestAgentClient("codex", {
|
||||
beforeCreateSession: async () => {
|
||||
creates++;
|
||||
started();
|
||||
await gate;
|
||||
},
|
||||
closeSession: async () => {
|
||||
closes++;
|
||||
},
|
||||
});
|
||||
const host = await createTestPaseoDaemon({ agentClients: { codex: provider } });
|
||||
let socket = await connect(host.port);
|
||||
const key = randomUUID();
|
||||
const creationKey = randomUUID();
|
||||
const config = { provider: "codex", cwd: host.paseoHome };
|
||||
const operation = { key, deadlineAt: new Date(Date.now() + 60_000).toISOString() };
|
||||
try {
|
||||
const unkeyed = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config,
|
||||
operation,
|
||||
initialPrompt: "must not launch",
|
||||
});
|
||||
expect(unkeyed).toMatchObject({ status: "agent_create_failed" });
|
||||
expect(creates).toBe(0);
|
||||
const creation = request(socket, {
|
||||
type: "create_agent_request",
|
||||
config,
|
||||
idempotencyKey: creationKey,
|
||||
operation,
|
||||
});
|
||||
await entered;
|
||||
const canceled = await request(socket, {
|
||||
type: "agent.requests.cancel.request",
|
||||
key,
|
||||
creationKey,
|
||||
});
|
||||
expect(["pending", "settled"]).toContain(canceled["outcome"]);
|
||||
expect(await creation).not.toMatchObject({ status: "agent_created" });
|
||||
release();
|
||||
await expect.poll(() => closes).toBe(1);
|
||||
await expect
|
||||
.poll(() => request(socket, { type: "agent.requests.inspect.request", key, creationKey }))
|
||||
.toMatchObject({ outcome: "settled", agentId: null });
|
||||
expect(host.daemon.agentManager.listAgents()).toEqual([]);
|
||||
|
||||
socket.close();
|
||||
socket = await connect(host.port);
|
||||
const replay = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config,
|
||||
idempotencyKey: creationKey,
|
||||
operation,
|
||||
});
|
||||
expect(replay).not.toMatchObject({ status: "agent_created" });
|
||||
expect(creates).toBe(1);
|
||||
const next = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config,
|
||||
idempotencyKey: creationKey,
|
||||
operation: { ...operation, key: randomUUID() },
|
||||
});
|
||||
expect(next).toMatchObject({ status: "agent_created" });
|
||||
const repeated = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config,
|
||||
idempotencyKey: creationKey,
|
||||
operation: { ...operation, key: randomUUID() },
|
||||
});
|
||||
expect(repeated["agentId"]).toBe(next["agentId"]);
|
||||
expect(creates).toBe(2);
|
||||
expect(host.daemon.agentManager.listAgents()).toHaveLength(1);
|
||||
} finally {
|
||||
release();
|
||||
socket.close();
|
||||
await host.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("expiry during provider send settles the socket caller and retains cleanup ownership until cancellation finishes", async () => {
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let turns = 0;
|
||||
const provider = createTestAgentClient("codex", {
|
||||
beforeStartTurn: async () => {
|
||||
entered();
|
||||
await gate;
|
||||
},
|
||||
onStartTurn: () => {
|
||||
turns++;
|
||||
},
|
||||
});
|
||||
const host = await createTestPaseoDaemon({ agentClients: { codex: provider } });
|
||||
const socket = await connect(host.port);
|
||||
const creationKey = randomUUID();
|
||||
const key = randomUUID();
|
||||
try {
|
||||
const created = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config: { provider: "codex", cwd: host.paseoHome },
|
||||
idempotencyKey: creationKey,
|
||||
});
|
||||
const agentId = z.string().parse(created["agentId"]);
|
||||
const messageId = randomUUID();
|
||||
const operation = { key, deadlineAt: new Date(Date.now() + 60_000).toISOString() };
|
||||
const sending = request(socket, {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId,
|
||||
text: "hello",
|
||||
operation,
|
||||
});
|
||||
await started;
|
||||
await request(socket, { type: "agent.requests.cancel.request", key, creationKey });
|
||||
expect(await sending).not.toMatchObject({ accepted: true });
|
||||
// The provider has not acknowledged start. Cancellation cannot claim this work settled yet.
|
||||
expect(
|
||||
await request(socket, { type: "agent.requests.inspect.request", key, creationKey }),
|
||||
).toMatchObject({ outcome: "pending", agentId });
|
||||
release();
|
||||
await expect
|
||||
.poll(() => request(socket, { type: "agent.requests.inspect.request", key, creationKey }))
|
||||
.toMatchObject({ outcome: "settled", agentId });
|
||||
expect(host.daemon.agentManager.hasInFlightRun(agentId)).toBe(false);
|
||||
await request(socket, {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId,
|
||||
text: "hello",
|
||||
operation,
|
||||
});
|
||||
expect(turns).toBe(1);
|
||||
const next = await request(socket, {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId: randomUUID(),
|
||||
text: "next",
|
||||
operation: { ...operation, key: randomUUID() },
|
||||
});
|
||||
expect(next).toMatchObject({ accepted: true });
|
||||
expect(turns).toBe(2);
|
||||
} finally {
|
||||
release();
|
||||
socket.close();
|
||||
await host.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("a delayed duplicate archive acknowledgement cannot archive a restored continuation again", async () => {
|
||||
const host = await createTestPaseoDaemon({
|
||||
agentClients: { codex: createTestAgentClient("codex") },
|
||||
});
|
||||
const socket = await connect(host.port);
|
||||
try {
|
||||
const created = await request(socket, {
|
||||
type: "create_agent_request",
|
||||
config: { provider: "codex", cwd: host.paseoHome },
|
||||
idempotencyKey: randomUUID(),
|
||||
});
|
||||
const workspaceId = z.object({ workspaceId: z.string() }).parse(created["agent"]).workspaceId;
|
||||
const archive = {
|
||||
type: "archive_workspace_request",
|
||||
workspaceId,
|
||||
operation: { key: randomUUID() },
|
||||
};
|
||||
expect(await request(socket, archive)).toMatchObject({ error: null });
|
||||
expect(
|
||||
await request(socket, { type: "workspace.recovery.restore.request", workspaceId }),
|
||||
).toMatchObject({ error: null });
|
||||
expect(await request(socket, archive)).toMatchObject({ error: null });
|
||||
expect(
|
||||
await request(socket, { type: "workspace.recovery.inspect.request", workspaceId }),
|
||||
).toMatchObject({ state: { kind: "unavailable", reason: "workspace_not_archived" } });
|
||||
} finally {
|
||||
socket.close();
|
||||
await host.close();
|
||||
}
|
||||
}, 30_000);
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import { AgentRequestError, type AgentRequests } from "./agent/requests/index.js";
|
||||
import type { AgentRequests } from "./agent/requests/index.js";
|
||||
import equal from "fast-deep-equal";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
||||
@@ -464,10 +464,7 @@ export interface SessionOptions {
|
||||
worktreesRoot?: string;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentRequests: Pick<
|
||||
AgentRequests,
|
||||
"create" | "send" | "run" | "control" | "cancelOperation" | "inspectOperation"
|
||||
>;
|
||||
agentRequests: Pick<AgentRequests, "create" | "send">;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
directorySync?: DirectorySyncService;
|
||||
@@ -757,10 +754,7 @@ export class Session {
|
||||
private readonly daemonSession: DaemonSession;
|
||||
private readonly hubExecutionController: HubExecutionController | null;
|
||||
private readonly workspaceScripts: WorkspaceScriptsService;
|
||||
private readonly agentRequests: Pick<
|
||||
AgentRequests,
|
||||
"create" | "send" | "run" | "control" | "cancelOperation" | "inspectOperation"
|
||||
>;
|
||||
private readonly agentRequests: Pick<AgentRequests, "create" | "send">;
|
||||
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
||||
|
||||
constructor(options: SessionOptions) {
|
||||
@@ -879,6 +873,7 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||
logger: this.sessionLogger,
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
@@ -2008,7 +2003,6 @@ export class Session {
|
||||
this.dispatchAgentRelationshipMessage(msg) ??
|
||||
this.dispatchAgentTimelineMessage(msg, source) ??
|
||||
this.dispatchHubExecutionMessage(msg) ??
|
||||
this.dispatchAgentRequestMessage(msg) ??
|
||||
this.dispatchAgentLifecycleMessage(msg) ??
|
||||
this.dispatchAgentConfigMessage(msg) ??
|
||||
this.dispatchCheckoutMessage(msg) ??
|
||||
@@ -2392,27 +2386,6 @@ export class Session {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private dispatchAgentRequestMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "agent.requests.inspect.request":
|
||||
return this.agentRequests.inspectOperation(msg.key, msg.creationKey).then((result) => {
|
||||
return this.emit({
|
||||
type: "agent.requests.inspect.response",
|
||||
payload: { requestId: msg.requestId, ...result },
|
||||
});
|
||||
});
|
||||
case "agent.requests.cancel.request":
|
||||
return this.agentRequests.cancelOperation(msg.key, msg.creationKey).then((result) => {
|
||||
return this.emit({
|
||||
type: "agent.requests.cancel.response",
|
||||
payload: { requestId: msg.requestId, ...result },
|
||||
});
|
||||
});
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "fetch_agents_request":
|
||||
@@ -2436,15 +2409,11 @@ export class Session {
|
||||
case "project.icon.set.request":
|
||||
return this.handleProjectIconSetRequest(msg);
|
||||
case "send_agent_message_request":
|
||||
return this.agentRequests.run(msg.operation, (signal) =>
|
||||
this.handleSendAgentMessageRequest(msg, signal),
|
||||
);
|
||||
return this.handleSendAgentMessageRequest(msg);
|
||||
case "wait_for_finish_request":
|
||||
return this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs);
|
||||
case "create_agent_request":
|
||||
return this.agentRequests.run(msg.operation, (signal) =>
|
||||
this.handleCreateAgentRequest(msg, signal),
|
||||
);
|
||||
return this.handleCreateAgentRequest(msg);
|
||||
case "resume_agent_request":
|
||||
return this.handleResumeAgentRequest(msg);
|
||||
case "import_agent_request":
|
||||
@@ -2452,7 +2421,7 @@ export class Session {
|
||||
case "refresh_agent_request":
|
||||
return this.handleRefreshAgentRequest(msg);
|
||||
case "cancel_agent_request":
|
||||
return this.handleCancelAgentRequest(msg.agentId, msg.requestId, msg.operation);
|
||||
return this.handleCancelAgentRequest(msg.agentId, msg.requestId);
|
||||
case "agent_permission_response":
|
||||
return this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response);
|
||||
case "clear_agent_attention":
|
||||
@@ -2692,9 +2661,7 @@ export class Session {
|
||||
case "workspace.recovery.inspect.request":
|
||||
return this.handleWorkspaceRecoveryInspectRequest(msg);
|
||||
case "workspace.recovery.restore.request":
|
||||
return this.agentRequests.run(msg.operation, (signal) =>
|
||||
this.handleWorkspaceRecoveryRestoreRequest(msg, signal),
|
||||
);
|
||||
return this.handleWorkspaceRecoveryRestoreRequest(msg);
|
||||
case "workspace.clear_attention.request":
|
||||
return this.handleWorkspaceClearAttentionRequest(msg);
|
||||
case "workspace.mark_unread.request":
|
||||
@@ -3515,10 +3482,9 @@ export class Session {
|
||||
|
||||
private async handleWorkspaceRecoveryRestoreRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.recovery.restore.request" }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.restoreWorkspaceAndEmit(request.workspaceId, signal);
|
||||
await this.restoreWorkspaceAndEmit(request.workspaceId);
|
||||
this.emit({
|
||||
type: "workspace.recovery.restore.response",
|
||||
payload: {
|
||||
@@ -3603,20 +3569,14 @@ export class Session {
|
||||
/**
|
||||
* Handle create agent request
|
||||
*/
|
||||
private async handleCreateAgentRequest(
|
||||
msg: CreateAgentRequestMessage,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
|
||||
try {
|
||||
if (msg.operation && msg.idempotencyKey === undefined) {
|
||||
throw new Error("Cancelable creation requires an idempotencyKey");
|
||||
}
|
||||
let agent: AgentSnapshotPayload;
|
||||
if (msg.idempotencyKey !== undefined) {
|
||||
if (msg.initialPrompt !== undefined) {
|
||||
throw new Error("Idempotent creation requires sending the initial prompt separately");
|
||||
}
|
||||
const { requestId: _requestId, idempotencyKey, operation: _operation, ...request } = msg;
|
||||
const { requestId: _requestId, idempotencyKey, ...request } = msg;
|
||||
const id = await this.agentRequests.create({
|
||||
key: idempotencyKey,
|
||||
request,
|
||||
@@ -3624,14 +3584,14 @@ export class Session {
|
||||
this.agentManager.getAgent(agentId) != null ||
|
||||
(await this.agentStorage.get(agentId)) !== null,
|
||||
create: async (agentId) => {
|
||||
await this.createSessionAgent(msg, agentId, signal);
|
||||
await this.createSessionAgent(msg, agentId);
|
||||
},
|
||||
});
|
||||
const record = await this.agentStorage.get(id);
|
||||
if (!record) throw new Error("Previously created agent no longer exists");
|
||||
agent = this.buildStoredAgentPayload(record);
|
||||
} else {
|
||||
agent = await this.createSessionAgent(msg, undefined, signal);
|
||||
agent = await this.createSessionAgent(msg);
|
||||
}
|
||||
this.emit({
|
||||
type: "status",
|
||||
@@ -3666,23 +3626,9 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupCanceledAgentCreate(
|
||||
agentId: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
if (!signal?.aborted || !agentId) return agentId;
|
||||
beginAgentDeleteIfSupported(this.agentStorage, agentId);
|
||||
await this.agentManager.closeAgent(agentId);
|
||||
await this.agentManager.flush();
|
||||
await this.agentStorage.remove(agentId);
|
||||
await this.agentManager.deleteAgentState(agentId);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async createSessionAgent(
|
||||
msg: CreateAgentRequestMessage,
|
||||
agentId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentSnapshotPayload> {
|
||||
const {
|
||||
config,
|
||||
@@ -3731,7 +3677,6 @@ export class Session {
|
||||
hasLegacyGitOptions: Boolean(git),
|
||||
});
|
||||
createdWorktreeForCleanup = createdWorktree;
|
||||
signal?.throwIfAborted();
|
||||
const resolvedIntent = await this.resolveSessionCreateAgentIntent({
|
||||
request: msg,
|
||||
createdWorktree,
|
||||
@@ -3753,7 +3698,6 @@ export class Session {
|
||||
},
|
||||
{
|
||||
kind: "session",
|
||||
signal,
|
||||
agentId,
|
||||
config: resolvedIntent.config,
|
||||
workspaceId: resolvedIntent.intent.workspaceId,
|
||||
@@ -3773,7 +3717,6 @@ export class Session {
|
||||
},
|
||||
);
|
||||
createdAgentId = snapshot.id;
|
||||
signal?.throwIfAborted();
|
||||
await this.agentUpdates.forwardLiveAgent(snapshot);
|
||||
if (resolvedIntent.createdDirectoryWorkspace && trimmedPrompt) {
|
||||
this.workspaceAutoName.scheduleForDirectory(
|
||||
@@ -3796,7 +3739,6 @@ export class Session {
|
||||
);
|
||||
return this.buildAgentPayload(liveSnapshot);
|
||||
} catch (error) {
|
||||
createdAgentId = await this.cleanupCanceledAgentCreate(createdAgentId, signal);
|
||||
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
|
||||
createdWorktree: createdWorktreeForCleanup,
|
||||
createdAgentId,
|
||||
@@ -4090,19 +4032,13 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCancelAgentRequest(
|
||||
agentId: string,
|
||||
requestId?: string,
|
||||
operation?: { key: string; deadlineAt?: string },
|
||||
): Promise<void> {
|
||||
private async handleCancelAgentRequest(agentId: string, requestId?: string): Promise<void> {
|
||||
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`);
|
||||
|
||||
try {
|
||||
await this.agentRequests.control(operation, { action: "interrupt", agentId }, () =>
|
||||
cancelAgentRunCommand(
|
||||
{ agentManager: this.agentManager, logger: this.sessionLogger },
|
||||
agentId,
|
||||
),
|
||||
await cancelAgentRunCommand(
|
||||
{ agentManager: this.agentManager, logger: this.sessionLogger },
|
||||
agentId,
|
||||
);
|
||||
if (requestId) {
|
||||
const agent = this.agentManager.getAgent(agentId);
|
||||
@@ -5334,8 +5270,8 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private async restoreWorkspaceAndEmit(workspaceId: string, signal?: AbortSignal): Promise<void> {
|
||||
await this.workspaceRecovery.restore(workspaceId, signal);
|
||||
private async restoreWorkspaceAndEmit(workspaceId: string): Promise<void> {
|
||||
await this.workspaceRecovery.restore(workspaceId);
|
||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) {
|
||||
throw new Error(`Recovered workspace record not found: ${workspaceId}`);
|
||||
@@ -6899,39 +6835,34 @@ export class Session {
|
||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||
}
|
||||
|
||||
await this.agentRequests.control(
|
||||
request.operation,
|
||||
{ action: "archive", workspaceId: request.workspaceId },
|
||||
() =>
|
||||
archiveByScope(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.worktreesRoot,
|
||||
github: this.github,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
||||
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
||||
this.markWorkspaceArchiving(workspaceIds, archivingAt),
|
||||
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
|
||||
assertWorkspaceAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
||||
stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId),
|
||||
sessionLogger: this.sessionLogger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: existing.workspaceId },
|
||||
requestId: request.requestId,
|
||||
},
|
||||
),
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.worktreesRoot,
|
||||
github: this.github,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
||||
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
||||
this.markWorkspaceArchiving(workspaceIds, archivingAt),
|
||||
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
|
||||
assertWorkspaceAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
||||
stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId),
|
||||
sessionLogger: this.sessionLogger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: existing.workspaceId },
|
||||
requestId: request.requestId,
|
||||
},
|
||||
);
|
||||
|
||||
const archivedWorkspace = await this.workspaceRegistry.get(request.workspaceId);
|
||||
@@ -7639,7 +7570,6 @@ export class Session {
|
||||
|
||||
private async handleSendAgentMessageRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "send_agent_message_request" }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const resolved = await this.resolveAgentIdentifier(msg.agentId);
|
||||
if (!resolved.ok) {
|
||||
@@ -7655,21 +7585,7 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancellation: Promise<unknown> | undefined;
|
||||
const abort = () => {
|
||||
if (this.agentManager.getAgent(resolved.agentId)) {
|
||||
cancellation ??= this.agentManager.cancelAgentRun(resolved.agentId).then((result) => {
|
||||
if (result.status === "refused")
|
||||
throw new AgentRequestError("agent_request_outcome_unknown");
|
||||
return result;
|
||||
});
|
||||
void cancellation.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const agentId = resolved.agentId;
|
||||
|
||||
const prompt = buildAgentPrompt(msg.text, msg.images, msg.attachments);
|
||||
@@ -7683,11 +7599,9 @@ export class Session {
|
||||
"agent.session.send_agent_message",
|
||||
);
|
||||
const send = async () => {
|
||||
signal?.throwIfAborted();
|
||||
const result = await sendPromptToAgent({
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
signal,
|
||||
agentId,
|
||||
prompt,
|
||||
messageId: msg.messageId,
|
||||
@@ -7695,12 +7609,8 @@ export class Session {
|
||||
clearPendingPermissions: true,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
await this.agentManager.cancelAgentRun(agentId);
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
if (result.disposition === "turn_started") {
|
||||
await waitForAgentRunStartWithTimeout(this.agentManager, agentId, signal);
|
||||
await waitForAgentRunStartWithTimeout(this.agentManager, agentId);
|
||||
}
|
||||
};
|
||||
if (msg.messageId) {
|
||||
@@ -7741,9 +7651,6 @@ export class Session {
|
||||
error: errorToFriendlyMessage(error),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
await cancellation;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AgentRequests } from "./agent/requests/index.js";
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
@@ -933,126 +932,6 @@ test("client heartbeat clears attention for the focused terminal", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("expiry after agent registration removes its durable record before creation can retry", async () => {
|
||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-canceled-create-"));
|
||||
const logger = createTestLogger();
|
||||
const agentStorage = new AgentStorage(path.join(workdir, "agents"), logger);
|
||||
const agentManager = new AgentManager({
|
||||
clients: { codex: new CreateAgentTestClient() },
|
||||
registry: agentStorage,
|
||||
logger,
|
||||
});
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(workdir, "projects.json"),
|
||||
logger,
|
||||
);
|
||||
const workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(workdir, "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
let expire!: () => void;
|
||||
const requests = new AgentRequests(path.join(workdir, "requests"), {
|
||||
now: () => 0,
|
||||
schedule(callback) {
|
||||
expire = callback;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
let registeredId: string | undefined;
|
||||
const unsubscribe = agentManager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && registeredId === undefined) {
|
||||
registeredId = event.agent.id;
|
||||
expire();
|
||||
}
|
||||
},
|
||||
{ replayState: false },
|
||||
);
|
||||
try {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: requests,
|
||||
clientId: "test-client",
|
||||
serverId: "test-server",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
appVersion: null,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
logger: asSessionLogger(logger),
|
||||
downloadTokenStore: asDownloadTokenStore(),
|
||||
pushNotifications: asPushNotifications(),
|
||||
paseoHome: path.join(workdir, "paseo-home"),
|
||||
agentManager,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
scheduleService: asScheduleService(),
|
||||
checkoutDiffManager: asCheckoutDiffManager({
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: workdir, files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
invalidateForge: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
}),
|
||||
workspaceGitService: createNoopWorkspaceGitService(),
|
||||
daemonConfigStore: asDaemonConfigStore({
|
||||
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
||||
onChange: () => () => {},
|
||||
}),
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
terminalManager: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "canceled",
|
||||
idempotencyKey: "conversation",
|
||||
operation: { key: "arrival", deadlineAt: "2026-09-09T00:00:00Z" },
|
||||
config: { provider: "codex", cwd: workdir },
|
||||
});
|
||||
await requests.cancelAndSettle("arrival");
|
||||
expect(registeredId).toEqual(expect.any(String));
|
||||
expect(agentManager.listAgents()).toEqual([]);
|
||||
expect(await agentStorage.list()).toEqual([]);
|
||||
expect(await requests.inspectOperation("arrival", "conversation")).toEqual({
|
||||
outcome: "settled",
|
||||
agentId: null,
|
||||
});
|
||||
unsubscribe();
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "retry",
|
||||
idempotencyKey: "conversation",
|
||||
operation: { key: "next", deadlineAt: "2026-09-09T00:00:00Z" },
|
||||
config: { provider: "codex", cwd: workdir },
|
||||
});
|
||||
expect(agentManager.listAgents()).toHaveLength(1);
|
||||
expect(
|
||||
emitted.filter(
|
||||
(message) => message.type === "status" && message.payload.status === "agent_created",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await Promise.all(agentManager.listAgents().map((agent) => agentManager.closeAgent(agent.id)));
|
||||
await agentManager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent_request keeps requested child cwd when grouped under an existing parent workspace", async () => {
|
||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-"));
|
||||
try {
|
||||
|
||||
+14
-102
@@ -76,6 +76,7 @@ function createHarness(input?: {
|
||||
const directories = new Set(input?.directories ?? ["/repo"]);
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: () => undefined } as never,
|
||||
paseoHome: input?.paseoHome ?? "/paseo-home",
|
||||
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
|
||||
getWorkspace: async (workspaceId) =>
|
||||
@@ -117,108 +118,6 @@ describe("workspace recovery", () => {
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
});
|
||||
|
||||
test("a repeated restore of an already active workspace succeeds without another unarchive", async () => {
|
||||
const workspace = createWorkspace({ archivedAt: null });
|
||||
const { service, unarchived } = createHarness({ workspace, directories: [workspace.cwd] });
|
||||
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
action: "unarchive",
|
||||
});
|
||||
expect(unarchived).toEqual([]);
|
||||
});
|
||||
|
||||
test("an active workspace with a missing directory cannot report a successful restore", async () => {
|
||||
const workspace = createWorkspace({ archivedAt: null });
|
||||
const { service, unarchived } = createHarness({ workspace });
|
||||
await expect(service.restore(workspace.workspaceId)).rejects.toThrow();
|
||||
expect(unarchived).toEqual([]);
|
||||
});
|
||||
|
||||
test("expiry after filesystem recreation rolls back the new checkout", async () => {
|
||||
const { tempDir, repoDir } = createGitRepository();
|
||||
const branch = "feature/canceled-restore";
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
const paseoHome = join(tempDir, "paseo-home");
|
||||
const worktreesRoot = join(tempDir, "worktrees");
|
||||
const created = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "canceled-restore",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
const worktreeRoot = realpathSync(created.worktreePath);
|
||||
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||
const workspace = createWorkspace({
|
||||
cwd: worktreeRoot,
|
||||
branch,
|
||||
worktreeRoot,
|
||||
mainRepoRoot: repoDir,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
let unarchived = false;
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async () => workspace,
|
||||
getProject: async () => createProject({ rootPath: repoDir }),
|
||||
isDirectory: async (target) => {
|
||||
const exists = existsSync(target) && statSync(target).isDirectory();
|
||||
if (target === worktreeRoot && exists) controller.abort(new Error("expired"));
|
||||
return exists;
|
||||
},
|
||||
unarchiveWorkspace: async () => {
|
||||
unarchived = true;
|
||||
},
|
||||
});
|
||||
await expect(service.restore(workspace.workspaceId, controller.signal)).rejects.toThrow(
|
||||
"expired",
|
||||
);
|
||||
expect(unarchived).toBe(false);
|
||||
expect(existsSync(worktreeRoot)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}).toString(),
|
||||
).not.toContain(worktreeRoot);
|
||||
});
|
||||
|
||||
test("expiry during restore preparation cannot unarchive the workspace", async () => {
|
||||
const workspace = createWorkspace({ kind: "directory", branch: null });
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let entered!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let unarchived = false;
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome: "/paseo-home",
|
||||
worktreesRoot: "/worktrees",
|
||||
getWorkspace: async () => workspace,
|
||||
getProject: async () => createProject(),
|
||||
isDirectory: async () => {
|
||||
entered();
|
||||
await gate;
|
||||
return true;
|
||||
},
|
||||
unarchiveWorkspace: async () => {
|
||||
unarchived = true;
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const restoring = service.restore(workspace.workspaceId, controller.signal);
|
||||
await started;
|
||||
controller.abort(new Error("expired"));
|
||||
release();
|
||||
await expect(restoring).rejects.toThrow("expired");
|
||||
expect(unarchived).toBe(false);
|
||||
});
|
||||
|
||||
test("does not offer recovery for a missing non-worktree directory", async () => {
|
||||
const workspace = createWorkspace({ kind: "directory", branch: null });
|
||||
const { service } = createHarness({ workspace });
|
||||
@@ -237,6 +136,7 @@ describe("workspace recovery", () => {
|
||||
const sourceSubdirectory = join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceSubdirectory, { recursive: true });
|
||||
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), "missing.local\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
@@ -272,7 +172,9 @@ describe("workspace recovery", () => {
|
||||
mainRepoRoot: repoDir,
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const warnings: unknown[][] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: (...args: unknown[]) => warnings.push(args) } as never,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
@@ -291,6 +193,15 @@ describe("workspace recovery", () => {
|
||||
expect(existsSync(worktreeRoot)).toBe(true);
|
||||
expect(existsSync(workspaceCwd)).toBe(true);
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
expect(warnings).toEqual([
|
||||
[
|
||||
expect.objectContaining({
|
||||
materialized: 0,
|
||||
skipped: [expect.objectContaining({ raw: "missing.local", reason: "missing" })],
|
||||
}),
|
||||
"Worktree include completed with skipped entries during workspace recovery",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => {
|
||||
@@ -322,6 +233,7 @@ describe("workspace recovery", () => {
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: () => undefined } as never,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
|
||||
+15
-22
@@ -1,4 +1,5 @@
|
||||
import { basename } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import { runGitCommand } from "../../../utils/run-git-command.js";
|
||||
@@ -40,10 +41,7 @@ export type WorkspaceRecoveryState =
|
||||
|
||||
export interface WorkspaceRecoveryService {
|
||||
inspect(workspaceId: string): Promise<WorkspaceRecoveryState>;
|
||||
restore(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
|
||||
restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
|
||||
}
|
||||
|
||||
type RecoveryPlan =
|
||||
@@ -62,6 +60,7 @@ type RecoveryPlan =
|
||||
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
|
||||
|
||||
export function createWorkspaceRecoveryService(deps: {
|
||||
logger: Logger;
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
@@ -81,7 +80,7 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
message: "This workspace is no longer known to the host.",
|
||||
};
|
||||
}
|
||||
if (!workspace.archivedAt && (await deps.isDirectory(workspace.cwd))) {
|
||||
if (!workspace.archivedAt) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
@@ -90,14 +89,6 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
};
|
||||
}
|
||||
|
||||
if (!workspace.archivedAt) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
reason: "workspace_directory_missing",
|
||||
message: "The workspace directory no longer exists.",
|
||||
};
|
||||
}
|
||||
const project = await deps.getProject(workspace.projectId);
|
||||
if (!project) {
|
||||
return {
|
||||
@@ -151,20 +142,15 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
|
||||
async function restore(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> {
|
||||
signal?.throwIfAborted();
|
||||
const resolved = await resolveRecovery(workspaceId);
|
||||
signal?.throwIfAborted();
|
||||
if (resolved.kind === "unavailable") {
|
||||
if (resolved.reason === "workspace_not_archived") return { workspaceId, action: "unarchive" };
|
||||
throw new Error(resolved.message);
|
||||
}
|
||||
|
||||
if (resolved.kind === "restore") {
|
||||
await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot, signal);
|
||||
await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await deps.unarchiveWorkspace(resolved.workspace);
|
||||
return { workspaceId, action: resolved.kind };
|
||||
}
|
||||
@@ -172,7 +158,6 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
async function recreateArchivedWorktree(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
sourceRepoRoot: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const branch = workspace.branch;
|
||||
if (!branch) {
|
||||
@@ -212,12 +197,21 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
});
|
||||
recreatedWorktreePath = result.worktreePath;
|
||||
if (result.worktreeIncludeSummary.skipped.length > 0) {
|
||||
deps.logger.warn(
|
||||
{
|
||||
materialized: result.worktreeIncludeSummary.materialized,
|
||||
skipped: result.worktreeIncludeSummary.skipped,
|
||||
worktreePath: result.worktreePath,
|
||||
},
|
||||
"Worktree include completed with skipped entries during workspace recovery",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath: previousWorktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
@@ -235,7 +229,6 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
message: `Selected project directory is missing from the restored worktree: ${recreatedWorkspacePath}`,
|
||||
});
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
} catch (error) {
|
||||
return rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
|
||||
@@ -58,14 +58,11 @@ interface FakeAgentSessionOptions {
|
||||
sessionId?: string;
|
||||
memoryMarker?: string | null;
|
||||
closeSession?: () => Promise<void>;
|
||||
beforeStartTurn?: () => Promise<void>;
|
||||
onStartTurn?: (prompt: AgentPromptInput) => void;
|
||||
}
|
||||
|
||||
export interface TestAgentClientOptions {
|
||||
beforeCreateSession?: () => Promise<void>;
|
||||
closeSession?: () => Promise<void>;
|
||||
beforeStartTurn?: () => Promise<void>;
|
||||
onStartTurn?: (prompt: AgentPromptInput) => void;
|
||||
supportsMcpServers?: boolean;
|
||||
}
|
||||
@@ -339,7 +336,6 @@ class FakeAgentSession implements AgentSession {
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
|
||||
private readonly closeSession: (() => Promise<void>) | undefined;
|
||||
private readonly beforeStartTurn: (() => Promise<void>) | undefined;
|
||||
private readonly onStartTurn: ((prompt: AgentPromptInput) => void) | undefined;
|
||||
|
||||
constructor(options: FakeAgentSessionOptions) {
|
||||
@@ -353,7 +349,6 @@ class FakeAgentSession implements AgentSession {
|
||||
this.memoryMarker = options.memoryMarker ?? null;
|
||||
this.closeSession = options.closeSession;
|
||||
this.onStartTurn = options.onStartTurn;
|
||||
this.beforeStartTurn = options.beforeStartTurn;
|
||||
this.historyPath = path.join(
|
||||
tmpdir(),
|
||||
"paseo-fake-provider-history",
|
||||
@@ -438,7 +433,6 @@ class FakeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
await this.beforeStartTurn?.();
|
||||
if (this.activeForegroundTurnId) {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
@@ -1208,14 +1202,12 @@ class FakeAgentClient implements AgentClient {
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
await this.options.beforeCreateSession?.();
|
||||
return new FakeAgentSession({
|
||||
providerName: this.provider,
|
||||
config: { ...config },
|
||||
supportsMcpServers: this.options.supportsMcpServers,
|
||||
closeSession: this.options.closeSession,
|
||||
onStartTurn: this.options.onStartTurn,
|
||||
beforeStartTurn: this.options.beforeStartTurn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1241,7 +1233,6 @@ class FakeAgentClient implements AgentClient {
|
||||
memoryMarker: typeof marker === "string" ? marker : null,
|
||||
closeSession: this.options.closeSession,
|
||||
onStartTurn: this.options.onStartTurn,
|
||||
beforeStartTurn: this.options.beforeStartTurn,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -286,12 +286,6 @@ export function createAgentRequestsStub(): SessionOptions["agentRequests"] {
|
||||
return agentId;
|
||||
},
|
||||
send: (input) => input.send(),
|
||||
run: (_startup, operation) => operation(),
|
||||
control: async (_context, _request, operation) => {
|
||||
await operation();
|
||||
},
|
||||
cancelOperation: async () => ({ agentId: null, outcome: "settled" }),
|
||||
inspectOperation: async () => ({ agentId: null, outcome: "settled" }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -650,7 +650,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
pluginRuntime?: SessionOptions["pluginRuntime"],
|
||||
orchestrationSkills?: SessionOptions["orchestrationSkills"],
|
||||
workspaceLabelService?: WorkspaceLabelService,
|
||||
agentRequests?: AgentRequests,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.workspaceSetupRuntime = workspaceSetupRuntime;
|
||||
@@ -669,7 +668,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.orchestrationSkills = orchestrationSkills;
|
||||
this.agentManager = agentManager;
|
||||
this.agentStorage = agentStorage;
|
||||
this.agentRequests = agentRequests ?? new AgentRequests(join(paseoHome, "agent-requests"));
|
||||
this.agentRequests = new AgentRequests(join(paseoHome, "agent-requests"));
|
||||
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
||||
this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
|
||||
this.workspaceLabelService = workspaceLabelService ?? null;
|
||||
@@ -1634,7 +1633,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
||||
features: {
|
||||
agentRequestReceipts: true,
|
||||
agentRequestCancellation: true,
|
||||
hubAgentRpc: true,
|
||||
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||
directorySync: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
|
||||
import {
|
||||
resolveWorktreeCreationIntent,
|
||||
type ResolveWorktreeCreationIntentInput,
|
||||
@@ -42,6 +43,7 @@ export interface CreateWorktreeCoreDeps {
|
||||
|
||||
export interface CreateWorktreeCoreResult {
|
||||
worktree: WorktreeConfig;
|
||||
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||
intent: WorktreeCreationIntent;
|
||||
repoRoot: string;
|
||||
created: boolean;
|
||||
@@ -115,15 +117,17 @@ async function createWorktreeCoreWithPriority(
|
||||
}
|
||||
}
|
||||
|
||||
const { worktreeIncludeSummary, ...worktree } = await createWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
source: intent,
|
||||
runSetup: input.runSetup ?? true,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
});
|
||||
return {
|
||||
worktree: await createWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
source: intent,
|
||||
runSetup: input.runSetup ?? true,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
}),
|
||||
worktree,
|
||||
worktreeIncludeSummary,
|
||||
intent,
|
||||
repoRoot,
|
||||
created: true,
|
||||
|
||||
@@ -628,6 +628,17 @@ export async function createPaseoWorktreeWorkflow(
|
||||
const workspace = createdWorktree.workspace;
|
||||
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
|
||||
|
||||
if (createdWorktree.created && createdWorktree.worktreeIncludeSummary?.skipped.length) {
|
||||
dependencies.sessionLogger.warn(
|
||||
{
|
||||
materialized: createdWorktree.worktreeIncludeSummary.materialized,
|
||||
skipped: createdWorktree.worktreeIncludeSummary.skipped,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
},
|
||||
"Worktree include completed with skipped entries",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await dependencies.assertWorkspaceAutomationAllowed?.(workspace.workspaceId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
existsSync,
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { dirname, join, relative } from "path";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { materializeWorktreeIncludePlan, readWorktreeIncludePlan } from "./worktree-include.js";
|
||||
|
||||
describe("worktree include planning", () => {
|
||||
let tempDir: string;
|
||||
let sourceRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-test-"));
|
||||
sourceRoot = join(tempDir, "source");
|
||||
mkdirSync(sourceRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copies bare entries and accepts explicit copy and symlink modes", async () => {
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
[".env.local", ".cache/**", "symlink shared-state", "copy packages/*/.runtime.env", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
writeFileSync(join(sourceRoot, ".env.local"), "source\n");
|
||||
mkdirSync(join(sourceRoot, ".cache"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, ".cache", "state.txt"), "cache\n");
|
||||
mkdirSync(join(sourceRoot, "shared-state"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "shared\n");
|
||||
mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toHaveLength(4);
|
||||
expect(plan.materializations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".env.local", sourceKind: "file" }),
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".cache", sourceKind: "directory" }),
|
||||
expect.objectContaining({
|
||||
mode: "symlink",
|
||||
relativePath: "shared-state",
|
||||
sourceKind: "directory",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mode: "copy",
|
||||
relativePath: "packages/api/.runtime.env",
|
||||
sourceKind: "file",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips invalid and conflicting entries while retaining safe entries", async () => {
|
||||
writeFileSync(join(sourceRoot, "shared"), "source\n");
|
||||
writeFileSync(join(sourceRoot, ".env"), "source\n");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["../outside", "symlink", "shared", "symlink shared", ".env", ""].join("\n"),
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ lineNumber: 1, raw: "../outside", reason: "invalid" }),
|
||||
expect.objectContaining({ lineNumber: 2, raw: "symlink", reason: "invalid" }),
|
||||
expect.objectContaining({ lineNumber: 3, raw: "shared", reason: "conflict" }),
|
||||
expect.objectContaining({ lineNumber: 4, raw: "symlink shared", reason: "conflict" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips missing entries while retaining existing literal and glob matches", async () => {
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
[".env", ".env.local", "config/*.env", "missing/**", ""].join("\n"),
|
||||
);
|
||||
writeFileSync(join(sourceRoot, ".env"), "source\n");
|
||||
mkdirSync(join(sourceRoot, "config"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "config", "runtime.env"), "runtime\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }),
|
||||
expect.objectContaining({
|
||||
mode: "copy",
|
||||
relativePath: "config/runtime.env",
|
||||
sourceKind: "file",
|
||||
}),
|
||||
]);
|
||||
expect(plan.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ raw: ".env.local", reason: "missing" }),
|
||||
expect.objectContaining({ raw: "missing/**", reason: "missing" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires the optimized trailing recursive form to resolve to a directory", async () => {
|
||||
writeFileSync(join(sourceRoot, "cache"), "not-a-directory\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: "cache/**", reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("skips directory copies that overlap protected worktree paths", async () => {
|
||||
const protectedWorktreeRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees", "project");
|
||||
mkdirSync(protectedWorktreeRoot, { recursive: true });
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [protectedWorktreeRoot],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("protects every managed project beneath checkout-local worktree storage", async () => {
|
||||
const managedWorktreesRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees");
|
||||
const siblingWorktree = join(managedWorktreesRoot, "other-project", "sibling");
|
||||
mkdirSync(siblingWorktree, { recursive: true });
|
||||
writeFileSync(join(siblingWorktree, ".env"), "secret\n");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
".dev/paseo-home/worktrees/other-project/sibling/.env\n",
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [managedWorktreesRoot],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("allows includes from a source worktree inside the protected worktree root", async () => {
|
||||
const managedWorktreesRoot = join(tempDir, "paseo-home", "worktrees", "project");
|
||||
sourceRoot = join(managedWorktreesRoot, "source-worktree");
|
||||
mkdirSync(sourceRoot, { recursive: true });
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".env\n");
|
||||
writeFileSync(join(sourceRoot, ".env"), "source\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [managedWorktreesRoot],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: ".env", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"skips protected paths reached through a symlink alias",
|
||||
async () => {
|
||||
const sourceAlias = join(tempDir, "source-alias");
|
||||
symlinkSync(sourceRoot, sourceAlias, "dir");
|
||||
mkdirSync(join(sourceRoot, ".dev", "paseo-home", "worktrees", "project"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [join(sourceAlias, ".dev", "paseo-home", "worktrees", "project")],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"skips external source links while retaining safe entries",
|
||||
async () => {
|
||||
const outsidePath = join(tempDir, "outside.txt");
|
||||
writeFileSync(outsidePath, "outside\n");
|
||||
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
|
||||
symlinkSync(outsidePath, join(sourceRoot, "linked.txt"));
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["safe.txt", "linked.txt", ""].join("\n"),
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "linked.txt", reason: "unsafe" }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32"))("matches globs beneath a safe symlinked directory", async () => {
|
||||
mkdirSync(join(sourceRoot, "actual-config"));
|
||||
writeFileSync(join(sourceRoot, "actual-config", "runtime.env"), "runtime\n");
|
||||
symlinkSync(join(sourceRoot, "actual-config"), join(sourceRoot, "config"), "dir");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/*.env\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "config/runtime.env", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"does not scan unrelated directories for bounded globs",
|
||||
async () => {
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n");
|
||||
mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n");
|
||||
const unreadableDirectory = join(sourceRoot, "unrelated");
|
||||
mkdirSync(unreadableDirectory);
|
||||
chmodSync(unreadableDirectory, 0o000);
|
||||
|
||||
try {
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "packages/api/.runtime.env" }),
|
||||
]);
|
||||
} finally {
|
||||
chmodSync(unreadableDirectory, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32") || process.getuid?.() === 0)(
|
||||
"skips inaccessible entries while retaining safe paths",
|
||||
async () => {
|
||||
const inaccessibleDirectory = join(sourceRoot, "private");
|
||||
mkdirSync(inaccessibleDirectory);
|
||||
writeFileSync(join(inaccessibleDirectory, "secret.txt"), "secret\n");
|
||||
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["private/**", "private/*", "safe.txt", ""].join("\n"),
|
||||
);
|
||||
chmodSync(inaccessibleDirectory, 0o000);
|
||||
|
||||
try {
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ raw: "private/**", reason: "materialization" }),
|
||||
expect.objectContaining({ raw: "private/*", reason: "materialization" }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
chmodSync(inaccessibleDirectory, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32") || process.getuid?.() === 0)(
|
||||
"retains valid glob matches when a viable sibling is unreadable",
|
||||
async () => {
|
||||
mkdirSync(join(sourceRoot, "packages", "good"), { recursive: true });
|
||||
mkdirSync(join(sourceRoot, "packages", "private"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "packages", "good", ".runtime.env"), "good\n");
|
||||
writeFileSync(join(sourceRoot, "packages", "private", ".runtime.env"), "private\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n");
|
||||
chmodSync(join(sourceRoot, "packages", "private"), 0o000);
|
||||
|
||||
try {
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "packages/good/.runtime.env" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "packages/*/.runtime.env", reason: "materialization" }),
|
||||
]);
|
||||
} finally {
|
||||
chmodSync(join(sourceRoot, "packages", "private"), 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("worktree include materialization", () => {
|
||||
let tempDir: string;
|
||||
let sourceRoot: string;
|
||||
let worktreeRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-materialize-test-"));
|
||||
sourceRoot = join(tempDir, "source");
|
||||
worktreeRoot = join(tempDir, "worktree");
|
||||
mkdirSync(sourceRoot);
|
||||
mkdirSync(worktreeRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copies snapshots, links shared paths, and is idempotent", async () => {
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["copy.txt", "copy-dir/**", "symlink linked.txt", "symlink linked-dir"].join("\n"),
|
||||
);
|
||||
writeFileSync(join(sourceRoot, "copy.txt"), "copy-v1\n");
|
||||
mkdirSync(join(sourceRoot, "copy-dir"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v1\n");
|
||||
writeFileSync(join(sourceRoot, "linked.txt"), "linked-v1\n");
|
||||
mkdirSync(join(sourceRoot, "linked-dir"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v1\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(lstatSync(join(worktreeRoot, "copy.txt")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(worktreeRoot, "copy-dir")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(worktreeRoot, "linked.txt")).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(join(worktreeRoot, "linked-dir")).isSymbolicLink()).toBe(true);
|
||||
|
||||
writeFileSync(join(sourceRoot, "copy.txt"), "copy-v2\n");
|
||||
writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v2\n");
|
||||
writeFileSync(join(sourceRoot, "linked.txt"), "linked-v2\n");
|
||||
writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v2\n");
|
||||
|
||||
expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v1\n");
|
||||
expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v1\n");
|
||||
expect(readFileSync(join(worktreeRoot, "linked.txt"), "utf8")).toBe("linked-v2\n");
|
||||
expect(readFileSync(join(worktreeRoot, "linked-dir", "state.txt"), "utf8")).toBe(
|
||||
"linked-dir-v2\n",
|
||||
);
|
||||
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v2\n");
|
||||
expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v2\n");
|
||||
});
|
||||
|
||||
it("replaces an existing directory snapshot without retaining destination-only files", async () => {
|
||||
mkdirSync(join(sourceRoot, "cache"));
|
||||
writeFileSync(join(sourceRoot, "cache", "current.txt"), "current\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n");
|
||||
mkdirSync(join(worktreeRoot, "cache"));
|
||||
writeFileSync(join(worktreeRoot, "cache", "stale.txt"), "stale\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(result).toMatchObject({ materialized: 1, skipped: [] });
|
||||
expect(readFileSync(join(worktreeRoot, "cache", "current.txt"), "utf8")).toBe("current\n");
|
||||
expect(existsSync(join(worktreeRoot, "cache", "stale.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("retains an explicit descendant when an overlapping directory copy is skipped", async () => {
|
||||
mkdirSync(join(sourceRoot, "config", "nested"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "config", "local.env"), "local\n");
|
||||
writeFileSync(join(sourceRoot, "config", "nested", "state.txt"), "state\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/**\nconfig/local.env\n");
|
||||
mkdirSync(join(worktreeRoot, "config"));
|
||||
writeFileSync(join(worktreeRoot, "config", "nested"), "conflict\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(result).toMatchObject({ materialized: 1 });
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "config/**", reason: "conflict" }),
|
||||
]);
|
||||
expect(readFileSync(join(worktreeRoot, "config", "local.env"), "utf8")).toBe("local\n");
|
||||
});
|
||||
|
||||
it("skips a destination parent symlink without writing through it", async () => {
|
||||
mkdirSync(join(sourceRoot, "config"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "config", "local.json"), "{}\n");
|
||||
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/local.json\nsafe.txt\n");
|
||||
|
||||
const outsideRoot = join(tempDir, "outside");
|
||||
mkdirSync(outsideRoot);
|
||||
symlinkSync(outsideRoot, join(worktreeRoot, "config"));
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(existsSync(join(outsideRoot, "local.json"))).toBe(false);
|
||||
expect(readFileSync(join(worktreeRoot, "safe.txt"), "utf8")).toBe("safe\n");
|
||||
expect(result).toMatchObject({ materialized: 1 });
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "config/local.json", reason: "conflict" }),
|
||||
]);
|
||||
expect(readdirSync(worktreeRoot)).not.toContain(
|
||||
expect.stringMatching(/^\.paseo-worktreeinclude-/),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies resolved source links and creates direct live links", async () => {
|
||||
const targetPath = join(sourceRoot, "target.txt");
|
||||
const targetDirectoryPath = join(sourceRoot, "target-directory");
|
||||
writeFileSync(targetPath, "v1\n");
|
||||
mkdirSync(targetDirectoryPath);
|
||||
writeFileSync(join(targetDirectoryPath, "state.txt"), "v1\n");
|
||||
symlinkSync(targetPath, join(sourceRoot, "copy-link.txt"));
|
||||
symlinkSync(targetPath, join(sourceRoot, "live-link.txt"));
|
||||
symlinkSync(targetDirectoryPath, join(sourceRoot, "copy-directory-link"), "dir");
|
||||
symlinkSync(targetDirectoryPath, join(sourceRoot, "live-directory-link"), "dir");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
[
|
||||
"copy-link.txt",
|
||||
"symlink live-link.txt",
|
||||
"copy-directory-link",
|
||||
"symlink live-directory-link",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
const copiedPath = join(worktreeRoot, "copy-link.txt");
|
||||
const linkedPath = join(worktreeRoot, "live-link.txt");
|
||||
const copiedDirectoryPath = join(worktreeRoot, "copy-directory-link");
|
||||
const linkedDirectoryPath = join(worktreeRoot, "live-directory-link");
|
||||
const canonicalWorktreeRoot = realpathSync(worktreeRoot);
|
||||
expect(result).toMatchObject({ materialized: 4, skipped: [] });
|
||||
expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(linkedPath).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(copiedDirectoryPath).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(linkedDirectoryPath).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(linkedPath)).toBe(
|
||||
relative(dirname(join(canonicalWorktreeRoot, "live-link.txt")), realpathSync(targetPath)),
|
||||
);
|
||||
expect(readlinkSync(linkedDirectoryPath)).toBe(
|
||||
relative(
|
||||
dirname(join(canonicalWorktreeRoot, "live-directory-link")),
|
||||
realpathSync(targetDirectoryPath),
|
||||
),
|
||||
);
|
||||
expect(realpathSync(linkedPath)).toBe(realpathSync(targetPath));
|
||||
expect(realpathSync(linkedDirectoryPath)).toBe(realpathSync(targetDirectoryPath));
|
||||
|
||||
writeFileSync(targetPath, "v2\n");
|
||||
writeFileSync(join(targetDirectoryPath, "state.txt"), "v2\n");
|
||||
expect(readFileSync(copiedPath, "utf8")).toBe("v1\n");
|
||||
expect(readFileSync(linkedPath, "utf8")).toBe("v2\n");
|
||||
expect(readFileSync(join(copiedDirectoryPath, "state.txt"), "utf8")).toBe("v1\n");
|
||||
expect(readFileSync(join(linkedDirectoryPath, "state.txt"), "utf8")).toBe("v2\n");
|
||||
});
|
||||
|
||||
it("rejects source links that alias Git metadata", async () => {
|
||||
mkdirSync(join(sourceRoot, ".git"));
|
||||
writeFileSync(join(sourceRoot, ".git", "config"), "secret\n");
|
||||
symlinkSync(join(sourceRoot, ".git"), join(sourceRoot, "metadata"), "dir");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "metadata\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: "metadata", reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("treats hard links as ordinary files", async () => {
|
||||
const targetPath = join(sourceRoot, "target.txt");
|
||||
const hardLinkPath = join(sourceRoot, "hard-link.txt");
|
||||
writeFileSync(targetPath, "v1\n");
|
||||
linkSync(targetPath, hardLinkPath);
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "hard-link.txt\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
const copiedPath = join(worktreeRoot, "hard-link.txt");
|
||||
expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false);
|
||||
expect(readFileSync(copiedPath, "utf8")).toBe("v1\n");
|
||||
});
|
||||
|
||||
it("skips a source link retargeted outside the checkout after planning", async () => {
|
||||
const insidePath = join(sourceRoot, "inside.txt");
|
||||
const linkedPath = join(sourceRoot, "linked.txt");
|
||||
const outsidePath = join(tempDir, "outside.txt");
|
||||
writeFileSync(insidePath, "inside\n");
|
||||
writeFileSync(outsidePath, "outside\n");
|
||||
symlinkSync(insidePath, linkedPath);
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink linked.txt\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
rmSync(linkedPath);
|
||||
symlinkSync(outsidePath, linkedPath);
|
||||
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(existsSync(join(worktreeRoot, "linked.txt"))).toBe(false);
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "symlink linked.txt", reason: "unsafe" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a linked directory that exposes an external nested link", async () => {
|
||||
const sharedPath = join(sourceRoot, "shared");
|
||||
const outsidePath = join(tempDir, "outside.txt");
|
||||
mkdirSync(sharedPath);
|
||||
writeFileSync(outsidePath, "outside\n");
|
||||
symlinkSync(outsidePath, join(sharedPath, "outside.txt"));
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "symlink shared", reason: "unsafe" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows a linked directory with internal nested links", async () => {
|
||||
const sharedPath = join(sourceRoot, "shared");
|
||||
const targetPath = join(sourceRoot, "shared-target");
|
||||
mkdirSync(sharedPath);
|
||||
mkdirSync(targetPath);
|
||||
writeFileSync(join(targetPath, "state.txt"), "source\n");
|
||||
symlinkSync(targetPath, join(sharedPath, "target"), "dir");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(result).toMatchObject({ materialized: 1, skipped: [] });
|
||||
expect(readFileSync(join(worktreeRoot, "shared", "target", "state.txt"), "utf8")).toBe(
|
||||
"source\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a source removed after planning", async () => {
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "runtime.env\n");
|
||||
const sourcePath = join(sourceRoot, "runtime.env");
|
||||
writeFileSync(sourcePath, "source\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
rmSync(sourcePath);
|
||||
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(existsSync(join(worktreeRoot, "runtime.env"))).toBe(false);
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "runtime.env", reason: "missing" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
"skips ordinary filesystem failures during materialization revalidation",
|
||||
async () => {
|
||||
mkdirSync(join(sourceRoot, "private"));
|
||||
writeFileSync(join(sourceRoot, "private", "state.txt"), "private\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "private/**\n");
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
chmodSync(join(sourceRoot, "private"), 0o000);
|
||||
|
||||
try {
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
expect(result).toMatchObject({ materialized: 0 });
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "private/**", reason: "materialization" }),
|
||||
]);
|
||||
} finally {
|
||||
chmodSync(join(sourceRoot, "private"), 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!isPlatform("win32"))("worktree include Windows directory links", () => {
|
||||
let tempDir: string;
|
||||
let sourceRoot: string;
|
||||
let worktreeRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-windows-test-"));
|
||||
sourceRoot = join(tempDir, "source");
|
||||
worktreeRoot = join(tempDir, "worktree");
|
||||
mkdirSync(sourceRoot);
|
||||
mkdirSync(worktreeRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses a live directory link and removes it without touching the source", async () => {
|
||||
mkdirSync(join(sourceRoot, "shared-state"));
|
||||
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v1\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared-state\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v2\n");
|
||||
expect(readFileSync(join(worktreeRoot, "shared-state", "state.txt"), "utf8")).toBe(
|
||||
"source-v2\n",
|
||||
);
|
||||
|
||||
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||
expect(readFileSync(join(sourceRoot, "shared-state", "state.txt"), "utf8")).toBe("source-v2\n");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -382,6 +382,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
|
||||
it("removes fetched branches when include planning fails", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||
execFileSync("git", ["clone", "--bare", repoDir, remoteDir]);
|
||||
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
|
||||
|
||||
execFileSync("git", ["clone", remoteDir, remoteCloneDir]);
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["checkout", "-b", "contributor/cleanup"], { cwd: remoteCloneDir });
|
||||
writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n");
|
||||
execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pr branch"], {
|
||||
cwd: remoteCloneDir,
|
||||
});
|
||||
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir })
|
||||
.toString()
|
||||
.trim();
|
||||
execFileSync("git", ["push", "origin", "contributor/cleanup"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/44/head", prHead]);
|
||||
mkdirSync(join(repoDir, ".worktreeinclude"));
|
||||
|
||||
await expect(
|
||||
createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "pr-44-cleanup",
|
||||
source: {
|
||||
kind: "checkout-github-pr",
|
||||
githubPrNumber: 44,
|
||||
headRef: "contributor/cleanup",
|
||||
baseRefName: "main",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EISDIR" });
|
||||
|
||||
expect(() =>
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
await expect(
|
||||
createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "branch-cleanup",
|
||||
source: { kind: "checkout-branch", branchName: "contributor/cleanup" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EISDIR" });
|
||||
|
||||
expect(() =>
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||
@@ -1298,6 +1360,241 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("materializes copies and symlinks before setup, then removes only the new worktree links", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, ".gitignore"),
|
||||
[".copy.env", "copy-cache/", "linked-file.txt", "linked-state", "setup.log", ""].join("\n"),
|
||||
);
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [
|
||||
"test -f .copy.env",
|
||||
"test -L linked-file.txt",
|
||||
"test -L linked-state",
|
||||
"cat linked-state/state.txt > setup.log",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", ".gitignore", "paseo.json"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include fixture"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
join(repoDir, ".worktreeinclude"),
|
||||
[".copy.env", "copy-cache/**", "symlink linked-file.txt", "symlink linked-state", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
writeFileSync(join(repoDir, ".copy.env"), "copy-v1\n");
|
||||
mkdirSync(join(repoDir, "copy-cache"), { recursive: true });
|
||||
writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v1\n");
|
||||
writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v1\n");
|
||||
mkdirSync(join(repoDir, "linked-state"), { recursive: true });
|
||||
writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v1\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-links",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/include-links" },
|
||||
runSetup: true,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe(
|
||||
"linked-state-v1\n",
|
||||
);
|
||||
expect(lstatSync(join(result.worktreePath, ".copy.env")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(result.worktreePath, "copy-cache")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(result.worktreePath, "linked-file.txt")).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(join(result.worktreePath, "linked-state")).isSymbolicLink()).toBe(true);
|
||||
expect(
|
||||
execFileSync("git", ["status", "--porcelain"], {
|
||||
cwd: result.worktreePath,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toBe("");
|
||||
|
||||
writeFileSync(join(repoDir, ".copy.env"), "copy-v2\n");
|
||||
writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v2\n");
|
||||
writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v2\n");
|
||||
writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v2\n");
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, ".copy.env"), "utf8")).toBe("copy-v1\n");
|
||||
expect(readFileSync(join(result.worktreePath, "copy-cache", "state.txt"), "utf8")).toBe(
|
||||
"copy-cache-v1\n",
|
||||
);
|
||||
expect(readFileSync(join(result.worktreePath, "linked-file.txt"), "utf8")).toBe(
|
||||
"linked-file-v2\n",
|
||||
);
|
||||
expect(readFileSync(join(result.worktreePath, "linked-state", "state.txt"), "utf8")).toBe(
|
||||
"linked-state-v2\n",
|
||||
);
|
||||
|
||||
await deletePaseoWorktree({
|
||||
cwd: repoDir,
|
||||
worktreePath: result.worktreePath,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(false);
|
||||
expect(readFileSync(join(repoDir, "linked-file.txt"), "utf8")).toBe("linked-file-v2\n");
|
||||
expect(readFileSync(join(repoDir, "linked-state", "state.txt"), "utf8")).toBe(
|
||||
"linked-state-v2\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips missing includes and materializes paths that exist", async () => {
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "missing-include");
|
||||
writeFileSync(
|
||||
join(repoDir, ".worktreeinclude"),
|
||||
[".env", ".env.local", ".sops.yaml", ""].join("\n"),
|
||||
);
|
||||
writeFileSync(join(repoDir, ".env"), "present\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "missing-include",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/missing-include" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n");
|
||||
expect(existsSync(join(result.worktreePath, ".env.local"))).toBe(false);
|
||||
expect(existsSync(join(result.worktreePath, ".sops.yaml"))).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
});
|
||||
|
||||
it("skips checkout-local worktree storage and creates the remaining includes", async () => {
|
||||
const checkoutLocalPaseoHome = join(repoDir, ".dev", "paseo-home");
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(
|
||||
checkoutLocalPaseoHome,
|
||||
"worktrees",
|
||||
projectHash,
|
||||
"protected-include",
|
||||
);
|
||||
mkdirSync(join(checkoutLocalPaseoHome, "worktrees", projectHash), { recursive: true });
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), [".dev/**", ".env", ""].join("\n"));
|
||||
writeFileSync(join(repoDir, ".env"), "present\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "protected-include",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/protected-include",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome: checkoutLocalPaseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n");
|
||||
expect(result.worktreeIncludeSummary?.skipped).toEqual([
|
||||
expect.objectContaining({ raw: ".dev/**", reason: "unsafe" }),
|
||||
]);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--list", "feature/protected-include"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toContain("feature/protected-include");
|
||||
});
|
||||
|
||||
it("keeps includes when branching from a Paseo-managed worktree", async () => {
|
||||
writeFileSync(join(repoDir, ".gitignore"), ".env\n");
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), ".env\n");
|
||||
execFileSync("git", ["add", ".gitignore", ".worktreeinclude"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include config"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writeFileSync(join(repoDir, ".env"), "source\n");
|
||||
|
||||
const sourceWorktree = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-source",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/include-source",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const nestedWorktree = await createLegacyWorktreeForTest({
|
||||
cwd: sourceWorktree.worktreePath,
|
||||
worktreeSlug: "include-nested",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "feature/include-source",
|
||||
branchName: "feature/include-nested",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(sourceWorktree.worktreePath, ".env"), "utf8")).toBe("source\n");
|
||||
expect(readFileSync(join(nestedWorktree.worktreePath, ".env"), "utf8")).toBe("source\n");
|
||||
expect(nestedWorktree.worktreeIncludeSummary?.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a symlink include conflict and keeps the new worktree", async () => {
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict");
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts: {} }));
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), "symlink paseo.json\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-conflict",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/include-conflict",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(existsSync(expectedWorktreePath)).toBe(true);
|
||||
expect(lstatSync(join(expectedWorktreePath, "paseo.json")).isSymbolicLink()).toBe(false);
|
||||
expect(result.worktreeIncludeSummary?.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "symlink paseo.json", reason: "conflict" }),
|
||||
]);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--list", "feature/include-conflict"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toContain("feature/include-conflict");
|
||||
});
|
||||
|
||||
it("creates a worktree without error when no paseo.json exists in the main repo", async () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
|
||||
@@ -44,6 +44,11 @@ import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
|
||||
import { terminateWithTreeKill } from "./tree-kill.js";
|
||||
import {
|
||||
materializeWorktreeIncludePlan,
|
||||
readWorktreeIncludePlan,
|
||||
type WorktreeIncludeSummary,
|
||||
} from "./worktree-include.js";
|
||||
|
||||
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
|
||||
@@ -57,6 +62,10 @@ export interface WorktreeConfig {
|
||||
worktreePath: string;
|
||||
}
|
||||
|
||||
export interface CreatedWorktreeConfig extends WorktreeConfig {
|
||||
worktreeIncludeSummary: WorktreeIncludeSummary;
|
||||
}
|
||||
|
||||
export interface WorktreeRuntimeEnv {
|
||||
[key: string]: string;
|
||||
PASEO_SOURCE_CHECKOUT_PATH: string;
|
||||
@@ -1145,12 +1154,18 @@ export async function deletePaseoWorktree({
|
||||
}
|
||||
|
||||
export async function rollbackCreatedPaseoWorktree(
|
||||
options: DeletePaseoWorktreeOptions,
|
||||
options: DeletePaseoWorktreeOptions & { createdBranchName?: string },
|
||||
cause: unknown,
|
||||
): Promise<never> {
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
await deletePaseoWorktree(options);
|
||||
if (options.createdBranchName && options.cwd) {
|
||||
await runGitCommand(["branch", "--delete", "--force", options.createdBranchName], {
|
||||
cwd: options.cwd,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
@@ -1216,8 +1231,25 @@ export const createWorktree = async ({
|
||||
runSetup,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
}: CreateWorktreeOptions): Promise<WorktreeConfig> => {
|
||||
}: CreateWorktreeOptions): Promise<CreatedWorktreeConfig> => {
|
||||
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
||||
const paseoWorktreesBaseRoot = resolvePaseoWorktreesBaseRoot({ paseoHome, worktreesRoot });
|
||||
const worktreeIncludePlan = await (async () => {
|
||||
try {
|
||||
return await readWorktreeIncludePlan({
|
||||
sourceRoot: cwd,
|
||||
excludedSourceRoots: [paseoWorktreesBaseRoot],
|
||||
});
|
||||
} catch (error) {
|
||||
if (sourcePlan.createdBranchName) {
|
||||
await runGitCommand(["branch", "--delete", "--force", sourcePlan.createdBranchName], {
|
||||
cwd,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
|
||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||
|
||||
@@ -1236,30 +1268,56 @@ export const createWorktree = async ({
|
||||
});
|
||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.pushRemote,
|
||||
});
|
||||
}
|
||||
if (sourcePlan.trackingRemote) {
|
||||
await configureWorktreeTrackingRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.trackingRemote,
|
||||
});
|
||||
}
|
||||
let worktreeIncludeSummary: WorktreeIncludeSummary = {
|
||||
materialized: 0,
|
||||
skipped: [...worktreeIncludePlan.skipped],
|
||||
};
|
||||
try {
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.pushRemote,
|
||||
});
|
||||
}
|
||||
if (sourcePlan.trackingRemote) {
|
||||
await configureWorktreeTrackingRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.trackingRemote,
|
||||
});
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
const materialization = await materializeWorktreeIncludePlan({
|
||||
plan: worktreeIncludePlan,
|
||||
worktreeRoot: worktreePath,
|
||||
});
|
||||
worktreeIncludeSummary = {
|
||||
materialized: materialization.materialized,
|
||||
skipped: [...worktreeIncludePlan.skipped, ...materialization.skipped],
|
||||
};
|
||||
} catch (error) {
|
||||
return rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
cwd,
|
||||
worktreePath,
|
||||
teardownCwds: [],
|
||||
paseoHome,
|
||||
worktreesBaseRoot: worktreesRoot,
|
||||
createdBranchName: sourcePlan.createdBranchName,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (runSetup) {
|
||||
await runWorktreeSetupCommands({
|
||||
@@ -1271,6 +1329,7 @@ export const createWorktree = async ({
|
||||
|
||||
return {
|
||||
branchName: sourcePlan.branchName,
|
||||
worktreeIncludeSummary,
|
||||
worktreePath,
|
||||
};
|
||||
};
|
||||
@@ -1283,6 +1342,7 @@ interface ResolveWorktreeSourcePlanOptions {
|
||||
|
||||
interface WorktreeSourcePlan {
|
||||
branchName: string;
|
||||
createdBranchName?: string;
|
||||
// Display name and exact ref are two different facts. The name cannot round-trip to a
|
||||
// commit — "main" resolves local-first even when the worktree was cut from a fork's
|
||||
// upstream — so comparisons and actions read the ref and the UI reads the name.
|
||||
@@ -1320,6 +1380,7 @@ async function resolveWorktreeSourcePlan({
|
||||
|
||||
return {
|
||||
branchName: newBranchName,
|
||||
createdBranchName: newBranchName,
|
||||
metadataBaseRefName: normalizedBaseBranch,
|
||||
metadataBaseRef: resolvedBaseBranch,
|
||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||
@@ -1340,11 +1401,22 @@ async function resolveWorktreeSourcePlan({
|
||||
} catch {
|
||||
throw new UnknownBranchError({ branchName: source.branchName, cwd });
|
||||
}
|
||||
return {
|
||||
branchName: source.branchName,
|
||||
createdBranchName: source.branchName,
|
||||
metadataBaseRefName: source.branchName,
|
||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||
headRef: source.branchName,
|
||||
localBranchName: source.branchName,
|
||||
}),
|
||||
addArguments: [source.branchName],
|
||||
};
|
||||
}
|
||||
if (await isBranchCheckedOut(cwd, source.branchName)) {
|
||||
const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName);
|
||||
return {
|
||||
branchName,
|
||||
createdBranchName: branchName,
|
||||
metadataBaseRefName: source.branchName,
|
||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||
headRef: branchName,
|
||||
@@ -1413,6 +1485,7 @@ async function resolveWorktreeSourcePlan({
|
||||
|
||||
return {
|
||||
branchName: localBranchName,
|
||||
createdBranchName: localBranchName,
|
||||
metadataBaseRefName: normalizedBaseRefName,
|
||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||
headRef: source.headRef,
|
||||
|
||||
@@ -188,26 +188,21 @@ An environment or dynamic named-agent expression must have a finite set of possi
|
||||
|
||||
### Steps
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --------------- | -------- | ---------------------------------------------------------------------------------------- |
|
||||
| `id` | yes | Unique within the workflow. |
|
||||
| `environment` | yes | Literal environment name or finite expression resolving to one. |
|
||||
| `max_runtime` | yes | Absolute step limit, including agent startup. |
|
||||
| `idle_timeout` | yes | Idle limit no longer than `max_runtime`. |
|
||||
| `agent` | yes | Named agent, finite expression selecting a named agent, or complete static inline agent. |
|
||||
| `prompt` | yes | Ordered `text` and `include` blocks. |
|
||||
| `if` | no | Expression deciding whether the step runs. |
|
||||
| `env` | no | Environment variables from connection values. |
|
||||
| `output.schema` | no | JSON Schema for structured step output. |
|
||||
| `allow_outputs` | no | Provider output capabilities with optional `max` and `required`. |
|
||||
| `auto_archive` | no | Archive the agent after the step ends. |
|
||||
| `github` | no | Explicit GitHub authority for this step. |
|
||||
|
||||
`max_runtime` starts when Hub dispatches the step. Preparing a filesystem worktree, creating the provider agent, restoring a continuation workspace, and acknowledging the initial prompt all consume that budget. The deadline does not reset when startup finishes or activity arrives. The trigger's whole-run deadline can end the step sooner. Provider-specific startup failures can also end a step before its deadline.
|
||||
|
||||
`idle_timeout` starts alongside dispatch. An `initializing` or `running` agent status clears the idle deadline; an `idle` status starts it again. Meaningful agent activity refreshes an armed idle deadline. Connection heartbeats do not count as agent activity. Allow enough idle time for preparation before the daemon reports an active agent.
|
||||
|
||||
Continuation requires a daemon that supports cancelable agent requests. Update the daemon if Hub requests an update. When a deadline expires, Hub ends the step and reconciles daemon cleanup before another step can use the same conversation. If a daemon restart leaves the operation's outcome unknown, inspect the agent and workspace in Paseo before retrying with a new conversation key; Hub does not automatically repeat an ambiguous launch or prompt.
|
||||
| Field | Required | Notes |
|
||||
| ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | yes | Unique within the workflow. |
|
||||
| `environment` | yes | Literal environment name or finite expression resolving to one. |
|
||||
| `max_runtime` | yes | Step hard limit. |
|
||||
| `idle_timeout` | yes | Idle limit no longer than `max_runtime`. |
|
||||
| `startup_timeout` | no | Step startup waiting budget. See [startup timeout](/docs/hub/configuration#startup-timeout) for defaults and limits. |
|
||||
| `agent` | yes | Named agent, finite expression selecting a named agent, or complete static inline agent. |
|
||||
| `prompt` | yes | Ordered `text` and `include` blocks. |
|
||||
| `if` | no | Expression deciding whether the step runs. |
|
||||
| `env` | no | Environment variables from connection values. |
|
||||
| `output.schema` | no | JSON Schema for structured step output. |
|
||||
| `allow_outputs` | no | Provider output capabilities with optional `max` and `required`. |
|
||||
| `auto_archive` | no | Archive the agent after the step ends. |
|
||||
| `github` | no | Explicit GitHub authority for this step. |
|
||||
|
||||
An inline agent is static and complete:
|
||||
|
||||
|
||||
@@ -64,6 +64,23 @@ A Discord starter uses `discord.mention`, your Discord user ID, and `discord.rep
|
||||
|
||||
Setup asks before replacing the selected trigger file. It preserves other triggers and any existing legacy bundle. Read [Hub security](/docs/hub/security) before widening `from_users` or the agent's authority.
|
||||
|
||||
## Startup timeout
|
||||
|
||||
Hub waits up to **two minutes** for agent startup, including worktree creation, provider startup, and initial prompt acceptance. For a slower machine, set `run.startup_timeout` in the trigger YAML:
|
||||
|
||||
```yaml
|
||||
name: inspect
|
||||
on:
|
||||
manual.run: {}
|
||||
run:
|
||||
target: { daemon: devbox, cwd: /workspace/project }
|
||||
agent: { provider: codex, mode: full-access }
|
||||
startup_timeout: 5m
|
||||
prompt: Inspect this repository and summarize its current state.
|
||||
```
|
||||
|
||||
Use a positive duration in `ms`, `s`, `m`, or `h`, up to `24h`. Omitting the field uses `2m`. The existing `max_runtime` and `idle_timeout` limits still apply during startup and can expire sooner. This setting changes Hub's waiting budget; provider-specific timeouts remain in effect.
|
||||
|
||||
## Deploy from the CLI
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
@@ -112,6 +112,46 @@ Both fields accept a multiline shell script or an array of commands; commands ru
|
||||
|
||||
Commands run with the worktree as `cwd`. Use `$PASEO_SOURCE_CHECKOUT_PATH` to reach files in the original checkout (untracked config, local caches, etc).
|
||||
|
||||
## .worktreeinclude
|
||||
|
||||
Use a root-level .worktreeinclude to materialize local source-checkout files before
|
||||
worktree.setup runs. Each path is relative to the source checkout.
|
||||
|
||||
# Copy is the default.
|
||||
.env.local
|
||||
.cache/**
|
||||
|
||||
# Modes can also be explicit.
|
||||
symlink node_modules
|
||||
copy .tool-state/**
|
||||
|
||||
Each line is `[copy|symlink] <path>`; the mode is optional and defaults to `copy`. Blank lines
|
||||
and whole-line comments are ignored. A single star matches within a path segment and a double
|
||||
star matches recursively; a directory ending in /\*\* materializes that directory as one
|
||||
recursive entry. Absolute paths, parent-directory paths, and .git paths are rejected.
|
||||
|
||||
Copy entries are independent snapshots: a copied file or directory replaces an existing path on
|
||||
a later materialization. Symlink entries point directly at
|
||||
the live source file or directory, so changes through either path affect the same data. Paseo
|
||||
does not replace an existing file, directory, or different link with a symlink.
|
||||
|
||||
Entries must resolve to regular files or directories. A top-level source symbolic link is
|
||||
dereferenced only when its canonical target remains inside the source checkout: `copy` snapshots
|
||||
that target and `symlink` links directly to it. Directory snapshots reject nested symbolic links
|
||||
so Paseo never writes through an unexpected path. A symlinked directory intentionally exposes its
|
||||
live source contents.
|
||||
|
||||
Prefer paths ignored by the target branch. For a symlinked directory, use an ignore rule without
|
||||
a trailing slash (for example, node_modules, not node_modules/), because Git treats the link
|
||||
itself as a file. Unignored materialized paths appear in git status.
|
||||
|
||||
On Windows, Paseo uses junctions for local directories. File links and network-directory links
|
||||
require Windows symbolic-link support. It never silently copies an explicit `symlink <path>`
|
||||
entry; enable Developer Mode or switch that entry to `copy <path>` if link creation fails.
|
||||
|
||||
Archiving removes only the worktree's links, not their source targets. If the source path is
|
||||
later moved or deleted, a symlink becomes broken; Paseo does not repair it automatically.
|
||||
|
||||
## Scripts and services
|
||||
|
||||
`scripts` are named commands you can run inside a worktree on demand. Mark one as a _service_ and Paseo supervises it as a long-running process, assigns it a port, and routes HTTP traffic to it through the daemon's reverse proxy.
|
||||
|
||||
Reference in New Issue
Block a user