fix(server): finish canceled creation and restore cleanup

Remove canceled durable agent records before evaluating receipt retry safety, and roll back a recreated checkout when restore expires. Verify cancellation at the registration boundary and drive journal deadlines with an injected clock.
This commit is contained in:
Mohamed Boudra
2026-09-09 19:45:38 +02:00
parent fb349d6bb7
commit 8860f2eb14
8 changed files with 279 additions and 38 deletions
@@ -1499,7 +1499,8 @@ test("canceling provider startup settles creation and closes a late session with
await client.waitForCreationToStart();
try {
controller.abort(new Error("startup expired"));
await expect.poll(() => outcome, { timeout: 500 }).toBe("canceled");
await creation;
expect(outcome).toBe("canceled");
client.finishCreating();
await expect.poll(() => client.createdSessionClosed).toBe(true);
expect(manager.listAgents()).toEqual([]);
@@ -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 } from "./index.js";
import { AgentRequests, AgentRequestError } from "./index.js";
const directories: string[] = [];
afterEach(async () => {
@@ -233,8 +233,8 @@ test("a reconstructed journal reports interrupted startup as unknown and never l
restarted.run(context, async () => "must not launch"),
]);
expect(replays).toEqual([
{ status: "rejected", reason: new Error("agent_request_outcome_unknown") },
{ status: "rejected", reason: new Error("agent_request_outcome_unknown") },
{ 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,
@@ -248,21 +248,36 @@ test("a reconstructed journal reports interrupted startup as unknown and never l
});
test("a live deadline settles a hung waiter without a cancel frame and preserves late cleanup", async () => {
const { requests } = await fixture();
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 entered = false;
let enter!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const pending = requests.run(
{ key: "deadline", deadlineAt: new Date(Date.now() + 200).toISOString() },
{ key: "deadline", deadlineAt: new Date(now + 120_000).toISOString() },
async () => {
entered = true;
enter();
await gate;
},
);
await expect(pending).rejects.toThrow("agent_request_canceled");
expect(entered).toBe(true);
const rejected = expect(pending).rejects.toThrow("agent_request_canceled");
await entered;
expire();
await rejected;
expect(await requests.inspectOperation("deadline", "conversation")).toMatchObject({
outcome: "pending",
});
@@ -270,9 +285,10 @@ test("a live deadline settles a hung waiter without a cancel frame and preserves
"agent_request_canceled",
);
release();
await expect
.poll(() => requests.inspectOperation("deadline", "conversation"))
.toMatchObject({ outcome: "settled" });
await requests.cancelAndSettle("deadline");
expect(await requests.inspectOperation("deadline", "conversation")).toMatchObject({
outcome: "settled",
});
expect(await requests.run({ key: "next" }, async () => "ready")).toBe("ready");
});
@@ -325,7 +341,7 @@ test("refused provider cancellation remains unknown after restart", async () =>
const { requests, directory } = await fixture();
await expect(
requests.run({ key: "refused" }, async () => {
throw new Error("agent_request_outcome_unknown");
throw new AgentRequestError("agent_request_outcome_unknown");
}),
).rejects.toThrow("agent_request_outcome_unknown");
expect(
@@ -11,6 +11,30 @@ 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>>();
@@ -20,7 +44,10 @@ export class AgentRequests {
>();
private readonly operationWrites = new Map<string, Promise<void>>();
constructor(private readonly directory: 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>(
@@ -37,7 +64,7 @@ export class AgentRequests {
initialized: (async () => {
await previousWrite;
if (await this.wasInterrupted(context.key))
throw new Error("agent_request_outcome_unknown");
throw new AgentRequestError("agent_request_outcome_unknown");
})(),
};
this.operations.set(context.key, active);
@@ -46,23 +73,28 @@ export class AgentRequests {
const controller = new AbortController();
const controllers = active.controllers;
controllers.add(controller);
let timer: ReturnType<typeof setTimeout> | undefined;
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) - Date.now();
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 Error("agent_request_canceled");
throw new AgentRequestError("agent_request_canceled");
}
controller.signal.throwIfAborted();
if (remaining !== undefined)
timer = setTimeout(() => controller.abort(new Error("agent_request_canceled")), remaining);
clearTimer = this.clock.schedule(
() => controller.abort(new AgentRequestError("agent_request_canceled")),
remaining,
);
await this.recordOperationState(context.key);
recorded = true;
controller.signal.throwIfAborted();
@@ -76,7 +108,7 @@ export class AgentRequests {
return operation(controller.signal);
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === "agent_request_outcome_unknown")
if (error instanceof AgentRequestError && error.code === "agent_request_outcome_unknown")
operationState.unknown = true;
throw error;
})
@@ -96,7 +128,7 @@ export class AgentRequests {
}),
]);
} finally {
clearTimeout(timer);
clearTimer?.();
if (abort) controller.signal.removeEventListener("abort", abort);
if (controller.signal.aborted) await this.recordCancellation(context.key);
if (!started) {
@@ -111,7 +143,7 @@ export class AgentRequests {
// 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 Error("agent_request_canceled"));
controller.abort(new AgentRequestError("agent_request_canceled"));
}
}
@@ -131,7 +163,8 @@ export class AgentRequests {
const receipt = z
.object({ fingerprint: z.string() })
.parse(JSON.parse(await readFile(file, "utf8")));
if (receipt.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict");
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;
@@ -145,7 +178,8 @@ export class AgentRequests {
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 Error("agent_request_outcome_unknown");
if (await this.wasInterrupted(key))
throw new AgentRequestError("agent_request_outcome_unknown");
}
async cancelOperation(
@@ -297,10 +331,11 @@ export class AgentRequests {
const file = path.join(this.directory, `${key}.json`);
const existing = await readReceipt(file);
if (existing) {
if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict");
if (existing.fingerprint !== fingerprint)
throw new AgentRequestError("agent_request_key_conflict");
if (existing.state === "completed") return existing.agentId;
if (!(await operation.recover(existing.agentId))) {
throw new Error("agent_request_outcome_unknown");
throw new AgentRequestError("agent_request_outcome_unknown");
}
await writeJsonFileAtomic(file, { ...existing, state: "completed" });
return existing.agentId;
@@ -333,13 +333,8 @@ test("control cancels a held legacy provider create and fences a delayed replay"
hub.beginOwnedCreate("held-create", "expired-create");
await hub.agentCreationAttempts(1);
const creation = hub.ownedCreateResult("held-create");
let settled = false;
const control = hub.controlExecution("expired-create", "archive").then((result) => {
settled = true;
return result;
});
const control = hub.controlExecution("expired-create", "archive");
try {
await expect.poll(() => settled, { timeout: 500 }).toBe(true);
expect(await control).toMatchObject({ success: true });
expect(await creation).toMatchObject({ payload: { success: false } });
} finally {
+6 -2
View File
@@ -1,5 +1,5 @@
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import type { AgentRequests } from "./agent/requests/index.js";
import { AgentRequestError, 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";
@@ -3671,7 +3671,10 @@ export class Session {
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;
}
@@ -7656,7 +7659,8 @@ export class Session {
const abort = () => {
if (this.agentManager.getAgent(resolved.agentId)) {
cancellation ??= this.agentManager.cancelAgentRun(resolved.agentId).then((result) => {
if (result.status === "refused") throw new Error("agent_request_outcome_unknown");
if (result.status === "refused")
throw new AgentRequestError("agent_request_outcome_unknown");
return result;
});
void cancellation.catch(() => undefined);
@@ -1,3 +1,4 @@
import { AgentRequests } from "./agent/requests/index.js";
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
import { execFileSync } from "node:child_process";
import {
@@ -932,6 +933,126 @@ 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 {
@@ -119,7 +119,7 @@ describe("workspace recovery", () => {
test("a repeated restore of an already active workspace succeeds without another unarchive", async () => {
const workspace = createWorkspace({ archivedAt: null });
const { service, unarchived } = createHarness({ workspace });
const { service, unarchived } = createHarness({ workspace, directories: [workspace.cwd] });
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
workspaceId: workspace.workspaceId,
action: "unarchive",
@@ -127,6 +127,64 @@ describe("workspace recovery", () => {
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;
@@ -81,7 +81,7 @@ export function createWorkspaceRecoveryService(deps: {
message: "This workspace is no longer known to the host.",
};
}
if (!workspace.archivedAt) {
if (!workspace.archivedAt && (await deps.isDirectory(workspace.cwd))) {
return {
kind: "unavailable",
workspaceId,
@@ -90,6 +90,14 @@ 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 {
@@ -154,7 +162,7 @@ export function createWorkspaceRecoveryService(deps: {
}
if (resolved.kind === "restore") {
await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot);
await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot, signal);
}
signal?.throwIfAborted();
await deps.unarchiveWorkspace(resolved.workspace);
@@ -164,6 +172,7 @@ export function createWorkspaceRecoveryService(deps: {
async function recreateArchivedWorktree(
workspace: PersistedWorkspaceRecord,
sourceRepoRoot: string,
signal?: AbortSignal,
): Promise<void> {
const branch = workspace.branch;
if (!branch) {
@@ -208,6 +217,7 @@ export function createWorkspaceRecoveryService(deps: {
}
try {
signal?.throwIfAborted();
const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({
sourceWorktreePath: previousWorktreePath,
workspaceCwd: workspace.cwd,
@@ -225,6 +235,7 @@ export function createWorkspaceRecoveryService(deps: {
message: `Selected project directory is missing from the restored worktree: ${recreatedWorkspacePath}`,
});
}
signal?.throwIfAborted();
} catch (error) {
return rollbackCreatedPaseoWorktree(
{