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 <noreply@anthropic.com>

* 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 <minhthong@gmail.com>

---------

Co-authored-by: Thomas Van <minhthong@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-21 04:54:43 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 Thomas Van
parent 1c62748084
commit 23b05bbcc5
5 changed files with 129 additions and 10 deletions
@@ -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 } : {}),
};
@@ -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<string, unknown>) => {
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,
);
@@ -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));
}
}
+17 -8
View File
@@ -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<string | null> {
): Promise<StoredAgentRecord | null> {
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 {
+1 -1
View File
@@ -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);
}