@@ -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 ) ) {
0 commit comments