Compare commits

...
Author SHA1 Message Date
ef7ed5e555 fix(server): keep cleared attention when a queued resume lands
Clearing attention on a closed agent no longer resumes its provider
session, so a resume that the loader queued behind the clear must not
restore the attention it read before the clear. Resume now reads
attention from the record inside the agent's lifecycle lane, as it
already does for metadata.

Adds a daemon regression test for a closed agent whose provider session
is gone: on main the clear_agent_attention request never gets a response
and the agent stays unread.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 04:22:36 +00:00
luke a0d8eb66f8 fix(server): serialize attention clearing with reloads 2026-11-15 04:17:06 +00:00
luke 84bb8b8cd7 fix(server): clear attention without resuming closed agents 2026-11-15 04:17:06 +00:00
4 changed files with 142 additions and 12 deletions
@@ -3020,6 +3020,72 @@ test("opening during reload waits for the replacement", async () => {
}
});
test("clearing attention during reload waits for the replacement", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-clear-attention-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldReloadCloseClient();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
try {
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.markAgentUnread(created.id);
const reloading = manager.reloadAgentSession(created.id);
await client.waitForCloseToStart();
let cleared = false;
const clearing = manager.clearAgentAttention(created.id).then(() => {
cleared = true;
return true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(cleared).toBe(false);
client.finishClosing();
await Promise.all([reloading, clearing]);
expect(manager.getAgent(created.id)?.attention.requiresAttention).toBe(false);
expect((await storage.get(created.id))?.requiresAttention).toBe(false);
await manager.closeAgent(created.id);
} finally {
client.finishClosing();
await storage.flush();
rmSync(workdir, { recursive: true, force: true });
}
});
test("resuming after a queued attention clear keeps the agent read", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-resume-clear-attention-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
try {
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const handle = manager.getAgent(created.id)!.persistence!;
await manager.closeAgent(created.id);
await manager.markAgentUnread(created.id);
// A loader read the record before the clear landed, then queued the resume behind it.
const unreadAttention = {
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: new Date(),
} as const;
const clearing = manager.clearAgentAttention(created.id);
const resumed = await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, created.id, {
attention: unreadAttention,
});
await clearing;
expect(resumed.attention.requiresAttention).toBe(false);
expect((await storage.get(created.id))?.requiresAttention).toBe(false);
await manager.closeAgent(created.id);
} finally {
await storage.flush();
rmSync(workdir, { recursive: true, force: true });
}
});
test("closing during reload leaves the replacement closed", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-race-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
@@ -1722,9 +1722,9 @@ export class AgentManager {
return this.registerSession(session, storedConfig, resolvedAgentId, {
launchedOptions,
...options,
// The caller read its metadata before this turn in the lane; a setMetadata queued ahead of
// it has saved a newer record, the one the open hooks saw.
...(record ? { metadata: record.metadata } : {}),
// The caller read its metadata and attention before this turn in the lane; a setMetadata
// or attention clear queued ahead of it has saved a newer record, the one the open hooks saw.
...(record ? { metadata: record.metadata, attention: extractAttention(record) } : {}),
workspaceId,
launchEnv,
persistence: handle,
@@ -2763,6 +2763,10 @@ export class AgentManager {
}
async clearAgentAttention(agentId: string): Promise<void> {
return this.runLifecycleMutation(agentId, () => this.clearAgentAttentionUnlocked(agentId));
}
private async clearAgentAttentionUnlocked(agentId: string): Promise<void> {
const agent = this.agents.get(agentId);
if (agent) {
if (agent.attention.requiresAttention) {
@@ -16,6 +16,7 @@ import {
} from "@getpaseo/protocol/binary-frames/index";
import { DaemonClient } from "./test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { withTimeout } from "../utils/promise-timeout.js";
import {
SessionInboundMessageSchema,
type SessionInboundMessage,
@@ -1645,6 +1646,69 @@ test("request outcomes for import and attention reach only their requesting sock
}
});
test("clearing a closed agent's attention does not resume its provider session", async () => {
const daemon = await createTestPaseoDaemon({
mcpEnabled: false,
isDev: true,
agentClients: { mock: new MockLoadTestAgentClient() },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
const agent = await client.createAgent({ provider: "mock", cwd: daemon.staticDir });
await daemon.daemon.agentManager.closeAgent(agent.id);
await daemon.daemon.agentManager.markAgentUnread(agent.id);
await client.clearAgentAttention(agent.id);
expect(daemon.daemon.agentManager.getAgent(agent.id)).toBeNull();
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot?.agent.requiresAttention).toBe(false);
expect(snapshot?.agent.status).toBe("closed");
} finally {
await client.close();
await daemon.close();
}
});
class SessionGoneAgentClient extends MockLoadTestAgentClient {
resumeCalls = 0;
override async resumeSession(): Promise<never> {
this.resumeCalls += 1;
throw new Error("Session not found");
}
}
test("clearing a closed agent's attention succeeds when its provider session is gone", async () => {
const agentClient = new SessionGoneAgentClient();
const daemon = await createTestPaseoDaemon({
mcpEnabled: false,
isDev: true,
agentClients: { mock: agentClient },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
const agent = await client.createAgent({ provider: "mock", cwd: daemon.staticDir });
await daemon.daemon.agentManager.closeAgent(agent.id);
await client.markWorkspaceUnread(agent.workspaceId!);
expect((await client.fetchAgent(agent.id))?.agent.requiresAttention).toBe(true);
await withTimeout(
client.clearAgentAttention(agent.id),
5_000,
"clear_agent_attention got no response",
);
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot?.agent.requiresAttention).toBe(false);
expect(snapshot?.agent.status).toBe("closed");
expect(agentClient.resumeCalls).toBe(0);
} finally {
await client.close();
await daemon.close();
}
});
test("SDK import failure settles without waiting for the request timeout", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
+5 -9
View File
@@ -5394,15 +5394,11 @@ export class Session {
const agentIds = Array.isArray(agentId) ? agentId : [agentId];
try {
await Promise.all(
agentIds.map((id) =>
ensureAgentLoaded(id, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
}),
),
);
for (const id of agentIds) {
if (!this.agentManager.getAgent(id) && !(await this.agentStorage.get(id))) {
throw new Error(`Agent not found: ${id}`);
}
}
await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id)));
if (requestId) {
const agents = (