Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 58e2b2de9c fix(server): persist workspace recovery before restoring agents
Remove unused undo-list tracking and pass named restore requests. A failed workspace record write leaves its agents archived for retry.
2026-11-09 16:10:27 +01:00
Mohamed Boudra c7b067c19b fix(server): restore workspace agents when unarchiving workspace 2026-11-09 16:06:11 +01:00
Mohamed Boudra e6fac1f592 refactor(server): share one timestamp across workspace archive 2026-11-09 15:52:58 +01:00
19 changed files with 505 additions and 296 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 });
}
@@ -14285,3 +14285,33 @@ test("turns keep distinct identities across an idle runtime collection and resum
rmSync(workdir, { recursive: true, force: true });
}
});
test("workspace archive preserves a loaded history agent stamp and stamps cascaded children", async () => {
const workdir = mkdtempSync(join(tmpdir(), "workspace-stamp-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
try {
const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const child = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
workspaceId: undefined,
});
const stamp = "2026-11-09T00:00:00.000Z";
await manager.archiveAgent(parent.id, stamp);
expect((await storage.get(parent.id))?.archivedAt).toBe(stamp);
expect((await storage.get(child.id))?.archivedAt).toBe(stamp);
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 });
}
});
@@ -2196,8 +2196,8 @@ export class AgentManager {
}
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId));
async archiveAgent(agentId: string, archivedAt?: string): Promise<{ archivedAt: string }> {
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId, archivedAt));
}
private async archiveAgentUnlocked(
@@ -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);
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">,
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);
await this.archiveAgentUnlocked(currentChild.id, parent.archivedAt);
} else {
await this.archiveSnapshotUnlocked(currentChild.id, new Date().toISOString());
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);
await this.archiveAgentUnlocked(current.id, parent.archivedAt);
});
}
}
@@ -2312,7 +2313,7 @@ export class AgentManager {
archivedAt = new Date().toISOString(),
): Promise<ArchivedStoredAgentRecord> {
const archivedRecord = await this.persistArchivedRecord(record, {
archivedAt,
archivedAt: record.archivedAt ?? archivedAt,
updatedAt: archivedAt,
});
@@ -2853,7 +2854,7 @@ export class AgentManager {
if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord);
await this.fireAgentArchived(agentId);
await this.cascadeArchiveChildren(record);
await this.cascadeArchiveChildren(nextRecord);
return nextRecord;
}
@@ -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 () => {
@@ -29,7 +29,11 @@ export interface AutoArchiveArchiveOptions {
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
getAutoArchivedChangeRequestUrl: (workspaceId: string) => Promise<string | null>;
archiveWorkspaceRecord: (workspaceId: string, context?: WorkspaceArchiveContext) => Promise<void>;
archiveWorkspaceRecord: (
workspaceId: string,
context?: WorkspaceArchiveContext,
archivedAt?: string,
) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
@@ -101,10 +105,14 @@ export async function archiveIfSafe(input: {
agentStorage: options.agentStorage,
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
listActiveWorkspaces: options.listActiveWorkspaces,
archiveWorkspaceRecord: (workspaceIdToArchive) =>
options.archiveWorkspaceRecord(workspaceIdToArchive, {
autoArchivedChangeRequestUrl: pullRequest.url,
}),
archiveWorkspaceRecord: (workspaceIdToArchive, archivedAt) =>
options.archiveWorkspaceRecord(
workspaceIdToArchive,
{
autoArchivedChangeRequestUrl: pullRequest.url,
},
archivedAt,
),
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
+18 -5
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,13 @@ export async function createPaseoDaemon(
}
});
const workspaceProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: (request) =>
unarchiveWorkspaceAgents({
...request,
agentStorage,
unarchiveAgent: (id) => unarchiveAgentState(agentStorage, agentManager, id),
sessionLogger: logger,
}),
lifecycle: pluginRuntime,
serverId,
projectRegistry,
@@ -1102,11 +1110,13 @@ export async function createPaseoDaemon(
const archiveWorkspaceRecordExternal = async (
workspaceId: string,
context?: WorkspaceArchiveContext,
archivedAt?: string,
) => {
const existingWorkspace = await archivePersistedWorkspaceRecord({
workspaceId,
workspaceRegistry,
context,
archivedAt,
});
if (!existingWorkspace || existingWorkspace.archivedAt) return;
teardownArchivedWorkspaceRuntime(workspaceId);
@@ -1301,7 +1311,8 @@ export async function createPaseoDaemon(
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
listActiveWorkspaces: listActiveWorkspacesExternal,
getWorkspace: (workspaceIdToGet) => workspaceRegistry.get(workspaceIdToGet),
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
archiveWorkspaceRecord: (workspaceIdToArchive, archivedAt) =>
archiveWorkspaceRecordExternal(workspaceIdToArchive, undefined, archivedAt),
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
@@ -1326,7 +1337,7 @@ export async function createPaseoDaemon(
archiveAgentCommand({ agentManager, agentStorage, logger }, agentId),
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
listActiveWorkspaces: listActiveWorkspacesExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
archiveWorkspaceRecord: (workspaceId) => archiveWorkspaceRecordExternal(workspaceId),
emit: emitExternalSessionMessage,
emitAgentRemove: async () => undefined,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
@@ -1422,7 +1433,8 @@ export async function createPaseoDaemon(
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
listActiveWorkspaces: listActiveWorkspacesExternal,
getWorkspace: (workspaceIdToGet) => workspaceRegistry.get(workspaceIdToGet),
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
archiveWorkspaceRecord: (workspaceIdToArchive, archivedAt) =>
archiveWorkspaceRecordExternal(workspaceIdToArchive, undefined, archivedAt),
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
@@ -1544,7 +1556,8 @@ export async function createPaseoDaemon(
workspaceGitService,
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
listActiveWorkspaces: listActiveWorkspacesExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
archiveWorkspaceRecord: (workspaceId, archivedAt) =>
archiveWorkspaceRecordExternal(workspaceId, undefined, archivedAt),
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
workspaceRegistry,
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,
+17 -16
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,13 @@ export class Session {
});
this.workspaceAutoName = workspaceAutoName;
this.workspaceProvisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: (request) =>
unarchiveWorkspaceAgents({
...request,
agentStorage: this.agentStorage,
unarchiveAgent: (id) => unarchiveAgentState(this.agentStorage, this.agentManager, id),
sessionLogger: this.sessionLogger,
}),
withCheckoutLock: this.workspaceBaseBranch.withCheckoutLock,
lifecycle: this.pluginRuntime,
serverId,
@@ -4074,6 +4082,7 @@ export class Session {
const removedWorkspaceIds: string[] = [];
try {
for (const workspaceId of activeWorkspaceIds) {
const archivedAt = new Date().toISOString();
await archiveWorkspaceContents(
{
agentManager: this.agentManager,
@@ -4083,8 +4092,9 @@ export class Session {
sessionLogger: this.sessionLogger,
},
workspaceId,
archivedAt,
);
await this.archiveWorkspaceRecord(workspaceId);
await this.archiveWorkspaceRecord(workspaceId, archivedAt);
removedWorkspaceIds.push(workspaceId);
}
@@ -5678,7 +5688,8 @@ export class Session {
agentStorage: this.agentStorage,
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
archiveWorkspaceRecord: (workspaceId, archivedAt) =>
this.archiveWorkspaceRecord(workspaceId, archivedAt),
emit: (message) => this.emit(message),
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
@@ -8019,7 +8030,8 @@ export class Session {
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
archiveWorkspaceRecord: (workspaceId, archivedAt) =>
this.archiveWorkspaceRecord(workspaceId, archivedAt),
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
@@ -8130,19 +8142,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);
}
@@ -19,6 +19,7 @@ import {
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
createPersistedWorkspaceRecord,
type WorkspaceRegistry,
} from "../../workspace-registry.js";
@@ -118,6 +119,7 @@ beforeEach(async () => {
await workspaceRegistry.initialize();
await projectRegistry.initialize();
provisioning = createWorkspaceProvisioningService({
unarchiveWorkspaceAgents: async () => {},
workspaceRegistry,
projectRegistry,
workspaceGitService: gitService(),
@@ -169,6 +171,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 +288,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 +378,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 +466,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 +519,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 +1023,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 +1097,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,
@@ -1117,3 +1126,45 @@ test("a directory that goes away while the git read is in flight keeps its place
branch: "feature/vanishing",
});
});
test("a failed workspace unarchive write keeps agents archived and permits retry", async () => {
const repo = path.join(tmpDir, "restore-write-failure");
mkdirSync(repo, { recursive: true });
const workspace = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.archive(workspace.workspaceId, ARCHIVED_AT);
const archived = (await workspaceRegistry.get(workspace.workspaceId))!;
class FailingWorkspaceRegistry extends FileBackedWorkspaceRegistry {
failWrites = true;
override async upsert(record: PersistedWorkspaceRecord): Promise<void> {
if (this.failWrites && !record.archivedAt) throw new Error("Workspace write failed");
await super.upsert(record);
}
}
const registry = new FailingWorkspaceRegistry(
path.join(tmpDir, "projects", "workspaces.json"),
logger,
);
await registry.initialize();
let agentArchived = true;
const recovery = createWorkspaceProvisioningService({
workspaceRegistry: registry,
projectRegistry,
workspaceGitService: gitService(),
isDirectory,
logger,
unarchiveWorkspaceAgents: async () => {
agentArchived = false;
},
});
await expect(recovery.ensureWorkspaceRecordUnarchived(archived)).rejects.toThrow(
"Workspace write failed",
);
expect((await registry.get(workspace.workspaceId))?.archivedAt).toBe(ARCHIVED_AT);
expect(agentArchived).toBe(true);
registry.failWrites = false;
await recovery.ensureWorkspaceRecordUnarchived(archived);
expect((await registry.get(workspace.workspaceId))?.archivedAt).toBeNull();
expect(agentArchived).toBe(false);
});
@@ -1,3 +1,4 @@
import type { WorkspaceArchiveRestoreRequest } from "../../workspace-archive-service.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
@@ -84,6 +85,7 @@ export interface WorkspaceProvisioningService {
findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord>;
ensureWorkspaceRecordUnarchived(
workspace: PersistedWorkspaceRecord,
archivedAt?: string | null,
): Promise<PersistedWorkspaceRecord>;
}
@@ -104,6 +106,7 @@ export class WorkspaceProvisioningError extends Error {
}
export function createWorkspaceProvisioningService(deps: {
unarchiveWorkspaceAgents: (request: WorkspaceArchiveRestoreRequest) => Promise<void>;
serverId?: string;
workspaceRegistry: WorkspaceRegistry;
projectRegistry: ProjectRegistry;
@@ -433,6 +436,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,9 +484,16 @@ export function createWorkspaceProvisioningService(deps: {
});
}
}
if (!next) return workspace;
await workspaceRegistry.upsert(next);
return next;
if (next) await workspaceRegistry.upsert(next);
// A canceled archive supplies a modern stamp before the record itself is archived.
if (archivedAt) {
await deps.unarchiveWorkspaceAgents({
workspaceId: workspace.workspaceId,
archivedAt,
allowLegacyFallback: Boolean(workspace.archivedAt),
});
}
return next ?? workspace;
}
async function refreshWorkspaceRecord(
@@ -19,10 +19,9 @@ function deferred<T = void>(): Deferred<T> {
return { promise, resolve, reject };
}
function archived(newlyArchivedAgentIds: string[]): ArchiveResult {
function archived(agentIds: string[]): ArchiveResult {
return {
archivedAgentIds: newlyArchivedAgentIds,
newlyArchivedAgentIds,
archivedAgentIds: agentIds,
archivedWorkspaceIds: ["ws"],
removedDirectory: false,
};
@@ -46,7 +45,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("stamp");
return archived(agentIds);
},
};
@@ -56,33 +55,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 +152,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 +187,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,56 +197,51 @@ 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 () => ({
archivedAgentIds: ["agent-a"],
newlyArchivedAgentIds: [],
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,
@@ -1059,7 +1060,7 @@ describe("archiveByScope cancellation", () => {
} as Pick<AgentStorage, "listByWorkspace">;
}
test("an abort while agents archive keeps the record and reports only the agents it took down", async () => {
test("an abort while agents archive keeps the workspace record active", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-agents");
@@ -1089,16 +1090,12 @@ describe("archiveByScope cancellation", () => {
await agentArchive.reached;
controller.abort();
agentArchive.open();
const error = await canceledArchive(archive);
await canceledArchive(archive);
expect({
newlyArchivedAgentIds: error.newlyArchivedAgentIds,
recordArchived: error.recordArchived,
activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId),
directoryExists: existsSync(worktree.worktreePath),
}).toEqual({
newlyArchivedAgentIds: ["agent-active"],
recordArchived: false,
activeWorkspaceIds: [workspaceId],
directoryExists: true,
});
@@ -1132,13 +1129,12 @@ describe("archiveByScope cancellation", () => {
await recordArchive.reached;
controller.abort();
recordArchive.open();
const error = await canceledArchive(archive);
await canceledArchive(archive);
expect({
recordArchived: error.recordArchived,
activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId),
directoryExists: existsSync(worktree.worktreePath),
}).toEqual({ recordArchived: true, activeWorkspaceIds: [], directoryExists: true });
}).toEqual({ activeWorkspaceIds: [], directoryExists: true });
});
test("an archive aborted before it starts touches nothing", async () => {
@@ -1154,7 +1150,7 @@ describe("archiveByScope cancellation", () => {
const controller = new AbortController();
controller.abort();
const error = await canceledArchive(
await canceledArchive(
archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
requestId: "req-cancel-before",
@@ -1162,63 +1158,10 @@ describe("archiveByScope cancellation", () => {
}),
);
expect(error.newlyArchivedAgentIds).toEqual([]);
expect(deps.archivedSnapshotIds).toEqual([]);
expect(deps.markWorkspaceArchiving).not.toHaveBeenCalled();
expect(existsSync(worktree.worktreePath)).toBe(true);
});
test("a completed archive reports the agents it newly archived", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "newly-archived");
const workspaceId = "ws-newly-archived";
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
});
deps.agentStorage = storedAgents(workspaceId, [
{ id: "agent-active", archivedAt: null },
{ id: "agent-already-archived", archivedAt: "2026-01-01T00:00:00.000Z" },
]);
const result = await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
requestId: "req-newly-archived",
signal: new AbortController().signal,
});
expect(result.archivedAgentIds.sort()).toEqual(["agent-active", "agent-already-archived"]);
expect(result.newlyArchivedAgentIds).toEqual(["agent-active"]);
});
test("an archived agent loaded to read its history is not reported as newly archived", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "loaded-archived");
const workspaceId = "ws-loaded-archived";
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
});
deps.agentStorage = storedAgents(workspaceId, [
{ id: "agent-active", archivedAt: null },
{ id: "agent-history", archivedAt: "2026-01-01T00:00:00.000Z" },
]);
deps.agentManager.listAgents = () =>
[
{ id: "agent-active", workspaceId },
{ id: "agent-history", workspaceId },
] as ManagedAgent[];
const result = await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
requestId: "req-loaded-archived",
signal: new AbortController().signal,
});
expect(result.newlyArchivedAgentIds).toEqual(["agent-active"]);
});
});
describe("resolveWorkspaceIdAtPath", () => {
@@ -1255,3 +1198,95 @@ describe("resolveWorkspaceIdAtPath", () => {
expect(result).toBe("ws-nested");
});
});
test("uses the operation timestamp for live agents, snapshots, and the workspace", async () => {
const { repoDir } = createGitRepo();
const deps = createArchiveDeps({
paseoHome: repoDir,
activeWorkspaces: [{ workspaceId: "stamped", cwd: repoDir, kind: "local_checkout" }],
});
const stamps: Array<string | undefined> = [];
deps.agentManager.listAgents = () => [{ id: "live", workspaceId: "stamped" }] as ManagedAgent[];
deps.agentManager.getAgent = () => ({ id: "live" }) as ManagedAgent;
deps.agentManager.archiveAgent = async (_id, stamp) => {
stamps.push(stamp);
return { archivedAt: stamp! };
};
deps.agentStorage.listByWorkspace = async () =>
[
{ id: "stored", workspaceId: "stamped", archivedAt: null },
{ id: "closed", workspaceId: "stamped", archivedAt: "2020-01-01T00:00:00.000Z" },
] as StoredAgentRecord[];
deps.agentManager.getAgent = (id) => (id === "live" ? ({ id } as ManagedAgent) : null);
deps.agentManager.archiveSnapshot = async (_id, stamp) => {
stamps.push(stamp);
return {} as StoredAgentRecord;
};
deps.archiveWorkspaceRecord = async (_id, stamp) => {
stamps.push(stamp);
};
await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId: "stamped" },
requestId: "stamp",
});
expect(stamps).toHaveLength(3);
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({
workspaceId: "workspace",
archivedAt: stamp,
agentStorage: {
listByWorkspace: async () =>
agents.map(([id, archivedAt]) => ({ id, archivedAt })) as StoredAgentRecord[],
},
unarchiveAgent: async (id) => {
restored.push(id);
},
});
expect(restored).toEqual(expected);
},
);
test("a canceled archive with no matching agents leaves earlier closed agents archived", async () => {
const restored: string[] = [];
await unarchiveWorkspaceAgents({
workspaceId: "workspace",
archivedAt: "2026-11-09T10:00:00.000Z",
allowLegacyFallback: false,
agentStorage: {
listByWorkspace: async () =>
[{ id: "closed", archivedAt: "2026-11-09T09:59:59.000Z" }] as StoredAgentRecord[],
},
unarchiveAgent: async (id) => {
restored.push(id);
},
});
expect(restored).toEqual([]);
});
@@ -45,7 +45,7 @@ export interface ArchiveDependencies {
// break a same-cwd tie in favor of the worktree-kind record when archiving by
// path (no explicit workspaceId).
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
archiveWorkspaceRecord: (workspaceId: string, archivedAt: string) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
@@ -67,9 +67,6 @@ export type ArchiveScope =
export interface ArchiveResult {
archivedAgentIds: string[];
// Agents this request moved from active to archived. `archivedAgentIds` also lists agents that
// were already archived; undo must restore only the ones this archive took down.
newlyArchivedAgentIds: string[];
archivedWorkspaceIds: string[];
removedDirectory: boolean;
// Set when the record archived but its worktree directory was left on
@@ -86,16 +83,9 @@ export interface ArchiveByScopeRequest {
signal?: AbortSignal;
}
/**
* The archive stopped at a checkpoint because its signal aborted. Whatever ran before the
* checkpoint stays done; `newlyArchivedAgentIds` and `recordArchived` say how far it got so the
* canceller can put it back.
*/
// Cancellation carries the operation stamp even before the workspace record is archived.
export class WorkspaceArchiveCanceledError extends Error {
constructor(
public readonly newlyArchivedAgentIds: string[],
public readonly recordArchived: boolean,
) {
constructor(public readonly archivedAt: string) {
super("Workspace archive was canceled");
this.name = "WorkspaceArchiveCanceledError";
}
@@ -165,13 +155,10 @@ async function archiveByScopeWithPriority(
dependencies: ArchiveDependencies,
request: ArchiveByScopeRequest,
): Promise<ArchiveResult> {
const progress: ArchiveProgress = { newlyArchivedAgentIds: new Set(), recordArchived: false };
const archivedAt = new Date().toISOString();
const checkpoint = () => {
if (request.signal?.aborted) {
throw new WorkspaceArchiveCanceledError(
Array.from(progress.newlyArchivedAgentIds),
progress.recordArchived,
);
throw new WorkspaceArchiveCanceledError(archivedAt);
}
};
@@ -198,7 +185,7 @@ async function archiveByScopeWithPriority(
const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords(
dependencies,
targetWorkspaceIds,
{ requestId: request.requestId, checkpoint, progress },
{ requestId: request.requestId, checkpoint, archivedAt },
);
checkpoint();
@@ -229,7 +216,6 @@ async function archiveByScopeWithPriority(
return {
archivedAgentIds: Array.from(archivedAgents),
newlyArchivedAgentIds: Array.from(progress.newlyArchivedAgentIds),
archivedWorkspaceIds,
removedDirectory,
...(directoryLeftBehind ? { directoryLeftBehind } : {}),
@@ -242,11 +228,6 @@ async function archiveByScopeWithPriority(
}
}
interface ArchiveProgress {
newlyArchivedAgentIds: Set<string>;
recordArchived: boolean;
}
async function resolveArchiveTarget(
dependencies: ArchiveDependencies,
scope: ArchiveScope,
@@ -393,23 +374,27 @@ async function resolveBackingDirectory(
async function archiveTargetRecords(
dependencies: ArchiveDependencies,
targetWorkspaceIds: string[],
options: { requestId: string; checkpoint: () => void; progress: ArchiveProgress },
options: {
requestId: string;
checkpoint: () => void;
archivedAt: string;
},
): Promise<{ archivedAgents: Set<string>; archivedWorkspaceIds: string[] }> {
const archivedAgents = new Set<string>();
const archivedWorkspaceIds: string[] = [];
const results = await Promise.allSettled(
targetWorkspaceIds.map(async (workspaceId) => {
const contents = await archiveWorkspaceContentsWithProgress(dependencies, workspaceId);
for (const agentId of contents.newlyArchivedAgentIds) {
options.progress.newlyArchivedAgentIds.add(agentId);
}
const contents = await archiveWorkspaceContents(
dependencies,
workspaceId,
options.archivedAt,
);
// Agents and terminals are down, the record is not: an abort here leaves the workspace
// listed, and the canceller restores the agents it reports.
// listed, and cancellation restores agents carrying this operation stamp.
options.checkpoint();
await dependencies.archiveWorkspaceRecord(workspaceId);
options.progress.recordArchived = true;
return { workspaceId, agents: contents.agentIds };
await dependencies.archiveWorkspaceRecord(workspaceId, options.archivedAt);
return { workspaceId, agents: contents };
}),
);
@@ -420,10 +405,7 @@ async function archiveTargetRecords(
archivedAgents.add(agentId);
}
} else if (result.reason instanceof WorkspaceArchiveCanceledError) {
throw new WorkspaceArchiveCanceledError(
Array.from(options.progress.newlyArchivedAgentIds),
options.progress.recordArchived,
);
throw result.reason;
} else {
dependencies.sessionLogger?.warn(
{ err: result.reason, requestId: options.requestId },
@@ -574,14 +556,8 @@ export type ArchiveWorkspaceContentsDependencies = Pick<
export async function archiveWorkspaceContents(
dependencies: ArchiveWorkspaceContentsDependencies,
workspaceId: string,
archivedAt = new Date().toISOString(),
): Promise<Set<string>> {
return (await archiveWorkspaceContentsWithProgress(dependencies, workspaceId)).agentIds;
}
async function archiveWorkspaceContentsWithProgress(
dependencies: ArchiveWorkspaceContentsDependencies,
workspaceId: string,
): Promise<{ agentIds: Set<string>; newlyArchivedAgentIds: string[] }> {
const archivedAgents = new Set<string>();
const liveAgents = dependencies.agentManager
@@ -605,34 +581,23 @@ async function archiveWorkspaceContentsWithProgress(
archivedAgents.add(record.id);
}
const archivedAt = new Date().toISOString();
const agentIdsToArchive = [
...new Set([
...liveAgents.map((agent) => agent.id),
...matchingStoredRecords.filter((record) => !record.archivedAt).map((record) => record.id),
]),
];
// Undo brings back only what this archive took down. Internal agents are retired rather than
// archived, and an agent loaded only to read its history was archived before this request.
const restorableAgentIds = new Set(agentIdsToArchive);
for (const agent of liveAgents) {
if (agent.internal) restorableAgentIds.delete(agent.id);
}
for (const record of matchingStoredRecords) {
if (record.archivedAt) restorableAgentIds.delete(record.id);
}
const [agentResults, [terminalResult]] = await Promise.all([
Promise.allSettled(
agentIdsToArchive.map((agentId) =>
dependencies.agentManager.getAgent(agentId)
? dependencies.agentManager.archiveAgent(agentId)
? dependencies.agentManager.archiveAgent(agentId, archivedAt)
: dependencies.agentManager.archiveSnapshot(agentId, archivedAt),
),
),
Promise.allSettled([dependencies.killTerminalsForWorkspace(workspaceId)]),
]);
const newlyArchivedAgentIds: string[] = [];
for (const [index, result] of agentResults.entries()) {
const agentId = agentIdsToArchive[index]!;
if (result.status === "rejected") {
@@ -640,8 +605,6 @@ async function archiveWorkspaceContentsWithProgress(
{ err: result.reason, workspaceId, agentId },
"Workspace archive teardown step failed; continuing",
);
} else if (restorableAgentIds.has(agentId)) {
newlyArchivedAgentIds.push(agentId);
}
}
if (terminalResult?.status === "rejected") {
@@ -651,7 +614,7 @@ async function archiveWorkspaceContentsWithProgress(
);
}
return { agentIds: archivedAgents, newlyArchivedAgentIds };
return archivedAgents;
}
// True when, after archiving
@@ -748,3 +711,43 @@ export async function archivePersistedWorkspaceRecord(input: {
return existingWorkspace;
}
export interface WorkspaceArchiveRestoreRequest {
workspaceId: string;
archivedAt: string;
allowLegacyFallback?: boolean;
}
interface UnarchiveWorkspaceAgentsInput extends WorkspaceArchiveRestoreRequest {
agentStorage: Pick<AgentStorage, "listByWorkspace">;
unarchiveAgent: (agentId: string) => Promise<unknown>;
sessionLogger?: Logger;
}
export async function unarchiveWorkspaceAgents(
input: UnarchiveWorkspaceAgentsInput,
): Promise<void> {
const { workspaceId, archivedAt, allowLegacyFallback = true } = input;
const records = await input.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) => input.unarchiveAgent(record.id)),
);
for (const [index, result] of results.entries()) {
if (result.status === "rejected") {
input.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,