mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58e2b2de9c | ||
|
|
c7b067c19b | ||
|
|
e6fac1f592 |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+51
@@ -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);
|
||||
});
|
||||
|
||||
+14
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user