mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Subscribe only the chats this session opened (#5040)
* fix(app): subscribe only the chats this session opened Open-chat membership was sourced from the persisted workspace layout, which is restored from disk at launch and carries a tab for every chat the user has ever opened on the host. Every member got an authoritative timeline catch-up at session start, and each of those fetches resumed its agent on the daemon: one provider session per historical tab, every launch. Two things followed from each resume. Registration stamped `updatedAt` over the restored timestamp, and the resumed agent started with no attention because the resume path never read the stored flag. Both were persisted, so restarting Paseo rewrote every workspace's last-used time in the sidebar and cleared Ready to review on chats the user never looked at. The timestamp loss was permanent: workspace `statusEnteredAt` is re-derived from `updatedAt` on every daemon start. Keep the open set on the session instead. A chat joins it the first time the user opens it and stays until its tab closes or the session ends, so hiding, switching workspaces, evicting a retained view, backgrounding and reconnect all preserve the subscription, with no recent-agent limit. Layout keeps its release role and loses its source role. Harden the producer as well, so a resume from any caller stays inert: registration adopts restored timestamps rather than stamping now, and resume carries the stored attention through. * fix(agents): keep a resumed agent's updatedAt from moving backwards Dropping the registration stamp exposed a skew the stamp had been erasing. `extractTimestamps` read `lastActivityAt ?? updatedAt`, but the two diverge: renaming, labelling, restoring from archive, marking unread, and clearing attention all move `updatedAt` on an unloaded agent without touching `lastActivityAt`. Eight of 854 agent records on one dev machine carry that skew, up to eight days of it. A resume then handed clients a timestamp older than one they already held, and `acceptAgentDirectoryUpdate` drops any state update that goes backwards. Marking a chat unread without opening it, then opening it, left the unread dot stuck in History, the agent list, Command Center and the tab: the daemon cleared attention and the client refused the update. Read both fields and take the newest, which is already the rule `resolveStoredAgentPayloadUpdatedAt` applies to the list payload. Move that function beside the other stored-record readers as `resolveStoredAgentUpdatedAt` so one rule covers both paths. Also replace the `!options?.updatedAt` inference in `registerSession` with an explicit `restoring` flag. The inference was correct for all four call sites, but it made a second meaning out of a field, and the next caller to seed a timestamp would have opted out of activity stamping silently. * test(app): assert a reloaded layout subscribes only the chat it restores into No e2e reloaded the page with a populated workspace layout and asserted which agents the daemon acknowledged, so the regression this branch fixes had no end-to-end guard. Opening four chats across two workspaces leaves a tab per chat in persisted layout; after a reload only the restored chat may be subscribed, because subscribing resumes that agent on the daemon. Verified it fails when session-context's layout callback is pointed back at replaceVisibleAgentIds: the three restored tabs nobody opened join the acknowledged set.
This commit is contained in:
@@ -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`.
|
||||
|
||||
+13
-8
@@ -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.
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<RestoredLayoutScenario> {
|
||||
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<RestoredLayoutChat> => {
|
||||
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);
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
): Promise<ManagedAgent> {
|
||||
@@ -1314,6 +1316,7 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
): Promise<ManagedAgent> {
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown>): 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<AgentProvider>,
|
||||
handle: StoredAgentRecord["persistence"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user