Skip to content

Commit 502c02b

Browse files
authored
fix(aio): harden cloud run terminal state
1 parent 19d972d commit 502c02b

17 files changed

Lines changed: 950 additions & 41 deletions

File tree

packages/agent/tsup.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ const sharedOptions = {
8888
"@posthog/git",
8989
"@posthog/enricher",
9090
"@posthog/harness",
91+
/^@opentelemetry\//,
9192
"fflate",
9293
],
9394
external: [

packages/core/src/sessions/sessionService.ts

Lines changed: 160 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5508,6 +5508,27 @@ export class SessionService {
55085508
if (onStatusChange) {
55095509
existingWatcher.onStatusChange = onStatusChange;
55105510
}
5511+
// The run finished while a live watcher was still attached: apply the
5512+
// final transcript, mark the terminal status, and tear the watcher down
5513+
// instead of leaving a live stream on a dead run.
5514+
if (isTerminalStatus(runStatus)) {
5515+
const terminalSession = this.d.store.getSessionByTaskId(taskId);
5516+
if (terminalSession?.taskRunId === taskRunId) {
5517+
void this.hydrateCloudTaskSessionFromLogs(
5518+
taskId,
5519+
taskRunId,
5520+
logUrl,
5521+
taskDescription,
5522+
runStatus,
5523+
runState,
5524+
);
5525+
}
5526+
this.finalizeTerminalCloudTask(taskId, taskRunId, {
5527+
status: runStatus,
5528+
});
5529+
this.stopCloudTaskWatch(taskId);
5530+
return () => {};
5531+
}
55115532
// Ensure configOptions is populated on revisit
55125533
const existing = this.d.store.getSessionByTaskId(taskId);
55135534
if (existing) {
@@ -5687,6 +5708,26 @@ export class SessionService {
56875708
initialReasoningEffort,
56885709
);
56895710

5711+
// A run that is already terminal has no live stream to subscribe to:
5712+
// hydrate the final transcript, settle the terminal status, and return
5713+
// without registering a watcher. Plain hydration already fetches the
5714+
// full resume chain for terminal runs, and the resume wrapper only
5715+
// exists to buffer a live stream that does not exist here.
5716+
if (isTerminalStatus(runStatus)) {
5717+
void this.hydrateCloudTaskSessionFromLogs(
5718+
taskId,
5719+
taskRunId,
5720+
logUrl,
5721+
taskDescription,
5722+
runStatus,
5723+
runState,
5724+
);
5725+
this.finalizeTerminalCloudTask(taskId, taskRunId, {
5726+
status: runStatus,
5727+
});
5728+
return () => {};
5729+
}
5730+
56905731
const processCloudUpdate = (update: CloudTaskUpdatePayload): void => {
56915732
if (update.kind === "logs" || update.kind === "snapshot") {
56925733
this.d.store.updateSession(taskRunId, {
@@ -5707,11 +5748,18 @@ export class SessionService {
57075748
),
57085749
}
57095750
: update;
5751+
// Evaluate staleness before handleCloudTaskUpdate mutates cloudStatus:
5752+
// a late non-terminal update must not re-notify after the run settled.
5753+
const isStaleNonTerminalStatus = this.isStaleNonTerminalCloudUpdate(
5754+
taskRunId,
5755+
normalizedUpdate,
5756+
);
57105757
this.handleCloudTaskUpdate(taskRunId, normalizedUpdate);
57115758
if (
57125759
(update.kind === "status" ||
57135760
update.kind === "snapshot" ||
57145761
update.kind === "error") &&
5762+
!isStaleNonTerminalStatus &&
57155763
watcher?.onStatusChange
57165764
) {
57175765
watcher.onStatusChange();
@@ -5928,7 +5976,8 @@ export class SessionService {
59285976
? runState.resume_from_run_id
59295977
: undefined;
59305978
const isResumeRun = Boolean(resumeFromRunId);
5931-
if (isTerminalStatus(runStatus) || isResumeRun) {
5979+
const isTerminalRun = isTerminalStatus(runStatus);
5980+
if (isTerminalRun || isResumeRun) {
59325981
// Resume chains need the full history even while the leaf run is still
59335982
// active; otherwise a renderer restart hydrates only the final run.
59345983
// Non-resume in-progress runs keep using the single-run log so hydrate
@@ -6002,6 +6051,16 @@ export class SessionService {
60026051
}
60036052
rawEntries = result.entries;
60046053
liveStreamLineCount = rawEntries.length;
6054+
// A terminal run whose persisted chain comes back empty can still
6055+
// have a complete S3 session log (persistence raced teardown); fall
6056+
// back to it rather than hydrating an empty final transcript.
6057+
if (rawEntries.length === 0 && logUrl) {
6058+
const parsed = await this.fetchSessionLogs(logUrl, taskRunId);
6059+
if (parsed.rawEntries.length > 0) {
6060+
rawEntries = parsed.rawEntries;
6061+
liveStreamLineCount = parsed.totalLineCount;
6062+
}
6063+
}
60056064
}
60066065
} else {
60076066
const parsed = await this.fetchSessionLogs(logUrl, taskRunId);
@@ -6049,7 +6108,7 @@ export class SessionService {
60496108
// its chip renders right away) over the bare task description.
60506109
const seedContent =
60516110
this.initialCloudOptimisticPrompt.get(taskId) ?? taskDescription;
6052-
if (!hasUserPrompt && seedContent?.trim()) {
6111+
if (!isTerminalRun && !hasUserPrompt && seedContent?.trim()) {
60536112
this.d.store.appendOptimisticItem(taskRunId, {
60546113
type: "user_message",
60556114
content: seedContent,
@@ -6061,6 +6120,12 @@ export class SessionService {
60616120
this.initialCloudOptimisticPrompt.delete(taskId);
60626121
this.d.store.clearTailOptimisticItems(taskRunId);
60636122
}
6123+
if (isTerminalRun) {
6124+
// A finished run gets no further echoes: any optimistic leftovers would
6125+
// otherwise linger as phantom tail items on the final transcript.
6126+
this.initialCloudOptimisticPrompt.delete(taskId);
6127+
this.d.store.clearTailOptimisticItems(taskRunId);
6128+
}
60646129

60656130
if (rawEntries.length === 0) {
60666131
this.pendingPermissionHydratedRuns.add(taskRunId);
@@ -6072,7 +6137,19 @@ export class SessionService {
60726137

60736138
// If live updates already populated a processed count, don't overwrite
60746139
// that newer state with the persisted baseline fetched during startup.
6075-
if (
6140+
// Terminal hydration is different: it is the final transcript, so apply
6141+
// it whenever the persisted chain has more lines than the local stream.
6142+
const effectiveLineCount = Math.max(liveStreamLineCount, rawEntries.length);
6143+
if (isTerminalRun) {
6144+
if ((session.processedLineCount ?? 0) >= effectiveLineCount) {
6145+
this.surfacePersistedPendingPermissions(taskRunId, rawEntries);
6146+
this.pendingPermissionHydratedRuns.add(taskRunId);
6147+
return {
6148+
historyEntryCount: rawEntries.length,
6149+
liveStreamLineCount: session.processedLineCount ?? liveStreamLineCount,
6150+
};
6151+
}
6152+
} else if (
60766153
session.processedLineCount !== undefined &&
60776154
session.processedLineCount > 0 &&
60786155
!isResumeRun
@@ -6090,20 +6167,83 @@ export class SessionService {
60906167
isCloud: true,
60916168
logUrl: logUrl ?? session.logUrl,
60926169
cloudTranscriptEntryCount: rawEntries.length,
6093-
processedLineCount: liveStreamLineCount,
6170+
// Terminal hydration records the whole chain as processed so nothing
6171+
// re-applies it; live resume runs keep the leaf-stream cursor.
6172+
processedLineCount: isTerminalRun ? effectiveLineCount : liveStreamLineCount,
60946173
});
60956174
this.surfacePersistedPendingPermissions(taskRunId, rawEntries);
60966175
this.pendingPermissionHydratedRuns.add(taskRunId);
60976176
// Without this the "Galumphing…" indicator stays hidden when the hydrated
60986177
// baseline already contains an in-flight session/prompt — the live delta
60996178
// path otherwise sees delta <= 0 and never re-evaluates the tail.
61006179
this.updatePromptStateFromEvents(taskRunId, events);
6180+
if (isTerminalRun) {
6181+
this.clearTerminalCloudPromptState(taskId, taskRunId);
6182+
}
61016183
return {
61026184
historyEntryCount: rawEntries.length,
61036185
liveStreamLineCount,
61046186
};
61056187
}
61066188

6189+
private finalizeTerminalCloudTask(
6190+
taskId: string,
6191+
taskRunId: string,
6192+
fields: {
6193+
status?: TaskRunStatus;
6194+
stage?: string | null;
6195+
output?: Record<string, unknown> | null;
6196+
errorMessage?: string | null;
6197+
branch?: string | null;
6198+
},
6199+
): void {
6200+
this.d.store.updateCloudStatus(taskRunId, fields);
6201+
this.clearTerminalCloudPromptState(taskId, taskRunId);
6202+
}
6203+
6204+
/**
6205+
* A terminal run can never flush its queue or answer a pending prompt;
6206+
* leaving those set keeps the composer in a busy state forever.
6207+
*/
6208+
private clearTerminalCloudPromptState(
6209+
taskId: string,
6210+
taskRunId: string,
6211+
): void {
6212+
const session = this.d.store.getSessions()[taskRunId];
6213+
if (
6214+
!session ||
6215+
(!session.isPromptPending && session.messageQueue.length === 0)
6216+
) {
6217+
return;
6218+
}
6219+
6220+
this.d.store.clearMessageQueue(taskId);
6221+
this.d.store.updateSession(taskRunId, {
6222+
isPromptPending: false,
6223+
});
6224+
}
6225+
6226+
/**
6227+
* SSE replays and out-of-order bursts can deliver a non-terminal status
6228+
* after the run already settled; applying it would revive a finished run.
6229+
*/
6230+
private isStaleNonTerminalCloudUpdate(
6231+
taskRunId: string,
6232+
update: CloudTaskUpdatePayload,
6233+
): boolean {
6234+
if (update.kind !== "status" && update.kind !== "snapshot") {
6235+
return false;
6236+
}
6237+
if (update.status === undefined) {
6238+
return false;
6239+
}
6240+
const currentCloudStatus =
6241+
this.d.store.getSessions()[taskRunId]?.cloudStatus;
6242+
return (
6243+
isTerminalStatus(currentCloudStatus) && !isTerminalStatus(update.status)
6244+
);
6245+
}
6246+
61076247
private isCurrentCloudTaskWatcher(
61086248
taskId: string,
61096249
runId: string,
@@ -7028,7 +7168,9 @@ export class SessionService {
70287168
}
70297169

70307170
if (update.kind === "snapshot" && !isTerminalStatus(update.status)) {
7031-
this.surfacePersistedPendingPermissions(taskRunId, update.newEntries);
7171+
if (!this.isStaleNonTerminalCloudUpdate(taskRunId, update)) {
7172+
this.surfacePersistedPendingPermissions(taskRunId, update.newEntries);
7173+
}
70327174
}
70337175

70347176
// NOTE: Don't auto-flush on `!isPromptPending && queue.length > 0` here.
@@ -7041,18 +7183,20 @@ export class SessionService {
70417183

70427184
// Update cloud status fields if present
70437185
if (update.kind === "status" || update.kind === "snapshot") {
7044-
this.d.store.updateCloudStatus(taskRunId, {
7045-
status: update.status,
7046-
stage: update.stage,
7047-
output: update.output,
7048-
errorMessage: update.errorMessage,
7049-
branch: update.branch,
7050-
});
7051-
7052-
if (update.status === "in_progress") {
7053-
this.tryRecoverIdleCloudQueue(taskRunId, {
7054-
serverSandboxAlive: update.sandboxAlive,
7186+
if (!this.isStaleNonTerminalCloudUpdate(taskRunId, update)) {
7187+
this.d.store.updateCloudStatus(taskRunId, {
7188+
status: update.status,
7189+
stage: update.stage,
7190+
output: update.output,
7191+
errorMessage: update.errorMessage,
7192+
branch: update.branch,
70557193
});
7194+
7195+
if (update.status === "in_progress") {
7196+
this.tryRecoverIdleCloudQueue(taskRunId, {
7197+
serverSandboxAlive: update.sandboxAlive,
7198+
});
7199+
}
70567200
}
70577201

70587202
if (isTerminalStatus(update.status)) {

packages/core/src/sessions/sessionStore.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
sendableQueuePrefixLength,
99
type TaskRunStatus,
1010
} from "@posthog/shared";
11+
import { isTerminalStatus } from "@posthog/shared/domain-types";
1112
import { setAutoFreeze } from "immer";
1213
import { immer } from "zustand/middleware/immer";
1314
import { createStore } from "zustand/vanilla";
@@ -180,7 +181,16 @@ export const sessionStoreSetters = {
180181
sessionStore.setState((state) => {
181182
const session = state.sessions[taskRunId];
182183
if (!session) return;
183-
if (fields.status !== undefined) session.cloudStatus = fields.status;
184+
if (fields.status !== undefined) {
185+
const currentStatus = session.cloudStatus;
186+
if (
187+
isTerminalStatus(currentStatus) &&
188+
!isTerminalStatus(fields.status)
189+
) {
190+
return;
191+
}
192+
session.cloudStatus = fields.status;
193+
}
184194
if (fields.stage !== undefined) session.cloudStage = fields.stage;
185195
if (fields.output !== undefined) session.cloudOutput = fields.output;
186196
if (fields.errorMessage !== undefined)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { AgentSession } from "@posthog/shared";
2+
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
3+
import { describe, expect, it } from "vitest";
4+
import { deriveSessionViewState } from "./sessionViewState";
5+
6+
function makeTask(runStatus: TaskRunStatus, runId = "run-1"): Task {
7+
return {
8+
id: "task-1",
9+
task_number: 1,
10+
slug: "task-1",
11+
title: "Task",
12+
description: "",
13+
created_at: "2026-01-01T00:00:00.000Z",
14+
updated_at: "2026-01-01T00:00:00.000Z",
15+
origin_product: "user_created",
16+
latest_run: {
17+
id: runId,
18+
status: runStatus,
19+
environment: "cloud",
20+
} as Task["latest_run"],
21+
};
22+
}
23+
24+
function makeSession(
25+
cloudStatus: TaskRunStatus,
26+
taskRunId = "run-1",
27+
): AgentSession {
28+
return {
29+
taskId: "task-1",
30+
taskRunId,
31+
taskTitle: "Task",
32+
channel: `agent-event:${taskRunId}`,
33+
status: "connected",
34+
events: [],
35+
startedAt: 0,
36+
isCloud: true,
37+
cloudStatus,
38+
isPromptPending: false,
39+
isCompacting: false,
40+
promptStartedAt: null,
41+
pendingPermissions: new Map(),
42+
pausedDurationMs: 0,
43+
messageQueue: [],
44+
optimisticItems: [],
45+
};
46+
}
47+
48+
describe("deriveSessionViewState", () => {
49+
it("uses terminal task status over stale same-run session status", () => {
50+
const state = deriveSessionViewState(
51+
makeSession("in_progress"),
52+
makeTask("completed"),
53+
null,
54+
true,
55+
);
56+
57+
expect(state.cloudStatus).toBe("completed");
58+
expect(state.isCloudRunTerminal).toBe(true);
59+
expect(state.isInitializing).toBe(false);
60+
});
61+
62+
it("uses the task status when the session belongs to an older run", () => {
63+
const state = deriveSessionViewState(
64+
makeSession("completed", "old-run"),
65+
makeTask("in_progress", "new-run"),
66+
null,
67+
true,
68+
);
69+
70+
expect(state.cloudStatus).toBe("in_progress");
71+
expect(state.isCloudRunNotTerminal).toBe(true);
72+
});
73+
74+
it("treats not_started as a non-terminal cloud state", () => {
75+
const state = deriveSessionViewState(
76+
undefined,
77+
makeTask("not_started"),
78+
null,
79+
true,
80+
);
81+
82+
expect(state.cloudStatus).toBe("not_started");
83+
expect(state.isCloudRunNotTerminal).toBe(true);
84+
expect(state.isCloudRunTerminal).toBe(false);
85+
expect(state.isInitializing).toBe(true);
86+
});
87+
});

0 commit comments

Comments
 (0)