Skip to content

Commit fa72652

Browse files
authored
Merge pull request Stack-Cairn#228 from Stack-Cairn/fix/webui-run-lost-truncation
fix: WebUI stream-loss truncation and touch/no-hover UI visibility
2 parents 7fbe347 + 89801e9 commit fa72652

32 files changed

Lines changed: 869 additions & 73 deletions

crates/agent-gateway/internal/session/conversation_ingress.go

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ func (m *Manager) ingestChatEvent(requestID string, event *gatewayv1.ChatEvent)
4444
eventType = chatwire.EventTypeName(event.GetType())
4545
}
4646

47+
if event.GetType() == gatewayv1.ChatEvent_USER_MESSAGE {
48+
if record := s.runs[runID]; record != nil && record.userMessageSeeded {
49+
// The gateway already appended this run's user_message at accept
50+
// time; swallow the agent echo so the message appears once.
51+
return
52+
}
53+
}
54+
55+
if stream.runFinishedRecently(runID) {
56+
// A live event for a run whose terminal was merely inferred proves the
57+
// inference wrong — reopen the run instead of dropping its stream.
58+
if !s.resurrectRunLocked(stream, runID) {
59+
// Late straggler after a genuine or duplicate terminal; drop it.
60+
return
61+
}
62+
}
63+
4764
switch event.GetType() {
4865
case gatewayv1.ChatEvent_DONE:
4966
delete(payload, "type")
@@ -57,17 +74,6 @@ func (m *Manager) ingestChatEvent(requestID string, event *gatewayv1.ChatEvent)
5774
delete(payload, "message")
5875
s.runFinishedLocked(stream, runID, "failed", "", strings.TrimSpace(message), payload, now)
5976
return
60-
case gatewayv1.ChatEvent_USER_MESSAGE:
61-
if record := s.runs[runID]; record != nil && record.userMessageSeeded {
62-
// The gateway already appended this run's user_message at accept
63-
// time; swallow the agent echo so the message appears once.
64-
return
65-
}
66-
}
67-
68-
if stream.runFinishedRecently(runID) {
69-
// Late straggler after a forced or duplicate terminal; drop it.
70-
return
7177
}
7278

7379
if event.GetType() == gatewayv1.ChatEvent_USER_MESSAGE {
@@ -154,8 +160,33 @@ func (m *Manager) ingestChatControl(requestID string, control *gatewayv1.ChatCon
154160

155161
switch controlType {
156162
case "started":
163+
// A reconnect republish may re-anchor a run this store wrongly gave up
164+
// on (inferred loss); resurrect before the started no-ops against the
165+
// finished set.
166+
if stream.runFinishedRecently(runID) && !s.resurrectRunLocked(stream, runID) {
167+
return
168+
}
157169
s.runStartedLocked(stream, runID, "", now)
158170
case "completed", "failed", "cancelled":
171+
inferredLoss := controlType == "failed" && isInferredRunLossCode(errorCode)
172+
if stream.runFinishedRecently(runID) {
173+
if inferredLoss || !s.resurrectRunLocked(stream, runID) {
174+
return
175+
}
176+
}
177+
// The desktop ledger flushes inferred losses (desktop_run_lost & co)
178+
// through this same channel. For the conversation's active run, ignore
179+
// such a verdict while the run's own events are fresh or it was
180+
// already falsified once — genuine terminals always pass.
181+
if inferredLoss &&
182+
stream.activity != nil && stream.activity.RunID == runID {
183+
record := s.runs[runID]
184+
eventsFresh := !stream.lastEventAt.IsZero() &&
185+
now.Sub(stream.lastEventAt) < s.runReportLostTimeout
186+
if eventsFresh || (record != nil && record.revived) {
187+
return
188+
}
189+
}
159190
s.runFinishedLocked(stream, runID, controlType, errorCode, message, nil, now)
160191
case "queued_in_gui":
161192
s.markRunQueuedInGUILocked(stream, runID, now)
@@ -228,16 +259,20 @@ func (m *Manager) ingestRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapshot)
228259
streamWasUnknown := existingStream == nil || (existingStream.lastSeq == 0 && existingStream.activity == nil)
229260
stream := s.streamLocked(conversationID, now)
230261
s.noteAgentEpochLocked(stream, epoch)
262+
if stream.runFinishedRecently(runID) {
263+
// Both running and terminal snapshots are authoritative runtime
264+
// evidence. A terminal snapshot must be able to correct an earlier
265+
// inferred loss even when no token arrived between the two verdicts.
266+
if !s.resurrectRunLocked(stream, runID) {
267+
return
268+
}
269+
}
231270

232271
switch state {
233272
case "completed", "failed", "cancelled":
234273
s.runFinishedLocked(stream, runID, state, "", "", nil, now)
235274
return
236275
}
237-
if stream.runFinishedRecently(runID) {
238-
return
239-
}
240-
241276
next := &RunSnapshot{
242277
RunID: runID,
243278
Revision: snapshot.GetRevision(),

crates/agent-gateway/internal/session/conversation_stream.go

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
671735
func (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

Comments
 (0)