From 094ce49d4ffea8aabc3faf2509f7ba555178c370 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Feb 2026 18:15:31 +0700 Subject: [PATCH] fix: make agent metadata title/branch application reliable (#81) --- .../src/server/agent/agent-manager.test.ts | 32 ++++++++++ .../server/src/server/agent/agent-manager.ts | 8 ++- .../server/agent/agent-metadata-generator.ts | 1 + .../agent-metadata-generator.unit.test.ts | 64 +++++++++++++++++++ .../src/server/agent/agent-storage.test.ts | 36 +++++++++++ .../server/src/server/agent/agent-storage.ts | 8 +++ 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 6461e9554..106be31f8 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -439,6 +439,38 @@ describe("AgentManager", () => { expect(afterReload?.config?.title).toBeUndefined(); }); + test("setTitle bumps updatedAt and persists title in the same snapshot write", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-title-updated-at-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000127", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const before = await storage.get(snapshot.id); + expect(before).not.toBeNull(); + + await manager.setTitle(snapshot.id, "Generated title"); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBe("Generated title"); + expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt)); + + const live = manager.getAgent(snapshot.id); + expect(live).not.toBeNull(); + expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); + }); + test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-")); 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 690d96777..aad60207e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -859,7 +859,12 @@ export class AgentManager { async setTitle(agentId: string, title: string): Promise { const agent = this.requireAgent(agentId); - await this.registry?.setTitle(agentId, title); + const normalizedTitle = title.trim(); + if (!normalizedTitle) { + return; + } + this.touchUpdatedAt(agent); + await this.persistSnapshot(agent, { title: normalizedTitle }); this.emitState(agent); } @@ -878,6 +883,7 @@ export class AgentManager { if (!agent || agent.internal) { return; } + this.touchUpdatedAt(agent); this.emitState(agent); } diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index 29343079d..88e479c69 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -237,6 +237,7 @@ export async function generateAndApplyAgentMetadata( try { await renameCurrentBranchImpl(options.cwd, normalizedBranch); + options.agentManager.notifyAgentState(options.agentId); } catch (error) { options.logger.warn( { err: error, agentId: options.agentId, branch: normalizedBranch }, diff --git a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts index 64c7c7d3a..5e0b0ac2d 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts @@ -19,6 +19,23 @@ const NON_GIT_CHECKOUT_STATUS = { ReturnType> >; +const ELIGIBLE_WORKTREE_CHECKOUT_STATUS = { + isGit: true, + repoRoot: "/tmp/repo/metadata-worktree", + mainRepoRoot: "/tmp/repo", + currentBranch: "metadata-worktree", + isDirty: false, + baseRef: "main", + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + remoteUrl: null, + isPaseoOwnedWorktree: true, +} as Awaited< + ReturnType> +>; + function createDeps( generateStructuredAgentResponseWithFallback: NonNullable< AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] @@ -80,4 +97,51 @@ describe("agent metadata generator auto-title", () => { expect(generateStructured).not.toHaveBeenCalled(); expect(setTitle).not.toHaveBeenCalled(); }); + + it("notifies agent state after successfully renaming a generated branch", async () => { + const setTitle = vi.fn().mockResolvedValue(undefined); + const notifyAgentState = vi.fn(); + const manager = { + setTitle, + notifyAgentState, + } as unknown as AgentManager; + const renameCurrentBranch = vi + .fn() + .mockResolvedValue({ + previousBranch: "metadata-worktree", + currentBranch: "feature/metadata-worktree", + }) as NonNullable; + const generateStructured = vi.fn().mockResolvedValue({ + branch: "feature/metadata-worktree", + }) as NonNullable< + AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] + >; + const getCheckoutStatus = vi + .fn() + .mockResolvedValue(ELIGIBLE_WORKTREE_CHECKOUT_STATUS) as NonNullable< + AgentMetadataGeneratorDeps["getCheckoutStatus"] + >; + + await generateAndApplyAgentMetadata({ + agentManager: manager, + agentId: "agent-branch", + cwd: "/tmp/repo/metadata-worktree", + initialPrompt: "Rename this worktree branch.", + explicitTitle: "Keep explicit title", + paseoHome: "/tmp/paseo-home", + logger, + deps: { + generateStructuredAgentResponseWithFallback: generateStructured, + getCheckoutStatus, + renameCurrentBranch, + }, + }); + + expect(renameCurrentBranch).toHaveBeenCalledWith( + "/tmp/repo/metadata-worktree", + "feature/metadata-worktree" + ); + expect(notifyAgentState).toHaveBeenCalledWith("agent-branch"); + expect(setTitle).not.toHaveBeenCalled(); + }); }); diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 17bad2832..8749322f6 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -269,6 +269,42 @@ describe("AgentStorage", () => { expect(record?.lastStatus).toBe("running"); }); + test("applySnapshot waits for in-flight writes before reading existing title", async () => { + const agentId = "agent-pending-write"; + await storage.applySnapshot(createManagedAgent({ id: agentId })); + const initialRecord = await storage.get(agentId); + expect(initialRecord).not.toBeNull(); + + let releasePendingWrite: (() => void) | null = null; + const pendingWrite = new Promise((resolve) => { + releasePendingWrite = resolve; + }); + + const storageInternals = storage as unknown as { + pendingWrites: Map>; + cache: Map; + }; + storageInternals.pendingWrites.set(agentId, pendingWrite); + + const applySnapshotPromise = storage.applySnapshot( + createManagedAgent({ + id: agentId, + lifecycle: "running", + updatedAt: new Date("2025-01-02T00:00:00.000Z"), + }) + ); + + storageInternals.cache.set(agentId, { + ...initialRecord!, + title: "Generated title", + }); + releasePendingWrite?.(); + + await applySnapshotPromise; + const record = await storage.get(agentId); + expect(record?.title).toBe("Generated title"); + }); + test("list returns all agents including internal ones", async () => { // Create a normal agent await storage.applySnapshot( diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 5b751d618..0f7b78dbd 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -228,6 +228,7 @@ export class AgentStorage { options?: { title?: string | null; internal?: boolean } ): Promise { await this.load(); + await this.waitForPendingWrite(agent.id); const existing = (await this.get(agent.id)) ?? null; const hasTitleOverride = options !== undefined && Object.prototype.hasOwnProperty.call(options, "title"); @@ -252,6 +253,7 @@ export class AgentStorage { async setTitle(agentId: string, title: string): Promise { await this.load(); + await this.waitForPendingWrite(agentId); const record = await this.get(agentId); if (!record) { throw new Error(`Agent ${agentId} not found`); @@ -377,6 +379,12 @@ export class AgentStorage { return null; } + + private async waitForPendingWrite(agentId: string): Promise { + await (this.pendingWrites.get(agentId) ?? Promise.resolve()).catch( + () => undefined + ); + } } function projectDirNameFromCwd(cwd: string): string {