refactor(creation): give legacy and modern requests one identity owner

This commit is contained in:
Mohamed Boudra
2026-09-14 13:17:28 +02:00
parent 0b5141cc49
commit 94a71acce7
18 changed files with 556 additions and 617 deletions
+5 -2
View File
@@ -327,8 +327,11 @@ the app keeps its existing disk-ready navigation and observes the remaining crea
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
identities, not ready workspace records.
The creation journal (`server/creation/`) keeps cumulative milestones and permanent IDs
under an operation kind and idempotency key. Reconnect subscribes to that key; retries
The creation journal (`server/creation/`) owns identity and execution for both legacy and
modern creation RPCs. Requesting progress never selects a different journal. Existing
agent receipts are imported at this boundary; message delivery receipts remain separate.
It keeps cumulative milestones and permanent IDs under an operation kind and idempotency
key. Reconnect subscribes to that key; retries
join active work or reuse committed stages. A persisted resource alone cannot prove
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
receipt return an unknown outcome instead of being repeated.
@@ -797,6 +797,11 @@ interface WorkspaceDraftSubmissionConfig {
target: WorkspaceTabTarget;
}
interface WorkspaceCreationResult {
workspace: ReturnType<typeof normalizeWorkspaceDescriptor>;
agent?: AgentSnapshotPayload;
}
async function createMultiplicityWorkspace(input: {
idempotencyKey: string;
worktreeSlug: string;
@@ -808,7 +813,6 @@ async function createMultiplicityWorkspace(input: {
withInitialAgent: boolean;
agent?: CreateWorkspaceRequestOptions["agent"];
onEvent?: (snapshot: CreationSnapshot) => void;
onAgentCreated?: (agent: AgentSnapshotPayload) => void;
prompt: string;
attachments: AgentAttachment[];
mergeWorkspaces: (
@@ -817,7 +821,7 @@ async function createMultiplicityWorkspace(input: {
) => void;
serverId: string;
createFailedMessage: string;
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
}): Promise<WorkspaceCreationResult> {
const projectId = getHostProjectId(input.project, input.serverId);
if (!projectId) throw new Error("Project is not available on the selected host");
const isWorktree = input.isolation === "worktree";
@@ -847,13 +851,12 @@ async function createMultiplicityWorkspace(input: {
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? input.createFailedMessage);
}
if (payload.agent) input.onAgentCreated?.(payload.agent);
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
const workspaceForInitialMerge = input.withInitialAgent
? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() }
: normalizedWorkspace;
input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]);
return normalizedWorkspace;
return { workspace: normalizedWorkspace, agent: payload.agent };
}
interface CreateChatAgentInput {
@@ -867,8 +870,7 @@ interface CreateChatAgentInput {
withInitialAgent: boolean;
agent?: CreateWorkspaceRequestOptions["agent"];
onEvent?: (snapshot: CreationSnapshot) => void;
onAgentCreated?: (agent: AgentSnapshotPayload) => void;
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
}) => Promise<WorkspaceCreationResult>;
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
@@ -965,14 +967,6 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
format: attachmentSubmitFormat,
});
const images = await encodeImages(wirePayload.images);
let resolveAgent!: (agent: AgentSnapshotPayload) => void;
let rejectAgent!: (error: unknown) => void;
const result = new Promise<AgentSnapshotPayload>((resolve, reject) => {
resolveAgent = resolve;
rejectAgent = reject;
});
// The destination may mount after creation finishes or fails.
void result.catch(() => undefined);
let navigated = false;
let outcome: SubmitOutcome = "background";
const initialAgent: NonNullable<CreateWorkspaceRequestOptions["agent"]> = {
@@ -990,16 +984,12 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined,
};
const execute = async (requestedAgent = initialAgent): Promise<AgentSnapshotPayload> => {
let createdAgent: AgentSnapshotPayload | undefined;
await ensureWorkspace({
const { agent } = await ensureWorkspace({
cwd,
prompt: text,
attachments: workspaceNamingAttachments,
withInitialAgent: true,
agent: requestedAgent,
onAgentCreated: (agent) => {
createdAgent = agent;
},
onEvent: (snapshot) => {
if (!snapshot.workspace || navigated) return;
navigated = true;
@@ -1035,11 +1025,11 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
});
},
});
if (!createdAgent) throw new Error("Workspace creation returned no agent");
return createdAgent;
if (!agent) throw new Error("Workspace creation returned no agent");
return agent;
};
const agentCreation = {
result,
result: Promise.resolve().then(() => execute()),
retry: (request: CreateAgentRequestOptions) =>
execute({
...initialAgent,
@@ -1050,14 +1040,9 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
attachments: request.attachments,
}),
};
try {
resolveAgent(await execute());
if (outcome === "background") clearConsumedDraft();
return outcome;
} catch (error) {
rejectAgent(error);
throw error;
}
await agentCreation.result;
if (outcome === "background") clearConsumedDraft();
return outcome;
}
function buildComposerConfig(input: {
@@ -1681,9 +1666,9 @@ export function NewWorkspaceScreen({
worktreeSlug: createNameId(),
}));
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
> | null>(null);
const [creationResult, setCreationResult] = useState<
WorkspaceCreationResult | { workspace: null }
>({ workspace: null });
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "terminal" | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
@@ -1740,7 +1725,7 @@ export function NewWorkspaceScreen({
return () => clearTimeout(timer);
}, [pickerSearchQuery]);
const workspace = createdWorkspace;
const { workspace } = creationResult;
const client = useHostRuntimeClient(selectedServerId);
const isConnected = useHostRuntimeIsConnected(selectedServerId);
const {
@@ -2049,10 +2034,9 @@ export function NewWorkspaceScreen({
withInitialAgent: boolean;
agent?: CreateWorkspaceRequestOptions["agent"];
onEvent?: (snapshot: CreationSnapshot) => void;
onAgentCreated?: (agent: AgentSnapshotPayload) => void;
}) => {
if (createdWorkspace) {
return createdWorkspace;
if (creationResult.workspace) {
return creationResult;
}
if (!selectedProject) {
throw new Error("Choose a project");
@@ -2088,17 +2072,16 @@ export function NewWorkspaceScreen({
attachments: input.attachments,
agent: input.agent,
onEvent: input.onEvent,
onAgentCreated: input.onAgentCreated,
mergeWorkspaces,
serverId: selectedServerId,
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
});
setCreatedWorkspace(normalizedWorkspace);
setCreationResult(normalizedWorkspace);
return normalizedWorkspace;
},
[
creationIdentity,
createdWorkspace,
creationResult,
effectiveIsolation,
mergeWorkspaces,
queryClient,
@@ -2123,7 +2106,7 @@ export function NewWorkspaceScreen({
let outcome: SubmitOutcome = "background";
await runCreateEmptyWorkspace({
payload,
ensureWorkspace,
ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace,
serverId: selectedServerId,
navigate: (targetServerId, workspaceId) => {
if (!isStillOnCreateScreen()) {
@@ -2200,7 +2183,7 @@ export function NewWorkspaceScreen({
prompt: terminalPromptText,
profile: selectedTerminalProfile,
profileName: selectedTerminalProfile?.name,
ensureWorkspace,
ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace,
createTerminal: async (input) => {
const connectedClient = withConnectedClient();
const createdTerminal = await connectedClient.createTerminal(
@@ -1,3 +1,7 @@
import { execFileSync } from "node:child_process";
import type { z } from "zod";
import { WebSocket } from "ws";
import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -121,3 +125,162 @@ test("creation progresses before agent readiness and continues after the disconn
await rm(directory, { recursive: true, force: true });
}
}, 60000);
async function connectCreationPeer(port: number) {
const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`);
const frames: SessionOutboundMessage[] = [];
socket.on("message", (data) => {
const frame = WSOutboundMessageSchema.parse(JSON.parse(data.toString()));
if (frame.type === "session") frames.push(frame.message);
});
await new Promise<void>((resolve, reject) => {
socket.once("open", resolve);
socket.once("error", reject);
});
socket.send(
JSON.stringify({
type: "hello",
clientType: "browser",
clientId: randomUUID(),
protocolVersion: 1,
appVersion: "0.8.0",
}),
);
await expect
.poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info"))
.toBe(true);
return {
close: () => socket.close(),
request: async (
message: z.input<typeof SessionInboundMessageSchema> & { requestId: string },
) => {
socket.send(JSON.stringify({ type: "session", message }));
const response = () =>
frames.find(
(m) =>
"payload" in m && "requestId" in m.payload && m.payload.requestId === message.requestId,
);
await expect.poll(response).toBeDefined();
return response()!;
},
};
}
test.each([false, true])(
"workspace identity does not depend on subscribing (first subscribe=%s)",
async (subscribe) => {
const directory = await mkdtemp(join(tmpdir(), "creation-identity-"));
const daemon = await createTestPaseoDaemon();
const peer = await connectCreationPeer(daemon.port);
try {
const request = {
type: "workspace.create.request" as const,
source: { kind: "directory" as const, path: directory },
idempotencyKey: "same-workspace",
};
const first = await peer.request({ ...request, requestId: "first", subscribe });
const replay = await peer.request({ ...request, requestId: "replay", subscribe: !subscribe });
if (first.type !== "workspace.create.response" || replay.type !== "workspace.create.response")
throw new Error("Expected workspace responses");
expect(first.payload.error).toBeNull();
expect(replay.payload.error).toBeNull();
expect(replay.payload.workspace?.id).toBe(first.payload.workspace?.id);
} finally {
peer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
},
60000,
);
test.each(["create_agent_request", "agent.create.request"] as const)(
"legacy and modern agent RPCs share one creation identity (first %s)",
async (type) => {
const directory = await mkdtemp(join(tmpdir(), "creation-agent-identity-"));
let creations = 0;
const daemon = await createTestPaseoDaemon({
agentClients: createTestAgentClients({
beforeCreateSession: async () => {
creations++;
},
}),
});
const peer = await connectCreationPeer(daemon.port);
try {
const request = {
config: { provider: "codex", cwd: directory },
idempotencyKey: "same-agent",
};
const first = await peer.request({ ...request, type, requestId: "first" });
const replay = await peer.request({
...request,
type: type === "agent.create.request" ? "create_agent_request" : "agent.create.request",
requestId: "replay",
});
const createdAgent = (message: SessionOutboundMessage) => {
if (message.type === "agent.create.response") {
expect(message.payload.error).toBeNull();
return message.payload.agent;
}
if (message.type === "status" && message.payload.status === "agent_created")
return message.payload.agent;
throw new Error(`Unexpected creation result ${JSON.stringify(message)}`);
};
expect(createdAgent(replay)?.id).toBe(createdAgent(first)?.id);
const agentId = createdAgent(first)!.id;
await daemon.daemon.agentManager.setTitle(agentId, "Updated after creation");
await daemon.daemon.agentManager.flush();
const legacyReplay = await peer.request({
...request,
type: "create_agent_request",
requestId: "legacy-after-update",
});
expect(createdAgent(legacyReplay)?.title).toBe("Updated after creation");
expect(creations).toBe(1);
} finally {
peer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
},
60000,
);
test("legacy keyed creation preserves checkout error codes", async () => {
const directory = await mkdtemp(join(tmpdir(), "creation-checkout-error-"));
execFileSync("git", ["init", "-b", "main", directory], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: directory, stdio: "pipe" },
);
const daemon = await createTestPaseoDaemon({ agentClients: createTestAgentClients() });
const peer = await connectCreationPeer(daemon.port);
try {
const result = await peer.request({
type: "create_agent_request",
requestId: "missing-branch",
idempotencyKey: "missing-branch",
config: { provider: "codex", cwd: directory },
worktree: { mode: "checkout-branch", branch: "does-not-exist" },
});
expect(result).toMatchObject({
type: "status",
payload: { status: "agent_create_failed", errorCode: "unknown_branch" },
});
} finally {
peer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60000);
@@ -1,4 +1,5 @@
import { cp, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
@@ -249,3 +250,44 @@ test("partial provisioning without a registered workspace is not repeated", asyn
expect(await new CreationService(f.directory).create(f.input)).toEqual(result);
expect(provisions).toBe(1);
});
test.each(["pending", "completed"])(
"imports a %s legacy agent receipt without creating another agent",
async (state) => {
const f = await fixture();
const legacyDirectory = join(f.directory, "legacy");
await mkdir(legacyDirectory);
const request = { config: { cwd: "/project", provider: "codex" } };
const hash = (value: unknown) =>
createHash("sha256").update(JSON.stringify(value)).digest("hex");
await writeFile(
join(legacyDirectory, `${hash(["create", "old-key"])}.json`),
JSON.stringify({
fingerprint: hash({ ...request, type: "create_agent_request" }),
state,
agentId: agent.id,
}),
);
const input: CreationInput = {
kind: "agent",
key: "old-key",
request,
hasAgent: true,
hasPrompt: false,
exists: async () => true,
readAgent: async () => agent,
createAgent: async () => {
throw new Error("Must not create again");
},
};
const service = new CreationService(f.directory, undefined, legacyDirectory);
await expect(
service.create({ ...input, request: { config: { ...request.config, cwd: "/changed" } } }),
).rejects.toThrow("agent_request_key_conflict");
expect(await service.create(input)).toMatchObject({ phase: "completed", agent });
expect(await new CreationService(f.directory).create(input)).toMatchObject({
phase: "completed",
agent,
});
},
);
+43 -4
View File
@@ -12,10 +12,9 @@ import { writeJsonFileAtomic } from "../atomic-file.js";
import { generateWorkspaceId } from "../workspace-registry-model.js";
type Observer = (snapshot: CreationSnapshot) => void;
export interface CreationInput {
kind: "workspace" | "agent";
interface CreationRequest {
key: string;
request: unknown;
request: { [key: string]: unknown };
workspaceId?: string;
agentId?: string;
hasAgent: boolean;
@@ -30,6 +29,11 @@ export interface CreationInput {
onReady: (agent: AgentSnapshotPayload) => Promise<void>,
) => Promise<AgentSnapshotPayload>;
}
export type CreationInput = CreationRequest &
(
| { kind: "workspace" }
| { kind: "agent"; readAgent: (id: string) => Promise<AgentSnapshotPayload | null> }
);
const RecordSchema = z.object({
fingerprint: z.string(),
snapshot: CreationSnapshotSchema,
@@ -47,14 +51,16 @@ export class CreationService {
private readonly validateCompleted: (
snapshot: CreationSnapshot,
) => Promise<void> = async () => {},
private readonly legacyDirectory?: string,
) {}
async create(input: CreationInput, observer?: Observer): Promise<CreationSnapshot> {
const identity = identityFor(input.kind, input.key);
const admitted = this.admission.then(async () => {
await mkdir(this.directory, { recursive: true });
let record = await this.read(identity);
const fingerprint = digest(input.request);
let record = await this.read(identity);
if (!record) record = await this.readLegacyAgent(input, fingerprint);
if (record && record.fingerprint !== fingerprint)
throw new Error(`${input.kind}_request_key_conflict`);
const running = this.active.get(identity);
@@ -204,6 +210,39 @@ export class CreationService {
return record.snapshot;
}
// COMPAT(agentRequestReceipts): added in v0.8.0, remove after 2027-03-11 once old creation receipts can expire.
// Import the previous receipt format; it is never a second execution path.
private async readLegacyAgent(input: CreationInput, fingerprint: string): Promise<Record | null> {
if (input.kind !== "agent" || !this.legacyDirectory) return null;
const text = await readOptional(
join(this.legacyDirectory, `${digest(["create", input.key])}.json`),
);
if (text === null) return null;
const receipt = z
.object({
fingerprint: z.string(),
state: z.enum(["pending", "completed"]),
agentId: z.string(),
})
.parse(JSON.parse(text));
if (receipt.fingerprint !== digest({ type: "create_agent_request", ...input.request }))
throw new Error("agent_request_key_conflict");
const agent = await input.readAgent(receipt.agentId);
if (!agent && receipt.state === "completed")
throw new Error("Previously created agent no longer exists");
const record = initialRecord({ ...input, agentId: receipt.agentId }, fingerprint);
record.snapshot = {
...record.snapshot,
phase: agent ? "completed" : "failed",
agent: agent ?? undefined,
workspaceId: agent?.workspaceId ?? record.snapshot.workspaceId,
error: agent ? null : "agent_request_outcome_unknown",
...(agent ? {} : { failedStage: "agent", outcomeUnknown: true }),
};
await this.write(identityFor(input.kind, input.key), record);
return record;
}
private async claimResources(
identity: string,
snapshot: CreationSnapshot,
@@ -0,0 +1,77 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import { MessageReceipts } from "./index.js";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});
async function fixture() {
const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-"));
directories.push(directory);
return { directory, requests: new MessageReceipts(directory) };
}
test("message retries survive reconstruction without submitting twice", async () => {
const { requests, directory } = await fixture();
let deliveries = 0;
const input = {
agentId: "agent",
messageId: "arrival",
request: { text: "hello" },
send: async () => {
deliveries++;
},
};
await Promise.all([requests.send(input), requests.send(input)]);
await new MessageReceipts(directory).send(input);
expect(deliveries).toBe(1);
await requests.send({ ...input, agentId: "another" });
expect(deliveries).toBe(2);
});
test("ambiguous provider delivery is never blindly replayed after restart", async () => {
const { requests, directory } = await fixture();
let deliveries = 0;
const input = {
agentId: "agent",
messageId: "arrival",
request: {},
send: async () => {
deliveries++;
throw new Error("connection lost");
},
};
await expect(requests.send(input)).rejects.toThrow("connection lost");
await expect(new MessageReceipts(directory).send(input)).rejects.toThrow(
"agent_request_outcome_unknown",
);
expect(deliveries).toBe(1);
});
test("failed local message preparation does not leave an ambiguous receipt", async () => {
const { requests, directory } = await fixture();
let available = false;
let sends = 0;
const input = {
agentId: "agent",
messageId: "message",
request: {},
prepare: async () => {
if (!available) throw new Error("load failed");
},
send: async () => {
sends++;
},
};
await expect(requests.send(input)).rejects.toThrow("load failed");
available = true;
await new MessageReceipts(directory).send(input);
available = false;
await requests.send(input);
expect(sends).toBe(1);
});
@@ -0,0 +1,81 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { writeJsonFileAtomic } from "../atomic-file.js";
const ReceiptSchema = z.object({
fingerprint: z.string(),
state: z.enum(["pending", "completed"]),
agentId: z.string(),
});
interface SendMessageInput {
agentId: string;
messageId: string;
request: unknown;
send: () => Promise<void>;
prepare?: () => Promise<void>;
}
/** Owns message delivery receipts; creation is owned by CreationService. */
export class MessageReceipts {
private readonly pending = new Map<string, Promise<void>>();
constructor(private readonly directory: string) {}
send(input: SendMessageInput): Promise<void> {
// Preserve the existing on-disk identity and shape across daemon upgrades.
const key = digest(["send", input.agentId, input.messageId]);
const previous = this.pending.get(key);
const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() =>
this.sendOnce(key, input),
);
this.pending.set(key, result);
void result
.finally(() => {
if (this.pending.get(key) === result) this.pending.delete(key);
})
.catch(() => undefined);
return result;
}
private async sendOnce(key: string, input: SendMessageInput): Promise<void> {
const file = path.join(this.directory, `${key}.json`);
const fingerprint = digest(input.request);
const existing = await readReceipt(file);
if (existing) {
if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict");
if (existing.state === "completed") return;
// A provider may have accepted the message before its receipt was committed.
throw new Error("agent_request_outcome_unknown");
}
await input.prepare?.();
const receipt = { fingerprint, agentId: input.agentId };
await writeJsonFileAtomic(file, { ...receipt, state: "pending" });
await input.send();
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
}
}
async function readReceipt(file: string): Promise<z.infer<typeof ReceiptSchema> | null> {
try {
return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8")));
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
throw error;
}
}
function digest(value: unknown): string {
return createHash("sha256")
.update(
JSON.stringify(value, (_key, candidate: unknown) => {
if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) {
return Object.fromEntries(
Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)),
);
}
return candidate;
}),
)
.digest("hex");
}
@@ -1,201 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import { RequestReceipts } from "./index.js";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});
async function fixture() {
const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-"));
directories.push(directory);
return { directory, requests: new RequestReceipts(directory) };
}
test("concurrent and reconstructed creates return the same durable agent", async () => {
const { requests, directory } = await fixture();
const agents = new Set<string>();
let creations = 0;
const input = {
key: "create-1",
request: { provider: "test", cwd: "/project" },
findAgent: async (id: string) => agents.has(id),
create: async (id: string) => {
creations++;
agents.add(id);
},
};
const ids = await Promise.all([requests.createAgent(input), requests.createAgent(input)]);
expect(ids[0]).toBe(ids[1]);
expect(await new RequestReceipts(directory).createAgent(input)).toBe(ids[0]);
expect(creations).toBe(1);
});
test("recovers creation when the agent was persisted before acknowledgement failed", async () => {
const { requests, directory } = await fixture();
const agents = new Set<string>();
const input = {
key: "lost-create",
request: {},
findAgent: async (id: string) => agents.has(id),
create: async (id: string) => {
agents.add(id);
throw new Error("acknowledgement lost");
},
};
await expect(requests.createAgent(input)).rejects.toThrow("acknowledgement lost");
expect(await new RequestReceipts(directory).createAgent(input)).toBe([...agents][0]);
expect(agents.size).toBe(1);
});
test("reusing a create key with different configuration is a conflict", async () => {
const { requests } = await fixture();
const input = {
key: "key",
request: { model: "a" },
findAgent: async () => true,
create: async () => {},
};
await requests.createAgent(input);
await expect(requests.createAgent({ ...input, request: { model: "b" } })).rejects.toThrow(
"agent_request_key_conflict",
);
});
test("message retries survive reconstruction without submitting twice", async () => {
const { requests, directory } = await fixture();
let deliveries = 0;
const input = {
agentId: "agent",
messageId: "arrival",
request: { text: "hello" },
send: async () => {
deliveries++;
},
};
await Promise.all([requests.sendMessage(input), requests.sendMessage(input)]);
await new RequestReceipts(directory).sendMessage(input);
expect(deliveries).toBe(1);
await requests.sendMessage({ ...input, agentId: "another" });
expect(deliveries).toBe(2);
});
test("ambiguous provider delivery is never blindly replayed after restart", async () => {
const { requests, directory } = await fixture();
let deliveries = 0;
const input = {
agentId: "agent",
messageId: "arrival",
request: {},
send: async () => {
deliveries++;
throw new Error("connection lost");
},
};
await expect(requests.sendMessage(input)).rejects.toThrow("connection lost");
await expect(new RequestReceipts(directory).sendMessage(input)).rejects.toThrow(
"agent_request_outcome_unknown",
);
expect(deliveries).toBe(1);
});
test("a creation failure with no stored agent can be retried", async () => {
const { requests } = await fixture();
let available = false;
const input = {
key: "unavailable",
request: {},
findAgent: async () => false,
create: async () => {
if (!available) throw new Error("provider unavailable");
},
};
await expect(requests.createAgent(input)).rejects.toThrow("provider unavailable");
available = true;
await expect(requests.createAgent(input)).resolves.toEqual(expect.any(String));
});
test("failed local message preparation does not leave an ambiguous receipt", async () => {
const { requests, directory } = await fixture();
let available = false;
let sends = 0;
const input = {
agentId: "agent",
messageId: "message",
request: {},
prepare: async () => {
if (!available) throw new Error("load failed");
},
send: async () => {
sends++;
},
};
await expect(requests.sendMessage(input)).rejects.toThrow("load failed");
available = true;
await new RequestReceipts(directory).sendMessage(input);
available = false;
await requests.sendMessage(input);
expect(sends).toBe(1);
});
test("workspace receipts serialize concurrent requests and survive reconstruction", async () => {
const { requests, directory } = await fixture();
const workspaces = new Set<string>();
let creations = 0;
const input = {
key: "workspace",
workspaceId: "wks_first",
request: { source: { kind: "directory", path: "/project" } },
findWorkspace: async (id: string) => workspaces.has(id),
create: async (id: string) => {
creations++;
workspaces.add(id);
},
};
expect(
await Promise.all([
requests.createWorkspace(input),
requests.createWorkspace({ ...input, workspaceId: "wks_second" }),
]),
).toEqual(["wks_first", "wks_first"]);
expect(await new RequestReceipts(directory).createWorkspace(input)).toBe("wks_first");
expect(creations).toBe(1);
await expect(
requests.createWorkspace({
...input,
request: { source: { kind: "directory", path: "/other" } },
}),
).rejects.toThrow("workspace_request_key_conflict");
expect(
await requests.createAgent({
key: input.key,
request: {},
findAgent: async () => false,
create: async () => {},
}),
).not.toBe("wks_first");
});
test("a workspace record left by failed provisioning is not mistaken for completion", async () => {
const { requests, directory } = await fixture();
const workspaces = new Set<string>();
const input = {
key: "partial-workspace",
workspaceId: "wks_created",
request: {},
findWorkspace: async (id: string) => workspaces.has(id),
create: async (id: string) => {
workspaces.add(id);
throw new Error("checkout rolled back");
},
};
await expect(requests.createWorkspace(input)).rejects.toThrow("checkout rolled back");
await expect(new RequestReceipts(directory).createWorkspace(input)).rejects.toThrow(
"workspace_request_outcome_unknown",
);
expect([...workspaces]).toEqual(["wks_created"]);
});
@@ -1,186 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { writeJsonFileAtomic } from "../atomic-file.js";
const ReceiptStateSchema = z.object({
fingerprint: z.string(),
state: z.enum(["pending", "completed"]),
});
// Preserve existing agent receipt files while giving workspaces their own identity.
const ReceiptSchema = z.union([
ReceiptStateSchema.extend({ agentId: z.string() }),
ReceiptStateSchema.extend({ workspaceId: z.string() }),
]);
type Receipt = z.infer<typeof ReceiptStateSchema> & {
resource: { kind: "agent" | "workspace"; id: string };
};
interface CreateAgentInput {
key: string;
request: unknown;
findAgent: (agentId: string) => Promise<boolean>;
create: (agentId: string) => Promise<void>;
}
interface CreateWorkspaceInput {
key: string;
request: unknown;
workspaceId: string;
findWorkspace: (workspaceId: string) => Promise<boolean>;
create: (workspaceId: string) => Promise<void>;
}
interface SendMessageInput {
agentId: string;
messageId: string;
request: unknown;
send: () => Promise<void>;
prepare?: () => Promise<void>;
}
/** One daemon-owned request journal, shared by all of its socket sessions. */
export class RequestReceipts {
private readonly pending = new Map<string, Promise<string>>();
constructor(private readonly directory: string) {}
createAgent(input: CreateAgentInput): Promise<string> {
return this.execute(["create", input.key], input.request, {
resource: { kind: "agent", id: randomUUID() },
recover: input.findAgent,
run: input.create,
retrySafe: async (agentId) => !(await input.findAgent(agentId)),
});
}
createWorkspace(input: CreateWorkspaceInput): Promise<string> {
return this.execute(["create-workspace", input.key], input.request, {
resource: { kind: "workspace", id: input.workspaceId },
// A registry entry can survive failed provisioning and checkout rollback.
// Only a completed receipt proves that the entire operation succeeded.
recover: async () => false,
run: input.create,
retrySafe: async (workspaceId) => !(await input.findWorkspace(workspaceId)),
});
}
async sendMessage(input: SendMessageInput): Promise<void> {
await this.execute(["send", input.agentId, input.messageId], input.request, {
resource: { kind: "agent", id: input.agentId },
// A provider call can take effect before the daemon records its outcome.
// Never repeat that call merely because a process died in this window.
recover: async () => false,
run: input.send,
prepare: input.prepare,
});
}
private execute(
identity: string[],
request: unknown,
operation: {
resource: Receipt["resource"];
recover: (agentId: string) => Promise<boolean>;
run: (agentId: string) => Promise<void>;
prepare?: (() => Promise<void>) | undefined;
retrySafe?: (agentId: string) => Promise<boolean>;
},
): Promise<string> {
const key = digest(identity);
const fingerprint = digest(request);
const previous = this.pending.get(key);
// Serialize even conflicting requests: each caller validates its own fingerprint.
const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() =>
this.executeOnce(key, fingerprint, operation),
);
this.pending.set(key, result);
void result
.finally(() => {
if (this.pending.get(key) === result) this.pending.delete(key);
})
.catch(() => undefined);
return result;
}
private async executeOnce(
key: string,
fingerprint: string,
operation: {
resource: Receipt["resource"];
recover: (agentId: string) => Promise<boolean>;
run: (agentId: string) => Promise<void>;
prepare?: (() => Promise<void>) | undefined;
retrySafe?: (agentId: string) => Promise<boolean>;
},
): Promise<string> {
const file = path.join(this.directory, `${key}.json`);
const existing = await readReceipt(file);
if (existing) {
if (existing.fingerprint !== fingerprint)
throw new Error(`${operation.resource.kind}_request_key_conflict`);
if (existing.state === "completed") return existing.resource.id;
if (!(await operation.recover(existing.resource.id))) {
throw new Error(`${operation.resource.kind}_request_outcome_unknown`);
}
await writeReceipt(file, { ...existing, state: "completed" });
return existing.resource.id;
}
await operation.prepare?.();
const receipt: Receipt = { fingerprint, resource: operation.resource, state: "pending" };
await writeReceipt(file, receipt);
try {
await operation.run(receipt.resource.id);
} catch (error) {
// Once creation cleanup finished, absence of its persisted resource
// confirms that retrying cannot duplicate it.
if (await operation.retrySafe?.(receipt.resource.id)) await rm(file, { force: true });
throw error;
}
await writeReceipt(file, { ...receipt, state: "completed" });
return receipt.resource.id;
}
}
async function readReceipt(file: string): Promise<Receipt | null> {
try {
const stored = ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8")));
return {
fingerprint: stored.fingerprint,
state: stored.state,
resource:
"agentId" in stored
? { kind: "agent", id: stored.agentId }
: { kind: "workspace", id: stored.workspaceId },
};
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
throw error;
}
}
async function writeReceipt(file: string, receipt: Receipt): Promise<void> {
await writeJsonFileAtomic(file, {
fingerprint: receipt.fingerprint,
state: receipt.state,
...(receipt.resource.kind === "agent"
? { agentId: receipt.resource.id }
: { workspaceId: receipt.resource.id }),
});
}
function digest(value: unknown): string {
return createHash("sha256")
.update(
JSON.stringify(value, (_key, candidate: unknown) => {
if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) {
return Object.fromEntries(
Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)),
);
}
return candidate;
}),
)
.digest("hex");
}
+4 -4
View File
@@ -1,6 +1,6 @@
import {
createRequestReceiptsStub,
createCreationServiceStub,
createMessageReceiptsStub,
createTestCreationService,
} from "./test-utils/session-stubs.js";
import { execSync } from "child_process";
import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
@@ -367,8 +367,8 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
const messages = options.messages ?? [];
const sessionOptions: SessionOptions = {
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: options.clientId ?? "test-client",
onMessage: (message) => messages.push(message),
...(options.targetedMessages
+52 -114
View File
@@ -5,7 +5,7 @@ import { relative } from "node:path";
import { isAbsolute } from "node:path";
import { CreationService } from "./creation/index.js";
import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages";
import type { RequestReceipts } from "./request-receipts/index.js";
import type { MessageReceipts } from "./message-receipts/index.js";
import equal from "fast-deep-equal";
import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js";
import { v4 as uuidv4 } from "uuid";
@@ -151,7 +151,6 @@ import {
checkoutLiteFromGitSnapshot,
checkoutFromPersistedWorkspacePlacement,
deriveWorkspaceDisplayName,
generateWorkspaceId,
} from "./workspace-registry-model.js";
import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js";
import {
@@ -206,7 +205,6 @@ import {
} from "./session/git-mutation/git-mutation-service.js";
import {
createWorkspaceProvisioningService,
WorkspaceProvisioningError,
type WorkspaceProvisioningService,
} from "./session/workspace-provisioning/workspace-provisioning-service.js";
import {
@@ -463,7 +461,7 @@ export interface SessionOptions {
worktreesRoot?: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
requestReceipts: Pick<RequestReceipts, "createAgent" | "createWorkspace" | "sendMessage">;
messageReceipts: Pick<MessageReceipts, "send">;
creationService: Pick<CreationService, "create" | "subscribe">;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
@@ -796,10 +794,7 @@ export class Session {
private readonly daemonSession: DaemonSession;
private readonly hubExecutionController: HubExecutionController | null;
private readonly workspaceScripts: WorkspaceScriptsService;
private readonly requestReceipts: Pick<
RequestReceipts,
"createAgent" | "createWorkspace" | "sendMessage"
>;
private readonly messageReceipts: Pick<MessageReceipts, "send">;
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
private readonly creationService: Pick<CreationService, "create" | "subscribe">;
@@ -875,7 +870,7 @@ export class Session {
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
this.pushNotifications = pushNotifications;
this.paseoHome = paseoHome;
this.requestReceipts = options.requestReceipts;
this.messageReceipts = options.messageReceipts;
this.creationService = options.creationService;
this.projectIcons = new ProjectIconReader(paseoHome);
this.worktreesRoot = worktreesRoot;
@@ -2667,9 +2662,7 @@ export class Session {
case "creation.subscribe.request":
return this.handleCreationSubscription(msg, source);
case "workspace.create.request":
if (msg.subscribe || msg.agent || msg.workspaceId)
return this.handleWorkspaceCreation(msg, source);
return undefined;
return this.handleWorkspaceCreation(msg, source);
default:
return undefined;
}
@@ -2872,8 +2865,6 @@ export class Session {
return this.handleArchiveWorkspaceRequest(msg);
case "project.remove.request":
return this.handleProjectRemoveRequest(msg);
case "workspace.create.request":
return this.handleWorkspaceCreateRequest(msg);
case "workspace.title.set.request":
return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
case "workspace.pin.set.request":
@@ -3923,8 +3914,43 @@ export class Session {
? (await this.workspaceRegistry.get(id)) !== null
: this.agentManager.getAgent(id) != null || (await this.agentStorage.get(id)) !== null;
private createRequestedAgent(
request: AgentCreateRequest,
observer?: (snapshot: CreationSnapshot) => void,
): Promise<CreationSnapshot> {
const { requestId, type: _type, subscribe: _subscribe, idempotencyKey, ...intent } = request;
return this.creationService.create(
{
kind: "agent",
key: idempotencyKey ?? requestId,
request: intent,
workspaceId: request.workspaceId,
agentId: request.agentId,
hasAgent: true,
hasPrompt: hasInitialCreationPrompt(request),
exists: this.creationResourceExists,
readAgent: async (id) => {
const record = await this.agentStorage.get(id);
return record ? this.buildStoredAgentPayload(record) : null;
},
createAgent: async (id, _workspace, onReady) => {
try {
return await this.createSessionAgent(
{ ...request, type: "create_agent_request" },
id,
onReady,
);
} catch (error) {
throw new WorktreeRequestError(toWorktreeWireError(error));
}
},
},
observer,
);
}
private async handleAgentCreation(request: AgentCreateRequest, source?: object): Promise<void> {
const { requestId, subscribe, type: _type, idempotencyKey, ...intent } = request;
const { requestId, subscribe } = request;
const progress = subscribe
? this.delivery.operation(
(message) =>
@@ -3933,19 +3959,8 @@ export class Session {
)
: undefined;
try {
const creation = await this.creationService.create(
{
kind: "agent",
key: idempotencyKey ?? requestId,
request: intent,
workspaceId: request.workspaceId,
agentId: request.agentId,
hasAgent: true,
hasPrompt: hasInitialCreationPrompt(request),
exists: this.creationResourceExists,
createAgent: (id, _workspace, onReady) =>
this.createSessionAgent({ ...request, type: "create_agent_request" }, id, onReady),
},
const creation = await this.createRequestedAgent(
request,
progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined,
);
this.emitForSource(
@@ -4100,18 +4115,13 @@ export class Session {
if (msg.initialPrompt !== undefined) {
throw new Error("Idempotent creation requires sending the initial prompt separately");
}
const { requestId: _requestId, idempotencyKey, ...request } = msg;
const id = await this.requestReceipts.createAgent({
key: idempotencyKey,
request,
findAgent: async (agentId) =>
this.agentManager.getAgent(agentId) != null ||
(await this.agentStorage.get(agentId)) !== null,
create: async (agentId) => {
await this.createSessionAgent(msg, agentId);
},
});
const record = await this.agentStorage.get(id);
const creation = await this.createRequestedAgent({ ...msg, type: "agent.create.request" });
if (creation.error || !creation.agent)
throw new SessionRequestError(
creation.errorCode ?? "unknown",
creation.error ?? "Agent creation failed",
);
const record = await this.agentStorage.get(creation.agent.id);
if (!record) throw new Error("Previously created agent no longer exists");
agent = this.buildStoredAgentPayload(record);
} else {
@@ -4127,7 +4137,7 @@ export class Session {
},
});
} catch (error) {
const wireError = toWorktreeWireError(error);
const wireError = error instanceof SessionRequestError ? error : toWorktreeWireError(error);
this.sessionLogger.error({ err: error }, "Failed to create agent");
this.emit({
type: "status",
@@ -6622,78 +6632,6 @@ export class Session {
}
}
private async handleWorkspaceCreateRequest(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> {
try {
let descriptor: WorkspaceDescriptorPayload | undefined;
if (request.idempotencyKey !== undefined) {
const { requestId: _requestId, idempotencyKey, ...payload } = request;
const workspaceId = await this.requestReceipts.createWorkspace({
key: idempotencyKey,
request: payload,
workspaceId: generateWorkspaceId(),
findWorkspace: async (id) => (await this.workspaceRegistry.get(id)) !== null,
create: async (id) => {
descriptor = await this.createRequestedWorkspace(request, id);
},
});
if (!descriptor) {
const workspace = await this.workspaceRegistry.get(workspaceId);
if (!workspace) throw new Error("Previously created workspace no longer exists");
if (!(await this.filesystem.isDirectory(workspace.cwd))) {
throw new SessionRequestError(
"directory_not_found",
`Directory not found: ${workspace.cwd}`,
);
}
await this.syncWorkspaceGitObserverForWorkspace(workspace);
descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
await this.emitCreatedWorkspaceUpdate(descriptor);
}
} else {
descriptor = await this.createRequestedWorkspace(request);
}
const workspace = await this.workspaceRegistry.get(descriptor.id);
this.emit({
type: "workspace.create.response",
payload: {
requestId: request.requestId,
workspace: descriptor,
setupTerminalId: null,
...(workspace?.untrustedSource
? {
setupSkippedReason: formatWorkspaceAutomationBlockedMessage(
workspace.untrustedSource,
),
}
: {}),
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create workspace";
this.sessionLogger.error(
{ err: error, sourceKind: request.source.kind, requestId: request.requestId },
"Failed to create workspace",
);
const errorCode =
error instanceof WorkspaceProvisioningError || error instanceof SessionRequestError
? error.code
: undefined;
this.emit({
type: "workspace.create.response",
payload: {
requestId: request.requestId,
workspace: null,
setupTerminalId: null,
error: message,
errorCode,
},
});
}
}
private async createRequestedWorkspace(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
workspaceId?: string,
@@ -8198,7 +8136,7 @@ export class Session {
}
};
if (msg.messageId) {
await this.requestReceipts.sendMessage({
await this.messageReceipts.send({
agentId,
messageId: msg.messageId,
request: { prompt, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt" },
@@ -1,6 +1,6 @@
import {
createRequestReceiptsStub,
createCreationServiceStub,
createMessageReceiptsStub,
createTestCreationService,
} from "./test-utils/session-stubs.js";
import { describe, expect, test, vi } from "vitest";
import path from "node:path";
@@ -188,8 +188,8 @@ function createSessionForWorkspaceGitWatchTests(options?: {
};
const session = new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message as { type: string; payload: unknown }),
@@ -1,6 +1,6 @@
import {
createRequestReceiptsStub,
createCreationServiceStub,
createMessageReceiptsStub,
createTestCreationService,
} from "./test-utils/session-stubs.js";
// Invariant tests for cwd → workspace resolution on the open_project_request path.
// Each test encodes a default behavior we want from `findOrCreateWorkspaceForDirectory`.
@@ -92,8 +92,8 @@ function createHarness(input: {
};
const session = new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test",
permissions: OWNER_PERMISSIONS,
appVersion: null,
@@ -1,6 +1,6 @@
import {
createRequestReceiptsStub,
createCreationServiceStub,
createMessageReceiptsStub,
createTestCreationService,
} from "./test-utils/session-stubs.js";
import { execFileSync } from "node:child_process";
import {
@@ -645,8 +645,8 @@ function createSessionForWorkspaceTests(
const session = asTestSession(
new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
appVersion: options.appVersion ?? null,
@@ -1007,8 +1007,8 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
const emitted: SessionOutboundMessage[] = [];
const session = asTestSession(
new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
serverId: "test-server",
permissions: OWNER_PERMISSIONS,
@@ -1162,8 +1162,8 @@ test("create_agent_request launches from an exact subdirectory in a created work
const emitted: SessionOutboundMessage[] = [];
const session = new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
appVersion: null,
@@ -1301,8 +1301,8 @@ test("create_agent_request does not title an existing workspace from the agent p
let generateCalls = 0;
const session = asTestSession(
new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
appVersion: null,
@@ -1572,8 +1572,8 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
const session = asTestSession(
new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message),
@@ -2056,8 +2056,8 @@ test("close_items_request archives agents and kills terminals in one batch", asy
const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const }));
const session = asTestSession(
new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message),
@@ -2226,8 +2226,8 @@ test("close_items_request archives stored agents that are not currently loaded",
const session = asTestSession(
new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message),
@@ -2387,8 +2387,8 @@ test("close_items_request continues after an archive failure", async () => {
const killTerminalBestEffort = vi.fn();
const session = asTestSession(
new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message),
@@ -3660,8 +3660,8 @@ test("workspace update stream keeps persisted workspace visible after agents sto
const session = asTestSession(
new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message),
@@ -1,6 +1,6 @@
import {
createRequestReceiptsStub,
createCreationServiceStub,
createMessageReceiptsStub,
createTestCreationService,
} from "./test-utils/session-stubs.js";
import os from "node:os";
import path from "node:path";
@@ -103,8 +103,8 @@ describe("snapshot mutation ownership boundary", () => {
const session = asInternals<SessionInternals>(
new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage,
@@ -1,5 +1,9 @@
import { randomUUID } from "node:crypto";
import { vi } from "vitest";
import { afterEach, vi } from "vitest";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CreationService } from "../creation/index.js";
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
@@ -281,18 +285,9 @@ export function createProviderSnapshotManagerStub(): {
};
}
export function createRequestReceiptsStub(): SessionOptions["requestReceipts"] {
export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] {
return {
async createAgent(input) {
const agentId = randomUUID();
await input.create(agentId);
return agentId;
},
async createWorkspace(input) {
await input.create(input.workspaceId);
return input.workspaceId;
},
sendMessage: (input) => input.send(),
send: (input) => input.send(),
};
}
@@ -309,13 +304,16 @@ export function createProviderSnapshot(
};
}
export function createCreationServiceStub(): SessionOptions["creationService"] {
return {
create: async () => {
throw new Error("Unexpected creation in legacy session fixture");
},
subscribe: async () => {
throw new Error("Unexpected creation subscription in legacy session fixture");
},
};
const creationDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
creationDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
export function createTestCreationService(): SessionOptions["creationService"] {
const directory = join(tmpdir(), `session-creation-${randomUUID()}`);
creationDirectories.push(directory);
return new CreationService(directory);
}
@@ -1,7 +1,7 @@
import { stat } from "node:fs/promises";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import { CreationService } from "./creation/index.js";
import { RequestReceipts } from "./request-receipts/index.js";
import { MessageReceipts } from "./message-receipts/index.js";
import { WebSocket, WebSocketServer } from "ws";
import type { IncomingMessage, Server as HTTPServer } from "http";
import { join } from "path";
@@ -532,7 +532,7 @@ export class VoiceAssistantWebSocketServer {
private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly requestReceipts: RequestReceipts;
private readonly messageReceipts: MessageReceipts;
private readonly creationService: CreationService;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
@@ -672,9 +672,11 @@ export class VoiceAssistantWebSocketServer {
this.orchestrationSkills = orchestrationSkills;
this.agentManager = agentManager;
this.agentStorage = agentStorage;
this.requestReceipts = new RequestReceipts(join(paseoHome, "agent-requests"));
this.creationService = new CreationService(join(paseoHome, "creations"), (snapshot) =>
this.validateCompletedCreation(snapshot),
this.messageReceipts = new MessageReceipts(join(paseoHome, "agent-requests"));
this.creationService = new CreationService(
join(paseoHome, "creations"),
(snapshot) => this.validateCompletedCreation(snapshot),
join(paseoHome, "agent-requests"),
);
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
@@ -1451,7 +1453,7 @@ export class VoiceAssistantWebSocketServer {
worktreesRoot: this.worktreesRoot,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
requestReceipts: this.requestReceipts,
messageReceipts: this.messageReceipts,
creationService: this.creationService,
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
@@ -1,6 +1,6 @@
import {
createRequestReceiptsStub,
createCreationServiceStub,
createMessageReceiptsStub,
createTestCreationService,
} from "./test-utils/session-stubs.js";
import pino from "pino";
import { z } from "zod";
@@ -211,8 +211,8 @@ function createSessionForWireCompatTest(options?: {
];
const session = new Session({
requestReceipts: createRequestReceiptsStub(),
creationService: createCreationServiceStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "wire-compat-client",
permissions: OWNER_PERMISSIONS,
clientCapabilities: options?.clientCapabilities ?? null,