Skip to content

Commit 213b0fb

Browse files
committed
fix(aio): harden cloud run terminal state
1 parent ed14102 commit 213b0fb

17 files changed

Lines changed: 953 additions & 52 deletions

File tree

packages/agent/tsup.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const sharedOptions = {
8787
"@posthog/shared",
8888
"@posthog/git",
8989
"@posthog/enricher",
90+
/^@opentelemetry\//,
9091
"fflate",
9192
],
9293
external: [

packages/core/src/sessions/sessionService.ts

Lines changed: 163 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,7 @@ export class SessionService {
591591
private scheduledCloudQueueFlushes = new Set<string>();
592592
private cloudRunIdleTracker: CloudRunIdleTracker;
593593
private nextCloudTaskWatchToken = 0;
594+
private hydratingCloudSessions = new Set<string>();
594595
private supersededRunIds = new Set<string>();
595596
private subscriptions = new Map<
596597
string,
@@ -4122,6 +4123,24 @@ export class SessionService {
41224123
if (onStatusChange) {
41234124
existingWatcher.onStatusChange = onStatusChange;
41244125
}
4126+
if (isTerminalStatus(runStatus)) {
4127+
const existing = this.d.store.getSessionByTaskId(taskId);
4128+
if (existing?.taskRunId === taskRunId) {
4129+
this.hydrateCloudTaskSessionFromLogs(
4130+
taskId,
4131+
taskRunId,
4132+
logUrl,
4133+
taskDescription,
4134+
runStatus,
4135+
runState,
4136+
);
4137+
}
4138+
this.finalizeTerminalCloudTask(taskId, taskRunId, {
4139+
status: runStatus,
4140+
});
4141+
this.stopCloudTaskWatch(taskId);
4142+
return () => {};
4143+
}
41254144
// Ensure configOptions is populated on revisit
41264145
const existing = this.d.store.getSessionByTaskId(taskId);
41274146
if (existing) {
@@ -4261,7 +4280,7 @@ export class SessionService {
42614280
initialReasoningEffort,
42624281
);
42634282

4264-
if (shouldHydrateSession) {
4283+
if (shouldHydrateSession || isTerminalStatus(runStatus)) {
42654284
this.hydrateCloudTaskSessionFromLogs(
42664285
taskId,
42674286
taskRunId,
@@ -4272,18 +4291,30 @@ export class SessionService {
42724291
);
42734292
}
42744293

4294+
if (isTerminalStatus(runStatus)) {
4295+
this.finalizeTerminalCloudTask(taskId, taskRunId, {
4296+
status: runStatus,
4297+
});
4298+
return () => {};
4299+
}
4300+
42754301
// Subscribe before starting the main-process watcher so the first replayed
42764302
// SSE/log burst cannot race ahead of the renderer subscription.
42774303
const subscription = this.d.trpc.cloudTask.onUpdate.subscribe(
42784304
{ taskId, runId },
42794305
{
42804306
onData: (update: CloudTaskUpdatePayload) => {
4307+
const isStaleNonTerminalStatus = this.isStaleNonTerminalCloudUpdate(
4308+
taskRunId,
4309+
update,
4310+
);
42814311
this.handleCloudTaskUpdate(taskRunId, update);
42824312
const watcher = this.cloudTaskWatchers.get(taskId);
42834313
if (
42844314
(update.kind === "status" ||
42854315
update.kind === "snapshot" ||
42864316
update.kind === "error") &&
4317+
!isStaleNonTerminalStatus &&
42874318
watcher?.onStatusChange
42884319
) {
42894320
watcher.onStatusChange();
@@ -4356,11 +4387,23 @@ export class SessionService {
43564387
runStatus?: TaskRunStatus,
43574388
runState?: Record<string, unknown>,
43584389
): void {
4390+
const isTerminalRun = isTerminalStatus(runStatus);
4391+
const isResumeRun = Boolean(runState?.resume_from_run_id);
4392+
const hydrationMode = isTerminalRun
4393+
? "terminal-chain"
4394+
: isResumeRun
4395+
? "resume-chain"
4396+
: "single";
4397+
const hydrationKey = `${taskId}:${taskRunId}:${hydrationMode}`;
4398+
if (this.hydratingCloudSessions.has(hydrationKey)) {
4399+
return;
4400+
}
4401+
this.hydratingCloudSessions.add(hydrationKey);
4402+
43594403
void (async () => {
43604404
let rawEntries: StoredLogEntry[];
43614405
let totalLineCount: number;
4362-
const isResumeRun = Boolean(runState?.resume_from_run_id);
4363-
if (isTerminalStatus(runStatus) || isResumeRun) {
4406+
if (isTerminalRun || isResumeRun) {
43644407
// Resume chains need the full history even while the leaf run is still
43654408
// active; otherwise a renderer restart hydrates only the final run.
43664409
// Non-resume in-progress runs keep using the single-run log so hydrate
@@ -4384,6 +4427,13 @@ export class SessionService {
43844427
return;
43854428
}
43864429
totalLineCount = rawEntries.length;
4430+
if (rawEntries.length === 0 && logUrl) {
4431+
const parsed = await this.fetchSessionLogs(logUrl, taskRunId);
4432+
if (parsed.rawEntries.length > 0) {
4433+
rawEntries = parsed.rawEntries;
4434+
totalLineCount = parsed.totalLineCount;
4435+
}
4436+
}
43874437
} else {
43884438
const parsed = await this.fetchSessionLogs(logUrl, taskRunId);
43894439
rawEntries = parsed.rawEntries;
@@ -4395,6 +4445,25 @@ export class SessionService {
43954445
return;
43964446
}
43974447

4448+
if (rawEntries.length === 0) {
4449+
if (isTerminalRun) {
4450+
this.initialCloudOptimisticPrompt.delete(taskId);
4451+
this.d.store.clearTailOptimisticItems(taskRunId);
4452+
} else {
4453+
const seedContent =
4454+
this.initialCloudOptimisticPrompt.get(taskId) ?? taskDescription;
4455+
if (seedContent?.trim()) {
4456+
this.d.store.appendOptimisticItem(taskRunId, {
4457+
type: "user_message",
4458+
content: seedContent,
4459+
timestamp: Date.now(),
4460+
});
4461+
}
4462+
}
4463+
this.pendingPermissionHydratedRuns.add(taskRunId);
4464+
return;
4465+
}
4466+
43984467
const events = convertStoredEntriesToEvents(rawEntries);
43994468
const hasUserPrompt = events.some(
44004469
(e: AcpMessage) =>
@@ -4409,7 +4478,7 @@ export class SessionService {
44094478
// its chip renders right away) over the bare task description.
44104479
const seedContent =
44114480
this.initialCloudOptimisticPrompt.get(taskId) ?? taskDescription;
4412-
if (!hasUserPrompt && seedContent?.trim()) {
4481+
if (!isTerminalRun && !hasUserPrompt && seedContent?.trim()) {
44134482
this.d.store.appendOptimisticItem(taskRunId, {
44144483
type: "user_message",
44154484
content: seedContent,
@@ -4421,15 +4490,21 @@ export class SessionService {
44214490
this.initialCloudOptimisticPrompt.delete(taskId);
44224491
this.d.store.clearTailOptimisticItems(taskRunId);
44234492
}
4424-
4425-
if (rawEntries.length === 0) {
4426-
this.pendingPermissionHydratedRuns.add(taskRunId);
4427-
return;
4493+
if (isTerminalRun) {
4494+
this.initialCloudOptimisticPrompt.delete(taskId);
4495+
this.d.store.clearTailOptimisticItems(taskRunId);
44284496
}
44294497

44304498
// If live updates already populated a processed count, don't overwrite
44314499
// that newer state with the persisted baseline fetched during startup.
4432-
if (
4500+
// Terminal hydration is different: it is the final transcript, so apply
4501+
// it when the persisted chain has more lines than the local stream.
4502+
const effectiveLineCount = Math.max(totalLineCount, rawEntries.length);
4503+
if (isTerminalRun) {
4504+
if ((session.processedLineCount ?? 0) >= effectiveLineCount) {
4505+
return;
4506+
}
4507+
} else if (
44334508
session.processedLineCount !== undefined &&
44344509
session.processedLineCount > 0
44354510
) {
@@ -4442,20 +4517,60 @@ export class SessionService {
44424517
events,
44434518
isCloud: true,
44444519
logUrl: logUrl ?? session.logUrl,
4445-
processedLineCount: totalLineCount,
4520+
processedLineCount: effectiveLineCount,
44464521
});
44474522
this.surfacePersistedPendingPermissions(taskRunId, rawEntries);
44484523
this.pendingPermissionHydratedRuns.add(taskRunId);
44494524
// Without this the "Galumphing…" indicator stays hidden when the hydrated
44504525
// baseline already contains an in-flight session/prompt — the live delta
44514526
// path otherwise sees delta <= 0 and never re-evaluates the tail.
44524527
this.updatePromptStateFromEvents(taskRunId, events);
4453-
})().catch((err: unknown) => {
4454-
this.d.log.warn("Failed to hydrate cloud task session from logs", {
4455-
taskId,
4456-
taskRunId,
4457-
err,
4528+
if (isTerminalRun) {
4529+
this.clearTerminalCloudPromptState(taskId, taskRunId);
4530+
}
4531+
})()
4532+
.catch((err: unknown) => {
4533+
this.d.log.warn("Failed to hydrate cloud task session from logs", {
4534+
taskId,
4535+
taskRunId,
4536+
err,
4537+
});
4538+
})
4539+
.finally(() => {
4540+
this.hydratingCloudSessions.delete(hydrationKey);
44584541
});
4542+
}
4543+
4544+
private finalizeTerminalCloudTask(
4545+
taskId: string,
4546+
taskRunId: string,
4547+
fields: {
4548+
status?: TaskRunStatus;
4549+
stage?: string | null;
4550+
output?: Record<string, unknown> | null;
4551+
errorMessage?: string | null;
4552+
branch?: string | null;
4553+
},
4554+
): void {
4555+
this.d.store.updateCloudStatus(taskRunId, fields);
4556+
this.clearTerminalCloudPromptState(taskId, taskRunId);
4557+
}
4558+
4559+
private clearTerminalCloudPromptState(
4560+
taskId: string,
4561+
taskRunId: string,
4562+
): void {
4563+
const session = this.d.store.getSessions()[taskRunId];
4564+
if (
4565+
!session ||
4566+
(!session.isPromptPending && session.messageQueue.length === 0)
4567+
) {
4568+
return;
4569+
}
4570+
4571+
this.d.store.clearMessageQueue(taskId);
4572+
this.d.store.updateSession(taskRunId, {
4573+
isPromptPending: false,
44594574
});
44604575
}
44614576

@@ -5376,7 +5491,9 @@ export class SessionService {
53765491
}
53775492

53785493
if (update.kind === "snapshot" && !isTerminalStatus(update.status)) {
5379-
this.surfacePersistedPendingPermissions(taskRunId, update.newEntries);
5494+
if (!this.isStaleNonTerminalCloudUpdate(taskRunId, update)) {
5495+
this.surfacePersistedPendingPermissions(taskRunId, update.newEntries);
5496+
}
53805497
}
53815498

53825499
// NOTE: Don't auto-flush on `!isPromptPending && queue.length > 0` here.
@@ -5389,18 +5506,20 @@ export class SessionService {
53895506

53905507
// Update cloud status fields if present
53915508
if (update.kind === "status" || update.kind === "snapshot") {
5392-
this.d.store.updateCloudStatus(taskRunId, {
5393-
status: update.status,
5394-
stage: update.stage,
5395-
output: update.output,
5396-
errorMessage: update.errorMessage,
5397-
branch: update.branch,
5398-
});
5399-
5400-
if (update.status === "in_progress") {
5401-
this.tryRecoverIdleCloudQueue(taskRunId, {
5402-
serverSandboxAlive: update.sandboxAlive,
5509+
if (!this.isStaleNonTerminalCloudUpdate(taskRunId, update)) {
5510+
this.d.store.updateCloudStatus(taskRunId, {
5511+
status: update.status,
5512+
stage: update.stage,
5513+
output: update.output,
5514+
errorMessage: update.errorMessage,
5515+
branch: update.branch,
54035516
});
5517+
5518+
if (update.status === "in_progress") {
5519+
this.tryRecoverIdleCloudQueue(taskRunId, {
5520+
serverSandboxAlive: update.sandboxAlive,
5521+
});
5522+
}
54045523
}
54055524

54065525
if (isTerminalStatus(update.status)) {
@@ -5422,6 +5541,23 @@ export class SessionService {
54225541

54235542
// --- Helper Methods ---
54245543

5544+
private isStaleNonTerminalCloudUpdate(
5545+
taskRunId: string,
5546+
update: CloudTaskUpdatePayload,
5547+
): boolean {
5548+
if (update.kind !== "status" && update.kind !== "snapshot") {
5549+
return false;
5550+
}
5551+
if (update.status === undefined) {
5552+
return false;
5553+
}
5554+
const currentCloudStatus =
5555+
this.d.store.getSessions()[taskRunId]?.cloudStatus;
5556+
return (
5557+
isTerminalStatus(currentCloudStatus) && !isTerminalStatus(update.status)
5558+
);
5559+
}
5560+
54255561
private async resolveCloudPrompt(
54265562
prompt: string | ContentBlock[],
54275563
): Promise<string | ContentBlock[]> {

packages/core/src/sessions/sessionStore.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
QueuedMessage,
88
TaskRunStatus,
99
} from "@posthog/shared";
10+
import { isTerminalStatus } from "@posthog/shared/domain-types";
1011
import { setAutoFreeze } from "immer";
1112
import { immer } from "zustand/middleware/immer";
1213
import { createStore } from "zustand/vanilla";
@@ -130,7 +131,16 @@ export const sessionStoreSetters = {
130131
sessionStore.setState((state) => {
131132
const session = state.sessions[taskRunId];
132133
if (!session) return;
133-
if (fields.status !== undefined) session.cloudStatus = fields.status;
134+
if (fields.status !== undefined) {
135+
const currentStatus = session.cloudStatus;
136+
if (
137+
isTerminalStatus(currentStatus) &&
138+
!isTerminalStatus(fields.status)
139+
) {
140+
return;
141+
}
142+
session.cloudStatus = fields.status;
143+
}
134144
if (fields.stage !== undefined) session.cloudStage = fields.stage;
135145
if (fields.output !== undefined) session.cloudOutput = fields.output;
136146
if (fields.errorMessage !== undefined)

0 commit comments

Comments
 (0)