From ff89c852555752dd63fd6abd1e792a5ff72aa4fc Mon Sep 17 00:00:00 2001 From: thevortex8 Date: Sat, 21 Mar 2026 18:41:10 -0400 Subject: [PATCH] fix daemon stale-progress semantics and hydrate replay --- packages/app/src/contexts/session-context.tsx | 29 ++++++++++++----- .../session-queued-message-replay.test.ts | 31 ++++++++++++++++++- .../contexts/session-queued-message-replay.ts | 10 ++++++ .../src/server/agent/agent-manager.test.ts | 28 +++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 1 - 5 files changed, 89 insertions(+), 10 deletions(-) diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 1708c2b615..629222559d 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -49,7 +49,11 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen import { resolveProjectPlacement } from "@/utils/project-placement"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; -import { takeQueuedAgentMessageReplay } from "@/contexts/session-queued-message-replay"; +import { + shouldAutoReplayQueuedAgentMessage, + takeQueuedAgentMessageReplay, + type QueuedAgentReplaySource, +} from "@/contexts/session-queued-message-replay"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -355,7 +359,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ); const applyAuthoritativeAgentSnapshot = useCallback( - (agent: Agent) => { + (agent: Agent, options?: { source?: QueuedAgentReplaySource }) => { setAgents(serverId, (prev) => { const current = prev.get(agent.id); if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) { @@ -429,7 +433,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); const prevStatus = previousAgentStatusRef.current.get(agent.id); - if (prevStatus === "running" && agent.status !== "running") { + if ( + shouldAutoReplayQueuedAgentMessage({ + previousStatus: prevStatus, + nextStatus: agent.status, + source: options?.source ?? "live", + }) + ) { const session = useSessionStore.getState().sessions[serverId]; const replay = takeQueuedAgentMessageReplay(session?.queuedMessages.get(agent.id)); if (replay) { @@ -732,7 +742,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }), }; - applyAuthoritativeAgentSnapshot(agent); + applyAuthoritativeAgentSnapshot(agent, { source: "live" }); }, [ applyAuthoritativeAgentSnapshot, @@ -782,10 +792,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if (payload.agent) { const normalized = normalizeAgentSnapshot(payload.agent, serverId); - applyAuthoritativeAgentSnapshot({ - ...normalized, - projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null, - }); + applyAuthoritativeAgentSnapshot( + { + ...normalized, + projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null, + }, + { source: "hydrate" }, + ); } // Call pure reducer diff --git a/packages/app/src/contexts/session-queued-message-replay.test.ts b/packages/app/src/contexts/session-queued-message-replay.test.ts index 67186947b4..fad4b679de 100644 --- a/packages/app/src/contexts/session-queued-message-replay.test.ts +++ b/packages/app/src/contexts/session-queued-message-replay.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { takeQueuedAgentMessageReplay } from "./session-queued-message-replay"; +import { + shouldAutoReplayQueuedAgentMessage, + takeQueuedAgentMessageReplay, +} from "./session-queued-message-replay"; describe("takeQueuedAgentMessageReplay", () => { it("preserves the queued message id for idempotent replay", () => { @@ -47,4 +50,30 @@ describe("takeQueuedAgentMessageReplay", () => { expect(takeQueuedAgentMessageReplay([])).toBeNull(); expect(takeQueuedAgentMessageReplay(undefined)).toBeNull(); }); + + it("only auto-replays queued messages for live running-to-idle transitions", () => { + expect( + shouldAutoReplayQueuedAgentMessage({ + previousStatus: "running", + nextStatus: "idle", + source: "live", + }), + ).toBe(true); + + expect( + shouldAutoReplayQueuedAgentMessage({ + previousStatus: "running", + nextStatus: "idle", + source: "hydrate", + }), + ).toBe(false); + + expect( + shouldAutoReplayQueuedAgentMessage({ + previousStatus: "idle", + nextStatus: "idle", + source: "live", + }), + ).toBe(false); + }); }); diff --git a/packages/app/src/contexts/session-queued-message-replay.ts b/packages/app/src/contexts/session-queued-message-replay.ts index 7b361ed90f..f6d8c105ca 100644 --- a/packages/app/src/contexts/session-queued-message-replay.ts +++ b/packages/app/src/contexts/session-queued-message-replay.ts @@ -13,6 +13,16 @@ export interface QueuedAgentMessageReplay { remainingQueue: QueuedAgentMessageReplayItem[]; } +export type QueuedAgentReplaySource = "hydrate" | "live"; + +export function shouldAutoReplayQueuedAgentMessage(input: { + previousStatus: string | undefined; + nextStatus: string; + source: QueuedAgentReplaySource; +}): boolean { + return input.source === "live" && input.previousStatus === "running" && input.nextStatus !== "running"; +} + export function takeQueuedAgentMessageReplay( queue: readonly QueuedAgentMessageReplayItem[] | undefined, ): QueuedAgentMessageReplay | null { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 3882a1a925..30eb814cec 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1588,6 +1588,34 @@ describe("AgentManager", () => { } }); + test("notifyAgentState does not mutate updatedAt", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + 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-000000000121", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const before = manager.getAgent(snapshot.id); + expect(before).toBeDefined(); + + manager.notifyAgentState(snapshot.id); + + const after = manager.getAgent(snapshot.id); + expect(after).toBeDefined(); + expect(after!.updatedAt.getTime()).toBe(before!.updatedAt.getTime()); + }); + test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); 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 1e258ec19a..16800ed23b 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -847,7 +847,6 @@ export class AgentManager { if (!agent || agent.internal) { return; } - this.touchUpdatedAt(agent); this.emitState(agent); }