Skip to content

Commit 192126e

Browse files
authored
fix(aio): never lower a settled terminal cursor from a late hydration
Review finding: cloudHydrationPromises memoizes terminal-chain and resume-chain hydrations under different mode keys, so both can be in flight for the same run. If the run settles while a resume-chain hydration's fetches are pending, the terminal-chain hydration records the full chain as processed - and the late resume-chain write then clobbered processedLineCount with its smaller leaf-only cursor (updateSession is a blind assign with no monotonic guard). Impact was bounded (the transcript content is identical and the next terminal hydration self-heals), but it violated the invariant the mode-keying fix set out to guarantee: the terminal transcript is authoritative. Re-read the session at write time and floor a non-terminal cursor at the settled value once cloudStatus is terminal. The regression test resolves the resume-chain fetches after the run settles and asserts the settled cursor survives; verified to fail without the fix. Generated-By: PostHog Code Task-Id: 0c511836-2180-455a-9b58-45df2a0661ec
1 parent 9284ebf commit 192126e

2 files changed

Lines changed: 97 additions & 1 deletion

File tree

packages/core/src/sessions/sessionService.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6220,6 +6220,15 @@ export class SessionService {
62206220
};
62216221
}
62226222

6223+
// A concurrent terminal-chain hydration (they memoize under different
6224+
// mode keys) may have already recorded the full chain as processed; a
6225+
// leaf-stream cursor from this older hydration must never lower it once
6226+
// the run has settled.
6227+
const settled = this.d.store.getSessions()[taskRunId];
6228+
const settledCursor = isTerminalStatus(settled?.cloudStatus)
6229+
? (settled?.processedLineCount ?? 0)
6230+
: 0;
6231+
62236232
this.d.store.updateSession(taskRunId, {
62246233
events,
62256234
isCloud: true,
@@ -6229,7 +6238,7 @@ export class SessionService {
62296238
// re-applies it; live resume runs keep the leaf-stream cursor.
62306239
processedLineCount: isTerminalRun
62316240
? effectiveLineCount
6232-
: liveStreamLineCount,
6241+
: Math.max(liveStreamLineCount, settledCursor),
62336242
});
62346243
this.surfacePersistedPendingPermissions(taskRunId, rawEntries);
62356244
this.pendingPermissionHydratedRuns.add(taskRunId);

packages/ui/src/features/sessions/sessionServiceHost.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1929,6 +1929,93 @@ describe("SessionService", () => {
19291929
resolveFirstHydration({ entries: [], complete: true });
19301930
});
19311931

1932+
it("keeps the settled terminal cursor when a resume-chain hydration resolves late", async () => {
1933+
const service = getSessionService();
1934+
const session = createMockSession({
1935+
taskId: "task-123",
1936+
taskRunId: "run-123",
1937+
isCloud: true,
1938+
events: [],
1939+
});
1940+
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(session);
1941+
mockSessionStoreSetters.getSessions.mockReturnValue({
1942+
"run-123": session,
1943+
});
1944+
mockTrpcLogs.readLocalLogs.query.mockResolvedValue("");
1945+
mockTrpcLogs.fetchS3Logs.query.mockResolvedValue("");
1946+
const entry = (text: string) => ({
1947+
timestamp: "2026-07-15T10:00:00+00:00",
1948+
notification: {
1949+
method: "session/update",
1950+
params: {
1951+
update: {
1952+
sessionUpdate: "agent_message",
1953+
content: { type: "text", text },
1954+
},
1955+
},
1956+
},
1957+
});
1958+
let resolveAncestor!: (r: {
1959+
entries: object[];
1960+
complete: boolean;
1961+
}) => void;
1962+
let resolveCurrent!: (r: {
1963+
entries: object[];
1964+
complete: boolean;
1965+
}) => void;
1966+
mockAuthenticatedClient.getTaskRunSessionLogsResult
1967+
.mockReturnValueOnce(
1968+
new Promise((r) => {
1969+
resolveAncestor = r;
1970+
}),
1971+
)
1972+
.mockReturnValueOnce(
1973+
new Promise((r) => {
1974+
resolveCurrent = r;
1975+
}),
1976+
);
1977+
1978+
service.watchCloudTask(
1979+
"task-123",
1980+
"run-123",
1981+
"https://api.anthropic.com",
1982+
123,
1983+
undefined,
1984+
"https://example.com/logs/run-123",
1985+
undefined,
1986+
"claude",
1987+
undefined,
1988+
undefined,
1989+
undefined,
1990+
"in_progress",
1991+
undefined,
1992+
{ resume_from_run_id: "previous-run" },
1993+
);
1994+
1995+
await vi.waitFor(() => {
1996+
expect(
1997+
mockAuthenticatedClient.getTaskRunSessionLogsResult,
1998+
).toHaveBeenCalledTimes(2);
1999+
});
2000+
2001+
// The run settles while the resume-chain fetches are still in flight,
2002+
// exactly as a concurrent terminal-chain hydration records it.
2003+
session.cloudStatus = "completed";
2004+
session.processedLineCount = 5;
2005+
2006+
resolveAncestor({ entries: [entry("a"), entry("b")], complete: true });
2007+
resolveCurrent({ entries: [entry("c")], complete: true });
2008+
2009+
// The late resume-chain write must not lower the settled cursor to its
2010+
// leaf-only count.
2011+
await vi.waitFor(() => {
2012+
expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith(
2013+
"run-123",
2014+
expect.objectContaining({ processedLineCount: 5 }),
2015+
);
2016+
});
2017+
});
2018+
19322019
it("does not re-subscribe across repeated calls for a hydrated terminal run", () => {
19332020
const service = getSessionService();
19342021
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(

0 commit comments

Comments
 (0)