diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 4545215eae..df4dd21c10 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -978,17 +978,13 @@ export async function createPaseoDaemon( } }); const workspaceProvisioning = createWorkspaceProvisioningService({ - unarchiveWorkspaceAgents: (workspaceId, archivedAt, allowLegacyFallback) => - unarchiveWorkspaceAgents( - { - agentStorage, - unarchiveAgent: (id) => unarchiveAgentState(agentStorage, agentManager, id), - sessionLogger: logger, - }, - workspaceId, - archivedAt, - allowLegacyFallback, - ), + unarchiveWorkspaceAgents: (request) => + unarchiveWorkspaceAgents({ + ...request, + agentStorage, + unarchiveAgent: (id) => unarchiveAgentState(agentStorage, agentManager, id), + sessionLogger: logger, + }), lifecycle: pluginRuntime, serverId, projectRegistry, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 934249172e..2192c699ad 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1004,17 +1004,13 @@ export class Session { }); this.workspaceAutoName = workspaceAutoName; this.workspaceProvisioning = createWorkspaceProvisioningService({ - unarchiveWorkspaceAgents: (workspaceId, archivedAt, allowLegacyFallback) => - unarchiveWorkspaceAgents( - { - agentStorage: this.agentStorage, - unarchiveAgent: (id) => unarchiveAgentState(this.agentStorage, this.agentManager, id), - sessionLogger: this.sessionLogger, - }, - workspaceId, - archivedAt, - allowLegacyFallback, - ), + 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, 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 f837ef4044..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"; @@ -1125,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 3c55031101..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"; @@ -105,11 +106,7 @@ export class WorkspaceProvisioningError extends Error { } export function createWorkspaceProvisioningService(deps: { - unarchiveWorkspaceAgents: ( - workspaceId: string, - archivedAt: string, - allowLegacyFallback: boolean, - ) => Promise; + unarchiveWorkspaceAgents: (request: WorkspaceArchiveRestoreRequest) => Promise; serverId?: string; workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; @@ -487,17 +484,16 @@ export function createWorkspaceProvisioningService(deps: { }); } } + if (next) await workspaceRegistry.upsert(next); // A canceled archive supplies a modern stamp before the record itself is archived. if (archivedAt) { - await deps.unarchiveWorkspaceAgents( - workspace.workspaceId, + await deps.unarchiveWorkspaceAgents({ + workspaceId: workspace.workspaceId, archivedAt, - Boolean(workspace.archivedAt), - ); + allowLegacyFallback: Boolean(workspace.archivedAt), + }); } - if (!next) return workspace; - await workspaceRegistry.upsert(next); - return next; + 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 1f0fec2445..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, "stamp"); + if (runSignal.aborted) throw new WorkspaceArchiveCanceledError("stamp"); return archived(agentIds); }, }; @@ -210,7 +209,6 @@ describe("WorkspaceArchiveOperations", () => { await operations.archive("ws", async () => archived(["agent-a"])); await operations.archive("ws", async () => ({ archivedAgentIds: ["agent-a"], - newlyArchivedAgentIds: [], archivedWorkspaceIds: [], removedDirectory: false, })); diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index 68388b7a0e..b5d656bf7c 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -1060,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"); @@ -1090,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, }); @@ -1133,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 () => { @@ -1155,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", @@ -1163,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", () => { @@ -1317,38 +1259,34 @@ test.each([ "restores only agents belonging to the workspace archive ($expected)", async ({ stamp, agents, expected }) => { const restored: string[] = []; - await unarchiveWorkspaceAgents( - { - agentStorage: { - listByWorkspace: async () => - agents.map(([id, archivedAt]) => ({ id, archivedAt })) as StoredAgentRecord[], - }, - unarchiveAgent: async (id) => { - restored.push(id); - }, + await unarchiveWorkspaceAgents({ + workspaceId: "workspace", + archivedAt: stamp, + agentStorage: { + listByWorkspace: async () => + agents.map(([id, archivedAt]) => ({ id, archivedAt })) as StoredAgentRecord[], }, - "workspace", - stamp, - ); + 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( - { - agentStorage: { - listByWorkspace: async () => - [{ id: "closed", archivedAt: "2026-11-09T09:59:59.000Z" }] as StoredAgentRecord[], - }, - unarchiveAgent: async (id) => { - restored.push(id); - }, + 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[], }, - "workspace", - "2026-11-09T10:00:00.000Z", - false, - ); + 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 3e889abb46..7a71b4682b 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -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,17 +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, - public readonly archivedAt: string, - ) { + constructor(public readonly archivedAt: string) { super("Workspace archive was canceled"); this.name = "WorkspaceArchiveCanceledError"; } @@ -167,14 +156,9 @@ async function archiveByScopeWithPriority( request: ArchiveByScopeRequest, ): Promise { const archivedAt = new Date().toISOString(); - const progress: ArchiveProgress = { newlyArchivedAgentIds: new Set(), recordArchived: false }; const checkpoint = () => { if (request.signal?.aborted) { - throw new WorkspaceArchiveCanceledError( - Array.from(progress.newlyArchivedAgentIds), - progress.recordArchived, - archivedAt, - ); + throw new WorkspaceArchiveCanceledError(archivedAt); } }; @@ -201,7 +185,7 @@ async function archiveByScopeWithPriority( const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords( dependencies, targetWorkspaceIds, - { requestId: request.requestId, checkpoint, progress, archivedAt }, + { requestId: request.requestId, checkpoint, archivedAt }, ); checkpoint(); @@ -232,7 +216,6 @@ async function archiveByScopeWithPriority( return { archivedAgentIds: Array.from(archivedAgents), - newlyArchivedAgentIds: Array.from(progress.newlyArchivedAgentIds), archivedWorkspaceIds, removedDirectory, ...(directoryLeftBehind ? { directoryLeftBehind } : {}), @@ -245,11 +228,6 @@ async function archiveByScopeWithPriority( } } -interface ArchiveProgress { - newlyArchivedAgentIds: Set; - recordArchived: boolean; -} - async function resolveArchiveTarget( dependencies: ArchiveDependencies, scope: ArchiveScope, @@ -399,7 +377,6 @@ async function archiveTargetRecords( options: { requestId: string; checkpoint: () => void; - progress: ArchiveProgress; archivedAt: string; }, ): Promise<{ archivedAgents: Set; archivedWorkspaceIds: string[] }> { @@ -408,20 +385,16 @@ async function archiveTargetRecords( const results = await Promise.allSettled( targetWorkspaceIds.map(async (workspaceId) => { - const contents = await archiveWorkspaceContentsWithProgress( + const contents = await archiveWorkspaceContents( dependencies, workspaceId, options.archivedAt, ); - for (const agentId of contents.newlyArchivedAgentIds) { - options.progress.newlyArchivedAgentIds.add(agentId); - } // 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.archivedAt); - options.progress.recordArchived = true; - return { workspaceId, agents: contents.agentIds }; + return { workspaceId, agents: contents }; }), ); @@ -432,11 +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, - options.archivedAt, - ); + throw result.reason; } else { dependencies.sessionLogger?.warn( { err: result.reason, requestId: options.requestId }, @@ -589,15 +558,6 @@ export async function archiveWorkspaceContents( workspaceId: string, archivedAt = new Date().toISOString(), ): Promise> { - return (await archiveWorkspaceContentsWithProgress(dependencies, workspaceId, archivedAt)) - .agentIds; -} - -async function archiveWorkspaceContentsWithProgress( - dependencies: ArchiveWorkspaceContentsDependencies, - workspaceId: string, - archivedAt: string, -): Promise<{ agentIds: Set; newlyArchivedAgentIds: string[] }> { const archivedAgents = new Set(); const liveAgents = dependencies.agentManager @@ -627,15 +587,6 @@ async function archiveWorkspaceContentsWithProgress( ...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) => @@ -647,7 +598,6 @@ async function archiveWorkspaceContentsWithProgress( Promise.allSettled([dependencies.killTerminalsForWorkspace(workspaceId)]), ]); - const newlyArchivedAgentIds: string[] = []; for (const [index, result] of agentResults.entries()) { const agentId = agentIdsToArchive[index]!; if (result.status === "rejected") { @@ -655,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") { @@ -666,7 +614,7 @@ async function archiveWorkspaceContentsWithProgress( ); } - return { agentIds: archivedAgents, newlyArchivedAgentIds }; + return archivedAgents; } // True when, after archiving @@ -764,19 +712,23 @@ export async function archivePersistedWorkspaceRecord(input: { return existingWorkspace; } -interface UnarchiveWorkspaceAgentsDependencies { +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( - dependencies: UnarchiveWorkspaceAgentsDependencies, - workspaceId: string, - archivedAt: string, - allowLegacyFallback = true, + input: UnarchiveWorkspaceAgentsInput, ): Promise { - const records = await dependencies.agentStorage.listByWorkspace(workspaceId); + 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 = @@ -788,11 +740,11 @@ export async function unarchiveWorkspaceAgents( return age >= 0 && age <= 60_000; }); const results = await Promise.allSettled( - matching.map((record) => dependencies.unarchiveAgent(record.id)), + matching.map((record) => input.unarchiveAgent(record.id)), ); for (const [index, result] of results.entries()) { if (result.status === "rejected") { - dependencies.sessionLogger?.warn( + input.sessionLogger?.warn( { err: result.reason, workspaceId, agentId: matching[index]!.id }, "Failed to unarchive workspace agent", );