diff --git a/packages/app/e2e/archive-tab.spec.ts b/packages/app/e2e/archive-tab.spec.ts new file mode 100644 index 000000000..cdaf31c1c --- /dev/null +++ b/packages/app/e2e/archive-tab.spec.ts @@ -0,0 +1,106 @@ +import { randomUUID } from "node:crypto"; +import { test } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + archiveAgentFromDaemon, + archiveAgentFromSessions, + connectArchiveTabDaemonClient, + createIdleAgent, + expectSessionRowVisible, + expectWorkspaceArchiveOutcome, + openSessions, + openWorkspaceWithAgents, + primeAdditionalPage, + resetSeededPageState, + reloadWorkspace, +} from "./helpers/archive-tab"; + +test.describe("Archive tab reconciliation", () => { + let client: Awaited>; + let tempRepo: { path: string; cleanup: () => Promise }; + + test.beforeAll(async () => { + tempRepo = await createTempGitRepo("archive-tab-"); + client = await connectArchiveTabDaemonClient(); + }); + + test.afterAll(async () => { + await client?.close(); + await tempRepo?.cleanup(); + }); + + test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => { + const archived = await createIdleAgent(client, { + cwd: tempRepo.path, + title: `cli-archive-${randomUUID().slice(0, 8)}`, + }); + const surviving = await createIdleAgent(client, { + cwd: tempRepo.path, + title: `cli-control-${randomUUID().slice(0, 8)}`, + }); + const passivePage = await page.context().newPage(); + + try { + await primeAdditionalPage(passivePage); + await resetSeededPageState(page); + await resetSeededPageState(passivePage); + await openSessions(page); + await expectSessionRowVisible(page, archived.title); + await expectSessionRowVisible(page, surviving.title); + await openSessions(passivePage); + await expectSessionRowVisible(passivePage, archived.title); + await expectSessionRowVisible(passivePage, surviving.title); + await openWorkspaceWithAgents(page, [archived, surviving]); + await openWorkspaceWithAgents(passivePage, [archived, surviving]); + await archiveAgentFromDaemon(client, archived.id); + await expectWorkspaceArchiveOutcome(page, { + archivedAgentId: archived.id, + survivingAgentId: surviving.id, + }); + await expectWorkspaceArchiveOutcome(passivePage, { + archivedAgentId: archived.id, + survivingAgentId: surviving.id, + }); + await reloadWorkspace(passivePage, tempRepo.path); + await expectWorkspaceArchiveOutcome(passivePage, { + archivedAgentId: archived.id, + survivingAgentId: surviving.id, + }); + } finally { + await passivePage.close(); + } + }); + + test("Sessions archive prunes the archived tab across open pages", async ({ page }) => { + const archived = await createIdleAgent(client, { + cwd: tempRepo.path, + title: `ui-archive-${randomUUID().slice(0, 8)}`, + }); + const surviving = await createIdleAgent(client, { + cwd: tempRepo.path, + title: `ui-control-${randomUUID().slice(0, 8)}`, + }); + const passivePage = await page.context().newPage(); + + try { + await primeAdditionalPage(passivePage); + await resetSeededPageState(page); + await resetSeededPageState(passivePage); + await openWorkspaceWithAgents(page, [archived, surviving]); + await openWorkspaceWithAgents(passivePage, [archived, surviving]); + await openSessions(page); + await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title }); + await reloadWorkspace(page, tempRepo.path); + await expectWorkspaceArchiveOutcome(page, { + archivedAgentId: archived.id, + survivingAgentId: surviving.id, + }); + await expectWorkspaceArchiveOutcome(passivePage, { + archivedAgentId: archived.id, + survivingAgentId: surviving.id, + }); + } finally { + await passivePage.close(); + } + }); +}); diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts new file mode 100644 index 000000000..fe72677b8 --- /dev/null +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -0,0 +1,257 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, type Page } from "@playwright/test"; +import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry"; +import { waitForWorkspaceTabsVisible } from "./workspace-tabs"; +import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; + +export type ArchiveTabAgent = { + id: string; + title: string; + cwd: string; +}; + +type ArchiveTabDaemonClient = { + connect(): Promise; + close(): Promise; + createAgent(options: { + provider: string; + model: string; + thinkingOptionId: string; + modeId: string; + cwd: string; + title: string; + initialPrompt: string; + }): Promise<{ id: string }>; + archiveAgent(agentId: string): Promise<{ archivedAt: string }>; + waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>; +}; + +function getDaemonPort(): string { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error("E2E_DAEMON_PORT is not set."); + } + if (daemonPort === "6767") { + throw new Error("E2E_DAEMON_PORT must not point at the developer daemon."); + } + return daemonPort; +} + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + return serverId; +} + +function getDaemonWsUrl(): string { + return `ws://127.0.0.1:${getDaemonPort()}/ws`; +} + +function buildSeededStoragePayload() { + const nowIso = new Date().toISOString(); + return { + daemon: buildSeededHost({ + serverId: getServerId(), + endpoint: `127.0.0.1:${getDaemonPort()}`, + nowIso, + }), + preferences: buildCreateAgentPreferences(getServerId()), + }; +} + +async function loadDaemonClientConstructor(): Promise< + new (config: { + url: string; + clientId: string; + clientType: "cli"; + }) => ArchiveTabDaemonClient +> { + const repoRoot = path.resolve(process.cwd(), "../.."); + const moduleUrl = pathToFileURL( + path.join(repoRoot, "packages/server/dist/server/server/exports.js"), + ).href; + const mod = (await import(moduleUrl)) as { + DaemonClient: new (config: { + url: string; + clientId: string; + clientType: "cli"; + }) => ArchiveTabDaemonClient; + }; + return mod.DaemonClient; +} + +export async function connectArchiveTabDaemonClient(): Promise { + const DaemonClient = await loadDaemonClientConstructor(); + const client = new DaemonClient({ + url: getDaemonWsUrl(), + clientId: `app-e2e-archive-tab-${randomUUID()}`, + clientType: "cli", + }); + await client.connect(); + return client; +} + +export async function createIdleAgent( + client: ArchiveTabDaemonClient, + input: { cwd: string; title: string }, +): Promise { + const created = await client.createAgent({ + provider: "codex", + model: "gpt-5.1-codex-mini", + thinkingOptionId: "low", + modeId: "full-access", + cwd: input.cwd, + title: input.title, + initialPrompt: "Reply with exactly READY.", + }); + const finished = await client.waitForFinish(created.id, 120_000); + if (finished.status !== "idle") { + throw new Error(`Expected agent ${created.id} to become idle, got ${finished.status}.`); + } + return { + id: created.id, + title: input.title, + cwd: input.cwd, + }; +} + +export async function archiveAgentFromDaemon( + client: ArchiveTabDaemonClient, + agentId: string, +): Promise { + await client.archiveAgent(agentId); +} + +export async function primeAdditionalPage(page: Page): Promise { + const seedNonce = randomUUID(); + const { daemon, preferences } = buildSeededStoragePayload(); + + await page.route(/:(6767)\b/, (route) => route.abort()); + await page.routeWebSocket(/:(6767)\b/, async (ws) => { + await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." }); + }); + await page.addInitScript( + ({ daemon, preferences, seedNonce }) => { + const disableOnceKey = "@paseo:e2e-disable-default-seed-once"; + const disableValue = localStorage.getItem(disableOnceKey); + if (disableValue) { + localStorage.removeItem(disableOnceKey); + if (disableValue === seedNonce) { + return; + } + } + + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon])); + localStorage.removeItem("@paseo:settings"); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + }, + { daemon, preferences, seedNonce }, + ); + await page.goto("/"); +} + +export async function resetSeededPageState(page: Page): Promise { + const { daemon, preferences } = buildSeededStoragePayload(); + await page.goto("/"); + await page.evaluate( + ({ daemon, preferences }) => { + localStorage.clear(); + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + localStorage.removeItem("@paseo:settings"); + }, + { daemon, preferences }, + ); + await page.goto("/"); +} + +export async function openWorkspaceWithAgents( + page: Page, + agents: [ArchiveTabAgent, ArchiveTabAgent], +): Promise { + const serverId = getServerId(); + for (const agent of agents) { + await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd)); + await waitForWorkspaceTabsVisible(page); + await expectWorkspaceTabVisible(page, agent.id); + } +} + +export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise { + await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({ + timeout: 30_000, + }); +} + +export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise { + await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, { + timeout: 30_000, + }); +} + +export async function expectWorkspaceArchiveOutcome( + page: Page, + input: { archivedAgentId: string; survivingAgentId: string }, +): Promise { + await expectWorkspaceTabHidden(page, input.archivedAgentId); + await expectWorkspaceTabVisible(page, input.survivingAgentId); +} + +export async function reloadWorkspace(page: Page, workspaceId: string): Promise { + const serverId = getServerId(); + await page.goto(buildHostWorkspaceRoute(serverId, workspaceId)); + await waitForWorkspaceTabsVisible(page); +} + +export async function openSessions(page: Page): Promise { + const sessionsButton = page.getByTestId("sidebar-sessions"); + await expect(sessionsButton).toBeVisible({ timeout: 30_000 }); + await sessionsButton.click(); + await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), { + timeout: 30_000, + }); + await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({ + timeout: 30_000, + }); +} + +function getSessionRowByTitle(page: Page, title: string) { + return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first(); +} + +export async function expectSessionRowVisible(page: Page, title: string): Promise { + await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 }); +} + +export async function expectSessionRowArchived(page: Page, title: string): Promise { + await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 }); +} + +export async function archiveAgentFromSessions( + page: Page, + input: { agentId: string; title: string }, +): Promise { + const row = getSessionRowByTitle(page, input.title); + await expect(row).toBeVisible({ timeout: 30_000 }); + const box = await row.boundingBox(); + if (!box) { + throw new Error(`Could not read bounding box for session row ${input.agentId}.`); + } + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(900); + await page.mouse.up(); + + const archiveButton = page.getByTestId("agent-action-archive").first(); + await expect(archiveButton).toBeVisible({ timeout: 10_000 }); + await archiveButton.click(); + await expectSessionRowArchived(page, input.title); +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 60c5fb0de..b91bef121 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -6,7 +6,7 @@ import type { FetchAgentsOptions, } from "@server/client/daemon-client"; import type { HostConnection, HostProfile } from "@/types/host-connection"; -import { useSessionStore } from "@/stores/session-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; import { HostRuntimeController, HostRuntimeStore, @@ -112,6 +112,7 @@ function makeFetchAgentsEntry(input: { title?: string | null; requiresAttention?: boolean; attentionReason?: "permission" | "error" | null; + archivedAt?: string | null; }): FetchAgentsEntry { return { agent: { @@ -145,7 +146,7 @@ function makeFetchAgentsEntry(input: { requiresAttention: input.requiresAttention ?? false, attentionReason: input.attentionReason ?? null, attentionTimestamp: input.requiresAttention && input.attentionReason ? input.updatedAt : null, - archivedAt: null, + archivedAt: input.archivedAt ?? null, labels: {}, }, project: { @@ -1134,6 +1135,93 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("rehydrates archived agents over stale active session state after reconnect bootstrap", async () => { + const host = makeHost({ + serverId: "srv_archived_rehydrate", + connections: [ + { + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + }, + ], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [ + makeFetchAgentsEntry({ + id: "agent-archived", + cwd: "/Users/moboudra/dev/paseo", + updatedAt: "2026-03-30T15:30:00.000Z", + archivedAt: "2026-03-30T15:31:00.000Z", + title: "Archived remotely", + }), + ], + subscriptionId: "app:srv_archived_rehydrate", + }), + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + useSessionStore.getState().setAgents(host.serverId, () => { + const stale = makeFetchAgentsEntry({ + id: "agent-archived", + cwd: "/Users/moboudra/dev/paseo", + updatedAt: "2026-03-30T15:29:00.000Z", + archivedAt: null, + title: "Stale active copy", + }).agent; + const staleAgent: Agent = { + ...stale, + serverId: host.serverId, + createdAt: new Date(stale.createdAt), + updatedAt: new Date(stale.updatedAt), + lastUserMessageAt: null, + lastActivityAt: new Date(stale.updatedAt), + archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null, + attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null, + }; + return new Map([ + [ + stale.id, + staleAgent, + ], + ]); + }); + + store.syncHosts([host]); + + const timeoutAt = Date.now() + 300; + let archivedAt = + useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived") + ?.archivedAt ?? null; + while (!archivedAt && Date.now() < timeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + archivedAt = + useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived") + ?.archivedAt ?? null; + } + + expect(archivedAt?.toISOString()).toBe("2026-03-30T15:31:00.000Z"); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + it("records unavailable startup probes when no connection can be established", async () => { const host = makeHost({ connections: [ diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index 2a63f4fb2..63229fc11 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -102,6 +102,17 @@ describe("workspace agent visibility", () => { ).toBe(true); }); + it("prunes pinned archived agent tabs because archive state is authoritative", () => { + expect( + shouldPruneWorkspaceAgentTab({ + agentId: "archived-agent", + agentsHydrated: true, + knownAgentIds: new Set(["archived-agent"]), + activeAgentIds: new Set(), + }), + ).toBe(true); + }); + it("does not prune active agent tabs", () => { const knownAgentIds = new Set(["active-agent"]); const activeAgentIds = new Set(["active-agent"]); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 913aff9f1..e9b9c9fe9 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -952,7 +952,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) if ( canPruneAgentTabs && tab.target.kind === "agent" && - !pinnedAgentIds.has(tab.target.agentId) && shouldPruneWorkspaceAgentTab({ agentId: tab.target.agentId, agentsHydrated: hasHydratedAgents, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 00866731b..eceaadd11 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2263,6 +2263,50 @@ describe("AgentManager", () => { expect(attentionReasons).toEqual(["error", "error"]); }); + test("archiveAgent persists archivedAt and updatedAt before emitting closed state", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000131", + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Archive target", + }); + + const lifecycles: string[] = []; + manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === agent.id) { + lifecycles.push(event.agent.lifecycle); + } + }, + { agentId: agent.id, replayState: false }, + ); + + const { archivedAt } = await manager.archiveAgent(agent.id); + const stored = await storage.get(agent.id); + + expect(stored).toMatchObject({ + id: agent.id, + archivedAt, + updatedAt: archivedAt, + lastStatus: "idle", + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + }); + expect(lifecycles.slice(-2)).toEqual(["idle", "closed"]); + }); + test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 58f8e5fee..c3a705202 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -883,11 +883,13 @@ export class AgentManager { await this.registry.upsert({ ...stored, archivedAt, + updatedAt: archivedAt, lastStatus: normalizedStatus, requiresAttention: false, attentionReason: null, attentionTimestamp: null, }); + this.notifyAgentState(agentId); await this.closeAgent(agentId); return { archivedAt }; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 45bb25437..ee1ac2af9 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -979,6 +979,16 @@ export class Session { const storedRecord = await this.agentStorage.get(agent.id); const title = storedRecord?.title ?? storedRecord?.config?.title ?? null; const payload = toAgentPayload(agent, { title }); + const storedUpdatedAt = storedRecord + ? this.resolveStoredAgentPayloadUpdatedAt(storedRecord) + : null; + if (storedUpdatedAt) { + const liveUpdatedAt = Date.parse(payload.updatedAt); + const persistedUpdatedAt = Date.parse(storedUpdatedAt); + if (!Number.isNaN(persistedUpdatedAt) && (Number.isNaN(liveUpdatedAt) || persistedUpdatedAt > liveUpdatedAt)) { + payload.updatedAt = storedUpdatedAt; + } + } payload.archivedAt = storedRecord?.archivedAt ?? null; return payload; } @@ -994,7 +1004,7 @@ export class Session { } as const; const createdAt = new Date(record.createdAt); - const updatedAt = new Date(record.lastActivityAt ?? record.updatedAt); + const updatedAt = new Date(this.resolveStoredAgentPayloadUpdatedAt(record)); const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null; const provider = coerceAgentProvider(this.sessionLogger, record.provider, record.id); @@ -1045,6 +1055,23 @@ export class Session { }; } + private resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string { + const timestamps = [record.updatedAt, record.lastActivityAt] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .map((value) => ({ + raw: value, + parsed: Date.parse(value), + })) + .filter((value) => !Number.isNaN(value.parsed)); + + if (timestamps.length === 0) { + return record.updatedAt; + } + + timestamps.sort((a, b) => b.parsed - a.parsed); + return timestamps[0].raw; + } + private async ensureAgentLoaded(agentId: string): Promise { const existing = this.agentManager.getAgent(agentId); if (existing) { @@ -1961,7 +1988,40 @@ export class Session { private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise { this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`); - const { archivedAt } = await this.archiveAgentState(agentId); + if (this.agentManager.getAgent(agentId)) { + await this.interruptAgentIfRunning(agentId); + await this.agentManager.clearAgentAttention(agentId).catch(() => undefined); + } + + const { archivedAt } = await this.agentManager.archiveAgent(agentId); + const archivedRecord = await this.agentStorage.get(agentId); + if (!archivedRecord) { + throw new Error(`Agent not found in storage after archive: ${agentId}`); + } + + if (this.agentUpdatesSubscription) { + const payload = this.buildStoredAgentPayload(archivedRecord); + const project = await this.buildProjectPlacement(payload.cwd); + const matches = this.matchesAgentFilter({ + agent: payload, + project, + filter: this.agentUpdatesSubscription.filter, + }); + this.bufferOrEmitAgentUpdate( + this.agentUpdatesSubscription, + matches + ? { + kind: "upsert", + agent: payload, + project, + } + : { + kind: "remove", + agentId, + }, + ); + await this.emitWorkspaceUpdateForCwd(payload.cwd); + } this.emit({ type: "agent_archived", @@ -1973,70 +2033,16 @@ export class Session { }); } - private async archiveAgentState(agentId: string): Promise<{ - archivedAt: string; - archivedRecord: StoredAgentRecord; - }> { - if (this.agentManager.getAgent(agentId)) { - await this.interruptAgentIfRunning(agentId); - await this.agentManager.clearAgentAttention(agentId).catch(() => undefined); - } - - const archivedAt = new Date().toISOString(); - const existing = await this.agentStorage.get(agentId); - let archivedRecord: StoredAgentRecord | null = existing; - if (!archivedRecord) { - const liveAgent = this.agentManager.getAgent(agentId); - if (!liveAgent) { - throw new Error(`Agent not found: ${agentId}`); - } - - await this.agentStorage.applySnapshot(liveAgent, { - internal: liveAgent.internal, - }); - archivedRecord = await this.agentStorage.get(agentId); - if (!archivedRecord) { - throw new Error(`Agent not found in storage after snapshot: ${agentId}`); - } - } - - const normalizedStatus = - archivedRecord.lastStatus === "running" || archivedRecord.lastStatus === "initializing" - ? "idle" - : archivedRecord.lastStatus; - - const nextRecord: StoredAgentRecord = { - ...archivedRecord, - archivedAt, - lastStatus: normalizedStatus, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - }; - await this.agentStorage.upsert(nextRecord); - - // Unload the agent from memory — the storage record is the source of truth now. - // This tears down the provider session and drops the hydrated timeline, - // freeing memory. ensureAgentLoaded will re-initialize if needed later. - if (this.agentManager.getAgent(agentId)) { - try { - await this.agentManager.closeAgent(agentId); - } catch (error) { - this.sessionLogger.warn({ err: error, agentId }, "Failed to close agent during archive"); - } - } - - return { archivedAt, archivedRecord: nextRecord }; - } - private async unarchiveAgentState(agentId: string): Promise { const record = await this.agentStorage.get(agentId); if (!record || !record.archivedAt) { return false; } + const updatedAt = new Date().toISOString(); await this.agentStorage.upsert({ ...record, archivedAt: null, + updatedAt, }); this.agentManager.notifyAgentState(agentId); return true; diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 7fd90d892..bd6442633 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -82,6 +82,9 @@ function createSessionForWorkspaceTests(): Session { subscribe: () => () => {}, listAgents: () => [], getAgent: () => null, + archiveAgent: async () => ({ archivedAt: new Date().toISOString() }), + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, } as any, agentStorage: { list: async () => [], @@ -130,6 +133,145 @@ function createSessionForWorkspaceTests(): Session { } describe("workspace aggregation", () => { + test("archive emits an authoritative agent_update upsert for subscribed clients", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const archivedRecord = { + id: "agent-1", + provider: "codex", + cwd: "/tmp/repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + lastActivityAt: "2026-03-30T15:00:00.000Z", + lastUserMessageAt: null, + lastStatus: "idle", + lastModeId: null, + runtimeInfo: null, + config: { + provider: "codex", + cwd: "/tmp/repo", + }, + persistence: null, + title: "Archive me", + labels: {}, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + }; + + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const session = new Session({ + clientId: "test-client", + onMessage: (message) => emitted.push(message as any), + logger: logger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: () => null, + archiveAgent: async () => { + const archivedAt = new Date().toISOString(); + Object.assign(archivedRecord, { + archivedAt, + updatedAt: archivedAt, + }); + return { archivedAt }; + }, + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, + } as any, + agentStorage: { + list: async () => [archivedRecord], + get: async (agentId: string) => (agentId === archivedRecord.id ? archivedRecord : null), + } as any, + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + checkoutDiffManager: { + subscribe: async () => ({ + initial: { cwd: "/tmp/repo", files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + } as any, + createAgentMcpTransport: async () => { + throw new Error("not used"); + }, + stt: null, + tts: null, + terminalManager: null, + }) as any; + + session.agentUpdatesSubscription = { + subscriptionId: "sub-agents", + filter: { includeArchived: true }, + isBootstrapping: false, + pendingUpdatesByAgentId: new Map(), + }; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo", + checkout: { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + await session.handleArchiveAgentRequest("agent-1", "req-archive"); + + const update = emitted.find((message) => message.type === "agent_update"); + expect(update?.payload).toMatchObject({ + kind: "upsert", + agent: { + id: "agent-1", + archivedAt: expect.any(String), + }, + }); + expect( + emitted.find((message) => message.type === "agent_archived")?.payload, + ).toMatchObject({ + agentId: "agent-1", + archivedAt: expect.any(String), + requestId: "req-archive", + }); + }); + test("non-git workspace uses deterministic directory name and no unknown branch fallback", async () => { const session = createSessionForWorkspaceTests() as any; session.workspaceRegistry.list = async () => [