fix(app): keep agent details and stay quiet after a current timeline answer

The timeline fetch that a current answer skips also delivered the full agent
row, and the replica cache stores only part of it. After a cold reload with
an empty incremental directory page, usage, modes, permissions and runtime
info stayed empty. The subscribe answer now carries the row for each current
chat, gated with the rest of the freshness check.

The panel also reported ready before the timeline owner was published, so a
connected render with cached content showed Updating messages. No owner now
reads as a check that has not begun.
This commit is contained in:
Mohamed Boudra
2026-11-09 09:48:36 +01:00
parent 44b608b854
commit 279211e110
8 changed files with 106 additions and 35 deletions
+4 -3
View File
@@ -110,9 +110,10 @@ A chat with a cached cursor asks the daemon first. The `agent.timeline.set_subsc
that attaches the chat carries its `{ epoch, seq }`, and the response answers `current` or `behind`
per chat. The daemon attaches before it compares, so an entry committed in between arrives live or
counts as behind. `current` means the agent is loaded, the epoch matches, and the cursor is at the
head; it completes catch-up with no fetch. `behind`, or no entry, runs the bounded catch-up. The
subscribe handler never loads an agent, so an agent the daemon has not loaded answers `behind` and
resumes in that fetch. Hosts without `features.timelineFreshnessCheck` get no cursors and always fetch.
head; it completes catch-up with no fetch. The response carries the agent row for each `current`
chat, because the timeline fetch is also what refreshes that row and the replica cache stores only
part of it. `behind`, or no entry, runs the bounded catch-up. The subscribe handler never loads an
agent, so an agent the daemon has not loaded answers `behind` and resumes in that fetch. Hosts without `features.timelineFreshnessCheck` get no cursors and always fetch.
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host
is offline and until a reconnected chat's check answers, so an unchanged chat never shows a second
+5 -7
View File
@@ -726,13 +726,11 @@ function ChatAgentContent({
(listener: () => void) => viewedTimelineSync?.subscribe(listener) ?? (() => {}),
[viewedTimelineSync],
);
const readTimelineStatus = useCallback(
() =>
!agentId || !viewedTimelineSync
? ("ready" as const)
: viewedTimelineSync.getAgentTimelineStatus(agentId),
[agentId, viewedTimelineSync],
);
const readTimelineStatus = useCallback((): ViewedTimelineStatus => {
if (!agentId) return "ready";
// Until the owner is published nothing has confirmed this chat, so its check has not begun.
return viewedTimelineSync?.getAgentTimelineStatus(agentId) ?? "checking";
}, [agentId, viewedTimelineSync]);
const timelineStatus = useSyncExternalStore(
subscribeToVisibilityCatchUp,
readTimelineStatus,
@@ -4,6 +4,7 @@ import type {
FetchAgentsEntry,
FetchAgentsOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
import {
normalizeProjectDescriptor,
@@ -482,6 +483,26 @@ export class DirectorySync {
return page;
}
/**
* Captures these chats' lifecycle before a subscribe. The returned function keeps the agent rows
* its answer carries, which is what a timeline fetch would have delivered for each of them.
*/
captureSubscribedAgents(
agentIds: string[],
): (agents: Record<string, AgentSnapshotPayload>) => void {
const tokens = new Map(agentIds.map((id) => [id, this.agents.captureTimeline(id)]));
return (agents) => {
let accepted = false;
for (const [agentId, agent] of Object.entries(agents)) {
const token = tokens.get(agentId);
if (token && this.agents.submitTimelineAgent(token, agent)) accepted = true;
}
if (!accepted) return;
this.revision += 1;
this.persistCheckpoint();
};
}
async refreshAgents(
input: RefreshAgentDirectoryInput = {},
): Promise<RefreshAgentDirectoryResult> {
+14 -1
View File
@@ -2880,7 +2880,20 @@ export class HostRuntimeStore {
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId),
applyTurnLiveness: (agentId, transitions) =>
directory.applyAgentTurnLiveness(agentId, transitions),
ports,
ports: {
...ports,
observe: (agentIds, cursors) => {
const keepAgents = directory.captureSubscribedAgents(agentIds);
const observation = ports.observe(agentIds, cursors);
return {
ready: observation.ready.then((answer) => {
if (answer?.agents) keepAgents(answer.agents);
return answer;
}),
release: () => observation.release(),
};
},
},
});
}
@@ -12,7 +12,7 @@ import {
type AgentTimelineState,
} from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getSendingClientMessageIds } from "@/composer/submission/model";
import {
getInitDeferred,
@@ -381,7 +381,10 @@ export interface ViewedTimelineSyncPorts {
agentIds: string[],
cursors?: Record<string, { epoch: string; seq: number }>,
): {
ready: Promise<{ timelines?: Record<string, TimelineFreshness> } | void>;
ready: Promise<{
timelines?: Record<string, TimelineFreshness>;
agents?: Record<string, AgentSnapshotPayload>;
} | void>;
release(): Promise<void>;
};
/** The daemon confirmed the cached timeline is at its head, as a zero-row after page would. */
+3
View File
@@ -5259,6 +5259,9 @@ export const SetAgentTimelineSubscriptionResponseMessageSchema = z.object({
requestId: z.string(),
// COMPAT(timelineFreshnessCheck): added after v0.15.3, remove after 2027-05-09.
timelines: z.record(z.string(), TimelineFreshnessSchema).optional(),
// The agent row for each chat answered "current", which gets no timeline fetch to carry it.
// COMPAT(timelineFreshnessCheck): added after v0.15.3, remove after 2027-05-09.
agents: z.record(z.string(), AgentSnapshotPayloadSchema).optional(),
}),
});
@@ -262,11 +262,15 @@ test("subscribing with a cursor answers whether each chat is at its head", async
},
);
expect((await observation.ready).timelines).toEqual({
const answer = await observation.ready;
expect(answer.timelines).toEqual({
[atHead.agentId]: "current",
[staleSeq.agentId]: "behind",
[otherEpoch.agentId]: "behind",
});
// Only a current chat skips the fetch that would have delivered its agent row.
expect(Object.keys(answer.agents ?? {})).toEqual([atHead.agentId]);
expect(answer.agents?.[atHead.agentId]).toMatchObject({ id: atHead.agentId, title: "At head" });
connected.clear();
await daemon.daemon.agentManager.appendTimelineItem(atHead.agentId, {
@@ -292,7 +296,9 @@ test("subscribing with a cursor answers behind for a stored agent and leaves it
cursors: { [stored.agentId]: stored.head },
});
expect((await observation.ready).timelines).toEqual({ [stored.agentId]: "behind" });
const answer = await observation.ready;
expect(answer.timelines).toEqual({ [stored.agentId]: "behind" });
expect(answer.agents).toEqual({});
expect(daemon.daemon.agentManager.getAgent(stored.agentId)).toBeNull();
await observation.release();
});
+46 -20
View File
@@ -1423,6 +1423,50 @@ export class Session {
return timelines;
}
private async answerTimelineFreshness(
agentIds: string[],
cursors: Record<string, AgentTimelineCursor>,
): Promise<{
timelines: Record<string, "current" | "behind">;
agents: Record<string, AgentSnapshotPayload>;
}> {
const snapshots = await Promise.all(
agentIds.flatMap((agentId) => {
const agent = cursors[agentId] ? this.agentManager.getAgent(agentId) : null;
return agent ? [this.buildAgentPayload(agent)] : [];
}),
);
// Compared after the await, so a row appended while reading the snapshots answers behind.
const timelines = this.compareTimelineCursors(agentIds, cursors);
const agents: Record<string, AgentSnapshotPayload> = {};
for (const snapshot of snapshots) {
if (timelines[snapshot.id] === "current") agents[snapshot.id] = snapshot;
}
return { timelines, agents };
}
private async handleAgentTimelineSubscription(
msg: Extract<SessionInboundMessage, { type: "agent.timeline.set_subscription.request" }>,
source?: object,
): Promise<void> {
const agentIds = [...new Set(msg.agentIds)].sort();
// Attach before comparing: a row appended after the comparison reaches this owner live.
const owner = this.subscribeAgentTimelines(agentIds);
const freshness = msg.cursors ? await this.answerTimelineFreshness(agentIds, msg.cursors) : {};
this.emitForSource(
{
type: "agent.timeline.set_subscription.response",
payload: {
agentIds,
requestId: msg.requestId,
subscriptionId: owner.responseId,
...freshness,
},
},
source,
);
}
private subscribeAgentTimelines(agentIds: string[]): OwnedSubscription {
const owner = this.delivery.begin("timelines", undefined, (id) => {
this.timelineSubscriptions.delete(id);
@@ -3058,26 +3102,8 @@ export class Session {
throw error;
});
}
case "agent.timeline.set_subscription.request": {
const agentIds = [...new Set(msg.agentIds)].sort();
// Attach before comparing: a row appended after the comparison reaches this owner live.
const owner = this.subscribeAgentTimelines(agentIds);
this.emitForSource(
{
type: "agent.timeline.set_subscription.response",
payload: {
agentIds,
requestId: msg.requestId,
subscriptionId: owner.responseId,
...(msg.cursors
? { timelines: this.compareTimelineCursors(agentIds, msg.cursors) }
: {}),
},
},
source,
);
return undefined;
}
case "agent.timeline.set_subscription.request":
return this.handleAgentTimelineSubscription(msg, source);
case "agent.fork_context.request":
return this.handleAgentForkContextRequest(msg);
default: