fix(server): restore workspace agents when unarchiving workspace

This commit is contained in:
Mohamed Boudra
2026-11-09 16:06:11 +01:00
parent e6fac1f592
commit c7b067c19b
18 changed files with 347 additions and 150 deletions
+7 -4
View File
@@ -257,7 +257,7 @@ Workspace archive is a separate lifecycle. Archiving or removing a worktree can
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt`
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
the route exposes explicit Unarchive and Restore actions.
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
The daemon runs one archive lane per workspace, shared by every connection
@@ -281,9 +281,12 @@ does not flip it back. Tabs and layout are purged only once the undo window clos
archive committed.
History navigation opens the selected agent without changing either archive state. Workspace
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
history hydration. Other archived agents stay archived.
**Unarchive**, **Restore**, Undo, and reopening the directory recover the agents closed by that
workspace archive, including
after a daemon restart or after the toast expires. Agents archived individually beforehand stay
archived. A failed worktree recreation leaves the workspace and its agents archived for retry.
Each recovered agent runs the provider's native unarchive hook before interactive resume and
history hydration.
Opening an agent is a navigation choice, independent of whether its details are cached. The
layout retains that choice across reload while the panel fetches the agent from the daemon.
@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
restoreArchivedWorkspace,
@@ -300,9 +299,7 @@ test.describe("Worktree restore", () => {
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("restores the workspace before explicitly unarchiving each selected agent", async ({
page,
}) => {
test("restores the workspace and all agents closed by its archive", async ({ page }) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
agentCount: 2,
keepAgentsArchived: true,
@@ -330,13 +327,11 @@ test.describe("Worktree restore", () => {
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toHaveAttribute("aria-selected", "true");
expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await expect.poll(() => fetchAgentArchivedAt(client, firstAgent.id)).toBeNull();
await expect.poll(() => fetchAgentArchivedAt(client, secondAgent.id)).toBeNull();
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
@@ -346,13 +341,8 @@ test.describe("Worktree restore", () => {
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Unarchive" }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
});
test("restores a branchless workspace so its archived agent can be used again", async ({
@@ -410,12 +400,7 @@ test.describe("Worktree restore", () => {
await expect
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
timeout: 30_000,
});
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toHaveCount(0);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
@@ -506,7 +491,11 @@ test.describe("Worktree restore", () => {
});
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
const screenshotDirectory = path.join(
process.cwd(),
".artifacts",
"paseo-branchless-restore-evidence",
);
mkdirSync(screenshotDirectory, { recursive: true });
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
}
@@ -14309,6 +14309,7 @@ test("workspace archive preserves a loaded history agent stamp and stamps cascad
await ensureAgentLoaded(parent.id, { agentManager: manager, agentStorage: storage, logger });
await manager.archiveAgent(parent.id, "2026-11-09T01:00:00.000Z");
expect((await storage.get(parent.id))?.archivedAt).toBe(stamp);
expect((await storage.get(parent.id))?.updatedAt).toBe("2026-11-09T01:00:00.000Z");
} finally {
await storage.flush();
rmSync(workdir, { recursive: true, force: true });
@@ -2234,13 +2234,14 @@ export class AgentManager {
throw new Error(`Agent ${agentId} not found in storage after snapshot`);
}
const { archivedAt } = await this.markRecordArchived(stored, requestedArchivedAt);
agent.updatedAt = new Date(archivedAt);
const archivedRecord = await this.markRecordArchived(stored, requestedArchivedAt);
const { archivedAt } = archivedRecord;
agent.updatedAt = new Date(archivedRecord.updatedAt);
await this.closeAgentRuntime(agentId);
await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive");
this.discardRetainedAgentState(agentId);
await this.cascadeArchiveChildren({ ...stored, archivedAt });
await this.cascadeArchiveChildren(archivedRecord);
return { archivedAt };
}
@@ -2250,7 +2251,7 @@ export class AgentManager {
// children so subagent fleets don't outlive their orchestrator. Detached
// handoff agents omit this label, so they stand outside the cascade.
private async cascadeArchiveChildren(
parent: Pick<StoredAgentRecord, "id" | "workspaceId" | "archivedAt">,
parent: Pick<ArchivedStoredAgentRecord, "id" | "workspaceId" | "archivedAt">,
): Promise<void> {
const registry = this.registry;
if (!registry) {
@@ -2284,9 +2285,9 @@ export class AgentManager {
this.dispatchStoredAgentState(detached.record);
}
} else if (this.agents.has(currentChild.id)) {
await this.archiveAgentUnlocked(currentChild.id, parent.archivedAt!);
await this.archiveAgentUnlocked(currentChild.id, parent.archivedAt);
} else {
await this.archiveSnapshotUnlocked(currentChild.id, parent.archivedAt!);
await this.archiveSnapshotUnlocked(currentChild.id, parent.archivedAt);
}
});
}
@@ -2302,7 +2303,7 @@ export class AgentManager {
if (!current || getParentAgentIdFromLabels(current.labels) !== parentAgentId) {
return;
}
await this.archiveAgentUnlocked(current.id, parent.archivedAt!);
await this.archiveAgentUnlocked(current.id, parent.archivedAt);
});
}
}
@@ -806,6 +806,7 @@ function createPaseoWorktreeForMcpTest(options: {
},
};
const workspaceProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
projectRegistry,
workspaceRegistry,
workspaceGitService,
@@ -2526,7 +2527,10 @@ describe("create_agent MCP tool", () => {
force: true,
reason: "archive-worktree",
});
expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(
"ws-archive-tool-worktree",
expect.any(String),
);
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
["ws-archive-tool-worktree"],
expect.any(String),
@@ -490,7 +490,7 @@ describe("archiveIfSafe", () => {
test("records the consumed change request in the workspace archive mutation", async () => {
const harness = createHarness({
archiveByScope: async (dependencies) => {
await dependencies.archiveWorkspaceRecord("ws-auto-archive");
await dependencies.archiveWorkspaceRecord("ws-auto-archive", "2026-11-09T00:00:00.000Z");
return {
archivedAgentIds: [],
archivedWorkspaceIds: ["ws-auto-archive"],
@@ -501,9 +501,13 @@ describe("archiveIfSafe", () => {
await runArchiveIfSafe(harness);
expect(harness.options.archiveWorkspaceRecord).toHaveBeenCalledWith("ws-auto-archive", {
autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123",
});
expect(harness.options.archiveWorkspaceRecord).toHaveBeenCalledWith(
"ws-auto-archive",
{
autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123",
},
"2026-11-09T00:00:00.000Z",
);
});
test("archives only the supplied workspace id and does not iterate siblings", async () => {
+13 -1
View File
@@ -1,5 +1,6 @@
import { unarchiveWorkspaceAgents } from "./workspace-archive-service.js";
import { createRestartRecovery } from "./agent/restart-recovery/index.js";
import { sendPromptToAgent } from "./agent/agent-prompt.js";
import { sendPromptToAgent, unarchiveAgentState } from "./agent/agent-prompt.js";
import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js";
import {
createFinishSubscriptions,
@@ -977,6 +978,17 @@ export async function createPaseoDaemon(
}
});
const workspaceProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: (workspaceId, archivedAt, allowLegacyFallback) =>
unarchiveWorkspaceAgents(
{
agentStorage,
unarchiveAgent: (id) => unarchiveAgentState(agentStorage, agentManager, id),
sessionLogger: logger,
},
workspaceId,
archivedAt,
allowLegacyFallback,
),
lifecycle: pluginRuntime,
serverId,
projectRegistry,
@@ -1150,6 +1150,7 @@ function createDeps(options?: {
};
const workspaceGitService = createWorkspaceGitServiceStub();
const workspaceProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
projectRegistry,
workspaceRegistry,
workspaceGitService,
@@ -232,6 +232,7 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
await projectRegistry.initialize();
const workspaceGitService = createNoopWorkspaceGitService();
const workspaceProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
projectRegistry,
workspaceRegistry,
workspaceGitService,
+14 -13
View File
@@ -219,6 +219,7 @@ import type { PushNotifications } from "./push/index.js";
import {
archivePersistedWorkspaceRecord,
archiveWorkspaceContents,
unarchiveWorkspaceAgents,
} from "./workspace-archive-service.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
import { renameCurrentBranch as renameCurrentBranchDefault } from "../utils/checkout-git.js";
@@ -1003,6 +1004,17 @@ export class Session {
});
this.workspaceAutoName = workspaceAutoName;
this.workspaceProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: (workspaceId, archivedAt, allowLegacyFallback) =>
unarchiveWorkspaceAgents(
{
agentStorage: this.agentStorage,
unarchiveAgent: (id) => unarchiveAgentState(this.agentStorage, this.agentManager, id),
sessionLogger: this.sessionLogger,
},
workspaceId,
archivedAt,
allowLegacyFallback,
),
withCheckoutLock: this.workspaceBaseBranch.withCheckoutLock,
lifecycle: this.pluginRuntime,
serverId,
@@ -8134,19 +8146,8 @@ export class Session {
}
if (workspace.archivedAt) {
await this.workspaceRecovery.restore(workspaceId);
}
const results = await Promise.allSettled(
input.agentIds.map((agentId) =>
unarchiveAgentState(this.agentStorage, this.agentManager, agentId),
),
);
for (const [index, result] of results.entries()) {
if (result.status === "rejected") {
this.sessionLogger.warn(
{ err: result.reason, workspaceId, agentId: input.agentIds[index] },
"Failed to unarchive agent while undoing workspace archive",
);
}
} else if (input.archivedAt) {
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace, input.archivedAt);
}
await this.publishRecoveredWorkspace(workspaceId);
}
@@ -118,6 +118,7 @@ beforeEach(async () => {
await workspaceRegistry.initialize();
await projectRegistry.initialize();
provisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
workspaceRegistry,
projectRegistry,
workspaceGitService: gitService(),
@@ -169,6 +170,7 @@ test("a workspace created during a base change records the new base", async () =
});
await locked;
const creator = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
workspaceRegistry,
projectRegistry,
workspaceGitService: git,
@@ -285,6 +287,7 @@ test("background workspace creation preserves hooks, inheritance and durable vis
const dir = path.join(tmpDir, "plain");
const events: string[] = [];
provisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
workspaceRegistry,
projectRegistry,
workspaceGitService: gitService(),
@@ -374,6 +377,7 @@ test("persists manual worktree ownership separately from its workspace kind", as
const cwd = path.join(tmpDir, "manual-worktree");
const mainRepoRoot = path.join(tmpDir, "main-repo");
const manualWorktreeProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
workspaceRegistry,
projectRegistry,
isDirectory,
@@ -461,6 +465,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
await workspaceRegistry.upsert(workspace);
await projectRegistry.archive(project.projectId, ARCHIVED_AT);
const archivedProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
workspaceRegistry,
projectRegistry,
isDirectory,
@@ -513,6 +518,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
remove: (workspaceId) => workspaceRegistry.remove(workspaceId),
};
const snapshotProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
workspaceRegistry: snapshotRegistry,
projectRegistry,
workspaceGitService: gitService(),
@@ -1016,6 +1022,7 @@ test("a failed import keeps an archived worktree's placement when its directory
});
await workspaceRegistry.upsert(archived);
const realCheckoutProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
workspaceRegistry,
projectRegistry,
logger,
@@ -1089,6 +1096,7 @@ test("a directory that goes away while the git read is in flight keeps its place
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/vanishing" });
const vanishingProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
workspaceRegistry,
projectRegistry,
isDirectory,
@@ -84,6 +84,7 @@ export interface WorkspaceProvisioningService {
findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord>;
ensureWorkspaceRecordUnarchived(
workspace: PersistedWorkspaceRecord,
archivedAt?: string | null,
): Promise<PersistedWorkspaceRecord>;
}
@@ -104,6 +105,11 @@ export class WorkspaceProvisioningError extends Error {
}
export function createWorkspaceProvisioningService(deps: {
unarchiveWorkspaceAgents: (
workspaceId: string,
archivedAt: string,
allowLegacyFallback: boolean,
) => Promise<void>;
serverId?: string;
workspaceRegistry: WorkspaceRegistry;
projectRegistry: ProjectRegistry;
@@ -433,6 +439,7 @@ export function createWorkspaceProvisioningService(deps: {
async function ensureWorkspaceRecordUnarchived(
workspace: PersistedWorkspaceRecord,
archivedAt = workspace.archivedAt,
): Promise<PersistedWorkspaceRecord> {
const project = await projectRegistry.get(workspace.projectId);
if (!project) throw new Error(`Unknown project: ${workspace.projectId}`);
@@ -480,6 +487,14 @@ export function createWorkspaceProvisioningService(deps: {
});
}
}
// A canceled archive supplies a modern stamp before the record itself is archived.
if (archivedAt) {
await deps.unarchiveWorkspaceAgents(
workspace.workspaceId,
archivedAt,
Boolean(workspace.archivedAt),
);
}
if (!next) return workspace;
await workspaceRegistry.upsert(next);
return next;
@@ -46,7 +46,7 @@ function parkedArchive(agentIds: string[]) {
signal = runSignal;
started.resolve();
await release.promise;
if (runSignal.aborted) throw new WorkspaceArchiveCanceledError(agentIds, false);
if (runSignal.aborted) throw new WorkspaceArchiveCanceledError(agentIds, false, "stamp");
return archived(agentIds);
},
};
@@ -56,33 +56,33 @@ describe("WorkspaceArchiveOperations", () => {
test("an undo during a running archive aborts it and restores the agents it took down", async () => {
const operations = new WorkspaceArchiveOperations();
const archive = parkedArchive(["agent-a", "agent-b"]);
const restored: string[][] = [];
const restored: Array<string | undefined> = [];
const archiveDone = operations.archive("ws", archive.run);
await archive.started;
const undoDone = operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
const undoDone = operations.undo("ws", async ({ archivedAt }) => {
restored.push(archivedAt);
});
expect(archive.aborted).toBe(true);
expect(restored).toEqual([]);
archive.release();
await expect(archiveDone).resolves.toEqual({ kind: "canceled" });
await expect(archiveDone).resolves.toEqual({ kind: "canceled", archivedAt: "stamp" });
await undoDone;
expect(restored).toEqual([["agent-a", "agent-b"]]);
expect(restored).toEqual(["stamp"]);
});
test("an undo after the archive finished restores the agents that archive took down", async () => {
test("an undo after archive completion delegates restoration to persisted state", async () => {
const operations = new WorkspaceArchiveOperations();
await operations.archive("ws", async () => archived(["agent-a"]));
const restored: string[][] = [];
await operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
const restored: Array<string | undefined> = [];
await operations.undo("ws", async ({ archivedAt }) => {
restored.push(archivedAt);
});
expect(restored).toEqual([["agent-a"]]);
expect(restored).toEqual([undefined]);
});
test("restore runs only after the aborted archive has stopped", async () => {
@@ -153,21 +153,21 @@ describe("WorkspaceArchiveOperations", () => {
test("archive, undo, archive in quick succession leaves the workspace archived", async () => {
const operations = new WorkspaceArchiveOperations();
const first = parkedArchive(["agent-a"]);
const restored: string[][] = [];
const restored: Array<string | undefined> = [];
const firstDone = operations.archive("ws", first.run);
await first.started;
const undoDone = operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
const undoDone = operations.undo("ws", async ({ archivedAt }) => {
restored.push(archivedAt);
});
// The re-archive must not join the archive the undo just aborted.
const secondDone = operations.archive("ws", async () => archived(["agent-a"]));
first.release();
await expect(firstDone).resolves.toEqual({ kind: "canceled" });
await expect(firstDone).resolves.toEqual({ kind: "canceled", archivedAt: "stamp" });
await undoDone;
await expect(secondDone).resolves.toMatchObject({ kind: "archived" });
expect(restored).toEqual([["agent-a"]]);
expect(restored).toEqual(["stamp"]);
});
test("a second undo while one runs joins it", async () => {
@@ -188,7 +188,7 @@ describe("WorkspaceArchiveOperations", () => {
expect(restores).toBe(1);
});
test("a failed restore keeps the agents for a retry", async () => {
test("a failed restore frees the lane for retry", async () => {
const operations = new WorkspaceArchiveOperations();
await operations.archive("ws", async () => archived(["agent-a"]));
@@ -198,14 +198,14 @@ describe("WorkspaceArchiveOperations", () => {
}),
).rejects.toThrow("worktree recreation failed");
const restored: string[][] = [];
await operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
const restored: Array<string | undefined> = [];
await operations.undo("ws", async ({ archivedAt }) => {
restored.push(archivedAt);
});
expect(restored).toEqual([["agent-a"]]);
expect(restored).toEqual([undefined]);
});
test("an archive of an already archived workspace keeps the earlier archive's agents for undo", async () => {
test("a repeat archive still delegates undo to persisted state", async () => {
const operations = new WorkspaceArchiveOperations();
await operations.archive("ws", async () => archived(["agent-a"]));
await operations.archive("ws", async () => ({
@@ -214,40 +214,36 @@ describe("WorkspaceArchiveOperations", () => {
archivedWorkspaceIds: [],
removedDirectory: false,
}));
const restored: string[][] = [];
const restored: Array<string | undefined> = [];
await operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
await operations.undo("ws", async ({ archivedAt }) => {
restored.push(archivedAt);
});
expect(restored).toEqual([["agent-a"]]);
expect(restored).toEqual([undefined]);
});
test("an undo restores the archive's agents once; a repeat undo restores none", async () => {
test("each completed undo reads the current persisted state", async () => {
const operations = new WorkspaceArchiveOperations();
await operations.archive("ws", async () => archived(["agent-a"]));
const restored: string[][] = [];
const restore = async ({ agentIds }: { agentIds: string[] }) => {
restored.push(agentIds);
const restored: Array<string | undefined> = [];
const restore = async ({ archivedAt }: { archivedAt?: string }) => {
restored.push(archivedAt);
};
await operations.undo("ws", restore);
await operations.undo("ws", restore);
expect(restored).toEqual([["agent-a"], []]);
expect(restored).toEqual([undefined, undefined]);
});
test("an undo after the retention window restores the workspace without agents", async () => {
let now = 0;
const operations = new WorkspaceArchiveOperations({ undoRetentionMs: 1000, now: () => now });
await operations.archive("ws", async () => archived(["agent-a"]));
now = 1000;
const restored: string[][] = [];
await operations.undo("ws", async ({ agentIds }) => {
restored.push(agentIds);
test("a fresh daemon delegates undo to persisted workspace state", async () => {
const operations = new WorkspaceArchiveOperations();
const inputs: unknown[] = [];
await operations.undo("ws", async (input) => {
inputs.push(input);
});
expect(restored).toEqual([[]]);
expect(inputs).toEqual([{ archivedAt: undefined }]);
});
test("workspaces have independent lanes", async () => {
@@ -2,24 +2,16 @@ import { type ArchiveResult, WorkspaceArchiveCanceledError } from "./workspace-a
export type WorkspaceArchiveOutcome =
| { kind: "archived"; result: ArchiveResult }
| { kind: "canceled" };
| { kind: "canceled"; archivedAt: string };
export interface WorkspaceArchiveUndoInput {
/** Agents the undone archive took down. Empty when the daemon no longer remembers it. */
agentIds: string[];
archivedAt?: string;
}
type LaneEntry =
| { kind: "archiving"; controller: AbortController; done: Promise<WorkspaceArchiveOutcome> }
| { kind: "undoing"; done: Promise<void> };
interface UndoableArchive {
agentIds: string[];
expiresAt: number;
}
const DEFAULT_UNDO_RETENTION_MS = 10 * 60_000;
/**
* One lane per workspace for archive and its undo, shared by every session on the daemon.
*
@@ -30,21 +22,9 @@ const DEFAULT_UNDO_RETENTION_MS = 10 * 60_000;
*
* Each call claims the lane synchronously, so whichever request arrives last decides the
* workspace's final state no matter how the earlier work interleaves.
*
* The agents an archive took down are kept in memory for the undo window. An archive that took
* nothing down, such as a repeat archive from a stale client, leaves them alone. After a daemon
* restart, undo still restores the workspace but its agents stay archived.
*/
export class WorkspaceArchiveOperations {
private readonly lanes = new Map<string, LaneEntry>();
private readonly undoable = new Map<string, UndoableArchive>();
private readonly undoRetentionMs: number;
private readonly now: () => number;
constructor(options: { undoRetentionMs?: number; now?: () => number } = {}) {
this.undoRetentionMs = options.undoRetentionMs ?? DEFAULT_UNDO_RETENTION_MS;
this.now = options.now ?? Date.now;
}
archive(
workspaceId: string,
@@ -59,16 +39,10 @@ export class WorkspaceArchiveOperations {
await settled(current?.done);
try {
const result = await run(controller.signal);
if (result.archivedWorkspaceIds.length > 0 || result.newlyArchivedAgentIds.length > 0) {
this.retainUndoable(workspaceId, result.newlyArchivedAgentIds);
}
return { kind: "archived", result };
} catch (error) {
if (!(error instanceof WorkspaceArchiveCanceledError)) throw error;
if (error.recordArchived || error.newlyArchivedAgentIds.length > 0) {
this.retainUndoable(workspaceId, error.newlyArchivedAgentIds);
}
return { kind: "canceled" };
return { kind: "canceled", archivedAt: error.archivedAt };
}
})();
this.claim(workspaceId, { kind: "archiving", controller, done });
@@ -85,15 +59,9 @@ export class WorkspaceArchiveOperations {
}
current?.controller.abort();
const done = (async () => {
await settled(current?.done);
const agentIds = this.takeUndoable(workspaceId);
try {
await restore({ agentIds });
} catch (error) {
// Keep the agents for a retry; the archive they came from is still the latest one.
this.retainUndoable(workspaceId, agentIds);
throw error;
}
const outcome =
current?.kind === "archiving" ? await current.done.catch(() => undefined) : undefined;
await restore({ archivedAt: outcome?.kind === "canceled" ? outcome.archivedAt : undefined });
})();
this.claim(workspaceId, { kind: "undoing", done });
return done;
@@ -106,21 +74,6 @@ export class WorkspaceArchiveOperations {
};
void (entry.done as Promise<unknown>).then(release, release);
}
private retainUndoable(workspaceId: string, agentIds: string[]): void {
const now = this.now();
for (const [id, entry] of this.undoable) {
if (entry.expiresAt <= now) this.undoable.delete(id);
}
this.undoable.set(workspaceId, { agentIds, expiresAt: now + this.undoRetentionMs });
}
private takeUndoable(workspaceId: string): string[] {
const entry = this.undoable.get(workspaceId);
this.undoable.delete(workspaceId);
if (!entry || entry.expiresAt <= this.now()) return [];
return entry.agentIds;
}
}
async function settled(promise: Promise<unknown> | undefined): Promise<void> {
@@ -15,6 +15,7 @@ import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
archiveByScope,
unarchiveWorkspaceAgents,
type ActiveWorkspaceRef,
type ArchiveDependencies,
type ArchiveResult,
@@ -1290,3 +1291,64 @@ test("uses the operation timestamp for live agents, snapshots, and the workspace
expect(typeof stamps[0]).toBe("string");
expect(new Set(stamps).size).toBe(1);
});
test.each([
{
stamp: "2026-11-09T10:00:00.000Z",
agents: [
["a", "2026-11-09T10:00:00.000Z"],
["b", "2026-11-09T10:00:00.000Z"],
["closed", "2026-11-09T09:59:50.000Z"],
["rearchived", "2026-11-09T10:00:00.001Z"],
],
expected: ["a", "b"],
},
{
stamp: "2026-11-09T10:00:00.000Z",
agents: [
["a", "2026-11-09T09:59:00.000Z"],
["b", "2026-11-09T09:59:59.000Z"],
["closed", "2026-11-09T09:58:59.999Z"],
["later", "2026-11-09T10:00:00.001Z"],
],
expected: ["a", "b"],
},
])(
"restores only agents belonging to the workspace archive ($expected)",
async ({ stamp, agents, expected }) => {
const restored: string[] = [];
await unarchiveWorkspaceAgents(
{
agentStorage: {
listByWorkspace: async () =>
agents.map(([id, archivedAt]) => ({ id, archivedAt })) as StoredAgentRecord[],
},
unarchiveAgent: async (id) => {
restored.push(id);
},
},
"workspace",
stamp,
);
expect(restored).toEqual(expected);
},
);
test("a canceled archive with no matching agents leaves earlier closed agents archived", async () => {
const restored: string[] = [];
await unarchiveWorkspaceAgents(
{
agentStorage: {
listByWorkspace: async () =>
[{ id: "closed", archivedAt: "2026-11-09T09:59:59.000Z" }] as StoredAgentRecord[],
},
unarchiveAgent: async (id) => {
restored.push(id);
},
},
"workspace",
"2026-11-09T10:00:00.000Z",
false,
);
expect(restored).toEqual([]);
});
@@ -95,6 +95,7 @@ export class WorkspaceArchiveCanceledError extends Error {
constructor(
public readonly newlyArchivedAgentIds: string[],
public readonly recordArchived: boolean,
public readonly archivedAt: string,
) {
super("Workspace archive was canceled");
this.name = "WorkspaceArchiveCanceledError";
@@ -172,6 +173,7 @@ async function archiveByScopeWithPriority(
throw new WorkspaceArchiveCanceledError(
Array.from(progress.newlyArchivedAgentIds),
progress.recordArchived,
archivedAt,
);
}
};
@@ -433,6 +435,7 @@ async function archiveTargetRecords(
throw new WorkspaceArchiveCanceledError(
Array.from(options.progress.newlyArchivedAgentIds),
options.progress.recordArchived,
options.archivedAt,
);
} else {
dependencies.sessionLogger?.warn(
@@ -760,3 +763,39 @@ export async function archivePersistedWorkspaceRecord(input: {
return existingWorkspace;
}
interface UnarchiveWorkspaceAgentsDependencies {
agentStorage: Pick<AgentStorage, "listByWorkspace">;
unarchiveAgent: (agentId: string) => Promise<unknown>;
sessionLogger?: Logger;
}
export async function unarchiveWorkspaceAgents(
dependencies: UnarchiveWorkspaceAgentsDependencies,
workspaceId: string,
archivedAt: string,
allowLegacyFallback = true,
): Promise<void> {
const records = await dependencies.agentStorage.listByWorkspace(workspaceId);
const exact = records.filter((record) => record.archivedAt === archivedAt);
// COMPAT(workspaceArchiveStamp): added in v0.15.3; remove after 2027-05-09 once old workspace archives no longer need restoration.
const matching =
exact.length > 0 || !allowLegacyFallback
? exact
: records.filter((record) => {
if (!record.archivedAt) return false;
const age = Date.parse(archivedAt) - Date.parse(record.archivedAt);
return age >= 0 && age <= 60_000;
});
const results = await Promise.allSettled(
matching.map((record) => dependencies.unarchiveAgent(record.id)),
);
for (const [index, result] of results.entries()) {
if (result.status === "rejected") {
dependencies.sessionLogger?.warn(
{ err: result.reason, workspaceId, agentId: matching[index]!.id },
"Failed to unarchive workspace agent",
);
}
}
}
@@ -1,5 +1,6 @@
import { DaemonClient } from "./test-utils/daemon-client.js";
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
@@ -273,3 +274,108 @@ test("an undo on a workspace that was never archived leaves it as it was", async
agentArchived: await isAgentArchived(agentId),
}).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false });
}, 60000);
test.each(["restore", "undo", "reopen", "refresh", "legacy"] as const)(
"%s after a daemon restart returns the archive's agents and leaves earlier closed agents archived",
async (route) => {
const artifacts = path.resolve(import.meta.dirname, "../../../../.artifacts");
mkdirSync(artifacts, { recursive: true });
const tempRoot = mkdtempSync(path.join(artifacts, "archive-restart-"));
tempRoots.push(tempRoot);
const repoDir = path.join(tempRoot, "repo");
git(tempRoot, ["init", "-b", "main", repoDir]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
]);
await ctx.cleanup();
ctx = await createDaemonTestContext({
paseoHomeRoot: path.join(tempRoot, "home"),
cleanup: false,
});
const created = await ctx.client.createWorkspace({
source: { kind: "directory", path: repoDir },
});
const workspaceId = created.workspace!.id;
const agents = [];
for (const title of ["A", "B", "C"]) {
agents.push(
await ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd: repoDir,
workspaceId,
title,
}),
);
}
const [a, b, c] = agents;
if (!a || !b || !c) throw new Error("Expected three agents");
await ctx.daemon.daemon.agentManager.archiveAgent(c.id, "2020-01-01T00:00:00.000Z");
const archive = await ctx.client.archiveWorkspace(workspaceId);
if (route === "legacy") {
const stamp = new Date(Date.parse(archive.archivedAt!) - 1000).toISOString();
await ctx.daemon.daemon.agentManager.archiveSnapshot(a.id, stamp);
await ctx.daemon.daemon.agentManager.archiveSnapshot(b.id, stamp);
}
await ctx.cleanup();
ctx = await createDaemonTestContext({
paseoHomeRoot: path.join(tempRoot, "home"),
cleanup: false,
});
expect(await isAgentArchived(a.id)).toBe(true);
if (route === "restore" || route === "legacy") await ctx.client.restoreWorkspace(workspaceId);
if (route === "undo") await ctx.client.undoWorkspaceArchive(workspaceId);
if (route === "reopen") await ctx.client.openProject(repoDir);
if (route === "refresh") {
const legacy = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
appVersion: "0.1.104",
});
await legacy.connect();
try {
await legacy.refreshAgent(a.id);
} finally {
await legacy.close();
}
}
expect(await isWorkspaceActive(workspaceId)).toBe(true);
expect(await isAgentArchived(a.id)).toBe(false);
expect(await isAgentArchived(b.id)).toBe(false);
expect(await isAgentArchived(c.id)).toBe(true);
},
60000,
);
test.skipIf(process.platform === "win32")(
"a failed worktree restore leaves every agent archived until retry succeeds",
async () => {
const repo = createRepoWithGatedTeardown();
repo.releaseTeardown();
const { workspaceId, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"restore-retry",
);
await ctx.client.archiveWorkspace(workspaceId);
const displaced = repo.repoDir + "-unavailable";
renameSync(repo.repoDir, displaced);
try {
await expect(ctx.client.restoreWorkspace(workspaceId)).rejects.toThrow("source repository");
expect(await isWorkspaceActive(workspaceId)).toBe(false);
expect(await isAgentArchived(agentId)).toBe(true);
} finally {
renameSync(displaced, repo.repoDir);
}
await ctx.client.restoreWorkspace(workspaceId);
expect(await isWorkspaceActive(workspaceId)).toBe(true);
expect(await isAgentArchived(agentId)).toBe(false);
},
60000,
);
@@ -354,6 +354,7 @@ function createPaseoWorktreeForTest(options: {
},
};
const workspaceProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
projectRegistry,
workspaceRegistry,
workspaceGitService,