From b97a2481a02757d4c4b447351232e52250615a88 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Nov 2026 08:10:34 +0100 Subject: [PATCH] fix(server): reopening a closed agent keeps metadata saved just before The loader reads the agent record, then queues a resume. A setMetadata queued ahead of that resume saves a newer record, and the open hooks saw it, but registration took metadata from the loader's older read and wrote it back over the saved patch. Registration now uses the metadata read inside the lifecycle lane. --- .../src/server/agent/agent-manager.test.ts | 31 +++++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 3 ++ 2 files changed, 34 insertions(+) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index b139b3a4b7..645b3aaa20 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -5597,6 +5597,37 @@ test("setLabels merges and persists labels", async () => { }); }); +test("reopening a closed agent keeps metadata saved while the loader was reading its record", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-metadata-reopen-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + const created = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Metadata reopen" }, + undefined, + { workspaceId: undefined, metadata: { first: 1 } }, + ); + const agentId = created.id; + await manager.closeAgent(agentId); + + // The loader reads the record, then a setMetadata is queued before its resume runs. + const readByLoader = await storage.get(agentId); + const saved = manager.setMetadata(agentId, { second: 2 }); + const reopened = manager.resumeAgentFromPersistence( + { provider: "codex", sessionId: "session-metadata", metadata: { cwd: workdir } }, + { cwd: workdir }, + agentId, + { metadata: readByLoader?.metadata }, + ); + + expect(await saved).toEqual({ first: 1, second: 2 }); + expect((await reopened).metadata).toEqual({ first: 1, second: 2 }); + expect((await storage.get(agentId))?.metadata).toEqual({ first: 1, second: 2 }); +}); + test("detachAgent removes relationship lifecycle labels from a live agent and emits state", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-")); 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 a3a046cf1d..98a6f00914 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1597,6 +1597,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 } : {}), launchEnv, persistence: handle, restoring: true,