diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 57bd7889cb..182690d6a0 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -164,6 +164,13 @@ A finished workspace can be marked unread after it has been reviewed. The daemon `finished` attention on its newest eligible workspace-root agent without sending a new completion notification. Opening the workspace clears that attention through the normal focus flow. +Attention is set by the agent finishing or failing and cleared by the client's +`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is +neither, so resuming carries the stored attention and the stored last-activity time through +untouched. Forging either makes a background resume look like the user read a workspace and like the +agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted +`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start. + ## The subagents track The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`. diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index c078f96f8a..efbb0e9252 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -166,14 +166,19 @@ replica cache. The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`: -- Selective daemons receive every open workspace chat plus any visible agent pane. Workspace layout - owns open-chat lifetime, independently of mounted or retained React views. Switching workspaces, - evicting a retained view, and app backgrounding preserve that demand; closing the chat releases it. - Reconnect restores the open set and gives visible chats the first catch-up attempt. Hidden chats - follow when those attempts settle, including failures, so a failed visible chat does not starve - background recovery. Split panes catch up together. Hidden chats update the replica; on web their - retained presentation stays suspended until revealed, on native it keeps rendering. Revealing a - chat reads the current store and preserves its local UI state. There is no recent-agent limit. +- Selective daemons receive the chats this session has opened, plus any visible agent pane. A chat + enters that set the first time the user opens it and leaves when its tab closes or the session + ends, independently of mounted or retained React views: switching workspaces, evicting a retained + view, app backgrounding, and reconnect all preserve the demand. There is no recent-agent limit. + Restored workspace layout is a release signal, never a source. It carries a tab for every chat the + user has ever opened on the host, and a catch-up fetch resumes its agent on the daemon, so + subscribing from layout spawns a provider session per historical tab at every launch and stamps + every one of those workspaces as just used (see + [agent lifecycle](agent-lifecycle.md#workspace-activity)). Visible chats get the first catch-up + attempt; the rest follow when those attempts settle, including failures, so a failed visible chat + does not starve background recovery. Split panes catch up together. Hidden chats update the + replica; on web their retained presentation stays suspended until revealed, on native it keeps + rendering. Revealing a chat reads the current store and preserves its local UI state. - Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing authoritative catch-up, but the app does not issue selective-subscription RPCs. diff --git a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts index dcfeb8b8b9..5ef70cca61 100644 --- a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts +++ b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts @@ -5,6 +5,7 @@ import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-cl import { getServerId } from "../support/helpers/server-id"; import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery"; import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { selectWorkspaceInSidebar } from "../support/helpers/sidebar"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; import { @@ -53,9 +54,58 @@ async function seedViewedTimelineScenario( }; } +interface RestoredLayoutChat { + workspaceId: string; + agentId: string; + title: string; +} + +interface RestoredLayoutScenario { + chats: RestoredLayoutChat[]; + cleanup(): Promise; +} + +/** + * Two workspaces with two chats each. Opening all four leaves the persisted workspace layout + * holding a tab per chat, which is the state a relaunch restores into. + */ +async function seedRestoredLayoutScenario(): Promise { + const workspaces = await Promise.all([ + seedWorkspace({ repoPrefix: "restored-layout-first-" }), + seedWorkspace({ repoPrefix: "restored-layout-second-" }), + ]); + try { + const chats = await Promise.all( + workspaces.flatMap((workspace, workspaceIndex) => + [1, 2].map(async (chatIndex): Promise => { + const title = `Restored chat ${workspaceIndex + 1}-${chatIndex}`; + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title, + modeId: "load-test", + model: "ten-second-stream", + }); + return { workspaceId: workspace.workspaceId, agentId: agent.id, title }; + }), + ), + ); + return { + chats, + cleanup: async () => { + for (const workspace of workspaces) await workspace.cleanup(); + }, + }; + } catch (error) { + for (const workspace of workspaces) await workspace.cleanup().catch(() => undefined); + throw error; + } +} + async function openAgent( page: Page, - scenario: ViewedTimelineScenario, + scenario: { workspaceId: string }, agentId: string, options: { recordRenders?: boolean } = {}, ) { @@ -205,6 +255,43 @@ async function expectCurrentChatWithoutCatchUp(page: Page, message: string) { } test.describe("Viewed agent timelines", () => { + test("a reloaded layout subscribes only the chat it restores into", async ({ page }) => { + test.setTimeout(120_000); + const subscriptions = observeTimelineSubscriptions(page); + const scenario = await seedRestoredLayoutScenario(); + try { + // One document load, then sidebar and tab clicks — the way a user reaches these chats. + // A `page.goto` per chat would restart the app and empty the session's set each time. + const [first, second, third, fourth] = scenario.chats; + await openAgent(page, first!, first!.agentId); + await selectAgent(page, second!.title); + await selectWorkspaceInSidebar(page, third!.workspaceId); + await selectAgent(page, third!.title); + await selectAgent(page, fourth!.title); + await subscriptions.waitForSubscribedAgents(scenario.chats.map((chat) => chat.agentId)); + + // Relaunch. Layout comes back from disk carrying a tab for every chat above; only the + // one on screen may be subscribed, because subscribing resumes the agent on the daemon. + const restored = scenario.chats.at(-1)!; + const sibling = scenario.chats.at(-2)!; + subscriptions.reset(); + await page.reload(); + await waitForWorkspaceTabsVisible(page); + await expect(page.getByRole("button", { name: restored.title, exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByRole("button", { name: sibling.title, exact: true })).toBeVisible(); + await subscriptions.waitForSubscribedAgents([restored.agentId]); + + // Opening the restored sibling is what adds it, and it adds only itself. + await selectAgent(page, sibling.title); + await subscriptions.waitForSubscribedAgents([restored.agentId, sibling.agentId]); + } finally { + await scenario.cleanup(); + } + }); + test("open chats stay current after switching beyond five agents", async ({ page }) => { await withViewedTimelineScenario(async (scenario) => { await openSevenChats(page, scenario); diff --git a/packages/app/e2e/support/helpers/timeline-delivery.ts b/packages/app/e2e/support/helpers/timeline-delivery.ts index b843318527..917a5da18f 100644 --- a/packages/app/e2e/support/helpers/timeline-delivery.ts +++ b/packages/app/e2e/support/helpers/timeline-delivery.ts @@ -40,6 +40,13 @@ export function observeTimelineSubscriptions(page: Page) { }); return { + /** + * Forget what the daemon acknowledged so far. Use this before a reload, so a wait that + * follows can only be satisfied by a subscription the reloaded app actually sent. + */ + reset(): void { + acknowledgedAgentIds = null; + }, async waitForSubscribedAgents( agentIds: string[], options: TimelineSubscriptionWaitOptions = {}, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index ca6138995a..c1aa3b1f2b 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -498,7 +498,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setViewedTimelineSync(serverId, sync); sync.setActive(getIsAppVisible(appStateRef.current)); const stopObservingOpenChats = observeOpenWorkspaceAgentIds(serverId, (agentIds) => - sync.replaceOpenAgentIds(agentIds), + sync.replaceOpenTabAgentIds(agentIds), ); return () => { diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 6605ecec3b..b5ff97e9a4 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -16,6 +16,7 @@ vi.mock("@react-native-async-storage/async-storage", () => { }); import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; import { defaultChangesState, type ChangesState } from "@/panels/changes/state"; import { defaultFileState, type FileState } from "@/panels/file/state"; @@ -107,6 +108,58 @@ it("observes open chats across unmounted workspaces until their tabs close", () expect(received).toEqual([[], ["agent-a"], ["agent-a", "agent-b"], ["agent-b"]]); }); +it("feeds restored layout to timeline sync as a release signal, not a subscription source", async () => { + const store = createWorkspaceLayoutStore(workspaceLayoutIds); + store.setState({ layoutByWorkspace: {} }); + // Launch: layout rehydrates tabs the user opened in earlier sessions. + const restored = store.getState().openTab({ + workspaceKey: "server-1:workspace-a", + target: { kind: "agent", agentId: "agent-a" }, + intent: "reveal", + }); + store.getState().openTab({ + workspaceKey: "server-1:workspace-b", + target: { kind: "agent", agentId: "agent-b" }, + intent: "background", + }); + + const observed: string[][] = []; + const sync = createViewedTimelineSync({ + replaceDemandedAgentIds: () => undefined, + prepare: async () => undefined, + observe: (agentIds) => { + observed.push(agentIds); + return { ready: Promise.resolve(), release: async () => undefined }; + }, + readCursor: () => undefined, + fetchPage: async () => ({ hasNewer: false, endCursor: null }), + fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), + reportError: () => undefined, + schedule: () => () => undefined, + }); + // The edge session-context.tsx owns. Pointing it at subscription instead would resume every + // agent behind a restored tab on the daemon. + const stop = observeOpenWorkspaceAgentIds( + "server-1", + (agentIds) => sync.replaceOpenTabAgentIds(agentIds), + store, + ); + + sync.setConnected(true); + expect(observed).toEqual([]); + + sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + await vi.waitFor(() => expect(observed).toEqual([["agent-a"]])); + + if (!restored) throw new Error("Expected an open agent tab"); + store.getState().closeTab("server-1:workspace-a", restored); + sync.replaceVisibleAgentIds("workspace", []); + await vi.waitFor(() => expect(observed.at(-1)).toEqual([])); + + stop(); + sync.dispose(); +}); + function useWorkspaceLayoutIds(...values: string[]) { workspaceLayoutIds.useValues(values); } diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 6fe51580ad..5de128b631 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -1794,7 +1794,12 @@ export function createWorkspaceLayoutStore( export const useWorkspaceLayoutStore = createWorkspaceLayoutStore(); -/** Observe open chats independently of which workspace views are mounted. */ +/** + * The agent tabs that exist right now, across every workspace of this host, independently + * of which workspace views are mounted. Timeline sync uses this to release a chat when its + * tab closes. It is not a subscription source: this layout is restored from disk at launch, + * so subscribing to it would resume every agent the user has ever opened. + */ export function observeOpenWorkspaceAgentIds( serverId: string, listener: (agentIds: string[]) => void, diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index ed1c9e71c7..e850621cbb 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -40,7 +40,7 @@ interface TimelineFetch { test("split panes catch up together before hidden open chats start fetching", async () => { const world = new TimelineWorld(); - world.sync.replaceOpenAgentIds(["a-hidden", "y-visible", "z-visible"]); + world.openedEarlier(["a-hidden"]); world.sync.replaceVisibleAgentIds("panes", ["y-visible", "z-visible"]); world.sync.setConnected(true); (await world.nextMembership()).succeed(); @@ -57,7 +57,7 @@ test("split panes catch up together before hidden open chats start fetching", as test("backgrounding releases hidden catch-ups waiting on a visible chat", async () => { const world = new TimelineWorld(); - world.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]); + world.openedEarlier(["a-hidden"]); world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); world.sync.setConnected(true); (await world.nextMembership()).succeed(); @@ -71,7 +71,7 @@ test("backgrounding releases hidden catch-ups waiting on a visible chat", async test("visible catch-up settles before hidden open chats start fetching", async () => { const world = new TimelineWorld(); - world.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]); + world.openedEarlier(["a-hidden"]); world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); world.sync.setConnected(true); (await world.nextMembership()).succeed(); @@ -86,7 +86,7 @@ test("visible catch-up settles before hidden open chats start fetching", async ( test("a failed visible catch-up releases hidden chats without bypassing its retry delay", async () => { const world = new TimelineWorld(); - world.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]); + world.openedEarlier(["a-hidden"]); world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); world.sync.setConnected(true); (await world.nextMembership()).succeed(); @@ -104,7 +104,7 @@ test("a failed visible catch-up releases hidden chats without bypassing its retr test("switching chats promotes the newly visible catch-up while another visible request is pending", async () => { const world = new TimelineWorld(); - world.sync.replaceOpenAgentIds(["a-hidden", "b-hidden", "z-visible"]); + world.openedEarlier(["a-hidden", "b-hidden"]); world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); world.sync.setConnected(true); (await world.nextMembership()).succeed(); @@ -202,14 +202,16 @@ class TimelineWorld { }, }); - private openAgentIds = new Set(); - show(sourceId: string, agentIds: string[]): void { - for (const id of agentIds) this.openAgentIds.add(id); - this.sync.replaceOpenAgentIds([...this.openAgentIds]); this.sync.replaceVisibleAgentIds(sourceId, agentIds); } + /** Chats the user opened earlier in this session and has since navigated away from. */ + openedEarlier(agentIds: string[]): void { + this.sync.replaceVisibleAgentIds("earlier", agentIds); + this.sync.replaceVisibleAgentIds("earlier", []); + } + private readonly memberships: MembershipRequest[] = []; private readonly membershipWaiters: Array<(request: MembershipRequest) => void> = []; private readonly fetches: TimelineFetch[] = []; @@ -548,6 +550,8 @@ test("an eviction starts and acknowledges B before A returns its late subscripti world.sync.setConnected(true); await vi.waitFor(() => expect(membershipRequests()).toHaveLength(1)); const a = membershipRequests()[0]!; + // Closing the five tabs releases them; opening a sixth chat is what commits the swap. + world.sync.replaceOpenTabAgentIds(["agent-f"]); world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); await vi.waitFor(() => expect(membershipRequests()).toHaveLength(2)); const b = membershipRequests()[1]!; @@ -585,10 +589,30 @@ test("an eviction starts and acknowledges B before A returns its late subscripti } }); +test("a restored workspace layout subscribes nothing until the user opens a chat", async () => { + const world = new TimelineWorld(); + // Launch. Workspace layout rehydrates from disk carrying a tab for every chat the user + // has ever opened on this host; none of them is open in this session yet. + world.sync.replaceOpenTabAgentIds(["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]); + world.sync.setConnected(true); + world.expectNoPendingMembership(); + world.expectNoPendingFetch(); + + // The workspace the app restores into shows one chat. Only that chat is fetched, so only + // that agent is resumed on the daemon. + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + expect(membership.agentIds).toEqual(["agent-a"]); + membership.succeed(); + (await world.nextFetch("agent-a")).respond({ hasNewer: false }); + world.expectNoPendingFetch(); + world.sync.dispose(); +}); + test("open chats remain subscribed beyond five views and without mounted workspace panes", async () => { const world = new TimelineWorld(); const agents = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]; - world.sync.replaceOpenAgentIds(agents); + world.openedEarlier(agents); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); world.sync.setConnected(true); const membership = await world.nextMembership(); @@ -605,7 +629,7 @@ test("open chats remain subscribed beyond five views and without mounted workspa world.expectNoPendingFetch(); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"); - world.sync.replaceOpenAgentIds(agents.slice(0, 5)); + world.sync.replaceOpenTabAgentIds(agents.slice(0, 5)); const closed = await world.nextMembership(); expect(closed.agentIds).toEqual(agents.slice(0, 5)); closed.succeed(); @@ -635,18 +659,19 @@ test("disconnect cancels paging and reconnect restores membership before fresh c world.expectNoPendingFetch(); }); -test("navigation while disconnected reconnects only the currently visible agent", async () => { +test("navigation while disconnected keeps both opened chats for reconnect", async () => { const world = new TimelineWorld(); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); world.sync.setConnected(true); const membership = await world.nextMembership(); + expect(membership.agentIds).toEqual(["agent-a", "agent-b"]); membership.succeed(); - const catchUp = await world.nextFetch("agent-b"); - catchUp.respond({ hasNewer: false }); - - expect(membership.agentIds).toEqual(["agent-b"]); + // A dropped socket is not the user closing a chat, so reconnect owes both of them a + // catch-up. The visible one goes first. + (await world.nextFetch("agent-b")).respond({ hasNewer: false }); + (await world.nextFetch("agent-a")).respond({ hasNewer: false }); world.expectNoPendingFetch(); }); @@ -1028,26 +1053,31 @@ test("visible agents stay subscribed even before open-tab membership arrives", a expect(membership.agentIds).toEqual(visible); + // Hiding a pane is not closing its chat, so the sixth stays subscribed. world.sync.replaceVisibleAgentIds("workspace", visible.slice(0, 5)); - const hiddenEviction = await world.nextMembership(); - hiddenEviction.succeed(); - expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5)); + world.expectNoPendingMembership(); + + // Closing its tab is what releases it. + world.sync.replaceOpenTabAgentIds(visible.slice(0, 5)); + const closed = await world.nextMembership(); + closed.succeed(); + expect(closed.agentIds).toEqual(visible.slice(0, 5)); }); -test("disconnect restores visible demand and revisiting a closed chat fetches again", async () => { +test("reconnect restores open demand and reopening a closed chat fetches again", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const agentAMembership = await world.nextMembership(); agentAMembership.succeed(); - const agentACatchUp = await world.nextFetch("agent-a"); - agentACatchUp.respond({ hasNewer: false }); + (await world.nextFetch("agent-a")).respond({ hasNewer: false }); + // Close agent-a's tab, then open agent-b. + world.sync.replaceOpenTabAgentIds(["agent-b"]); world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); const agentBMembership = await world.nextMembership(); agentBMembership.succeed(); - const agentBCatchUp = await world.nextFetch("agent-b"); - agentBCatchUp.respond({ hasNewer: false }); + (await world.nextFetch("agent-b")).respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); world.sync.setConnected(false); @@ -1055,17 +1085,14 @@ test("disconnect restores visible demand and revisiting a closed chat fetches ag world.sync.setConnected(true); const restored = await world.nextMembership(); restored.succeed(); - const restoredCatchUp = await world.nextFetch("agent-b"); - restoredCatchUp.respond({ hasNewer: false }); - + (await world.nextFetch("agent-b")).respond({ hasNewer: false }); expect(restored.agentIds).toEqual(["agent-b"]); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const reopened = await world.nextMembership(); reopened.succeed(); - const reopenedCatchUp = await world.nextFetch("agent-a"); - reopenedCatchUp.respond({ hasNewer: false }); - expect(reopened.agentIds).toEqual(["agent-a"]); + (await world.nextFetch("agent-a")).respond({ hasNewer: false }); + expect(reopened.agentIds).toEqual(["agent-a", "agent-b"]); }); test("disposing a view releases its pending observation before bootstrap completes", async () => { diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 8bc2ca83f7..5f805a829b 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -332,7 +332,12 @@ export interface ViewedTimelineUiBridge { } export interface ViewedTimelineSync extends ViewedTimelineUiBridge { - replaceOpenAgentIds(agentIds: string[]): void; + /** + * The agent tabs that currently exist in workspace layout. This only ever *releases* + * subscriptions: closing a tab drops its chat. It never adds one, because layout is + * restored from disk at launch and those tabs are chats the user opened days ago. + */ + replaceOpenTabAgentIds(agentIds: string[]): void; setActive(active: boolean): void; setConnected(connected: boolean): void; recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void; @@ -495,10 +500,21 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed let membershipNeedsRetry = false; let membershipRetryDelayMs: number | undefined; let cancelMembershipRetry: (() => void) | null = null; - let openAgentIds: string[] = []; + // Chats this session has opened. Visibility is the only way in, so an agent is never + // subscribed — and never resumed on the daemon — until the user actually opens it. + // Membership then survives hiding, workspace switches, backgrounding and reconnect; + // only closing the tab or ending the session takes it out. + let openedAgentIds: string[] = []; const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat()); + const rememberOpenedAgentIds = (agentIds: string[]) => { + if (agentIds.length === 0) return; + const next = normalizeAgentIds([...openedAgentIds, ...agentIds]); + if (sameAgentIds(next, openedAgentIds)) return; + openedAgentIds = next; + }; + const isAcknowledged = (agentId: string) => acknowledged.includes(agentId); const isDesired = (agentId: string) => desired.includes(agentId); const canCatchUp = (agentId: string) => @@ -795,7 +811,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed }; const publishVisibleMembership = () => { - commitDesiredMembership(normalizeAgentIds([...openAgentIds, ...visibleAgentIds()])); + commitDesiredMembership(normalizeAgentIds([...openedAgentIds, ...visibleAgentIds()])); }; return { @@ -812,16 +828,18 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed getAgentTimelineError(agentId) { return visibilityCatchUpErrors.get(agentId) ?? null; }, - replaceOpenAgentIds(agentIds) { - const next = normalizeAgentIds(agentIds); - if (sameAgentIds(next, openAgentIds)) return; - openAgentIds = next; + replaceOpenTabAgentIds(agentIds) { + const openTabs = new Set(agentIds); + const next = openedAgentIds.filter((agentId) => openTabs.has(agentId)); + if (sameAgentIds(next, openedAgentIds)) return; + openedAgentIds = next; publishVisibleMembership(); }, replaceVisibleAgentIds(sourceId, agentIds) { const normalized = normalizeAgentIds(agentIds); if (normalized.length === 0) sources.delete(sourceId); else sources.set(sourceId, normalized); + rememberOpenedAgentIds(normalized); publishVisibleMembership(); startAcknowledgedCatchUps(); }, @@ -879,7 +897,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed desired = []; ports.replaceDemandedAgentIds([]); acknowledged = []; - openAgentIds = []; + openedAgentIds = []; loadedCache.clear(); cacheLoads.clear(); visibilityCatchUpPending.clear(); diff --git a/packages/server/src/server/agent/agent-loading.test.ts b/packages/server/src/server/agent/agent-loading.test.ts index 0f58d94c62..9158202979 100644 --- a/packages/server/src/server/agent/agent-loading.test.ts +++ b/packages/server/src/server/agent/agent-loading.test.ts @@ -80,3 +80,63 @@ test("loads archived records for history and active records with the interactive await rm(root, { recursive: true, force: true }); } }); + +test("resuming a stored agent keeps its unread flag and its last-activity time", async () => { + const root = await mkdtemp(path.join(tmpdir(), "agent-loading-resume-")); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(root, "agents"), logger); + const manager = new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + logger, + }); + + const agentId = "00000000-0000-4000-8000-000000000401"; + const lastActive = "2026-01-02T03:04:05.000Z"; + const markedUnread = "2026-01-09T03:04:05.000Z"; + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: root }, agentId, { + workspaceId: "workspace-a", + }); + await manager.closeAgent(agent.id); + await manager.flush(); + await storage.flush(); + + const stored = await storage.get(agentId); + if (!stored) { + throw new Error("expected a stored agent"); + } + // The agent finished days ago, and was marked unread later without being opened, which + // moves `updatedAt` on its own. Clients already hold that newer time, and + // `acceptAgentDirectoryUpdate` drops anything older, so the resumed agent must not come + // back carrying only `lastActivityAt`. + await storage.upsert({ + ...stored, + updatedAt: markedUnread, + lastActivityAt: lastActive, + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: lastActive, + }); + + await ensureAgentLoaded(agentId, { agentManager: manager, agentStorage: storage, logger }); + await manager.flush(); + await storage.flush(); + + // Loading the runtime is neither the agent working nor the user reading the chat. + // Forging either rewrites the workspace's "last used" and drops it out of Ready to review. + const resumed = await storage.get(agentId); + expect(resumed?.requiresAttention).toBe(true); + expect(resumed?.attentionReason).toBe("finished"); + expect(resumed?.updatedAt).toBe(markedUnread); + expect(resumed?.lastActivityAt).toBe(markedUnread); + expect(manager.getAgent(agentId)?.attention.requiresAttention).toBe(true); + expect(manager.getAgent(agentId)?.updatedAt.toISOString()).toBe(markedUnread); + } finally { + await manager.closeAgent(agentId).catch(() => undefined); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index f87f38911c..248e8d2b1b 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -6,6 +6,7 @@ import type { AgentStorage } from "./agent-storage.js"; import { buildConfigOverrides, buildSessionConfig, + extractAttention, extractTimestamps, isStoredAgentProviderAvailable, toAgentPersistenceHandle, @@ -109,11 +110,14 @@ export async function ensureAgentLoaded( handle, buildConfigOverrides(record), agentId, - extractTimestamps(record), + { ...extractTimestamps(record), attention: extractAttention(record) }, record.archivedAt ? { purpose: "history" } : undefined, ); deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence"); } else { + // No provider handle to resume: this starts the agent's first session rather than + // bringing one back, so it stamps activity and carries no stored attention. Records + // without a handle never got far enough to accumulate either. const config = buildSessionConfig(record, { validProviders, }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 5232b33a17..9d3a51feb2 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -89,6 +89,7 @@ import { type ProviderSubagentStoreEvent, } from "./provider-subagents/store.js"; import { withTimeout } from "../../utils/promise-timeout.js"; +import { extractAttention } from "../persistence-hooks.js"; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; @@ -340,7 +341,7 @@ export interface WaitForAgentStartOptions { signal?: AbortSignal; } -type AttentionState = +export type AttentionState = | { requiresAttention: false } | { requiresAttention: true; @@ -1283,6 +1284,7 @@ export class AgentManager { labels?: Record; workspaceId?: string; owner?: AgentOwner; + attention?: AttentionState; }, resumeOptions?: AgentResumeSessionOptions, ): Promise { @@ -1314,6 +1316,7 @@ export class AgentManager { labels?: Record; workspaceId?: string; owner?: AgentOwner; + attention?: AttentionState; }, resumeOptions?: AgentResumeSessionOptions, ): Promise { @@ -1370,6 +1373,7 @@ export class AgentManager { return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, persistence: handle, + restoring: true, }); } @@ -1559,6 +1563,7 @@ export class AgentManager { lastUsage: preservedLastUsage, lastError: preservedLastError, attention: preservedAttention, + restoring: true, }); } catch (error) { if (closedExisting) { @@ -1838,14 +1843,7 @@ export class AgentManager { private dispatchStoredAgentState(record: StoredAgentRecord): void { const updatedAt = new Date(record.updatedAt); - const attention: AttentionState = - record.requiresAttention && record.attentionReason && record.attentionTimestamp - ? { - requiresAttention: true, - attentionReason: record.attentionReason, - attentionTimestamp: new Date(record.attentionTimestamp), - } - : { requiresAttention: false }; + const attention = extractAttention(record); this.dispatch({ type: "agent_state", agent: { @@ -3414,6 +3412,12 @@ export class AgentManager { lastUsage?: AgentUsage; lastError?: string; attention?: AttentionState; + /** + * Bringing a known agent back, rather than starting a new one. Its timestamps and + * attention come from what was already recorded, and installing the session is not + * activity in it. + */ + restoring?: boolean; initialTitle?: string | null; publishWhenReady?: boolean; workspaceId?: string; @@ -3467,7 +3471,12 @@ export class AgentManager { await this.refreshSessionState(managed, { emit: false }); this.assertAgentRegistrationActive(managed); managed.lifecycle = "idle"; - this.touchUpdatedAt(managed); + // Stamping now over a restored timestamp rewrote the workspace's "last used" in the + // sidebar every time a chat was reopened, because workspace `statusEnteredAt` is + // re-derived from persisted agent `updatedAt` on every daemon start. + if (!options?.restoring) { + this.touchUpdatedAt(managed); + } await this.persistSnapshot(managed); this.assertAgentRegistrationActive(managed); this.emitState(managed, { persist: false }); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index bd4042796a..05f932aea9 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -19,7 +19,11 @@ import type { } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { JsonValue } from "../json-utils.js"; -import { isStoredAgentProviderAvailable, toAgentPersistenceHandle } from "../persistence-hooks.js"; +import { + isStoredAgentProviderAvailable, + resolveStoredAgentUpdatedAt, + toAgentPersistenceHandle, +} from "../persistence-hooks.js"; export type { ManagedAgent }; interface ProjectionOptions { @@ -208,7 +212,7 @@ export function buildStoredAgentPayload( } as const; const createdAt = new Date(record.createdAt); - const updatedAt = new Date(resolveStoredAgentPayloadUpdatedAt(record)); + const updatedAt = new Date(resolveStoredAgentUpdatedAt(record)); const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null; const runtimeInfo = buildStoredRuntimeInfo(record); @@ -287,23 +291,6 @@ export function toRecentProviderSessionDescriptorPayload( }; } -export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string { - const timestamps = [record.updatedAt, record.lastActivityAt] - .filter((value): value is string => typeof value === "string" && value.length > 0) - .map((value) => ({ - raw: value, - parsed: Date.parse(value), - })) - .filter((value) => !Number.isNaN(value.parsed)); - - if (timestamps.length === 0) { - return record.updatedAt; - } - - timestamps.sort((a, b) => b.parsed - a.parsed); - return timestamps[0].raw; -} - function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null { const serializable: SerializableAgentConfig = {}; if (config.modeId) { diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 16e850990f..45443a7af0 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -6,6 +6,7 @@ import type { AgentSessionConfig, } from "./agent/agent-sdk-types.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; +import type { AttentionState } from "./agent/agent-manager.js"; interface LoggerLike { child(bindings: Record): LoggerLike; @@ -106,6 +107,30 @@ export function isStoredAgentProviderAvailable( return isProviderRegistered(validProviders, record.provider); } +/** + * When the record last changed, from either of the two timestamps it carries. They diverge + * because renaming, labelling, restoring from archive, marking unread, and clearing attention + * all move `updatedAt` on an unloaded agent without touching `lastActivityAt`. Reading one + * field alone hands consumers a time older than one they have already seen, and + * `acceptAgentDirectoryUpdate` drops every state update that goes backwards. + */ +export function resolveStoredAgentUpdatedAt(record: StoredAgentRecord): string { + const timestamps = [record.updatedAt, record.lastActivityAt] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .map((value) => ({ + raw: value, + parsed: Date.parse(value), + })) + .filter((value) => !Number.isNaN(value.parsed)); + + if (timestamps.length === 0) { + return record.updatedAt; + } + + timestamps.sort((a, b) => b.parsed - a.parsed); + return timestamps[0].raw; +} + export function extractTimestamps(record: StoredAgentRecord): { createdAt: Date; updatedAt: Date; @@ -116,7 +141,7 @@ export function extractTimestamps(record: StoredAgentRecord): { } { return { createdAt: new Date(record.createdAt), - updatedAt: new Date(record.lastActivityAt ?? record.updatedAt), + updatedAt: new Date(resolveStoredAgentUpdatedAt(record)), lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null, labels: record.labels, workspaceId: record.workspaceId, @@ -124,6 +149,22 @@ export function extractTimestamps(record: StoredAgentRecord): { }; } +/** + * Unread state survives a resume. Attention is set by the agent finishing or failing and + * cleared by the user reading the chat (`workspace.clear_attention`); reloading the runtime + * is neither, so a resumed agent that drops it silently marks the chat read. + */ +export function extractAttention(record: StoredAgentRecord): AttentionState { + if (!record.requiresAttention || !record.attentionReason || !record.attentionTimestamp) { + return { requiresAttention: false }; + } + return { + requiresAttention: true, + attentionReason: record.attentionReason, + attentionTimestamp: new Date(record.attentionTimestamp), + }; +} + export function toAgentPersistenceHandle( registeredProviders: Iterable, handle: StoredAgentRecord["persistence"], diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c79ae5869b..5dbcb63731 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -48,6 +48,7 @@ import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js" import { buildConfigOverrides, isStoredAgentProviderAvailable, + resolveStoredAgentUpdatedAt, toAgentPersistenceHandle, } from "./persistence-hooks.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent/agent-loading.js"; @@ -113,11 +114,7 @@ import { setAgentModeCommand, updateAgentCommand, } from "./agent/lifecycle-command.js"; -import { - buildStoredAgentPayload, - resolveStoredAgentPayloadUpdatedAt, - toAgentPayload, -} from "./agent/agent-projections.js"; +import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js"; import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, @@ -3331,8 +3328,8 @@ export class Session { return candidate; } const updatedDelta = - Date.parse(resolveStoredAgentPayloadUpdatedAt(candidate)) - - Date.parse(resolveStoredAgentPayloadUpdatedAt(latest)); + Date.parse(resolveStoredAgentUpdatedAt(candidate)) - + Date.parse(resolveStoredAgentUpdatedAt(latest)); if (updatedDelta !== 0) { return updatedDelta > 0 ? candidate : latest; }