@@ -180,6 +180,27 @@ type chatRunRecord struct {
180180 // the agent's ref-bearing user_message, so a reconnect replay of the same
181181 // event cannot seed a second truncation.
182182 rebaseSeeded bool
183+ // lostInferred marks a run whose terminal was inferred from missing
184+ // liveness reports (desktop_run_lost and friends) rather than delivered by
185+ // the run itself. Such a terminal is falsifiable: fresh events for the run
186+ // prove it wrong and resurrect the run instead of being dropped as
187+ // stragglers.
188+ lostInferred bool
189+ // revived marks a run resurrected after a wrong inferred terminal; further
190+ // inferred-loss signals for it are ignored (the desktop-side ledger may
191+ // keep repeating the stale verdict) until a genuine terminal arrives.
192+ revived bool
193+ }
194+
195+ // isInferredRunLossCode reports whether an error code represents a liveness
196+ // inference (nobody vouched for the run) instead of an outcome the run itself
197+ // produced. Inferred terminals must stay reversible: the run may well be alive.
198+ func isInferredRunLossCode (errorCode string ) bool {
199+ switch errorCode {
200+ case "desktop_run_lost" , "stale_run" , "agent_offline" , "desktop_runtime_lease_expired" :
201+ return true
202+ }
203+ return false
183204}
184205
185206// chatCommandDedupeRecord is the process-local idempotency key for WebUI chat
@@ -645,9 +666,17 @@ func (s *conversationStreamStore) runFinishedLocked(
645666 payload [key ] = value
646667 }
647668 }
648- if record := s .runs [runID ]; record != nil && record .clientRequestID != "" {
669+ record := s .runRecordLocked (runID , stream .conversationID )
670+ if record .clientRequestID != "" {
649671 payload ["client_request_id" ] = record .clientRequestID
650672 }
673+ // Inferred terminals (nobody vouched for the run) stay falsifiable: a
674+ // later event for the run resurrects it instead of being dropped. Genuine
675+ // terminals settle the run for good.
676+ record .lostInferred = status == "failed" && isInferredRunLossCode (errorCode )
677+ if ! record .lostInferred {
678+ record .revived = false
679+ }
651680 s .appendEventLocked (stream , runID , StreamEventRunFinished , payload , now )
652681 stream .finishedRuns = append (stream .finishedRuns , runID )
653682 if len (stream .finishedRuns ) > conversationFinishedRunMemory {
@@ -666,6 +695,41 @@ func (s *conversationStreamStore) runFinishedLocked(
666695 }
667696}
668697
698+ // resurrectRunLocked reopens a run that was force-finished by a liveness
699+ // inference: fresh agent traffic for the run proves the inference wrong. The
700+ // run leaves the finished set (so runStartedLocked re-registers it), is
701+ // flagged to ignore repeats of the stale verdict, and the stream is marked
702+ // snapshot-hungry so subscribers rebuild the tail that was dropped while the
703+ // run was considered dead. Refuses when another run owns the conversation —
704+ // then the late events really are stragglers.
705+ func (s * conversationStreamStore ) resurrectRunLocked (
706+ stream * conversationStream ,
707+ runID string ,
708+ ) bool {
709+ record := s .runs [runID ]
710+ if record == nil || ! record .lostInferred {
711+ return false
712+ }
713+ if stream .activity != nil {
714+ return false
715+ }
716+ kept := stream .finishedRuns [:0 ]
717+ for _ , finished := range stream .finishedRuns {
718+ if finished != runID {
719+ kept = append (kept , finished )
720+ }
721+ }
722+ stream .finishedRuns = kept
723+ record .lostInferred = false
724+ record .revived = true
725+ // The events dropped between the wrong terminal and this resurrection are
726+ // unrecoverable from the log; late joiners and current subscribers rebuild
727+ // from the next runtime snapshot.
728+ stream .runNeedsSnapshot = true
729+ stream .snapshotDirty = true
730+ return true
731+ }
732+
669733// markRunQueuedLocked records that a run's command is pending in the gateway
670734// (accepted but not yet started). No log event — activity only.
671735func (s * conversationStreamStore ) markRunQueuedLocked (
@@ -1123,17 +1187,37 @@ func (s *conversationStreamStore) onRuntimeStatus(event *gatewayv1.RuntimeStatus
11231187 stream .activity .UpdatedAt = now
11241188 continue
11251189 }
1190+ record := s .runs [runID ]
1191+ revived := record != nil && record .revived
1192+ eventsFresh := ! stream .lastEventAt .IsZero () &&
1193+ now .Sub (stream .lastEventAt ) < s .runReportLostTimeout
11261194 if report , ok := finished [runID ]; ok {
11271195 state := report .GetState ()
11281196 errorCode := report .GetErrorCode ()
1197+ // Judged on the report's own fields: a ledger-swept loss is an
1198+ // inference, while an unknown state is still a desktop-asserted
1199+ // terminal (normalized below) and stays adopted verbatim.
1200+ inferred := state == "failed" && isInferredRunLossCode (errorCode )
11291201 switch state {
11301202 case "completed" , "failed" , "cancelled" :
11311203 default :
11321204 state = "failed"
11331205 errorCode = "desktop_run_lost"
11341206 }
1135- s .runFinishedLocked (stream , runID , state , errorCode , report .GetMessage (),
1136- map [string ]any {"reason" : "desktop_reported" }, now )
1207+ // A loss the desktop merely inferred (its ledger starved while the
1208+ // run's events still flow through this relay, or the verdict was
1209+ // already falsified once) is not adopted — genuine terminals the
1210+ // run itself produced always are.
1211+ if ! inferred || (! eventsFresh && ! revived ) {
1212+ s .runFinishedLocked (stream , runID , state , errorCode , report .GetMessage (),
1213+ map [string ]any {"reason" : "desktop_reported" }, now )
1214+ continue
1215+ }
1216+ }
1217+ if revived {
1218+ // Resurrected after a wrong loss verdict: liveness inferences no
1219+ // longer end this run; the reaper's stale-run timeout is the
1220+ // backstop for a genuinely dead one.
11371221 continue
11381222 }
11391223 // Stream events vouch too: never finalize a run whose events are still
0 commit comments