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
@@ -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,