From e07440ae069c09c6714fa5c693070bc3280cc8ab 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 0bf3526857..afa6d51848 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,