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