diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 26c9df8823..dea6c68255 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -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. diff --git a/packages/app/e2e/browser/worktree-restore.spec.ts b/packages/app/e2e/browser/worktree-restore.spec.ts index 6158906f3f..5849415984 100644 --- a/packages/app/e2e/browser/worktree-restore.spec.ts +++ b/packages/app/e2e/browser/worktree-restore.spec.ts @@ -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 { - 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 }); } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 250c675739..717745ee18 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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 }); + } +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 5cdbc7c195..808f207e84 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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, + parent: Pick, ): Promise { 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 { 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; } diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 1b91a7d90a..6d267ff53a 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -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), diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index f30966af2f..6fd3484ca3 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -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 () => { diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index da94c5931b..7ea7604b3e 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -29,7 +29,11 @@ export interface AutoArchiveArchiveOptions { findWorkspaceIdForCwd: (cwd: string) => Promise; listActiveWorkspaces: () => Promise; getAutoArchivedChangeRequestUrl: (workspaceId: string) => Promise; - archiveWorkspaceRecord: (workspaceId: string, context?: WorkspaceArchiveContext) => Promise; + archiveWorkspaceRecord: ( + workspaceId: string, + context?: WorkspaceArchiveContext, + archivedAt?: string, + ) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; clearWorkspaceArchiving: (workspaceIds: Iterable) => void; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; @@ -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, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 59990f817f..df4dd21c10 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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, diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 2243d936e9..68cc6a673f 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1150,6 +1150,7 @@ function createDeps(options?: { }; const workspaceGitService = createWorkspaceGitServiceStub(); const workspaceProvisioning = createWorkspaceProvisioningService({ + unarchiveWorkspaceAgents: async () => {}, projectRegistry, workspaceRegistry, workspaceGitService, diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 5f185676a8..26c4d0a62c 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -232,6 +232,7 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi await projectRegistry.initialize(); const workspaceGitService = createNoopWorkspaceGitService(); const workspaceProvisioning = createWorkspaceProvisioningService({ + unarchiveWorkspaceAgents: async () => {}, projectRegistry, workspaceRegistry, workspaceGitService, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 13f8db7397..2192c699ad 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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); } diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 706f199114..af5fc9c9f8 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -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 { + 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); +}); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 28a412e7e5..cec8850e37 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -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; ensureWorkspaceRecordUnarchived( workspace: PersistedWorkspaceRecord, + archivedAt?: string | null, ): Promise; } @@ -104,6 +106,7 @@ export class WorkspaceProvisioningError extends Error { } export function createWorkspaceProvisioningService(deps: { + unarchiveWorkspaceAgents: (request: WorkspaceArchiveRestoreRequest) => Promise; serverId?: string; workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; @@ -433,6 +436,7 @@ export function createWorkspaceProvisioningService(deps: { async function ensureWorkspaceRecordUnarchived( workspace: PersistedWorkspaceRecord, + archivedAt = workspace.archivedAt, ): Promise { 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( diff --git a/packages/server/src/server/workspace-archive-operations.test.ts b/packages/server/src/server/workspace-archive-operations.test.ts index c262f58fa3..4c6264d484 100644 --- a/packages/server/src/server/workspace-archive-operations.test.ts +++ b/packages/server/src/server/workspace-archive-operations.test.ts @@ -19,10 +19,9 @@ function deferred(): Deferred { 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 = []; 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 = []; + 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 = []; 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 = []; + 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 = []; - 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 = []; + 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 () => { diff --git a/packages/server/src/server/workspace-archive-operations.ts b/packages/server/src/server/workspace-archive-operations.ts index c8bb15a9d2..6083b07cc5 100644 --- a/packages/server/src/server/workspace-archive-operations.ts +++ b/packages/server/src/server/workspace-archive-operations.ts @@ -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 } | { kind: "undoing"; done: Promise }; -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(); - private readonly undoable = new Map(); - 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).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 | undefined): Promise { diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index ecf4210ece..b5d656bf7c 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -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; } - 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 = []; + 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([]); +}); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 54d772add1..7a71b4682b 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -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; - archiveWorkspaceRecord: (workspaceId: string) => Promise; + archiveWorkspaceRecord: (workspaceId: string, archivedAt: string) => Promise; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; clearWorkspaceArchiving: (workspaceIds: Iterable) => 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 { - 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; - 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; archivedWorkspaceIds: string[] }> { const archivedAgents = new Set(); 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> { - return (await archiveWorkspaceContentsWithProgress(dependencies, workspaceId)).agentIds; -} - -async function archiveWorkspaceContentsWithProgress( - dependencies: ArchiveWorkspaceContentsDependencies, - workspaceId: string, -): Promise<{ agentIds: Set; newlyArchivedAgentIds: string[] }> { const archivedAgents = new Set(); 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; + unarchiveAgent: (agentId: string) => Promise; + sessionLogger?: Logger; +} + +export async function unarchiveWorkspaceAgents( + input: UnarchiveWorkspaceAgentsInput, +): Promise { + 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", + ); + } + } +} diff --git a/packages/server/src/server/workspace-archive-undo.e2e.test.ts b/packages/server/src/server/workspace-archive-undo.e2e.test.ts index 674e99415b..a509cb2b6a 100644 --- a/packages/server/src/server/workspace-archive-undo.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-undo.e2e.test.ts @@ -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, +); diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 845e8e0a75..843fdaafca 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -354,6 +354,7 @@ function createPaseoWorktreeForTest(options: { }, }; const workspaceProvisioning = createWorkspaceProvisioningService({ + unarchiveWorkspaceAgents: async () => {}, projectRegistry, workspaceRegistry, workspaceGitService,