From 23b05bbcc5e01189b4892f8a258633e7b5e50a76 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:54:43 +0200 Subject: [PATCH] Keep archived agents out of discovery after history reads (#7532) * fix(server): report archived agents correctly over MCP An archived agent is resumed back into memory whenever something reads its history: agent-loading passes `{ purpose: "history" }` to resumeAgentFromPersistence exactly when `record.archivedAt` is set. `list_agents` then sees it as a live ManagedAgent, which carries no archive timestamp, so AgentDirectory's archive predicate passes it and the stored record's timestamp never reaches the result: - `list_agents` returns the agent with `archivedAt: null`, because serializeSnapshotWithMetadata merges only the title from storage. A caller reconciling ownership from that list concludes an archived agent is still running. - a default call (`includeArchived: false`) returns it anyway. Storage stays the single owner of the timestamp. The MCP boundary merges the stored record the same way the WebSocket path does in Session.enrichAgentPayload, reusing the storage read it already makes for the title, and `archivedAt` becomes a projection option beside `title`. `list_agents` filters archives after projection, where the timestamp is known. Copying the timestamp onto the managed agent was the other option and is worse: unarchiveSnapshot clears only the stored record, so a managed copy would report an unarchived agent as archived until it was reloaded. The wire change is additive: `archivedAt` is already nullable and optional in the protocol, and already populated on the stored path. Fixes #4753 Co-Authored-By: Claude Opus 5.5 * fix(server): keep archived agents out of discovery after history reads Apply archive filtering after stored metadata is projected in the shared directory. Exercise archive, history hydration, MCP discovery, WebSocket discovery and unarchive through a real daemon instead of mocked manager snapshots. Co-authored-by: Thomas Van --------- Co-authored-by: Thomas Van Co-authored-by: Claude Opus 5.5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-projections.ts | 8 ++ .../agent/archive-discovery.e2e.test.ts | 100 ++++++++++++++++++ packages/server/src/server/agent/directory.ts | 4 +- .../server/src/server/agent/mcp-shared.ts | 25 +++-- packages/server/src/server/messages.ts | 2 +- 5 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 packages/server/src/server/agent/archive-discovery.e2e.test.ts diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 3f2a9b7775..dbad9586db 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -30,6 +30,13 @@ interface ProjectionOptions { title?: string | null; createdAt?: string; internal?: boolean; + /** + * Archive timestamp, supplied by a caller holding the stored record. A live + * managed agent cannot know this: an archived agent is resumed back into + * memory to serve a history read, so being in the manager is not evidence + * that it is active. Storage stays the single owner. + */ + archivedAt?: string | null; } interface RecentProviderSessionProjectionOptions { @@ -139,6 +146,7 @@ export function toAgentPayload( persistence: projectPersistenceHandleForWire(agent.persistence), title: options?.title ?? null, labels: agent.labels, + archivedAt: options?.archivedAt ?? null, ...(agent.internal ? { internal: true } : {}), }; diff --git a/packages/server/src/server/agent/archive-discovery.e2e.test.ts b/packages/server/src/server/agent/archive-discovery.e2e.test.ts new file mode 100644 index 0000000000..f8d1dbc8e0 --- /dev/null +++ b/packages/server/src/server/agent/archive-discovery.e2e.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { z } from "zod"; +import { + AgentListItemPayloadSchema, + AgentSnapshotPayloadSchema, +} from "@getpaseo/protocol/messages"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; +import { ClaudeAgentClient } from "./providers/claude/agent.js"; + +// Opt in to the same workflow with the installed providers instead of test clients. +const real = process.env.PASEO_ARCHIVE_REAL === "1"; +const StatusSchema = z.object({ snapshot: AgentSnapshotPayloadSchema }); +const ListSchema = z.object({ agents: z.array(AgentListItemPayloadSchema) }); + +test.each(["codex", "claude"] as const)( + "archived %s history remains archived across MCP discovery", + async (provider) => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "archive-discovery-")); + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: real + ? { + [provider]: + provider === "codex" + ? new CodexAppServerAgentClient(logger) + : new ClaudeAgentClient({ logger }), + } + : createTestAgentClients(), + pluginsEnabled: false, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const mcp = new Client({ name: "archive-discovery-test", version: "1.0.0" }); + const call = async (name: string, args: Record) => { + const result = await mcp.callTool({ name, arguments: args }); + if (result.isError) throw new Error(JSON.stringify(result.content)); + return result.structuredContent; + }; + const list = async (includeArchived: boolean) => + ListSchema.parse(await call("list_agents", { cwd, includeArchived })).agents; + const status = async (agentId: string) => + StatusSchema.parse(await call("get_agent_status", { agentId })).snapshot; + try { + await client.connect(); + await mcp.connect( + new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${daemon.port}/mcp/agents`)), + ); + const realModel = provider === "codex" ? "gpt-5.6-sol" : "haiku"; + const agent = await client.createAgent({ + config: { + provider, + cwd, + model: real ? realModel : undefined, + modeId: provider === "codex" ? "full-access" : "bypassPermissions", + }, + }); + await daemon.daemon.agentManager.runAgent( + agent.id, + "Reply with ARCHIVE_OK. Do not use tools.", + ); + expect((await list(false)).map((item) => item.id)).toContain(agent.id); + await call("archive_agent", { agentId: agent.id }); + const archivedAt = (await status(agent.id)).archivedAt; + expect(archivedAt).toEqual(expect.any(String)); + expect((await list(false)).map((item) => item.id)).not.toContain(agent.id); + + const timeline = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 }); + expect(timeline.entries.length).toBeGreaterThan(0); + expect.soft((await status(agent.id)).archivedAt).toBe(archivedAt); + expect.soft((await list(false)).map((item) => item.id)).not.toContain(agent.id); + expect + .soft((await list(true)).find((item) => item.id === agent.id)?.archivedAt) + .toBe(archivedAt); + const ordinary = await client.fetchAgents({ filter: { includeArchived: false } }); + expect.soft(ordinary.entries.map((entry) => entry.agent.id)).not.toContain(agent.id); + const inclusive = await client.fetchAgents({ filter: { includeArchived: true } }); + expect + .soft(inclusive.entries.find((entry) => entry.agent.id === agent.id)?.agent.archivedAt) + .toBe(archivedAt); + + await client.refreshAgent(agent.id); + expect((await status(agent.id)).archivedAt).toBeNull(); + expect((await list(false)).map((item) => item.id)).toContain(agent.id); + } finally { + await mcp.close(); + await client.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, + 120_000, +); diff --git a/packages/server/src/server/agent/directory.ts b/packages/server/src/server/agent/directory.ts index 2f51d4a4c1..9df68dfde6 100644 --- a/packages/server/src/server/agent/directory.ts +++ b/packages/server/src/server/agent/directory.ts @@ -60,6 +60,8 @@ export class AgentDirectory { const active = await Promise.all( live.filter(matches).map((agent) => this.deps.projectLive(agent)), ); - return [...active, ...stored].filter((agent) => this.deps.isProviderVisible(agent.provider)); + return [...active, ...stored] + .filter(matches) + .filter((agent) => this.deps.isProviderVisible(agent.provider)); } } diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts index 4622d7bba4..0936474255 100644 --- a/packages/server/src/server/agent/mcp-shared.ts +++ b/packages/server/src/server/agent/mcp-shared.ts @@ -5,7 +5,7 @@ import type { AgentPermissionRequest } from "./agent-sdk-types.js"; import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js"; import { curateAgentActivity } from "./activity-curator.js"; import { selectItemsByProjectedLimit } from "./timeline-projection.js"; -import type { AgentStorage } from "./agent-storage.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import { serializeAgentSnapshot } from "../messages.js"; import { StoredScheduleSchema } from "@getpaseo/protocol/schedule/types"; import type { AgentProvider } from "./agent-sdk-types.js"; @@ -202,27 +202,36 @@ export function sanitizePermissionRequest( return sanitized; } -export async function resolveAgentTitle( +async function resolveAgentStorageRecord( agentStorage: AgentStorage, agentId: string, logger: Logger, -): Promise { +): Promise { try { - const record = await agentStorage.get(agentId); - return record?.title ?? null; + return (await agentStorage.get(agentId)) ?? null; } catch (error) { - logger.error({ err: error, agentId }, "Failed to load agent title"); + logger.error({ err: error, agentId }, "Failed to load agent record"); return null; } } +// Storage owns the archive timestamp, and a live snapshot cannot carry it: an +// archived agent is resumed back into memory to serve a history read (see the +// `purpose: "history"` resume in agent-loading), so presence in the manager is +// not evidence that the agent is active. Merge the stored record here, the same +// way the WebSocket path does in Session.enrichAgentPayload, rather than +// copying the value onto the managed agent, where it would be a second source +// of truth that unarchive has to remember to clear. export async function serializeSnapshotWithMetadata( agentStorage: AgentStorage, snapshot: ManagedAgent, logger: Logger, ) { - const title = await resolveAgentTitle(agentStorage, snapshot.id, logger); - return serializeAgentSnapshot(snapshot, { title }); + const record = await resolveAgentStorageRecord(agentStorage, snapshot.id, logger); + return serializeAgentSnapshot(snapshot, { + title: record?.title ?? null, + archivedAt: record?.archivedAt ?? null, + }); } export function parseDurationString(input: string): number { diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 3258c9e885..ddfe9e57f1 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -16,7 +16,7 @@ function validateStreamEventPayload(payload: unknown): AgentStreamEventPayload | export function serializeAgentSnapshot( agent: ManagedAgent, - options?: { title?: string | null }, + options?: { title?: string | null; archivedAt?: string | null }, ): AgentSnapshotPayload { return toAgentPayload(agent, options); }